actual-mcp-server 0.7.8 → 0.7.11

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
@@ -468,8 +468,11 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
468
468
  | **Security & Authentication** ||||
469
469
  | `AUTH_PROVIDER` | `none` | No | Auth mode: `none` (static Bearer) or `oidc` (JWKS-validated JWT) |
470
470
  | `MCP_SSE_AUTHORIZATION` | _(none)_ | No | Static Bearer token (`AUTH_PROVIDER=none`; highly recommended in production) |
471
+ | `MCP_ALLOW_UNAUTHENTICATED` | `false` | No | Opt-out for required-by-default HTTP auth (#242). On a non-loopback bind with no token and no OIDC the server refuses to start; set to `true` to run open deliberately (e.g. behind your own proxy) |
471
472
  | `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
473
+ | `OIDC_ALLOW_INSECURE_ISSUER` | `false` | No | Allow a plaintext (http) OIDC issuer on a trusted network (#244). Off by default (http issuer refused at startup); set `true` only for local/LAN testing |
472
474
  | `OIDC_RESOURCE` | _(none)_ | No | Expected `aud` claim in JWT (your client ID) |
475
+ | `OIDC_ACCEPTED_AUDIENCES` | _(none)_ | No | Extra accepted `aud` values beyond `OIDC_RESOURCE`, comma-separated (#245). For IdPs that put the client-id in `aud` (e.g. Authentik). Strict allowlist, never a wildcard |
473
476
  | `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
474
477
  | `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
475
478
  | `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
@@ -715,4 +718,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
715
718
 
716
719
  ---
717
720
 
718
- **Version:** 0.7.8 | **Tool Count:** 71 (verified LibreChat-compatible)
721
+ **Version:** 0.7.11 | **Tool Count:** 71 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.7.8",
4
+ "version": "0.7.11",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,14 +30,14 @@
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/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.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/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/payees_delete.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/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/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/tool_count_sync.test.js && node tests/unit/stdio_transport.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",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/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/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/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/payees_delete.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/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/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/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/tool_count_sync.test.js && node tests/unit/stdio_transport.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",
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",
37
37
  "test:e2e:docker:smoke": "./tests/e2e/run-docker-e2e.sh smoke",
38
38
  "test:e2e:docker:full": "./tests/e2e/run-docker-e2e.sh full",
39
39
  "test:all": "npm run test:adapter && npm run test:unit-js && npm run test:e2e:docker:smoke",
40
- "test:mcp-client": "npm run build && node scripts/register-tsconfig-paths.js -- --http --test-mcp-client",
40
+ "test:mcp-client": "npm run build && MCP_ALLOW_UNAUTHENTICATED=true node scripts/register-tsconfig-paths.js -- --http --test-mcp-client",
41
41
  "test:integration": "MCP_TEST_LEVEL=sanity node tests/manual/index.js",
42
42
  "test:integration:sanity": "MCP_TEST_LEVEL=sanity node tests/manual/index.js",
43
43
  "test:integration:smoke": "MCP_TEST_LEVEL=smoke node tests/manual/index.js",
@@ -13,6 +13,11 @@ export const configSchema = z.object({
13
13
  MCP_BRIDGE_PORT: z.string().default('3600'),
14
14
  MCP_TRANSPORT_MODE: z.enum(['--http']).default('--http'),
15
15
  MCP_SSE_AUTHORIZATION: z.string().optional(),
16
+ // #242: explicit opt-out for the required-by-default HTTP auth gate. When auth
17
+ // is unconfigured AND the bind is non-loopback, the server refuses to start
18
+ // unless this is the exact string 'true'. Strict parse (only 'true' enables)
19
+ // so a typo like 'yes'/'1' cannot silently leave the server open.
20
+ MCP_ALLOW_UNAUTHENTICATED: z.string().optional().transform(val => val === 'true'),
16
21
  MCP_ENABLE_HTTPS: z.string().optional().transform(val => val === 'true'),
17
22
  MCP_HTTPS_CERT: z.string().optional(),
18
23
  MCP_HTTPS_KEY: z.string().optional(),
@@ -28,8 +33,19 @@ export const configSchema = z.object({
28
33
  AUTH_PROVIDER: z.enum(['none', 'oidc']).default('none'),
29
34
  // OIDC issuer URL (e.g. https://auth.example.com/realms/myrealm). Required when AUTH_PROVIDER=oidc.
30
35
  OIDC_ISSUER: z.string().optional(),
36
+ // #244: escape hatch for a plaintext (http) OIDC issuer on a trusted network
37
+ // (e.g. a LAN Casdoor for local testing). Off by default: an http issuer is
38
+ // refused because a network attacker could swap the JWKS and forge tokens.
39
+ // Set to 'true' ONLY when the issuer hop is genuinely trusted. Mirrors
40
+ // ALLOW_INSECURE_UPSTREAM (#161). Loopback issuers never need this.
41
+ OIDC_ALLOW_INSECURE_ISSUER: z.string().optional().transform(val => val === 'true'),
31
42
  // This server's resource identifier URL (e.g. https://actual-mcp.example.com). Required when AUTH_PROVIDER=oidc.
32
43
  OIDC_RESOURCE: z.string().optional(),
44
+ // #245: extra accepted JWT audiences beyond OIDC_RESOURCE, comma-separated.
45
+ // For IdPs that put the client-id (not the resource URI) in `aud`, e.g. Authentik.
46
+ // Strict closed allowlist: the accepted set is OIDC_RESOURCE plus these; never a
47
+ // wildcard. Default empty, so single-audience deployments are unchanged.
48
+ OIDC_ACCEPTED_AUDIENCES: z.string().optional(),
33
49
  // Comma-separated required scopes (e.g. "read,write"). Optional.
34
50
  OIDC_SCOPES: z.string().optional(),
35
51
  // JSON map of principal → budget sync-ID list for per-user budget ACL.
package/dist/src/index.js CHANGED
@@ -124,9 +124,10 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
124
124
  import('./actualConnection.js'),
125
125
  import('./lib/ActualMCPConnection.js'),
126
126
  ]);
127
- const [{ startHttpServer }, { startStdioServer }, loggerModule, utilsModule, actualToolsManagerModule,] = await Promise.all([
127
+ const [{ startHttpServer }, { startStdioServer }, { enforceHttpAuthPosture }, loggerModule, utilsModule, actualToolsManagerModule,] = await Promise.all([
128
128
  import('./server/httpServer.js'),
129
129
  import('./server/stdioServer.js'),
130
+ import('./lib/authPosture.js'),
130
131
  import('./logger.js'),
131
132
  import('./utils.js'),
132
133
  import('./actualToolsManager.js'),
@@ -242,12 +243,31 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
242
243
  }
243
244
  }
244
245
  }
246
+ // #242: the interface both the self-test and the normal HTTP path bind to.
247
+ const bindHost = process.env.MCP_BRIDGE_BIND_HOST || '0.0.0.0';
248
+ // #242: HTTP auth required-by-default. Decide ONCE, before binding, whether
249
+ // the configured posture is safe. On a non-loopback bind with no auth and no
250
+ // explicit opt-out this refuses startup (exit 1) rather than silently serving
251
+ // financial data open on the LAN. stdio mode never reaches this check.
252
+ if (useHttp || useTestMcpClient) {
253
+ const decision = enforceHttpAuthPosture({
254
+ bindHost,
255
+ hasStaticToken: !!process.env.MCP_SSE_AUTHORIZATION,
256
+ oidcEnabled: process.env.AUTH_PROVIDER === 'oidc',
257
+ allowUnauthenticated: process.env.MCP_ALLOW_UNAUTHENTICATED === 'true',
258
+ });
259
+ // enforceHttpAuthPosture already called process.exit(1) on 'refuse'. This
260
+ // explicit return makes the open-start path structurally unreachable even
261
+ // if process.exit were ever non-terminal, so we never bind on a refuse.
262
+ if (decision === 'refuse')
263
+ return;
264
+ }
245
265
  // If requested, run the MCP client-side tests now that all variables are ready
246
266
  if (useTestMcpClient) {
247
267
  logger.info('⚙️ --test-mcp-client specified, starting HTTP server and running client-side MCP tests...');
248
268
  // start server in http mode. Bind all interfaces by default (#239): advertisedUrl
249
269
  // uses the LAN IP (getLocalIp), so the self-test client must be able to reach it.
250
- await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, SERVER_INSTRUCTIONS, toolSchemas, version, process.env.MCP_BRIDGE_BIND_HOST || '0.0.0.0', advertisedUrl);
270
+ await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, SERVER_INSTRUCTIONS, toolSchemas, version, bindHost, advertisedUrl);
251
271
  // dynamic import to avoid circular at top
252
272
  const { testMcpClient } = await import('./tests/testMcpClient.js');
253
273
  try {
@@ -284,7 +304,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
284
304
  logger.info('Mode: HTTP');
285
305
  await startHttpServer(mcp, PORT, HTTP_PATH, capabilities, implementedTools, SERVER_DESCRIPTION, SERVER_INSTRUCTIONS, toolSchemas, version,
286
306
  // bind host (ensure server accepts connections on that interface)
287
- process.env.MCP_BRIDGE_BIND_HOST || '0.0.0.0',
307
+ bindHost,
288
308
  // advertised URL shown to clients
289
309
  advertisedUrl);
290
310
  }
@@ -0,0 +1,91 @@
1
+ import { createModuleLogger } from './loggerFactory.js';
2
+ /**
3
+ * Auth posture gate (#242): make HTTP authentication required-by-default.
4
+ *
5
+ * Background: with AUTH_PROVIDER unset and a blank MCP_SSE_AUTHORIZATION the
6
+ * per-request gate in httpServer.ts serves every request, and the server binds
7
+ * 0.0.0.0 by default, so a forgotten token publishes a financial MCP server open
8
+ * on the LAN. This module decides ONCE at startup whether that posture is safe,
9
+ * before the socket is bound.
10
+ *
11
+ * We refuse startup (Option 1) rather than 401 per request (Option 2): a
12
+ * per-request loopback check would key on req.ip, which is attacker-suppliable
13
+ * via X-Forwarded-For once `trust proxy` is enabled behind the documented reverse
14
+ * proxy, turning the check into a spoofable bypass. The bind host is fixed config
15
+ * no client can influence, so deciding once at startup is the sound boundary.
16
+ */
17
+ const logger = createModuleLogger('AUTH_POSTURE');
18
+ /**
19
+ * True only for binds that never leave the local host: `localhost`, the entire
20
+ * IPv4 loopback block (127.0.0.0/8, so 127.0.0.2 counts, not just 127.0.0.1),
21
+ * IPv6 loopback in compressed and longhand form, and the IPv4-mapped IPv6
22
+ * loopback. `0.0.0.0` and `::` bind ALL interfaces (including non-loopback ones)
23
+ * so they are exposed, not loopback. An empty or undefined host is treated as
24
+ * exposed (fail-safe): the real runtime default is `0.0.0.0`.
25
+ */
26
+ export function isLoopbackHost(host) {
27
+ if (!host)
28
+ return false;
29
+ const h = host.trim().toLowerCase();
30
+ if (h === 'localhost')
31
+ return true;
32
+ // IPv4 loopback is the whole 127.0.0.0/8 block, not only 127.0.0.1.
33
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h))
34
+ return true;
35
+ // IPv6 loopback: compressed (::1) and fully-expanded longhand.
36
+ if (h === '::1' || h === '0:0:0:0:0:0:0:1' || h === '0000:0000:0000:0000:0000:0000:0000:0001')
37
+ return true;
38
+ // IPv4-mapped IPv6 loopback (e.g. ::ffff:127.0.0.1).
39
+ if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h))
40
+ return true;
41
+ return false;
42
+ }
43
+ /**
44
+ * 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.
48
+ */
49
+ export function evaluateHttpAuthPosture(input) {
50
+ const { bindHost, hasStaticToken, oidcEnabled, allowUnauthenticated } = input;
51
+ if (hasStaticToken || oidcEnabled) {
52
+ return { decision: 'serve', reason: 'authentication is configured' };
53
+ }
54
+ if (isLoopbackHost(bindHost)) {
55
+ return { decision: 'serve', reason: `loopback bind (${bindHost}) is not exposed` };
56
+ }
57
+ if (allowUnauthenticated) {
58
+ return {
59
+ decision: 'serve-open-warn',
60
+ reason: `MCP_ALLOW_UNAUTHENTICATED=true on a non-loopback bind (${bindHost ?? '0.0.0.0'})`,
61
+ };
62
+ }
63
+ return {
64
+ decision: 'refuse',
65
+ reason: `no authentication configured on a non-loopback bind (${bindHost ?? '0.0.0.0'})`,
66
+ };
67
+ }
68
+ /**
69
+ * Evaluate the posture and enforce it: refuse exits the process non-zero with a
70
+ * remediation message, the opt-out logs a loud warning, and a safe posture is a
71
+ * no-op. `exit` and `log` are injectable only for the existing process.exit-spy
72
+ * test pattern; production callers use the defaults. Returns the decision so a
73
+ * caller (or test that stubs exit to not throw) can observe it.
74
+ */
75
+ export function enforceHttpAuthPosture(input, log = logger, exit = process.exit) {
76
+ const { decision, reason } = evaluateHttpAuthPosture(input);
77
+ if (decision === 'refuse') {
78
+ log.error(`Refusing to start: ${reason}. HTTP auth is required-by-default. ` +
79
+ 'Set MCP_SSE_AUTHORIZATION to a strong token (openssl rand -hex 32), or ' +
80
+ 'set AUTH_PROVIDER=oidc, or bind a loopback host. To deliberately run open ' +
81
+ '(for example behind your own authenticating proxy), set MCP_ALLOW_UNAUTHENTICATED=true.');
82
+ exit(1);
83
+ }
84
+ else if (decision === 'serve-open-warn') {
85
+ log.warn(`Serving HTTP WITHOUT authentication: ${reason}. Anyone who can reach this ` +
86
+ 'port can read and modify your financial data. This is allowed only because ' +
87
+ 'MCP_ALLOW_UNAUTHENTICATED=true. Put an authenticating reverse proxy in front, ' +
88
+ 'or set MCP_SSE_AUTHORIZATION / AUTH_PROVIDER=oidc.');
89
+ }
90
+ return decision;
91
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * OIDC accepted-audiences allowlist (#245).
3
+ *
4
+ * Build the strict, closed set of audiences a JWT's `aud` claim may match: the
5
+ * required `OIDC_RESOURCE` plus any explicitly configured extras
6
+ * (`OIDC_ACCEPTED_AUDIENCES`, comma-separated, e.g. an IdP like Authentik that
7
+ * puts the client-id in `aud`). The list is trimmed, empties are removed, and
8
+ * duplicates are collapsed. There is NO wildcard and NO accept-any: an `aud`
9
+ * outside this set is rejected by jose, preserving the cross-relying-party replay
10
+ * protection from #160. With no extras configured the set is exactly
11
+ * `[OIDC_RESOURCE]`, so existing single-audience deployments are unchanged.
12
+ *
13
+ * Pure and side-effect-free so it is unit-testable without booting a server.
14
+ */
15
+ export function buildAcceptedAudiences(resource, configured) {
16
+ const parts = [resource ?? '', ...(configured ?? '').split(',')];
17
+ return Array.from(new Set(parts.map((a) => a.trim()).filter((a) => a.length > 0)));
18
+ }
@@ -0,0 +1,132 @@
1
+ import { createModuleLogger } from './loggerFactory.js';
2
+ /**
3
+ * OIDC JWKS discovery (#244).
4
+ *
5
+ * Background: the OIDC verifier used to build its key set from a hardcoded
6
+ * `${OIDC_ISSUER}/.well-known/jwks`, which is wrong for most identity providers
7
+ * (their real `jwks_uri` comes from the discovery document and often lives on a
8
+ * different path). The fetch then 404s and every token is rejected. This module
9
+ * resolves the real `jwks_uri` from the issuer's OpenID discovery document, with
10
+ * the hardening the security review required:
11
+ * - the issuer must be https (a plaintext issuer lets a network attacker swap
12
+ * the JWKS and forge tokens), except an explicit loopback host for local dev;
13
+ * - the discovery fetch must not follow cross-origin redirects (fail closed);
14
+ * - the resolved `jwks_uri` must be https and on the SAME host as the issuer, so
15
+ * a tampered or misconfigured discovery document cannot point key fetching at
16
+ * an attacker-controlled host. Cross-host issuers (for example Google, which
17
+ * serves keys from googleapis.com) are intentionally not auto-trusted; that is
18
+ * a separate, configurable follow-up (#245).
19
+ *
20
+ * The pure helpers (assertSecureIssuer, resolveJwksUri) are unit-tested without a
21
+ * network; discoverJwksUri wraps them around a single startup fetch and fails
22
+ * closed on any problem.
23
+ */
24
+ const logger = createModuleLogger('OIDC_DISCOVERY');
25
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
26
+ /** Bound the startup discovery fetch so a hung IdP cannot hang startup forever. */
27
+ const DISCOVERY_TIMEOUT_MS = 10_000;
28
+ export function isLoopbackHost(host) {
29
+ return host ? LOOPBACK_HOSTS.has(host.toLowerCase()) : false;
30
+ }
31
+ /**
32
+ * Parse and validate the issuer URL. Throws unless it is https, or an explicit
33
+ * loopback host (local dev). Returns the parsed URL.
34
+ */
35
+ export function assertSecureIssuer(issuer, allowInsecure = false) {
36
+ if (!issuer)
37
+ throw new Error('OIDC_ISSUER is not set');
38
+ let url;
39
+ try {
40
+ url = new URL(issuer);
41
+ }
42
+ catch {
43
+ throw new Error(`OIDC_ISSUER is not a valid URL: ${issuer}`);
44
+ }
45
+ // https is required unless the host is loopback (local dev) or the operator has
46
+ // explicitly opted into a plaintext issuer on a trusted network (#244).
47
+ if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname) && !allowInsecure) {
48
+ throw new Error(`OIDC_ISSUER must use https (got ${url.protocol}//${url.hostname}). A plaintext issuer ` +
49
+ 'lets a network attacker swap the JWKS and forge tokens. Use https, a loopback host for ' +
50
+ 'local dev, or set OIDC_ALLOW_INSECURE_ISSUER=true only on a trusted network.');
51
+ }
52
+ // Reject embedded credentials (user:pass@host): URL.hostname strips them, so a
53
+ // credentialed issuer could slip past the same-origin check and leak secrets to logs.
54
+ if (url.username || url.password) {
55
+ throw new Error('OIDC_ISSUER must not contain embedded credentials (user:pass@host)');
56
+ }
57
+ // A real issuer has no query or fragment; reject them so the discovery URL
58
+ // built from the issuer cannot be malformed or misdirected.
59
+ if (url.search || url.hash) {
60
+ throw new Error('OIDC_ISSUER must not contain a query string or fragment');
61
+ }
62
+ return url;
63
+ }
64
+ /**
65
+ * Pure: resolve and validate the `jwks_uri` from a parsed discovery document
66
+ * against the issuer. Throws (fail closed) if it is missing, not https, carries
67
+ * embedded credentials, or is not same-origin (scheme + host + port) with the issuer.
68
+ */
69
+ export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false) {
70
+ const issuerUrl = assertSecureIssuer(issuer, allowInsecure);
71
+ if (!discoveryDoc || typeof discoveryDoc !== 'object') {
72
+ throw new Error('OIDC discovery document is empty or not an object');
73
+ }
74
+ const jwksUri = discoveryDoc.jwks_uri;
75
+ if (typeof jwksUri !== 'string' || jwksUri.length === 0) {
76
+ throw new Error('OIDC discovery document has no usable "jwks_uri"');
77
+ }
78
+ let jwks;
79
+ try {
80
+ jwks = new URL(jwksUri);
81
+ }
82
+ catch {
83
+ throw new Error(`OIDC "jwks_uri" is not a valid URL: ${jwksUri}`);
84
+ }
85
+ if (jwks.protocol !== 'https:' && !isLoopbackHost(jwks.hostname) && !allowInsecure) {
86
+ throw new Error(`OIDC "jwks_uri" must use https (got ${jwks.protocol}); refusing to fetch keys over plaintext`);
87
+ }
88
+ if (jwks.username || jwks.password) {
89
+ throw new Error('OIDC "jwks_uri" must not contain embedded credentials (user:pass@host)');
90
+ }
91
+ // Same ORIGIN (scheme + host + port), not just hostname: a different port on the
92
+ // same host is a different service, so comparing origins keeps the guarantee the
93
+ // security review asked for. Both are https here, so this is effectively host+port.
94
+ if (jwks.origin.toLowerCase() !== issuerUrl.origin.toLowerCase()) {
95
+ throw new Error(`OIDC "jwks_uri" origin (${jwks.origin}) does not match the issuer origin (${issuerUrl.origin}); ` +
96
+ 'refusing a cross-origin key fetch. A cross-host issuer (e.g. Google) needs an explicit trusted-host allowlist (#245).');
97
+ }
98
+ return jwks.toString();
99
+ }
100
+ /**
101
+ * Fetch the issuer's OpenID discovery document and return a validated `jwks_uri`.
102
+ * Fails closed on any problem (non-https issuer, redirect, non-200, bad JSON,
103
+ * missing/invalid/cross-host jwks_uri). `fetchImpl` is injectable for testing.
104
+ */
105
+ export async function discoverJwksUri(issuer, allowInsecure = false, fetchImpl = fetch) {
106
+ assertSecureIssuer(issuer, allowInsecure);
107
+ const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
108
+ const discoveryUrl = `${base}/.well-known/openid-configuration`;
109
+ let res;
110
+ try {
111
+ // redirect: 'error' fails the fetch on ANY redirect, so a 30x to another
112
+ // origin can never silently redirect key discovery to an attacker host. The
113
+ // timeout keeps a hung IdP from hanging startup forever (it fails closed).
114
+ res = await fetchImpl(discoveryUrl, { redirect: 'error', signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
115
+ }
116
+ catch (err) {
117
+ throw new Error(`OIDC discovery fetch failed for ${discoveryUrl}: ${err.message}`);
118
+ }
119
+ if (!res.ok) {
120
+ throw new Error(`OIDC discovery fetch returned HTTP ${res.status} for ${discoveryUrl}`);
121
+ }
122
+ let doc;
123
+ try {
124
+ doc = await res.json();
125
+ }
126
+ catch {
127
+ throw new Error(`OIDC discovery document at ${discoveryUrl} is not valid JSON`);
128
+ }
129
+ const jwksUri = resolveJwksUri(doc, issuer, allowInsecure);
130
+ logger.info('Resolved JWKS URI from OIDC discovery', { jwksUri });
131
+ return jwksUri;
132
+ }
@@ -11,11 +11,14 @@ import { connectionPool } from '../lib/ActualConnectionPool.js';
11
11
  import observability from '../observability.js';
12
12
  import config from '../config.js';
13
13
  import { createRemoteJWKSet, jwtVerify } from 'jose';
14
+ import { MCPAuthTokenVerificationError } from 'mcp-auth';
14
15
  import { createMcpAuth } from '../auth/setup.js';
16
+ import { discoverJwksUri } from '../lib/oidc-discovery.js';
17
+ import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
15
18
  import { budgetAclMiddleware } from '../auth/budget-acl.js';
16
19
  import * as https from 'node:https';
17
20
  import * as fs from 'node:fs';
18
- // AsyncLocalStorage for request context — moved to src/lib/requestContext.ts
21
+ // AsyncLocalStorage for request context: moved to src/lib/requestContext.ts
19
22
  // so adapter code can import it without a circular dependency on httpServer.
20
23
  // Re-exported here for backward compatibility with any callers that imported
21
24
  // `requestContext` from this module.
@@ -57,28 +60,75 @@ advertisedUrl) {
57
60
  const requiredScopes = config.OIDC_SCOPES
58
61
  ? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
59
62
  : [];
60
- // Custom jose-based JWT verifier — bypasses mcp-auth's strict PKCE/discovery
63
+ // #245: the strict, closed audience allowlist (OIDC_RESOURCE plus any
64
+ // explicitly configured OIDC_ACCEPTED_AUDIENCES). jose treats the array as
65
+ // match-any membership, so an IdP that puts the client-id in `aud` works
66
+ // while an `aud` outside the set is still rejected (#160 preserved). No
67
+ // wildcard, empties filtered; default is exactly [OIDC_RESOURCE].
68
+ const acceptedAudiences = buildAcceptedAudiences(config.OIDC_RESOURCE, config.OIDC_ACCEPTED_AUDIENCES);
69
+ // #245: fail closed if the allowlist is empty (e.g. a whitespace-only
70
+ // OIDC_RESOURCE that passes the truthy check in setup.ts but trims away).
71
+ // Never fall through to "no audience option", which would disable the #160
72
+ // audience check and accept any aud from the trusted issuer.
73
+ if (acceptedAudiences.length === 0) {
74
+ throw new Error('OIDC mode requires a non-empty OIDC_RESOURCE (the JWT audience). Refusing to start ' +
75
+ 'without audience enforcement, which would allow cross-relying-party token replay (#160/#245).');
76
+ }
77
+ // Custom jose-based JWT verifier that bypasses mcp-auth's strict PKCE/discovery
61
78
  // validation that fails when the IdP (e.g. Casdoor v2.13) doesn't advertise
62
79
  // code_challenge_methods_supported in its discovery document.
63
- // JWKS is fetched lazily on the first request and cached by jose internally.
64
- const jwks = createRemoteJWKSet(new URL(`${config.OIDC_ISSUER}/.well-known/jwks`));
80
+ // #244: resolve the JWKS URI from the issuer's OpenID discovery document
81
+ // instead of a hardcoded `${OIDC_ISSUER}/.well-known/jwks` (which 404s for
82
+ // most IdPs). discoverJwksUri fetches discovery once at startup and fails
83
+ // closed on a non-https issuer, a cross-origin redirect, a missing/invalid
84
+ // jwks_uri, or a jwks_uri on a different host than the issuer. jose's
85
+ // createRemoteJWKSet then fetches the keys lazily and caches them.
86
+ const jwksUri = await discoverJwksUri(config.OIDC_ISSUER, config.OIDC_ALLOW_INSECURE_ISSUER);
87
+ const jwks = createRemoteJWKSet(new URL(jwksUri));
65
88
  const customJwtVerify = async (token) => {
66
89
  // Enforce the audience claim (#160, OWASP A07). Without it, any
67
90
  // signature-valid token from the trusted issuer is accepted, so a token
68
91
  // minted for a different relying party in a shared IdP tenant can be
69
- // replayed against this server. OIDC_RESOURCE is the client-id the IdP
70
- // puts in `aud` (Casdoor sets aud=clientId) and is required in OIDC mode,
71
- // so it is always present here; the spread is defensive. jose throws
72
- // ERR_JWT_CLAIM_VALIDATION_FAILED on a missing or mismatched aud.
73
- const { payload } = await jwtVerify(token, jwks, {
74
- issuer: config.OIDC_ISSUER,
75
- ...(config.OIDC_RESOURCE ? { audience: config.OIDC_RESOURCE } : {}),
76
- });
92
+ // replayed against this server. The accepted set is the strict allowlist
93
+ // built above (#245: OIDC_RESOURCE plus configured extras); it is always
94
+ // non-empty because OIDC_RESOURCE is required in OIDC mode, so the spread
95
+ // is defensive. jose throws ERR_JWT_CLAIM_VALIDATION_FAILED on a missing
96
+ // or out-of-set aud.
97
+ //
98
+ // #244: map a jose verification failure (expired, malformed, wrong aud,
99
+ // bad signature) to MCPAuthTokenVerificationError so mcp-auth's bearer
100
+ // handler returns a clean 401 with a WWW-Authenticate header instead of
101
+ // re-throwing the raw jose error as a 500. Pass only the jose error as
102
+ // the cause (never the raw token), so no token material leaks.
103
+ let payload;
104
+ try {
105
+ ({ payload } = await jwtVerify(token, jwks, {
106
+ issuer: config.OIDC_ISSUER,
107
+ // Guaranteed non-empty by the startup guard above, so audience is
108
+ // always enforced (#160/#245); never omit it.
109
+ audience: acceptedAudiences,
110
+ }));
111
+ }
112
+ catch (err) {
113
+ throw new MCPAuthTokenVerificationError('invalid_token', err instanceof Error ? err : undefined);
114
+ }
115
+ // #244: require a usable `sub`. authenticateRequest (#163) and budget-acl
116
+ // key authorization on req.auth.subject, so a token with no string `sub`
117
+ // cannot be mapped to a principal. Reject it here as an invalid token (a
118
+ // clean 401) rather than letting it through to a downstream subject check.
119
+ if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
120
+ throw new MCPAuthTokenVerificationError('invalid_token');
121
+ }
77
122
  const rawAud = payload.aud;
78
123
  const audience = Array.isArray(rawAud) ? rawAud : (rawAud ? [rawAud] : []);
79
124
  const rawScope = typeof payload.scope === 'string' ? payload.scope : '';
80
125
  return {
81
126
  token,
127
+ // #244: populate subject from the JWT `sub` claim (guaranteed a non-empty
128
+ // string by the check above). mcp-auth sets req.auth to exactly this
129
+ // object, and authenticateRequest (#163) plus budget-acl.ts key off
130
+ // req.auth.subject; without it OIDC mode rejects every request.
131
+ subject: payload.sub,
82
132
  issuer: payload.iss ?? config.OIDC_ISSUER,
83
133
  clientId: audience[0] ?? '',
84
134
  audience,
@@ -94,7 +144,7 @@ advertisedUrl) {
94
144
  requiredScopes,
95
145
  showErrorDetails: process.env.NODE_ENV !== 'production',
96
146
  }), budgetAclMiddleware);
97
- logger.info(`[OIDC] JWT authentication enabled — issuer: ${config.OIDC_ISSUER}`);
147
+ logger.info(`[OIDC] JWT authentication enabled. Issuer: ${config.OIDC_ISSUER}`);
98
148
  }
99
149
  }
100
150
  const transports = new Map();
@@ -352,7 +402,7 @@ advertisedUrl) {
352
402
  // when rejectInit fires (e.g. session init fails before any tools/call
353
403
  // arrives), the rejection would otherwise hit process.on('unhandledRejection')
354
404
  // and exit the server. The original error is already logged inside the
355
- // onsessioninitialized catch block below — we deliberately do NOT re-log here
405
+ // onsessioninitialized catch block below: we deliberately do NOT re-log here
356
406
  // to avoid duplicate noise and any risk of leaking credentials.
357
407
  initPromise.catch(() => { });
358
408
  const transport = new StreamableHTTPServerTransport({
@@ -590,7 +640,7 @@ advertisedUrl) {
590
640
  console.info(`📨 MCP endpoint: ${advertised}`);
591
641
  console.info(`❤️ Health check: ${scheme}://${healthHost}:${port}/health`);
592
642
  if (config.AUTH_PROVIDER === 'oidc') {
593
- logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required — issuer: ${config.OIDC_ISSUER})`);
643
+ logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required; issuer: ${config.OIDC_ISSUER})`);
594
644
  }
595
645
  else if (config.MCP_SSE_AUTHORIZATION) {
596
646
  logger.info(`🔒 HTTP authentication enabled (static Bearer token required)`);
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.7.8",
4
+ "version": "0.7.11",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,14 +30,14 @@
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/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.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/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/payees_delete.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/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/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/tool_count_sync.test.js && node tests/unit/stdio_transport.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",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/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/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/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/payees_delete.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/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/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/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/tool_count_sync.test.js && node tests/unit/stdio_transport.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",
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",
37
37
  "test:e2e:docker:smoke": "./tests/e2e/run-docker-e2e.sh smoke",
38
38
  "test:e2e:docker:full": "./tests/e2e/run-docker-e2e.sh full",
39
39
  "test:all": "npm run test:adapter && npm run test:unit-js && npm run test:e2e:docker:smoke",
40
- "test:mcp-client": "npm run build && node scripts/register-tsconfig-paths.js -- --http --test-mcp-client",
40
+ "test:mcp-client": "npm run build && MCP_ALLOW_UNAUTHENTICATED=true node scripts/register-tsconfig-paths.js -- --http --test-mcp-client",
41
41
  "test:integration": "MCP_TEST_LEVEL=sanity node tests/manual/index.js",
42
42
  "test:integration:sanity": "MCP_TEST_LEVEL=sanity node tests/manual/index.js",
43
43
  "test:integration:smoke": "MCP_TEST_LEVEL=smoke node tests/manual/index.js",