actual-mcp-server 0.22.0 → 0.22.4

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
@@ -507,9 +507,11 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
507
507
  | `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) |
508
508
  | `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
509
509
  | `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 |
510
- | `OIDC_RESOURCE` | _(none)_ | No | Expected `aud` claim in JWT (your client ID) |
510
+ | `OIDC_RESOURCE` | _(none)_ | If OIDC | This server's canonical URL, the RFC 9728 resource identifier and the expected `aud` (#461). Use the full public MCP endpoint URL, e.g. `https://actual-mcp.example.com/http`, no trailing slash. Must be an absolute http(s) URL (a bare client id refuses to start); put a client-id `aud` in `OIDC_ACCEPTED_AUDIENCES` instead |
511
511
  | `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 |
512
512
  | `OIDC_JWKS_TRUSTED_HOSTS` | _(none)_ | No | Opt-in cross-origin JWKS hosts, comma-separated `host` or `host:port` (#254). For IdPs whose `jwks_uri` lives on another host, e.g. Google needs `www.googleapis.com`. Exact match, no wildcards; empty default keeps same-origin-only |
513
+ | `OIDC_JWKS_URI` | _(none)_ | No | Direct JWKS URL that bypasses OpenID discovery (#462), for issuers with no discovery document (Cloudflare Access: `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`). Same https, credentials and origin-or-trusted-host rules as a discovered `jwks_uri`, plus no query or fragment. When set, `/.well-known/oauth-authorization-server` is not served and `AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo` is refused |
514
+ | `OIDC_TOKEN_SOURCE` | `authorization` | No | Where the bearer JWT is read from (#462): `authorization` (default) or `cf-access-jwt-assertion` for an MCP server behind Cloudflare Access with Managed OAuth (the edge forwards the signed Access JWT in that header; `Authorization` is then never consulted, `OIDC_SCOPES` must be empty, and the origin must be reachable only via Cloudflare). See the Cloudflare recipe in `docs/guides/AI_CLIENT_SETUP.md` |
513
515
  | `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
514
516
  | `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
515
517
  | `AUTH_BUDGET_ACL_SOURCE` | `static` | No | `static` uses the `AUTH_BUDGET_ACL` map above. `actual` derives the ACL from the Actual server's own per-file access list, so revoking someone in Actual takes effect here without a config edit. Requires a multi-user (OpenID) Actual server that was password-bootstrapped first |
@@ -662,13 +664,16 @@ Clients send: `Authorization: Bearer your_token_here`
662
664
  ```bash
663
665
  AUTH_PROVIDER=oidc
664
666
  OIDC_ISSUER=https://sso.yourdomain.com
665
- OIDC_RESOURCE=your-client-id # must match 'aud' JWT claim
666
- OIDC_SCOPES= # leave empty for Casdoor
667
+ OIDC_RESOURCE=https://actual-mcp.yourdomain.com/http # this server's public MCP URL (the expected 'aud')
668
+ OIDC_ACCEPTED_AUDIENCES=your-client-id # only if your IdP puts the client id in 'aud' (#245)
669
+ OIDC_SCOPES= # leave empty for Casdoor
667
670
  ```
668
671
 
672
+ `OIDC_RESOURCE` is the canonical URL of this MCP server (the MCP authorization spec's "canonical server URI" and the RFC 9728 resource identifier), not an IdP client id: the protected-resource metadata route and the expected token audience are both derived from it, and a value that is not an absolute http(s) URL refuses to start with a message naming the variable (#461). Use the full public endpoint URL (`https://host/http`, or whatever `MCP_BRIDGE_HTTP_PATH` / `MCP_HTTP_PATH` advertises) rather than the bare origin: with the endpoint form the server publishes the path-specific document a strict client looks for, and it warns at startup when the two disagree.
673
+
669
674
  **OAuth discovery endpoints (automatic in OIDC mode).** When `AUTH_PROVIDER=oidc`, the server publishes the two metadata documents an OAuth client needs to bootstrap a login, so `mcp-remote` and Claude.ai's native connector can discover the flow without manual endpoint configuration:
670
675
 
671
- - `GET /.well-known/oauth-protected-resource` (RFC 9728): identifies this server as a protected resource and points at your `OIDC_ISSUER` as the authorization server.
676
+ - `GET /.well-known/oauth-protected-resource[<path>]` (RFC 9728): identifies this server as a protected resource and points at your `OIDC_ISSUER` as the authorization server. The route follows `OIDC_RESOURCE`: `https://host/http` is served at `/.well-known/oauth-protected-resource/http` (RFC 9728 section 3.1), the bare origin at the root path. The official MCP SDK tries the path-specific URL first and falls back to the root; clients without that fallback need the endpoint form (#461).
672
677
  - `GET /.well-known/oauth-authorization-server` (RFC 8414, #285): the authorization server metadata (its `authorization_endpoint` / `token_endpoint` / `registration_endpoint`), re-served from your IdP's own OpenID discovery document. This is here because several clients look for it on the resource-server origin, and some IdPs (e.g. Authentik) do not expose it where those clients look. It is fetched once at startup and served verbatim, exposes only endpoints your IdP already publishes publicly, and requires no authentication (a client reads it before it has a token). No extra configuration is needed; it is absent when `AUTH_PROVIDER` is not `oidc`.
673
678
 
674
679
  **Where the per-user budget ACL comes from.** By default the ACL is the `AUTH_BUDGET_ACL` map you maintain by hand. Since v0.10.x it can instead be derived from the Actual server's own per-file access list, so granting or revoking someone in Actual takes effect here without a config edit and a restart:
@@ -781,7 +786,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
781
786
 
782
787
  ---
783
788
 
784
- **Version:** 0.22.0 | **Tool Count:** 81 (verified LibreChat-compatible)
789
+ **Version:** 0.22.4 | **Tool Count:** 81 (verified LibreChat-compatible)
785
790
 
786
791
  ## Sponsor
787
792
 
@@ -1,7 +1,7 @@
1
1
  // src/auth/setup.ts
2
2
  //
3
3
  // Factory for the mcp-auth MCPAuth instance (CF-5: OIDC multi-user auth).
4
- // Returns null when AUTH_PROVIDER !== 'oidc' — existing static Bearer token
4
+ // Returns null when AUTH_PROVIDER !== 'oidc'; the existing static Bearer token
5
5
  // auth (MCP_SSE_AUTHORIZATION) is then used unchanged.
6
6
  //
7
7
  // Uses the mcp-auth "discovery config" approach: OIDC metadata is fetched
@@ -9,14 +9,17 @@
9
9
  import { MCPAuth } from 'mcp-auth';
10
10
  import config from '../config.js';
11
11
  import logger from '../logger.js';
12
+ import { validateOidcResource } from '../lib/oidc-resource.js';
12
13
  let _instance = null;
13
14
  /**
14
15
  * Returns an MCPAuth instance configured for this server's OIDC settings,
15
16
  * or null if AUTH_PROVIDER is not 'oidc'.
16
17
  *
17
- * The instance is a singleton — safe to call multiple times.
18
+ * The instance is a singleton, safe to call multiple times.
18
19
  *
19
- * @throws If AUTH_PROVIDER=oidc but OIDC_ISSUER or OIDC_RESOURCE are unset.
20
+ * @throws If AUTH_PROVIDER=oidc but OIDC_ISSUER or OIDC_RESOURCE are unset, or if
21
+ * OIDC_RESOURCE is not an absolute http(s) URL (#461: mcp-auth derives the RFC 9728
22
+ * well-known route from it and would otherwise throw its own TypeError at startup).
20
23
  */
21
24
  export function createMcpAuth() {
22
25
  if (config.AUTH_PROVIDER !== 'oidc') {
@@ -28,15 +31,21 @@ export function createMcpAuth() {
28
31
  }
29
32
  if (!config.OIDC_RESOURCE) {
30
33
  throw new Error('[OIDC] AUTH_PROVIDER=oidc requires OIDC_RESOURCE to be set. ' +
31
- 'Example: OIDC_RESOURCE=https://actual-mcp.example.com');
34
+ 'Example: OIDC_RESOURCE=https://actual-mcp.example.com/http');
32
35
  }
36
+ // #461: validate the shape BEFORE mcp-auth sees it. The parsed URL is used only for
37
+ // checks and for the log line below (origin plus pathname, so an accepted query
38
+ // component never reaches a log); the RAW string is what MCPAuth receives, because
39
+ // url.href would append a trailing slash to the origin form and split the metadata
40
+ // `resource`, the WWW-Authenticate value and the accepted audience into three strings.
41
+ const resourceUrl = validateOidcResource(config.OIDC_RESOURCE);
33
42
  if (_instance)
34
43
  return _instance;
35
44
  const scopesSupported = config.OIDC_SCOPES
36
45
  ? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
37
46
  : [];
38
- logger.info(`[OIDC] Configuring mcp-auth — issuer: ${config.OIDC_ISSUER}`);
39
- logger.info(`[OIDC] Resource identifier: ${config.OIDC_RESOURCE}`);
47
+ logger.info(`[OIDC] Configuring mcp-auth, issuer: ${config.OIDC_ISSUER}`);
48
+ logger.info(`[OIDC] Resource identifier: ${resourceUrl.origin}${resourceUrl.pathname}`);
40
49
  logger.info(`[OIDC] Scopes required: ${scopesSupported.length ? scopesSupported.join(', ') : '(none)'}`);
41
50
  _instance = new MCPAuth({
42
51
  protectedResources: [
@@ -117,6 +117,18 @@ export const configSchema = z.object({
117
117
  // here; parsed fail-fast by buildTrustedJwksHosts (oidc-discovery.ts) at the
118
118
  // composition root, per the #245 pattern. Default empty: same-origin only.
119
119
  OIDC_JWKS_TRUSTED_HOSTS: z.string().optional(),
120
+ // #462: a DIRECT JWKS URL that bypasses OpenID discovery, for issuers that publish
121
+ // no discovery document (Cloudflare Access: https://<team>.cloudflareaccess.com/cdn-cgi/access/certs).
122
+ // Validated at startup by resolveDirectJwksUri with the same https / credentials /
123
+ // origin-or-trusted-host rules as a discovered jwks_uri, plus no query or fragment.
124
+ // When set, nothing is discovered, so there is no RFC 8414 document to re-serve.
125
+ OIDC_JWKS_URI: z.string().optional(),
126
+ // #462: where the bearer JWT is read from. 'cf-access-jwt-assertion' is Cloudflare's
127
+ // documented pattern for an MCP server behind Access with Managed OAuth: the client's
128
+ // bearer is opaque, the edge forwards a signed JWT in that header, and the origin must
129
+ // validate it. In that mode Authorization is NEVER consulted (one source, one
130
+ // verification, no fallback). Closed enum, default is today's behaviour.
131
+ OIDC_TOKEN_SOURCE: z.enum(['authorization', 'cf-access-jwt-assertion']).default('authorization'),
120
132
  // Comma-separated required scopes (e.g. "read,write"). Optional.
121
133
  OIDC_SCOPES: z.string().optional(),
122
134
  // JSON map of principal → budget sync-ID list for per-user budget ACL.
@@ -231,7 +243,38 @@ export const configSchema = z.object({
231
243
  // Refuse to send the E2E budget encryption password over a plaintext upstream
232
244
  // (#161, CWE-319). If ACTUAL_BUDGET_PASSWORD is set, the default upstream must
233
245
  // be https:// unless ALLOW_INSECURE_UPSTREAM=true is set explicitly.
234
- .refine((cfg) => !cfg.ACTUAL_BUDGET_PASSWORD || cfg.ALLOW_INSECURE_UPSTREAM || !/^http:\/\//i.test(cfg.ACTUAL_SERVER_URL), { message: 'ACTUAL_BUDGET_PASSWORD (E2E encryption) must not be sent over an http:// upstream. Use https:// for ACTUAL_SERVER_URL, or set ALLOW_INSECURE_UPSTREAM=true to override (e.g. a trusted isolated network).' });
246
+ .refine((cfg) => !cfg.ACTUAL_BUDGET_PASSWORD || cfg.ALLOW_INSECURE_UPSTREAM || !/^http:\/\//i.test(cfg.ACTUAL_SERVER_URL), { message: 'ACTUAL_BUDGET_PASSWORD (E2E encryption) must not be sent over an http:// upstream. Use https:// for ACTUAL_SERVER_URL, or set ALLOW_INSECURE_UPSTREAM=true to override (e.g. a trusted isolated network).' })
247
+ // #462: the assertion mode and the direct JWKS knob only mean something under
248
+ // AUTH_PROVIDER=oidc. Under 'none' the static bearer branch authenticates, and
249
+ // silently ignoring the knob would let an operator believe Cloudflare protects an
250
+ // origin that the static token does. Refuse loudly.
251
+ .refine((cfg) => cfg.AUTH_PROVIDER === 'oidc' || (cfg.OIDC_TOKEN_SOURCE === 'authorization' && !cfg.OIDC_JWKS_URI), {
252
+ path: ['OIDC_TOKEN_SOURCE'],
253
+ message: 'OIDC_TOKEN_SOURCE=cf-access-jwt-assertion and OIDC_JWKS_URI require AUTH_PROVIDER=oidc. Under ' +
254
+ 'AUTH_PROVIDER=none the static bearer token is what authenticates, so these settings would be ' +
255
+ 'silently ignored (#462).',
256
+ })
257
+ // #462: the Access application token carries no `scope` claim, so any required
258
+ // scope would make mcp-auth answer 403 missing_required_scopes on EVERY request,
259
+ // invisibly to the 401 tests. Refuse the pairing rather than ship a dead deployment.
260
+ .refine((cfg) => cfg.OIDC_TOKEN_SOURCE !== 'cf-access-jwt-assertion' || !(cfg.OIDC_SCOPES ?? '').trim(), {
261
+ path: ['OIDC_SCOPES'],
262
+ message: 'OIDC_SCOPES must be empty with OIDC_TOKEN_SOURCE=cf-access-jwt-assertion: the Cloudflare Access ' +
263
+ 'assertion carries no scope claim, so every request would be refused with missing_required_scopes (#462).',
264
+ })
265
+ // #462: the UserInfo identity source (#346) needs a resolved discovery document and
266
+ // a bearer that the IdP's UserInfo endpoint accepts. With OIDC_JWKS_URI nothing is
267
+ // discovered, and userinfo-identity.ts would fetch discovery on the REQUEST path
268
+ // against a domain that does not publish it, denying every request with a log that
269
+ // blames UserInfo (the #343 shape). In assertion mode the bearer is the assertion,
270
+ // which Cloudflare's UserInfo does not accept. Either pairing is refused at startup.
271
+ .refine((cfg) => cfg.AUTH_BUDGET_ACL_IDENTITY_SOURCE !== 'userinfo' || (!cfg.OIDC_JWKS_URI && cfg.OIDC_TOKEN_SOURCE === 'authorization'), {
272
+ path: ['AUTH_BUDGET_ACL_IDENTITY_SOURCE'],
273
+ message: 'AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo cannot be combined with OIDC_JWKS_URI (no discovery ' +
274
+ 'document is fetched, so the UserInfo endpoint cannot be resolved) or with ' +
275
+ 'OIDC_TOKEN_SOURCE=cf-access-jwt-assertion (the bearer is the Cloudflare assertion, which the ' +
276
+ "IdP's UserInfo endpoint does not accept). Use AUTH_BUDGET_ACL_IDENTITY_SOURCE=token (#462).",
277
+ });
235
278
  function getConfig() {
236
279
  const result = configSchema.safeParse(process.env);
237
280
  if (!result.success) {
@@ -389,13 +389,29 @@ export async function withActualApi(rawOperation) {
389
389
  // Denial here means the lock is never acquired and no upstream resource is
390
390
  // touched.
391
391
  _enforceBudgetAcl();
392
- // #276: on the FIRST successful op, warn (once) if the Actual server version is outside
393
- // the range this build supports. It runs inside the op while the connection is live and
394
- // reuses it via rawGetServerVersion (NOT the withActualApi-wrapped getServerVersion, which
395
- // would re-enter the lock). The once-guard flips its flag synchronously, so this never
396
- // repeats per session or op. It is advisory: it never throws and never blocks the op.
397
- const operation = async () => {
398
- const result = await rawOperation();
392
+ // #276: on the FIRST successful op, warn (once) if the Actual server version is outside the
393
+ // range this build supports, reusing the live connection via rawGetServerVersion (NOT the
394
+ // withActualApi-wrapped getServerVersion, which would re-enter the lock).
395
+ //
396
+ // It runs AFTER withOpTimeout, not inside it. Review caught that bounding the probe was not
397
+ // enough while it still sat inside the operation's own timeout: its 5 seconds came out of the
398
+ // operation's 30, so an op that took more than ~25s and SUCCEEDED was still reported to the
399
+ // caller as "Actual API operation timed out", and on a deliberately lowered
400
+ // ACTUAL_OP_TIMEOUT_MS the probe alone could consume the entire budget. The timeout text is
401
+ // classed transient, so it would also drop the pooled connection. Still inside withApiLock,
402
+ // because it needs the connection the operation established.
403
+ //
404
+ // WHAT THIS DOES AND DOES NOT BUY, stated precisely because the first version of this comment
405
+ // overclaimed. The probe can no longer FAIL an operation that succeeded. It can still SLOW one,
406
+ // by up to SERVER_VERSION_PROBE_TIMEOUT_MS, and that bound is FIXED: it does not scale down
407
+ // with ACTUAL_OP_TIMEOUT_MS, so an operator who sets the 250ms floor for a fast-fail posture
408
+ // still pays up to ~5s of api-mutex hold against a stalled /info, and on the legacy path can
409
+ // pay it twice in one call (the pre-download site, then here). It is bounded and finite (at
410
+ // most MAX_PROBE_ATTEMPTS times per process, then never again), which is the trade being made:
411
+ // a diagnostic that is occasionally slow beats one that is silently disabled by a single
412
+ // transient failure, which is what the previous design did.
413
+ const runOperation = async () => {
414
+ const result = await withOpTimeout(rawOperation);
399
415
  await checkServerVersionOnce(() => rawGetServerVersion(), logger);
400
416
  return result;
401
417
  };
@@ -411,7 +427,7 @@ export async function withActualApi(rawOperation) {
411
427
  // #390: verify the singleton holds THIS session's budget before the operation runs.
412
428
  // Inside the lock, so no other session can change it in between.
413
429
  await ensureLoadedBudgetMatchesSession();
414
- return await withOpTimeout(operation);
430
+ return await runOperation();
415
431
  }
416
432
  catch (err) {
417
433
  // Only drop the pool connection on errors that suggest the api
@@ -442,7 +458,7 @@ export async function withActualApi(rawOperation) {
442
458
  let forceFullShutdown = false;
443
459
  try {
444
460
  await initActualApiForOperation();
445
- return await withOpTimeout(operation);
461
+ return await runOperation();
446
462
  }
447
463
  catch (err) {
448
464
  // #419: the stdio keep-alive branch leaves the singleton live; on an
@@ -3002,13 +3018,13 @@ export async function runBankSync(accountId) {
3002
3018
  const reset = error?.details?.rateLimitHeaders?.http_x_ratelimit_account_success_reset;
3003
3019
  const retryIn = reset ? ` Retry in ~${Math.ceil(Number(reset) / 60)} minute(s).` : '';
3004
3020
  throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account.${retryIn} ` +
3005
- `(NORDIGEN RATE_LIMIT_EXCEEDED — account success quota exhausted)`);
3021
+ `(NORDIGEN RATE_LIMIT_EXCEEDED: account success quota exhausted)`);
3006
3022
  }
3007
3023
  if (error?.type === 'BankSyncError' ||
3008
3024
  errorMsg.includes('BankSyncError') ||
3009
3025
  errorMsg.includes('NORDIGEN_ERROR') ||
3010
3026
  errorMsg.includes('Failed syncing account')) {
3011
- throw new Error(`Bank sync failed: Provider error — ${errorMsg}`, { cause: error });
3027
+ throw new Error(`Bank sync failed: Provider error: ${errorMsg}`, { cause: error });
3012
3028
  }
3013
3029
  throw new Error(`Bank sync failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
3014
3030
  }
@@ -411,13 +411,6 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
411
411
  throw err;
412
412
  }
413
413
  }
414
- /**
415
- * How long the #453 pre-download version probe may take before it is abandoned. Deliberately a
416
- * CONSTANT and not an env var: it is an advisory diagnostic, nobody should have to tune it, and
417
- * a new config key would need to be threaded through config.ts, .env.example and the README
418
- * table (the config-drift guard enforces all three) to buy nothing.
419
- */
420
- const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
421
414
  /**
422
415
  * #394: the same discipline for an IMPORT.
423
416
  *
@@ -472,27 +465,23 @@ export async function loadBudgetTracked(syncId, encryptionPassword, label = 'dow
472
465
  // Three properties hold it in place:
473
466
  // - OUTSIDE the tracked chain (before the try), because nothing in that chain may take the
474
467
  // api lock or extend the window an abandoned load holds open.
475
- // - BOUNDED by withOpTimeout: this runs inside the process-global api mutex, so an
476
- // unbounded advisory GET would stall every session. If /info is stalled the download was
477
- // going to fail anyway, so the bound costs nothing real.
478
- // - ONCE per process, via the guard's own synchronous flag, which is shared with the post-op
479
- // call site in actual-adapter.ts. Whichever fires first wins; the other becomes a no-op, so
480
- // there is no double warning. This one normally fires first, being earlier.
468
+ // - BOUNDED by the GUARD itself, not here. Review of the first version moved it: this runs
469
+ // inside the process-global api mutex, so an unbounded advisory GET would stall every
470
+ // session, and a bound each call site has to remember is one a call site will forget.
471
+ // - ONCE per process ON SUCCESS, and the qualifier matters. The guard's latch is set only
472
+ // when a version was actually READ (#453 review: latching on the attempt meant one failed
473
+ // probe disabled the warning for the life of the process). It is shared with the post-op
474
+ // call site in actual-adapter.ts, so on success whichever fires first wins and the other is
475
+ // a no-op, and this one normally fires first, being earlier. On FAILURE both sites keep
476
+ // retrying until MAX_PROBE_ATTEMPTS, so an unreachable /info costs up to three bounded
477
+ // probes rather than one. That is the deliberate trade: a bounded, finite cost in exchange
478
+ // for not being silenced by a single transient failure.
481
479
  //
482
480
  // `api.getServerVersion` is read at call time (not destructured) so it needs only the server URL
483
481
  // that api.init() already established: NO budget has to be loaded, which is what makes a probe
484
482
  // before the download possible at all.
485
- await checkServerVersionOnce(() => withOpTimeout(() => api
486
- .getServerVersion(), 'server-version-probe',
487
- // A SHORT dedicated bound, not the general ACTUAL_OP_TIMEOUT_MS (30s by default).
488
- //
489
- // Review of the first version caught the reason: `/info` is an unauthenticated GET on a
490
- // different route from the sync endpoints, so it can stall while the login and download
491
- // are perfectly healthy. At the general bound that stall costs 30s of the process-global
492
- // api mutex, and since a failed probe leaves the guard armed it would be paid again on
493
- // each of the next two loads. An ADVISORY diagnostic must never be able to do that. If
494
- // the probe cannot answer in 5 seconds it has nothing useful to say.
495
- SERVER_VERSION_PROBE_TIMEOUT_MS, 'the server-version probe bound (fixed, not configurable)'), log);
483
+ await checkServerVersionOnce(() => api
484
+ .getServerVersion(), log);
496
485
  try {
497
486
  await trackBudgetMutation(
498
487
  // #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
@@ -32,8 +32,8 @@ export const RAW_ENV_ALLOWLIST = [
32
32
  { name: 'MCP_STDIO_MODE', reason: 'mirrors the --stdio CLI flag, set in-process before the logger import; not a .env knob', readSite: 'logger.ts:345, index.ts', documented: false },
33
33
  // Deferred-config entry: read in src/index.ts to preserve the fast --help exit (#230).
34
34
  { name: 'MCP_BRIDGE_BIND_HOST', reason: 'deferred-config entry (index.ts reads process.env directly, #230)', readSite: 'index.ts' },
35
- { name: 'MCP_HTTP_PATH', reason: 'deferred-config entry; the path the server LISTENS on', readSite: 'index.ts:195' },
36
- { name: 'MCP_BRIDGE_HTTP_PATH', reason: 'deferred-config entry; the path ADVERTISED to clients, falls back to MCP_HTTP_PATH', readSite: 'index.ts:281' },
35
+ { name: 'MCP_HTTP_PATH', reason: 'deferred-config entry; the path the server LISTENS on', readSite: 'index.ts:235' },
36
+ { name: 'MCP_BRIDGE_HTTP_PATH', reason: 'deferred-config entry; the path ADVERTISED to clients, falls back to MCP_HTTP_PATH', readSite: 'index.ts:321' },
37
37
  { name: 'MCP_BRIDGE_PUBLIC_HOST', reason: 'deferred-config entry; advertised public host', readSite: 'index.ts:269, httpServer.ts:581' },
38
38
  { name: 'MCP_BRIDGE_PUBLIC_SCHEME', reason: 'deferred-config entry; advertised scheme override', readSite: 'index.ts' },
39
39
  { name: 'MCP_BRIDGE_USE_TLS', reason: 'deprecated alias of MCP_ENABLE_HTTPS; affects only advertised-scheme detection', readSite: 'index.ts:277' },
@@ -113,15 +113,21 @@ function classifyOne(err) {
113
113
  const e = err;
114
114
  // `.code` FIRST: upstream's api/download-budget and api/load-budget never let a
115
115
  // SyncError escape, they throw a plain Error carrying .code via withErrorCode.
116
+ // `Object.hasOwn`, never a bare truthiness test on the lookup: these are object literals, so
117
+ // they inherit Object.prototype and an error carrying `code: 'constructor'` (or 'toString',
118
+ // 'valueOf') would match, returning a `cause` that is not an InitFailureCause and a sentence
119
+ // that is `undefined`. The stdio handler would then answer a tool call with
120
+ // `{ isError: true, content: [{ type: 'text', text: undefined }] }`. Pre-existing from #438,
121
+ // but #452 is what routes EVERY stdio tool error through here, so it is reachable now.
116
122
  const code = typeof e?.code === 'string' ? e.code : undefined;
117
- if (code && REASON_TO_CAUSE[code])
123
+ if (code && Object.hasOwn(REASON_TO_CAUSE, code))
118
124
  return done(REASON_TO_CAUSE[code]);
119
- if (code && SYSTEM_CODE_TO_CAUSE[code])
125
+ if (code && Object.hasOwn(SYSTEM_CODE_TO_CAUSE, code))
120
126
  return done(SYSTEM_CODE_TO_CAUSE[code]);
121
127
  // `.reason` only as a defensive fallback, for a SyncError that reaches us by
122
128
  // some path that does not go through those two handlers.
123
129
  const reason = typeof e?.reason === 'string' ? e.reason : undefined;
124
- if (reason && REASON_TO_CAUSE[reason])
130
+ if (reason && Object.hasOwn(REASON_TO_CAUSE, reason))
125
131
  return done(REASON_TO_CAUSE[reason]);
126
132
  const message = typeof e?.message === 'string' ? e.message : '';
127
133
  // Our OWN synthesized post-condition error (#396) embeds the upstream reason
@@ -130,7 +136,7 @@ function classifyOne(err) {
130
136
  // handled only the upstream shapes would answer `unknown` for exactly the
131
137
  // failures this ticket is about.
132
138
  const embedded = /Upstream reason: \[([a-z-]+)\]/.exec(message)?.[1];
133
- if (embedded && REASON_TO_CAUSE[embedded])
139
+ if (embedded && Object.hasOwn(REASON_TO_CAUSE, embedded))
134
140
  return done(REASON_TO_CAUSE[embedded]);
135
141
  // Last resort, message shapes. `network-failure` is tested BEFORE the auth
136
142
  // wording because upstream reports an unreachable server as
@@ -130,6 +130,20 @@ export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false, trus
130
130
  catch {
131
131
  throw new Error(`OIDC "jwks_uri" is not a valid URL: ${jwksUri}`);
132
132
  }
133
+ return validateJwksUrl(jwks, issuerUrl, allowInsecure, trustedHosts);
134
+ }
135
+ /**
136
+ * The ONE validation body shared by the discovered path (`resolveJwksUri`) and the
137
+ * direct path (`resolveDirectJwksUri`, #462), so the rules that matter for key-source
138
+ * integrity cannot drift between them: https (loopback or the insecure opt-out only
139
+ * same-origin, never for a trusted cross-origin host), no embedded credentials, and
140
+ * same ORIGIN as the issuer unless the host is in OIDC_JWKS_TRUSTED_HOSTS (#254, with
141
+ * the audit log). Deliberately NOT here: a query or fragment refusal. A discovered
142
+ * jwks_uri is IdP-controlled and a query string on it is legitimate (Azure AD B2C
143
+ * publishes `.../discovery/v2.0/keys?p={policy}`), so that rule lives on the direct
144
+ * path only, where the URL is operator-typed.
145
+ */
146
+ function validateJwksUrl(jwks, issuerUrl, allowInsecure, trustedHosts) {
133
147
  if (jwks.protocol !== 'https:' && !isLoopbackHost(jwks.hostname) && !allowInsecure) {
134
148
  throw new Error(`OIDC "jwks_uri" must use https (got ${jwks.protocol}); refusing to fetch keys over plaintext`);
135
149
  }
@@ -171,6 +185,52 @@ export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false, trus
171
185
  }
172
186
  return jwks.toString();
173
187
  }
188
+ /**
189
+ * #462: validate an operator-supplied DIRECT JWKS URL (OIDC_JWKS_URI), for issuers
190
+ * that publish no discovery document (Cloudflare Access serves its keys at
191
+ * `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`). Two direct-path-only
192
+ * rules first (no query string, no fragment: an operator-typed key URL has no
193
+ * legitimate use for either), then the shared body. `assertSecureIssuer` still runs
194
+ * here because discovery, which would otherwise run it, is skipped. Network-free.
195
+ */
196
+ export function resolveDirectJwksUri(uri, issuer, allowInsecure = false, trustedHosts = []) {
197
+ const issuerUrl = assertSecureIssuer(issuer, allowInsecure);
198
+ if (typeof uri !== 'string' || uri.length === 0) {
199
+ throw new Error('OIDC_JWKS_URI is empty');
200
+ }
201
+ let jwks;
202
+ try {
203
+ jwks = new URL(uri);
204
+ }
205
+ catch {
206
+ throw new Error('OIDC_JWKS_URI is not a valid URL');
207
+ }
208
+ if (jwks.search || jwks.hash) {
209
+ throw new Error(`OIDC_JWKS_URI must not contain a query string or fragment (${jwks.origin}${jwks.pathname}). ` +
210
+ 'An IdP whose key URL needs a query publishes it through discovery; leave OIDC_JWKS_URI unset for it.');
211
+ }
212
+ try {
213
+ return validateJwksUrl(jwks, issuerUrl, allowInsecure, trustedHosts);
214
+ }
215
+ catch (err) {
216
+ // Same rules, named for the knob the operator actually set.
217
+ throw new Error(`OIDC_JWKS_URI rejected: ${err.message}`);
218
+ }
219
+ }
220
+ /**
221
+ * #462: the single JWKS-source decision for startHttpServer. With `directJwksUri` set,
222
+ * validate it and fetch nothing (`metadata: null`: there is no discovery document to
223
+ * re-serve as RFC 8414, and userinfo identity (#346) is refused at config time for this
224
+ * mode). Otherwise delegate to discovery exactly as before. The composition root does
225
+ * not branch on OIDC_JWKS_URI itself. An EMPTY string is unset (the repo convention for
226
+ * optional vars, `OIDC_SCOPES=` included), and the config refines read it the same way.
227
+ */
228
+ export async function resolveJwksSource(opts, fetchImpl = fetch) {
229
+ if (opts.directJwksUri) {
230
+ return { jwksUri: resolveDirectJwksUri(opts.directJwksUri, opts.issuer, opts.allowInsecure, opts.trustedHosts), metadata: null };
231
+ }
232
+ return discoverOidcMetadata(opts.issuer, opts.allowInsecure, opts.trustedHosts, fetchImpl);
233
+ }
174
234
  /**
175
235
  * Fetch the issuer's OpenID discovery document ONCE and return both the raw
176
236
  * document and its validated `jwks_uri` (#285). Fails closed on any problem
@@ -0,0 +1,40 @@
1
+ // src/lib/oidc-error-cause.ts
2
+ //
3
+ // #463: the cause attached to a bearer-auth 401 must not carry the presented token's
4
+ // decoded claims.
5
+ //
6
+ // customJwtVerify wraps a failed jose verification as
7
+ // MCPAuthTokenVerificationError('invalid_token', cause). Outside NODE_ENV=production the
8
+ // server passes showErrorDetails=true and mcp-auth serialises that cause into the 401
9
+ // body with JSON.stringify, which emits every OWN ENUMERABLE property. jose's
10
+ // JWTClaimValidationFailed and JWTExpired both carry `payload`, the decoded token, as
11
+ // exactly such a property, so the body reflected `sub`, `email` and `aud` back to the
12
+ // caller. A Node system error from a failed JWKS fetch would reflect `address`, `port`
13
+ // and `syscall` the same way.
14
+ //
15
+ // This helper is STRUCTURAL rather than a list of jose classes: two classes carry the
16
+ // payload today, a third would be missed by any instanceof list, and plain objects keep
17
+ // it unit-testable without importing jose. It copies an allowlist (`code`, `claim`,
18
+ // `reason`, all strings) plus the message, and nothing else, so `Object.keys` of the
19
+ // serialised cause is always a subset of those three. The claim name stays because it
20
+ // is what an operator debugging an audience mismatch needs (#245); the payload goes.
21
+ //
22
+ // Pure: no imports, no logger, no config. Never log the original error whole either:
23
+ // `payload` is not a key the redaction format recognises.
24
+ const COPIED_KEYS = ['code', 'claim', 'reason'];
25
+ /**
26
+ * A fresh Error carrying only the message and the string-valued `code`, `claim` and
27
+ * `reason` of the thrown value, or undefined when the thrown value is not an object.
28
+ */
29
+ export function stripJoseCause(err) {
30
+ if (typeof err !== 'object' || err === null)
31
+ return undefined;
32
+ const source = err;
33
+ const stripped = new Error(typeof source.message === 'string' ? source.message : undefined);
34
+ for (const key of COPIED_KEYS) {
35
+ const value = source[key];
36
+ if (typeof value === 'string')
37
+ stripped[key] = value;
38
+ }
39
+ return stripped;
40
+ }
@@ -0,0 +1,151 @@
1
+ // src/lib/oidc-resource.ts
2
+ //
3
+ // #461: the OIDC resource identifier (OIDC_RESOURCE) validated and advised on, as PURE
4
+ // functions. This module imports no config and no logger, so every rule here is
5
+ // unit-testable without a server boot; the composition root (`httpServer.ts`) logs what
6
+ // `oidcResourceStartupWarnings` returns and does nothing else with it.
7
+ //
8
+ // WHY THE IDENTIFIER MUST BE A URL. `mcp-auth` derives the RFC 9728 well-known route
9
+ // from `new URL(resource).pathname` (a resource with a path such as `https://host/http`
10
+ // is served at `/.well-known/oauth-protected-resource/http`, RFC 9728 section 3.1) and
11
+ // echoes the configured string verbatim as the document's `resource` value (section 3.3
12
+ // requires it to be identical to what the client inserted the suffix into). A bare
13
+ // client id therefore throws inside the library at startup. Four of our own recipes
14
+ // recommended exactly that value; this validator turns the library's TypeError into a
15
+ // message that names the variable and the fix.
16
+ //
17
+ // WHY http: IS ACCEPTED WITH A WARNING, NOT REFUSED. Unlike OIDC_ISSUER (#244), where a
18
+ // plaintext issuer lets a network attacker swap the JWKS, the resource identifier is a
19
+ // label compared byte for byte against `aud` and published in metadata; the token in
20
+ // transit is protected by MCP_ENABLE_HTTPS or the reverse proxy. Refusing would break
21
+ // working LAN deployments to enforce nothing.
22
+ //
23
+ // WHY THE RAW STRING STILL REACHES mcp-auth. `url.href` appends a trailing slash to the
24
+ // origin form (`https://host` becomes `https://host/`), which would make the metadata
25
+ // `resource`, the `WWW-Authenticate` `resource_metadata` and the accepted-audience set
26
+ // three different strings. The validator returns the URL for checks and for log lines;
27
+ // callers keep passing the raw value on.
28
+ import { isLoopbackHost } from './oidc-discovery.js';
29
+ const VAR = 'OIDC_RESOURCE';
30
+ /** origin plus pathname: what every message prints, so a query or credentials never reach a log. */
31
+ function displayForm(url) {
32
+ return `${url.origin}${url.pathname}`;
33
+ }
34
+ /**
35
+ * Validate OIDC_RESOURCE as an absolute http(s) URL with no embedded credentials, no
36
+ * fragment and no surrounding whitespace. Throws a message that names the variable,
37
+ * the requirement and the recommended form; never echoes a credentialed value.
38
+ */
39
+ export function validateOidcResource(raw) {
40
+ if (!raw) {
41
+ throw new Error(`[OIDC] ${VAR} is not set. It must be the absolute URL of this MCP server, for example https://actual-mcp.example.com/http`);
42
+ }
43
+ if (raw !== raw.trim()) {
44
+ throw new Error(`[OIDC] ${VAR} has surrounding whitespace. The value is published verbatim as the resource identifier, ` +
45
+ 'so it must be the absolute URL with no padding, for example https://actual-mcp.example.com/http');
46
+ }
47
+ let url;
48
+ try {
49
+ url = new URL(raw);
50
+ }
51
+ catch {
52
+ throw new Error(`[OIDC] ${VAR} must be an absolute URL, the canonical URL of this MCP server ` +
53
+ '(for example https://actual-mcp.example.com/http), not a client id. ' +
54
+ 'The RFC 9728 metadata route and the expected token audience are both derived from it.');
55
+ }
56
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
57
+ throw new Error(`[OIDC] ${VAR} must be an absolute http(s) URL (got scheme ${url.protocol}).`);
58
+ }
59
+ // URL.hostname strips credentials, so a credentialed value would pass every later check
60
+ // and could leak into a log line. Do not echo the value.
61
+ if (url.username || url.password) {
62
+ throw new Error(`[OIDC] ${VAR} must not contain embedded credentials (user:pass@host).`);
63
+ }
64
+ // RFC 9728 section 2 and RFC 8707 section 2: no fragment.
65
+ if (url.hash) {
66
+ throw new Error(`[OIDC] ${VAR} must not contain a fragment (${displayForm(url)}#...).`);
67
+ }
68
+ return url;
69
+ }
70
+ /**
71
+ * Advisories about an ACCEPTED identifier: plaintext scheme on a non-loopback host, and a
72
+ * query component (RFC 8707 section 2 SHOULD NOT; mcp-auth publishes it verbatim and it
73
+ * becomes part of the expected `aud`).
74
+ */
75
+ export function resourceSchemeAdvisories(url) {
76
+ const out = [];
77
+ if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
78
+ out.push(`[OIDC] ${VAR} uses plaintext http on a non-loopback host (${displayForm(url)}). ` +
79
+ 'The identifier itself is only a label, but the token that carries it must travel over TLS: ' +
80
+ 'terminate TLS at a reverse proxy or set MCP_ENABLE_HTTPS=true.');
81
+ }
82
+ if (url.search) {
83
+ out.push(`[OIDC] ${VAR} carries a query component (${displayForm(url)}?...). It is published verbatim in the ` +
84
+ 'protected-resource metadata and forms part of the expected aud; never place a secret in it, and ' +
85
+ 'prefer the canonical form without a query.');
86
+ }
87
+ return out;
88
+ }
89
+ /**
90
+ * The path clients reach this server on, from the advertised URL when it parses, else
91
+ * the listen path. `advertisedUrl` is assembled from raw env in index.ts and has never
92
+ * been parsed before this module: a MCP_BRIDGE_HTTP_PATH without a leading slash yields
93
+ * `http://host:3600mcp`, which `new URL` rejects, and that must stay an advisory rather
94
+ * than becoming the first thing that refuses to start.
95
+ */
96
+ export function advertisedPathFrom(advertisedUrl, httpPath) {
97
+ if (advertisedUrl === undefined)
98
+ return { path: httpPath, warning: null };
99
+ try {
100
+ return { path: new URL(advertisedUrl).pathname, warning: null };
101
+ }
102
+ catch {
103
+ return {
104
+ path: httpPath,
105
+ warning: '[OIDC] The advertised MCP URL does not parse as a URL, so the resource-identifier check falls back to ' +
106
+ `the listen path ${httpPath}. Check MCP_BRIDGE_HTTP_PATH (it needs a leading slash) and MCP_BRIDGE_PUBLIC_HOST.`,
107
+ };
108
+ }
109
+ }
110
+ /**
111
+ * Null when the identifier's path equals the advertised MCP path (the form under which
112
+ * mcp-auth serves the path-specific RFC 9728 document a strict client looks for);
113
+ * otherwise one line that names the variable and recommends `<origin><advertisedPath>`.
114
+ */
115
+ export function resourceMetadataWarning(opts) {
116
+ const url = new URL(opts.resource);
117
+ const stripped = (p) => (p.endsWith('/') && p.length > 1 ? p.slice(0, -1) : p);
118
+ // The canonical form has no trailing slash (MCP authorization spec, Canonical Server
119
+ // URI), so the recommendation never carries one even when MCP_BRIDGE_HTTP_PATH does.
120
+ const canonicalPath = stripped(opts.advertisedPath);
121
+ if (url.pathname === opts.advertisedPath || url.pathname === canonicalPath)
122
+ return null;
123
+ const recommended = `${url.origin}${canonicalPath}`;
124
+ const onlyTrailingSlash = stripped(url.pathname) === canonicalPath;
125
+ // Express serves the metadata route with and without the slash, so the slash form is
126
+ // not a discovery 404; the hazard is the audience: a client that canonicalises the
127
+ // resource mints aud=${recommended}, which the closed allowlist then rejects.
128
+ const detail = onlyTrailingSlash
129
+ ? `The only difference is the trailing slash. A client that canonicalises the resource will request a token for ${recommended}, which is not in the accepted audience set, so use the form without the slash.`
130
+ : `Protected-resource metadata is served at /.well-known/oauth-protected-resource${url.pathname === '/' ? '' : url.pathname} only; ` +
131
+ `a client that looks for the path-specific document under ${canonicalPath} and does not fall back to the root will fail discovery. ` +
132
+ `Switching changes the expected aud and stops serving the current document, so during the switch add OIDC_ACCEPTED_AUDIENCES=${url.origin}${url.pathname === '/' ? '' : url.pathname} to keep tokens minted for the old value valid.`;
133
+ return (`[OIDC] ${VAR} is ${displayForm(url)} but the MCP endpoint is advertised at ${opts.advertisedPath}. ${detail} ` +
134
+ `Recommended: ${VAR}=${recommended}`);
135
+ }
136
+ /**
137
+ * Everything the composition root should warn about for an ACCEPTED identifier, in one
138
+ * call. Throws only what `validateOidcResource` throws.
139
+ */
140
+ export function oidcResourceStartupWarnings(opts) {
141
+ const url = validateOidcResource(opts.resource);
142
+ const advertised = advertisedPathFrom(opts.advertisedUrl, opts.httpPath);
143
+ const out = [];
144
+ if (advertised.warning)
145
+ out.push(advertised.warning);
146
+ const pathWarning = resourceMetadataWarning({ resource: opts.resource, advertisedPath: advertised.path });
147
+ if (pathWarning)
148
+ out.push(pathWarning);
149
+ out.push(...resourceSchemeAdvisories(url));
150
+ return out;
151
+ }
@@ -0,0 +1,65 @@
1
+ // src/lib/oidc-token-source.ts
2
+ //
3
+ // #462: where the bearer JWT comes from. Cloudflare's documented pattern for an MCP
4
+ // server behind Access with Managed OAuth: the client sends an OPAQUE access token as
5
+ // the bearer, Cloudflare resolves it at the edge and forwards a signed JWT in
6
+ // `Cf-Access-Jwt-Assertion`, and "the MCP server must validate the Access JWT sent in
7
+ // the Cf-Access-Jwt-Assertion header". mcp-auth reads only `headers.authorization`, so
8
+ // this module promotes the assertion into that slot BEFORE `bearerAuth` runs, and
9
+ // everything downstream (signature, issuer, closed audience allowlist, non-empty sub,
10
+ // budget ACL) is the code that already verifies any bearer JWT.
11
+ //
12
+ // ONE token source per mode, ONE verification per request, no fallback (gate decision
13
+ // D4): in assertion mode `Authorization` is never consulted. A missing, empty,
14
+ // whitespace-containing or duplicate assertion header deletes `authorization`, so
15
+ // mcp-auth answers 401 `missing_auth_header`. That is what makes the mode a narrowing
16
+ // and never a widening: a valid JWT arriving as a bearer at the origin with no
17
+ // assertion is the captured-assertion-direct-to-origin case and is refused.
18
+ //
19
+ // Why whitespace is refused rather than trimmed (D3): a compact JWS never contains
20
+ // whitespace, Node's parser already strips OWS, and Node joins DUPLICATE custom headers
21
+ // with ", ". Two assertion headers therefore arrive as one whitespace-containing string
22
+ // and are refused, rather than one of them being silently chosen.
23
+ //
24
+ // Pure: imports neither config, logger nor Express. The composition root passes
25
+ // `config.OIDC_TOKEN_SOURCE` in; `httpServer.ts` never names a header, and adding a
26
+ // second edge value is an enum entry, a table entry here and a SENSITIVE_KEYS entry in
27
+ // logger.ts (a literal there, because logger.ts loads before config.ts). The
28
+ // `satisfies` clause makes a missing table entry a compile error.
29
+ /** Header carrying the JWT for every non-`authorization` source (lowercase, as Node exposes it). */
30
+ export const ASSERTION_HEADERS = {
31
+ 'cf-access-jwt-assertion': 'cf-access-jwt-assertion',
32
+ };
33
+ /**
34
+ * The full value to place in `headers.authorization` for the configured source, or
35
+ * `undefined` when the request must be refused. For `authorization` the existing value
36
+ * is returned untouched (this function is then a no-op for the caller).
37
+ */
38
+ export function promoteAssertionHeader(headers, source) {
39
+ if (source === 'authorization') {
40
+ return typeof headers.authorization === 'string' ? headers.authorization : undefined;
41
+ }
42
+ const raw = headers[ASSERTION_HEADERS[source]];
43
+ if (typeof raw !== 'string' || raw.length === 0)
44
+ return undefined;
45
+ if (/\s/.test(raw))
46
+ return undefined;
47
+ return `Bearer ${raw}`;
48
+ }
49
+ /**
50
+ * Express-shaped middleware that rewrites `req.headers.authorization` in place from the
51
+ * configured source. Mount it FIRST in the same chain as `bearerAuth`. With source
52
+ * `authorization` it is the identity middleware.
53
+ */
54
+ export function createAssertionPromotionMiddleware(source) {
55
+ return function promoteAssertion(req, _res, next) {
56
+ if (source !== 'authorization') {
57
+ const promoted = promoteAssertionHeader(req.headers, source);
58
+ if (promoted === undefined)
59
+ delete req.headers.authorization;
60
+ else
61
+ req.headers.authorization = promoted;
62
+ }
63
+ next();
64
+ };
65
+ }
@@ -11,12 +11,19 @@
11
11
  * - The comparator is PURE (no I/O, no logging), so its truth table is unit-testable.
12
12
  * - It FAILS OPEN: an unparseable version produces no warning. A spurious warning on a
13
13
  * healthy deployment would be worse than staying quiet.
14
- * - The firing is a per-PROCESS once-guard: the flag is flipped SYNCHRONOUSLY before any
15
- * await, so the check runs exactly once no matter how many ops or sessions occur, and it
16
- * reuses the connection the triggering op already established (no extra auth burst,
17
- * avoiding the #127/#134 class). There is NO boot-time probe: index.ts deliberately has
18
- * no startup connection, and a naive boot call would double-init then be torn down by
19
- * shutdownActualApi.
14
+ * - The firing is a per-PROCESS once-guard ON SUCCESS, and the qualifier is #453's change:
15
+ * `checked` is set only once a version has actually been READ, with `inFlight` providing the
16
+ * concurrency property the old synchronous latch provided and `attempts` capping the retries
17
+ * at MAX_PROBE_ATTEMPTS. A FAILING probe is therefore retried up to that cap instead of
18
+ * silencing the warning for the life of the process, which is what the synchronous latch did
19
+ * once the probe moved ahead of the first budget download. There are TWO call sites now: the
20
+ * pre-download one in budgetLoader (which issues its own bounded /info read) and the original
21
+ * post-op one in withActualApi (which reuses the connection the triggering op established, so
22
+ * it adds no auth burst, avoiding the #127/#134 class). NEITHER call site bounds its reader:
23
+ * the bound lives in `raceWithBound` below, deliberately, so the set of call sites cannot
24
+ * matter. Do not delete it on the strength of a caller looking safe. There is still NO boot-time probe:
25
+ * index.ts deliberately has no startup connection, and a naive boot call would double-init
26
+ * then be torn down by shutdownActualApi.
20
27
  */
21
28
  import { SUPPORTED_ACTUAL_SERVER_RANGE } from './constants.js';
22
29
  import { INSTALLED_API_VERSION } from './installed-api-version.js';
@@ -99,11 +106,63 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
99
106
  // ---------------------------------------------------------------------------
100
107
  // Per-process once-guard.
101
108
  // ---------------------------------------------------------------------------
109
+ /**
110
+ * How long a version probe may take before it is abandoned. Deliberately a CONSTANT and not an
111
+ * env var: it is an advisory diagnostic, nobody should have to tune it, and a new config key would
112
+ * need threading through config.ts, .env.example and the README table (the config-drift guard
113
+ * enforces all three) to buy nothing. 5 seconds is far more than a healthy /info needs.
114
+ */
115
+ export const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
102
116
  let checked = false; // a version WAS read and judged: never check again
103
117
  let inFlight = false; // a check is running right now: a concurrent caller must not duplicate it
104
118
  let attempts = 0; // failed attempts, so an unreachable /info cannot be probed forever
105
119
  /** Failed probes tolerated before the check gives up for the process. */
106
120
  const MAX_PROBE_ATTEMPTS = 3;
121
+ /**
122
+ * Bound the read WITHOUT importing `withOpTimeout`.
123
+ *
124
+ * The obvious implementation reuses that helper, and review caught what it drags in: `opTimeout`
125
+ * imports `config.ts`, which Zod-validates the environment AT MODULE LOAD and hard-exits when it
126
+ * is missing. That breaks this module's own design note, that the comparator is PURE and
127
+ * unit-testable without I/O, and it makes `tests/unit/server_version_guard.test.js` unable to
128
+ * load at all without an environment.
129
+ *
130
+ * ONE CORRECTION to how this was first justified, since a wrong reason in a comment outlives the
131
+ * observation that produced it: the original wording claimed the import turned the unit chain
132
+ * "red locally, green in CI". The chain ALREADY dies without env vars, earlier, at
133
+ * `config_https_validation.test.js`. So the import does not change what the chain does on a bare
134
+ * machine. The property is still worth enforcing (a pure comparator that any harness can import
135
+ * and test in isolation), the drama was not.
136
+ *
137
+ * So the bound is an inline race with a fixed constant, and this module keeps importing nothing
138
+ * but its two pure siblings. The timer is always cleared, so a fast read leaves nothing pending
139
+ * to hold the process open.
140
+ */
141
+ async function raceWithBound(read) {
142
+ let timer;
143
+ try {
144
+ return await Promise.race([
145
+ read(),
146
+ new Promise((_resolve, reject) => {
147
+ timer = setTimeout(() => reject(new Error(`server-version probe exceeded ${SERVER_VERSION_PROBE_TIMEOUT_MS}ms`)), SERVER_VERSION_PROBE_TIMEOUT_MS);
148
+ // NOT unref'd, and that is deliberate. `unref` was the first instinct ("never hold the
149
+ // process open for an advisory diagnostic") and it quietly breaks the bound: an unref'd
150
+ // timer does not keep the event loop alive, so in a process with nothing else pending
151
+ // Node exits before it fires and the race never rejects. The test that pins this bound
152
+ // caught exactly that, as an unsettled top-level await. A live server always has other
153
+ // work, so it would have fired there and the hazard would have stayed hidden.
154
+ //
155
+ // The cost of keeping it ref'd is bounded by construction: at most
156
+ // SERVER_VERSION_PROBE_TIMEOUT_MS, and the `finally` below clears it as soon as the read
157
+ // answers, so a healthy probe leaves nothing pending at all.
158
+ }),
159
+ ]);
160
+ }
161
+ finally {
162
+ if (timer)
163
+ clearTimeout(timer);
164
+ }
165
+ }
107
166
  /**
108
167
  * Run the compatibility check at most once per process. Call this from inside a successful
109
168
  * `withActualApi` operation (while the connection is live), passing a function that reads the
@@ -129,13 +188,19 @@ bundledApi = INSTALLED_API_VERSION) {
129
188
  // the process, including #276's surviving post-op call site. The deployment that silenced it
130
189
  // is precisely the one the guard exists for.
131
190
  //
191
+ // THE BOUND IS APPLIED HERE, not by the caller. It started at the call sites and review was
192
+ // right that prose cannot hold it: `inFlight` is cleared in a `finally`, so a reader whose
193
+ // promise NEVER settles leaves it true for the life of the process and permanently disables the
194
+ // warning, which is the very failure this rework removed, just relocated. Bounding inside makes
195
+ // the set of call sites stop mattering, the same argument #393 used for the abandoned-load wait.
196
+ //
132
197
  // So: `inFlight` keeps the concurrency property the synchronous latch was actually providing,
133
198
  // `checked` is now set only once a version was really read and judged, and `attempts` bounds
134
199
  // the retries so an unreachable server costs at most MAX_PROBE_ATTEMPTS extra /info calls per
135
200
  // process rather than one per budget load.
136
201
  inFlight = true;
137
202
  try {
138
- const result = await readVersion();
203
+ const result = await raceWithBound(readVersion);
139
204
  if (!result || 'error' in result || typeof result.version !== 'string') {
140
205
  attempts++;
141
206
  log.debug('[server-version] could not read the Actual server version; skipping the compatibility check');
@@ -147,10 +212,17 @@ bundledApi = INSTALLED_API_VERSION) {
147
212
  log.warn(verdict.message);
148
213
  }
149
214
  }
150
- catch {
215
+ catch (err) {
151
216
  // Advisory only: a failure to check must never surface as an error or affect the op.
217
+ //
218
+ // NAME THE CAUSE. Once the bound moved inside this function, "probe exceeded 5000ms" became
219
+ // the most common way through here, and it is indistinguishable from `rawGetServerVersion`
220
+ // being undefined (it is destructured from `api as any` at module load, so an upstream build
221
+ // that stops exporting it throws a TypeError down this same path). An operator wondering why
222
+ // the compatibility warning never appears would have had nothing to go on.
152
223
  attempts++;
153
- log.debug('[server-version] compatibility check threw; ignored');
224
+ const reason = err instanceof Error ? err.message : String(err);
225
+ log.debug(`[server-version] compatibility check failed; ignored: ${reason}`);
154
226
  }
155
227
  finally {
156
228
  inFlight = false;
@@ -145,6 +145,10 @@ const SENSITIVE_KEYS = new Set([
145
145
  'authorization', 'proxy-authorization', 'token', 'password', 'encryptionpassword',
146
146
  'cookie', 'set-cookie', 'secret', 'apikey', 'api_key', 'x-api-key',
147
147
  'access_token', 'refresh_token', 'client_secret',
148
+ // #462: the Cloudflare Access assertion header is a bearer JWT under another name.
149
+ // A literal here rather than the enum in oidc-token-source.ts: logger.ts loads
150
+ // before config.ts and must not import from the tree.
151
+ 'cf-access-jwt-assertion',
148
152
  ]);
149
153
  /** A metadata key is sensitive if its lowercased name is denylisted or ends in a secret suffix. */
150
154
  export function isSensitiveKey(key) {
@@ -13,7 +13,10 @@ import config from '../config.js';
13
13
  import { createRemoteJWKSet, jwtVerify } from 'jose';
14
14
  import { MCPAuthTokenVerificationError } from 'mcp-auth';
15
15
  import { createMcpAuth } from '../auth/setup.js';
16
- import { discoverOidcMetadata, buildTrustedJwksHosts, setResolvedOidcMetadata } from '../lib/oidc-discovery.js';
16
+ import { resolveJwksSource, buildTrustedJwksHosts, setResolvedOidcMetadata } from '../lib/oidc-discovery.js';
17
+ import { createAssertionPromotionMiddleware } from '../lib/oidc-token-source.js';
18
+ import { oidcResourceStartupWarnings } from '../lib/oidc-resource.js';
19
+ import { stripJoseCause } from '../lib/oidc-error-cause.js';
17
20
  import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
18
21
  import { budgetAclMiddleware } from '../auth/budget-acl.js';
19
22
  import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
@@ -59,7 +62,12 @@ advertisedUrl) {
59
62
  // When AUTH_PROVIDER=none (default), the existing static Bearer token check applies.
60
63
  let mcpAuth = null;
61
64
  if (config.AUTH_PROVIDER === 'oidc') {
62
- mcpAuth = createMcpAuth(); // throws if OIDC_ISSUER / OIDC_RESOURCE missing
65
+ mcpAuth = createMcpAuth(); // throws if OIDC_ISSUER / OIDC_RESOURCE missing or malformed (#461)
66
+ // #461: advisories only (the validator inside createMcpAuth already refused anything
67
+ // fatal). Compared against the ADVERTISED path, not the listen path: OIDC_RESOURCE is
68
+ // the public URL, and a reverse proxy may rewrite the path in between.
69
+ for (const w of oidcResourceStartupWarnings({ resource: config.OIDC_RESOURCE, advertisedUrl, httpPath }))
70
+ logger.warn(w);
63
71
  if (mcpAuth) {
64
72
  // Serve RFC 8707 Protected Resource Metadata (/.well-known/oauth-protected-resource/...)
65
73
  app.use(mcpAuth.protectedResourceMetadataRouter());
@@ -93,28 +101,39 @@ advertisedUrl) {
93
101
  // #254: opt-in cross-origin JWKS trusted hosts (e.g. Google serves keys from
94
102
  // www.googleapis.com). Parsed fail-fast here at the composition root and
95
103
  // threaded as a parameter; empty (the default) keeps same-origin-only.
96
- const { jwksUri, metadata: oidcMetadata } = await discoverOidcMetadata(config.OIDC_ISSUER, config.OIDC_ALLOW_INSECURE_ISSUER, buildTrustedJwksHosts(config.OIDC_JWKS_TRUSTED_HOSTS));
97
- // #346: publish the startup-validated document so the ACL's UserInfo path can
98
- // reuse it instead of re-fetching discovery on the authorization path with a
99
- // different (empty) trusted-hosts list.
100
- setResolvedOidcMetadata(oidcMetadata);
101
- // #285: serve RFC 8414 OAuth Authorization Server Metadata at this origin so
102
- // mcp-remote and Claude.ai's native OAuth connector can discover the IdP's
103
- // token_endpoint. We already serve RFC 9728 protected-resource metadata (whose
104
- // authorization_servers points at the IdP), but several clients resolve the
105
- // oauth-authorization-server well-known path against the RESOURCE server root,
106
- // and some IdPs (e.g. Authentik) do not expose it where those clients look.
107
- // The document is the IdP's own public discovery doc, fetched ONCE above (so no
108
- // request-path fetch and no SSRF surface) and re-served verbatim as a BARE JSON
109
- // object. Never emit a {jsonrpc,result} envelope on a well-known metadata path:
110
- // that shape is not RFC-compliant and would break client parsing (the server-info
111
- // probe was moved off this reserved path to /mcp-info as a bare object in #286).
112
- // Unauthenticated by design: a client reads this before it holds a token, so it is
113
- // registered here, before the bearer-auth middleware, and only when
114
- // AUTH_PROVIDER=oidc (this block).
115
- app.get('/.well-known/oauth-authorization-server', (_req, res) => {
116
- res.json(oidcMetadata);
104
+ // #462: ONE decision on where the keys come from. With OIDC_JWKS_URI set the URL
105
+ // is validated (same rules as a discovered jwks_uri, plus no query or fragment)
106
+ // and NOTHING is fetched, so `oidcMetadata` is null: there is no discovery
107
+ // document to publish or re-serve, and this block does not branch on the knob.
108
+ const { jwksUri, metadata: oidcMetadata } = await resolveJwksSource({
109
+ issuer: config.OIDC_ISSUER,
110
+ allowInsecure: config.OIDC_ALLOW_INSECURE_ISSUER,
111
+ trustedHosts: buildTrustedJwksHosts(config.OIDC_JWKS_TRUSTED_HOSTS),
112
+ directJwksUri: config.OIDC_JWKS_URI,
117
113
  });
114
+ if (oidcMetadata !== null) {
115
+ // #346: publish the startup-validated document so the ACL's UserInfo path can
116
+ // reuse it instead of re-fetching discovery on the authorization path with a
117
+ // different (empty) trusted-hosts list.
118
+ setResolvedOidcMetadata(oidcMetadata);
119
+ // #285: serve RFC 8414 OAuth Authorization Server Metadata at this origin so
120
+ // mcp-remote and Claude.ai's native OAuth connector can discover the IdP's
121
+ // token_endpoint. We already serve RFC 9728 protected-resource metadata (whose
122
+ // authorization_servers points at the IdP), but several clients resolve the
123
+ // oauth-authorization-server well-known path against the RESOURCE server root,
124
+ // and some IdPs (e.g. Authentik) do not expose it where those clients look.
125
+ // The document is the IdP's own public discovery doc, fetched ONCE above (so no
126
+ // request-path fetch and no SSRF surface) and re-served verbatim as a BARE JSON
127
+ // object. Never emit a {jsonrpc,result} envelope on a well-known metadata path:
128
+ // that shape is not RFC-compliant and would break client parsing (the server-info
129
+ // probe was moved off this reserved path to /mcp-info as a bare object in #286).
130
+ // Unauthenticated by design: a client reads this before it holds a token, so it is
131
+ // registered here, before the bearer-auth middleware, and only when
132
+ // AUTH_PROVIDER=oidc (this block).
133
+ app.get('/.well-known/oauth-authorization-server', (_req, res) => {
134
+ res.json(oidcMetadata);
135
+ });
136
+ }
118
137
  const jwks = createRemoteJWKSet(new URL(jwksUri));
119
138
  const customJwtVerify = async (token) => {
120
139
  // Enforce the audience claim (#160, OWASP A07). Without it, any
@@ -129,8 +148,10 @@ advertisedUrl) {
129
148
  // #244: map a jose verification failure (expired, malformed, wrong aud,
130
149
  // bad signature) to MCPAuthTokenVerificationError so mcp-auth's bearer
131
150
  // handler returns a clean 401 with a WWW-Authenticate header instead of
132
- // re-throwing the raw jose error as a 500. Pass only the jose error as
133
- // the cause (never the raw token), so no token material leaks.
151
+ // re-throwing the raw jose error as a 500. #463: pass a STRIPPED cause
152
+ // (code, claim, reason only), never the jose error itself: outside
153
+ // production showErrorDetails serialises the cause into the 401 body, and
154
+ // jose's claim errors carry the decoded token as an own property.
134
155
  let payload;
135
156
  try {
136
157
  ({ payload } = await jwtVerify(token, jwks, {
@@ -141,7 +162,7 @@ advertisedUrl) {
141
162
  }));
142
163
  }
143
164
  catch (err) {
144
- throw new MCPAuthTokenVerificationError('invalid_token', err instanceof Error ? err : undefined);
165
+ throw new MCPAuthTokenVerificationError('invalid_token', stripJoseCause(err));
145
166
  }
146
167
  // #244: require a usable `sub`. authenticateRequest (#163) and budget-acl
147
168
  // key authorization on req.auth.subject, so a token with no string `sub`
@@ -168,7 +189,11 @@ advertisedUrl) {
168
189
  claims: payload,
169
190
  };
170
191
  };
171
- app.use(httpPath, mcpAuth.bearerAuth(customJwtVerify, {
192
+ // #462: with OIDC_TOKEN_SOURCE=cf-access-jwt-assertion the signed JWT Cloudflare
193
+ // forwards in that header is promoted into `Authorization` BEFORE bearerAuth
194
+ // reads it, and the opaque client bearer is discarded. With the default source
195
+ // this middleware is the identity function. Never log headers on this path.
196
+ app.use(httpPath, createAssertionPromotionMiddleware(config.OIDC_TOKEN_SOURCE), mcpAuth.bearerAuth(customJwtVerify, {
172
197
  resource: config.OIDC_RESOURCE,
173
198
  // Audience (aud=clientId) is enforced inside customJwtVerify via jose's
174
199
  // jwtVerify audience option (#160), not here.
@@ -176,6 +201,11 @@ advertisedUrl) {
176
201
  showErrorDetails: process.env.NODE_ENV !== 'production',
177
202
  }), budgetAclMiddleware);
178
203
  logger.info(`[OIDC] JWT authentication enabled. Issuer: ${config.OIDC_ISSUER}`);
204
+ // #462 audit line: which header carries the JWT and where the keys come from.
205
+ logger.info('[OIDC] token source and JWKS mode', {
206
+ tokenSource: config.OIDC_TOKEN_SOURCE,
207
+ jwksMode: config.OIDC_JWKS_URI ? `direct: ${jwksUri}` : 'discovered',
208
+ });
179
209
  }
180
210
  }
181
211
  const transports = new Map();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.22.0",
4
+ "version": "0.22.4",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/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/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/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/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/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/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
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",