actual-mcp-server 0.22.1 → 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.1 | **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) {
@@ -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' },
@@ -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
+ }
@@ -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.1",
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/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",
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",