actual-mcp-server 0.22.5 → 0.22.7

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
@@ -508,11 +508,12 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
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
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
- | `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 |
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) or the OAuth client's redirect URI (Cloudflare Access for SaaS). 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
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` |
515
- | `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
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, `OIDC_SCOPES_SUPPORTED` is allowed, and the origin must be reachable only via Cloudflare). See the Cloudflare recipe in `docs/guides/AI_CLIENT_SETUP.md` |
515
+ | `OIDC_SCOPES` | _(none)_ | No | Comma-separated required (enforced) scopes, also advertised. Leave empty when the IdP's JWTs carry no `scope` claim (Casdoor, Cloudflare Access for SaaS) |
516
+ | `OIDC_SCOPES_SUPPORTED` | _(none)_ | No | Comma-separated scopes advertised in `scopes_supported` but not enforced. Use when the IdP's JWTs carry no `scope` claim but the client needs a scope such as `offline_access` advertised (e.g. Gemini with Cloudflare Access for SaaS). Required scopes from `OIDC_SCOPES` are always advertised too |
516
517
  | `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
517
518
  | `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 |
518
519
  | `AUTH_BUDGET_ACL_CLAIM` | `auto` | No | Which token claim identifies the principal when the source is `actual`. `auto` mirrors Actual's own precedence (`preferred_username`, `login`, `email`, `id`, `sub`) and matches the result against Actual's `userName`. Set a single claim name to pin one instead |
@@ -666,14 +667,15 @@ AUTH_PROVIDER=oidc
666
667
  OIDC_ISSUER=https://sso.yourdomain.com
667
668
  OIDC_RESOURCE=https://actual-mcp.yourdomain.com/http # this server's public MCP URL (the expected 'aud')
668
669
  OIDC_ACCEPTED_AUDIENCES=your-client-id # only if your IdP puts the client id in 'aud' (#245)
669
- OIDC_SCOPES= # leave empty for Casdoor
670
+ OIDC_SCOPES= # leave empty for Casdoor / Cloudflare Access for SaaS
671
+ # OIDC_SCOPES_SUPPORTED=openid,offline_access # extra scopes advertised in discovery without enforcement (#472)
670
672
  ```
671
673
 
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.
674
+ `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 both the path-specific document (canonical) and a root copy for clients that look only at the root (#473), and it warns at startup when the resource path and advertised path disagree.
673
675
 
674
676
  **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:
675
677
 
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).
678
+ - `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`: with `https://host/http`, the canonical document is served at `/.well-known/oauth-protected-resource/http` (RFC 9728 section 3.1) and a root copy is also served at `/.well-known/oauth-protected-resource` for clients that skip the path-specific URL (e.g. Google Gemini, #473); the bare origin serves only the root path. The official MCP SDK tries the path-specific URL first and falls back to the root.
677
679
  - `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`.
678
680
 
679
681
  **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:
@@ -693,7 +695,7 @@ Three things to know before enabling it:
693
695
 
694
696
  It is opt-in and fails closed: if the budget list cannot be read, or the principal matches no file, access is denied rather than granted.
695
697
 
696
- See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) for `AUTH_BUDGET_ACL` format and Casdoor notes.
698
+ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) for `AUTH_BUDGET_ACL` format, Casdoor notes, and the Cloudflare Access for SaaS + Gemini recipe.
697
699
 
698
700
  ---
699
701
 
@@ -786,7 +788,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
786
788
 
787
789
  ---
788
790
 
789
- **Version:** 0.22.5 | **Tool Count:** 81 (verified LibreChat-compatible)
791
+ **Version:** 0.22.7 | **Tool Count:** 81 (verified LibreChat-compatible)
790
792
 
791
793
  ## Sponsor
792
794
 
@@ -10,6 +10,7 @@ import { MCPAuth } from 'mcp-auth';
10
10
  import config from '../config.js';
11
11
  import logger from '../logger.js';
12
12
  import { validateOidcResource } from '../lib/oidc-resource.js';
13
+ import { parseScopeList, buildScopesSupported } from '../lib/oidc-scopes.js';
13
14
  let _instance = null;
14
15
  /**
15
16
  * Returns an MCPAuth instance configured for this server's OIDC settings,
@@ -41,12 +42,12 @@ export function createMcpAuth() {
41
42
  const resourceUrl = validateOidcResource(config.OIDC_RESOURCE);
42
43
  if (_instance)
43
44
  return _instance;
44
- const scopesSupported = config.OIDC_SCOPES
45
- ? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
46
- : [];
45
+ const requiredScopes = parseScopeList(config.OIDC_SCOPES);
46
+ const scopesSupported = buildScopesSupported(config.OIDC_SCOPES_SUPPORTED, config.OIDC_SCOPES);
47
47
  logger.info(`[OIDC] Configuring mcp-auth, issuer: ${config.OIDC_ISSUER}`);
48
48
  logger.info(`[OIDC] Resource identifier: ${resourceUrl.origin}${resourceUrl.pathname}`);
49
- logger.info(`[OIDC] Scopes required: ${scopesSupported.length ? scopesSupported.join(', ') : '(none)'}`);
49
+ logger.info(`[OIDC] Scopes required: ${requiredScopes.length ? requiredScopes.join(', ') : '(none)'}`);
50
+ logger.info(`[OIDC] Scopes advertised: ${scopesSupported.length ? scopesSupported.join(', ') : '(none)'}`);
50
51
  _instance = new MCPAuth({
51
52
  protectedResources: [
52
53
  {
@@ -11,7 +11,7 @@ import { parseIdentityMap } from './auth/identity-map.js';
11
11
  // accepts as NO cap at all: GHSA-v422-hmwv-36x6 by another route.
12
12
  const BODY_LIMIT_UNITS = { b: 1, kb: 2 ** 10, mb: 2 ** 20, gb: 2 ** 30, tb: 2 ** 40, pb: 2 ** 50 };
13
13
  const BODY_LIMIT_RE = new RegExp(`^(\\d+(?:\\.\\d+)?) *(${Object.keys(BODY_LIMIT_UNITS).join('|')})?$`, 'i');
14
- function parseStrictByteSize(value) {
14
+ export function parseStrictByteSize(value) {
15
15
  const m = BODY_LIMIT_RE.exec(value);
16
16
  if (!m)
17
17
  return null;
@@ -155,8 +155,10 @@ export const configSchema = z.object({
155
155
  // validate it. In that mode Authorization is NEVER consulted (one source, one
156
156
  // verification, no fallback). Closed enum, default is today's behaviour.
157
157
  OIDC_TOKEN_SOURCE: z.enum(['authorization', 'cf-access-jwt-assertion']).default('authorization'),
158
- // Comma-separated required scopes (e.g. "read,write"). Optional.
158
+ // Comma-separated required (enforced) scopes; also advertised (e.g. "read,write"). Optional.
159
159
  OIDC_SCOPES: z.string().optional(),
160
+ // Comma-separated scopes advertised in discovery (scopes_supported) but not enforced. Optional.
161
+ OIDC_SCOPES_SUPPORTED: z.string().optional(),
160
162
  // JSON map of principal → budget sync-ID list for per-user budget ACL.
161
163
  // Keys: email, sub, or "group:<name>". Values: array of sync IDs or ["*"] for all.
162
164
  // Example: {"alice@example.com":["budget-1"],"group:admin":["*"]}
@@ -107,6 +107,39 @@ export function advertisedPathFrom(advertisedUrl, httpPath) {
107
107
  };
108
108
  }
109
109
  }
110
+ const METADATA_ROOT = '/.well-known/oauth-protected-resource';
111
+ /**
112
+ * The route mcp-auth 0.2.0 serves the RFC 9728 document under for `resource` (#473), or
113
+ * null for the origin form, where it serves the root itself. This copies the rule in
114
+ * mcp-auth's `create-resource-metadata-endpoint.js` exactly, raw pathname included: it
115
+ * must NOT use `stripped()` below, because mcp-auth registers the route under the
116
+ * pathname as given, trailing slash and all, and a rewrite aimed anywhere else 404s.
117
+ */
118
+ export function resourceMetadataPath(resource) {
119
+ const { pathname } = new URL(resource);
120
+ return pathname === '/' ? null : `${METADATA_ROOT}${pathname}`;
121
+ }
122
+ /**
123
+ * The request URL a root metadata request is rewritten to (#473): the fixed target plus
124
+ * the original query string, verbatim from the first `?`. Pure so the query handling is
125
+ * testable: mcp-auth's route ignores the query, so a served 200 cannot tell a kept query
126
+ * from a dropped one.
127
+ */
128
+ export function rootMetadataRewriteUrl(url, target) {
129
+ const q = url.indexOf('?');
130
+ return q >= 0 ? `${target}${url.slice(q)}` : target;
131
+ }
132
+ /**
133
+ * True for a request the root rewrite applies to (#473). Exact lowercase path, with or
134
+ * without one trailing slash: Express 5's default non-strict routing already serves
135
+ * mcp-auth's own path-specific route both ways, so the root behaves the same, but it is
136
+ * two exact comparisons rather than a prefix, so no deeper path is ever rewritten. Only
137
+ * GET, HEAD (Express answers it through GET routes) and the OPTIONS CORS preflight.
138
+ */
139
+ export function isRootMetadataRequest(method, path) {
140
+ return ((method === 'GET' || method === 'HEAD' || method === 'OPTIONS') &&
141
+ (path === METADATA_ROOT || path === `${METADATA_ROOT}/`));
142
+ }
110
143
  /**
111
144
  * Null when the identifier's path equals the advertised MCP path (the form under which
112
145
  * mcp-auth serves the path-specific RFC 9728 document a strict client looks for);
@@ -122,13 +155,15 @@ export function resourceMetadataWarning(opts) {
122
155
  return null;
123
156
  const recommended = `${url.origin}${canonicalPath}`;
124
157
  const onlyTrailingSlash = stripped(url.pathname) === canonicalPath;
158
+ const metadataPath = resourceMetadataPath(opts.resource);
125
159
  // Express serves the metadata route with and without the slash, so the slash form is
126
160
  // not a discovery 404; the hazard is the audience: a client that canonicalises the
127
161
  // resource mints aud=${recommended}, which the closed allowlist then rejects.
128
162
  const detail = onlyTrailingSlash
129
163
  ? `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. ` +
164
+ : (metadataPath === null
165
+ ? `Protected-resource metadata is served at the root ${METADATA_ROOT} only; a client that looks for the path-specific document under ${canonicalPath} and does not fall back to the root will fail discovery. `
166
+ : `Protected-resource metadata is served at ${metadataPath} and, for clients that look only there, the root ${METADATA_ROOT} (#473); a client that looks for the path-specific document under ${canonicalPath} will fail discovery. `) +
132
167
  `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
168
  return (`[OIDC] ${VAR} is ${displayForm(url)} but the MCP endpoint is advertised at ${opts.advertisedPath}. ${detail} ` +
134
169
  `Recommended: ${VAR}=${recommended}`);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * OIDC scope parsing and discovery helpers (#472).
3
+ *
4
+ * Separates scopes advertised in discovery (RFC 9728 `scopes_supported`) from
5
+ * scopes required (enforced) on incoming access tokens (`requiredScopes`).
6
+ *
7
+ * Some IdPs (such as Cloudflare Access for SaaS or Casdoor) issue JWTs without
8
+ * a `scope` claim, while clients (such as Google Gemini) require `offline_access`
9
+ * to be advertised in discovery in order to request and receive a refresh token.
10
+ *
11
+ * Pure and side-effect-free for hermetic unit testing.
12
+ */
13
+ /**
14
+ * Split a comma-separated scope string into trimmed, non-empty, de-duplicated tokens,
15
+ * preserving first-seen order.
16
+ *
17
+ * Returns an empty array if `csv` is undefined, empty, or whitespace-only.
18
+ */
19
+ export function parseScopeList(csv) {
20
+ if (!csv)
21
+ return [];
22
+ const seen = new Set();
23
+ const result = [];
24
+ for (const part of csv.split(',')) {
25
+ const trimmed = part.trim();
26
+ if (trimmed && !seen.has(trimmed)) {
27
+ seen.add(trimmed);
28
+ result.push(trimmed);
29
+ }
30
+ }
31
+ return result;
32
+ }
33
+ /**
34
+ * Build the full list of scopes advertised in RFC 9728 `scopes_supported`.
35
+ *
36
+ * Advertised scopes (`OIDC_SCOPES_SUPPORTED`) come first, followed by any
37
+ * required scopes (`OIDC_SCOPES`) not already present. A required scope is
38
+ * always advertised so clients cannot fail to request a scope the server demands.
39
+ * With `OIDC_SCOPES_SUPPORTED` unset, this matches `parseScopeList(requiredCsv)`.
40
+ */
41
+ export function buildScopesSupported(advertisedCsv, requiredCsv) {
42
+ const advertised = parseScopeList(advertisedCsv);
43
+ const required = parseScopeList(requiredCsv);
44
+ const seen = new Set(advertised);
45
+ const result = [...advertised];
46
+ for (const s of required) {
47
+ if (!seen.has(s)) {
48
+ seen.add(s);
49
+ result.push(s);
50
+ }
51
+ }
52
+ return result;
53
+ }
@@ -15,9 +15,10 @@ import { MCPAuthTokenVerificationError } from 'mcp-auth';
15
15
  import { createMcpAuth } from '../auth/setup.js';
16
16
  import { resolveJwksSource, buildTrustedJwksHosts, setResolvedOidcMetadata } from '../lib/oidc-discovery.js';
17
17
  import { createAssertionPromotionMiddleware } from '../lib/oidc-token-source.js';
18
- import { oidcResourceStartupWarnings } from '../lib/oidc-resource.js';
18
+ import { isRootMetadataRequest, oidcResourceStartupWarnings, resourceMetadataPath, rootMetadataRewriteUrl } from '../lib/oidc-resource.js';
19
19
  import { stripJoseCause } from '../lib/oidc-error-cause.js';
20
20
  import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
21
+ import { parseScopeList } from '../lib/oidc-scopes.js';
21
22
  import { budgetAclMiddleware } from '../auth/budget-acl.js';
22
23
  import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
23
24
  import * as https from 'node:https';
@@ -69,12 +70,25 @@ advertisedUrl) {
69
70
  for (const w of oidcResourceStartupWarnings({ resource: config.OIDC_RESOURCE, advertisedUrl, httpPath }))
70
71
  logger.warn(w);
71
72
  if (mcpAuth) {
73
+ // #473: clients that skip the path-specific RFC 9728 discovery URL (e.g. Google Gemini)
74
+ // fetch /.well-known/oauth-protected-resource at the root. When OIDC_RESOURCE has a path,
75
+ // rewrite those requests onto the path-specific route so mcp-auth serves the identical
76
+ // document and CORS headers in-process. The target is fixed at startup from
77
+ // OIDC_RESOURCE, never taken from the request; the match rule is isRootMetadataRequest.
78
+ // Mounted inside `if (mcpAuth)` and before the router, so `none` mode never gets it and
79
+ // clients reach it without a token.
80
+ const metadataTarget = config.OIDC_RESOURCE ? resourceMetadataPath(config.OIDC_RESOURCE) : null;
81
+ if (metadataTarget) {
82
+ app.use((req, _res, next) => {
83
+ if (isRootMetadataRequest(req.method, req.path))
84
+ req.url = rootMetadataRewriteUrl(req.url, metadataTarget);
85
+ next();
86
+ });
87
+ }
72
88
  // Serve RFC 8707 Protected Resource Metadata (/.well-known/oauth-protected-resource/...)
73
89
  app.use(mcpAuth.protectedResourceMetadataRouter());
74
90
  // Protect ALL httpPath routes with JWT validation + budget ACL
75
- const requiredScopes = config.OIDC_SCOPES
76
- ? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
77
- : [];
91
+ const requiredScopes = parseScopeList(config.OIDC_SCOPES);
78
92
  // #245: the strict, closed audience allowlist (OIDC_RESOURCE plus any
79
93
  // explicitly configured OIDC_ACCEPTED_AUDIENCES). jose treats the array as
80
94
  // match-any membership, so an IdP that puts the client-id in `aud` works
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.5",
4
+ "version": "0.22.7",
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/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",
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 && node tests/unit/oidc_scope_list.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",