actual-mcp-server 0.24.15 → 0.25.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/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.0 | **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,105 @@ 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
+ * #612: the Host (and, in open mode, Origin) hostnames the HTTP server accepts, or
92
+ * `null` to skip the check. DNS rebinding protection: a hostile page can rebind its
93
+ * own name to 127.0.0.1, but cannot make the browser send a Host it does not own.
94
+ *
95
+ * - Open mode (no bearer token, no OIDC): the loopback names, `allowedHosts`, and the
96
+ * bind address itself when it is a concrete, non-wildcard address (an IP-literal
97
+ * Host cannot come out of rebinding). An IPv6 bind is added bracketed and canonical.
98
+ * - Authenticated: `null` when `allowedHosts` is empty (unchanged behaviour), else
99
+ * `allowedHosts` plus the loopback names.
100
+ *
101
+ * Pure: `allowedHosts` is the already-validated MCP_ALLOWED_HOSTS list.
102
+ */
103
+ export function resolveAllowedHosts({ openMode, allowedHosts, bindHost, }) {
104
+ if (!openMode && allowedHosts.length === 0)
105
+ return null;
106
+ const hosts = new Set([...LOOPBACK_HOSTNAMES, ...allowedHosts]);
107
+ if (openMode && bindHost) {
108
+ const h = bindHost.trim().toLowerCase();
109
+ if (h && h !== '0.0.0.0' && h !== '::' && h !== '[::]') {
110
+ const bracketed = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
111
+ // The SDK compares the URL-parsed Host, which is the canonical form
112
+ // ([2001:db8:0:0::5] arrives as [2001:db8::5]), so store the same form.
113
+ let canonical = bracketed;
114
+ try {
115
+ canonical = new URL(`http://${bracketed}`).hostname;
116
+ }
117
+ catch { /* keep as typed */ }
118
+ hosts.add(canonical);
119
+ }
120
+ }
121
+ return [...hosts];
122
+ }
43
123
  /**
44
124
  * 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.
125
+ * without booting a server. Precedence: a weak token refuses a non-loopback bind
126
+ * (#611, before and regardless of the opt-out, which cannot make a weak token
127
+ * safe) and serves with a warning on loopback; then configured auth serves;
128
+ * otherwise a loopback bind serves; otherwise the explicit opt-out serves (with a
129
+ * warning); otherwise refuse.
48
130
  */
49
131
  export function evaluateHttpAuthPosture(input) {
50
- const { bindHost, hasStaticToken, oidcEnabled, allowUnauthenticated } = input;
132
+ const { bindHost, hasStaticToken, oidcEnabled, allowUnauthenticated, weakTokenVars = [] } = input;
133
+ if (weakTokenVars.length > 0) {
134
+ if (isLoopbackHost(bindHost)) {
135
+ return { decision: 'serve', reason: `loopback bind (${bindHost}) with a weak token`, weakTokenVars };
136
+ }
137
+ return {
138
+ decision: 'refuse',
139
+ reason: `weak token on a non-loopback bind (${bindHost ?? '0.0.0.0'})`,
140
+ weakTokenVars,
141
+ };
142
+ }
51
143
  if (hasStaticToken || oidcEnabled) {
52
144
  return { decision: 'serve', reason: 'authentication is configured' };
53
145
  }
@@ -73,7 +165,24 @@ export function evaluateHttpAuthPosture(input) {
73
165
  * caller (or test that stubs exit to not throw) can observe it.
74
166
  */
75
167
  export function enforceHttpAuthPosture(input, log = logger, exit = process.exit) {
76
- const { decision, reason } = evaluateHttpAuthPosture(input);
168
+ const { decision, reason, weakTokenVars } = evaluateHttpAuthPosture(input);
169
+ if (weakTokenVars) {
170
+ // #611: names only, never values. A separate message from the no-auth refusal:
171
+ // MCP_ALLOW_UNAUTHENTICATED cannot rescue a weak token, so it is not offered.
172
+ if (decision === 'refuse') {
173
+ log.error(`Refusing to start: ${reason}. ${weakTokenVars.join(', ')} is shorter than ${MIN_TOKEN_LENGTH} ` +
174
+ 'characters or an example placeholder. Generate a token with openssl rand -hex 32.');
175
+ exit(1);
176
+ }
177
+ else {
178
+ for (const name of weakTokenVars) {
179
+ log.warn(`${name} is shorter than ${MIN_TOKEN_LENGTH} characters or an example placeholder. ` +
180
+ 'Allowed only on a loopback bind; a non-loopback bind refuses to start. ' +
181
+ 'Generate a token with openssl rand -hex 32.');
182
+ }
183
+ }
184
+ return decision;
185
+ }
77
186
  if (decision === 'refuse') {
78
187
  log.error(`Refusing to start: ${reason}. HTTP auth is required-by-default. ` +
79
188
  '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,64 @@ 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
+ const allowedHosts = resolveAllowedHosts({ openMode, allowedHosts: config.MCP_ALLOWED_HOSTS, bindHost });
98
+ if (allowedHosts) {
99
+ const rejectHost = (req, reason, value) => logger.warn('[HTTP] Rejected request: hostname not allowed (DNS rebinding protection, #612)', {
100
+ header: reason, hostname: value.slice(0, 253), path: req.path,
101
+ });
102
+ const sdkHostCheck = hostHeaderValidation(allowedHosts);
103
+ app.use((req, res, next) => {
104
+ let passed = false;
105
+ sdkHostCheck(req, res, () => { passed = true; });
106
+ if (passed) {
107
+ next();
108
+ return;
109
+ }
110
+ // The SDK middleware is synchronous: it already answered 403.
111
+ let hostname = String(req.headers.host ?? '');
112
+ try {
113
+ hostname = new URL(`http://${hostname}`).hostname;
114
+ }
115
+ catch { /* log the raw value */ }
116
+ rejectHost(req, 'host', hostname);
117
+ });
118
+ if (openMode) {
119
+ // The SDK checks Host only. In open mode a browser Origin must also be an allowed
120
+ // hostname; an absent Origin (non-browser clients) passes. Comparing Origin to Host
121
+ // would not help: under rebinding both carry the attacker's name. /.well-known/* is
122
+ // exempt because mcp-auth serves it with cors() for public cross-origin reads.
123
+ app.use((req, res, next) => {
124
+ const origin = req.headers.origin;
125
+ if (origin === undefined || req.path.startsWith('/.well-known/')) {
126
+ next();
127
+ return;
128
+ }
129
+ let hostname = null;
130
+ try {
131
+ hostname = origin === 'null' ? null : new URL(origin).hostname;
132
+ }
133
+ catch {
134
+ hostname = null;
135
+ }
136
+ if (hostname !== null && allowedHosts.includes(hostname)) {
137
+ next();
138
+ return;
139
+ }
140
+ rejectHost(req, 'origin', hostname ?? 'null');
141
+ res.status(403).json({ jsonrpc: '2.0', error: { code: -32000, message: `Invalid Origin: ${hostname ?? 'null'}` }, id: null });
142
+ });
143
+ }
144
+ if (openMode && config.MCP_ALLOWED_HOSTS.length === 0 && (bindHost === '0.0.0.0' || bindHost === '::')) {
145
+ logger.info(`[HTTP] Accepting Host ${allowedHosts.join(', ')} only (DNS rebinding protection, #612). Reach this server by another name? Add it to MCP_ALLOWED_HOSTS.`);
146
+ }
147
+ }
89
148
  // Explicit body-size cap (#168). An oversized payload is rejected with HTTP 413
90
149
  // rather than buffered unbounded. Tunable via MCP_HTTP_BODY_LIMIT (default 512kb).
91
150
  app.use(express.json({ limit: config.MCP_HTTP_BODY_LIMIT }));
@@ -366,7 +425,7 @@ advertisedUrl) {
366
425
  // (#572). Only when NEITHER is configured is every request allowed. Static tokens are
367
426
  // authentication, so MCP_ALLOW_UNAUTHENTICATED (a posture opt-out for the no-auth
368
427
  // case) does not relax this.
369
- if (bearerEntries.length === 0) {
428
+ if (openMode) {
370
429
  return true;
371
430
  }
372
431
  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.0",
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",