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 +4 -1
- package/dist/package.json +3 -3
- package/dist/src/config.js +16 -0
- package/dist/src/index.js +23 -3
- package/dist/src/lib/authPosture.js +91 -0
- package/dist/src/lib/oidc-audiences.js +18 -0
- package/dist/src/lib/oidc-discovery.js +132 -0
- package/dist/src/server/httpServer.js +65 -15
- package/package.json +3 -3
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.
|
|
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.
|
|
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",
|
package/dist/src/config.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
64
|
-
|
|
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.
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// ERR_JWT_CLAIM_VALIDATION_FAILED on a missing
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|