actual-mcp-server 0.22.6 → 0.22.8
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 +22 -19
- package/dist/src/actualToolsManager.js +1 -0
- package/dist/src/auth/setup.js +5 -4
- package/dist/src/config.js +3 -1
- package/dist/src/lib/actual-adapter.js +140 -18
- package/dist/src/lib/constants.js +12 -0
- package/dist/src/lib/oidc-resource.js +37 -2
- package/dist/src/lib/oidc-scopes.js +53 -0
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/tool-annotations.js +1 -0
- package/dist/src/server/httpServer.js +18 -4
- package/dist/src/tools/get_context.js +49 -0
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/transactions_update.js +13 -9
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -24,13 +24,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
24
24
|
┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
|
|
25
25
|
│ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
26
26
|
│ LobeChat │ │ Server │ │ Budget │
|
|
27
|
-
│ (remote) │ │ (
|
|
27
|
+
│ (remote) │ │ (82 tools) │ │ Server │
|
|
28
28
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
29
29
|
|
|
30
30
|
┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
|
|
31
31
|
│ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
32
32
|
│ Desktop │ │ Server │ │ Budget │
|
|
33
|
-
│ (local) │ │ (
|
|
33
|
+
│ (local) │ │ (82 tools) │ │ Server │
|
|
34
34
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
35
35
|
```
|
|
36
36
|
|
|
@@ -38,14 +38,14 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
38
38
|
|
|
39
39
|
Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
|
|
40
40
|
|
|
41
|
-
- **
|
|
41
|
+
- **82 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
|
|
42
42
|
- **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
|
|
43
43
|
- **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
|
|
44
44
|
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
|
|
45
45
|
- **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
|
|
46
46
|
- **Production-grade reliability on both transports.** HTTP connection pooling (up to 15 concurrent sessions), and a long-lived stdio process that logs in ONCE and reuses that connection for every tool call instead of re-authenticating per call, so Claude Desktop and Claude Code stay fast and a burst of calls no longer risks a per-call login storm against the upstream limiter. Automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
|
|
47
47
|
|
|
48
|
-
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All
|
|
48
|
+
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 82 tools tested end-to-end. Any MCP-compatible client should work.
|
|
49
49
|
|
|
50
50
|
---
|
|
51
51
|
|
|
@@ -182,7 +182,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
|
|
|
182
182
|
}
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All
|
|
185
|
+
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 82 tools are available.
|
|
186
186
|
>
|
|
187
187
|
> **`MCP_BRIDGE_DATA_DIR` should be an absolute path.** Without one, the data directory resolves relative to wherever the client spawns the process, which can be unpredictable. The directory is created automatically on first run.
|
|
188
188
|
|
|
@@ -308,7 +308,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
308
308
|
|
|
309
309
|
## Available Tools
|
|
310
310
|
|
|
311
|
-
**
|
|
311
|
+
**82 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
312
312
|
|
|
313
313
|
### Accounts (12)
|
|
314
314
|
|
|
@@ -337,10 +337,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
337
337
|
| `actual_transactions_filter` | Filter with advanced criteria |
|
|
338
338
|
| `actual_transactions_create` | Create new transaction(s), including splits (pass a `subtransactions` array) |
|
|
339
339
|
| `actual_transactions_import` | Import and reconcile transactions |
|
|
340
|
-
| `actual_transactions_update` | Update a transaction, or edit the children of an existing split via `subtransactions` |
|
|
340
|
+
| `actual_transactions_update` | Update a transaction, split a plain one in place, or edit the children of an existing split via `subtransactions` |
|
|
341
341
|
| `actual_transactions_delete` | Delete a transaction |
|
|
342
342
|
|
|
343
|
-
> **Split transactions:** pass a `subtransactions` array (each child needs an `amount` in integer cents; `category` and `notes` are optional). The child amounts must sum to the parent `amount` (the server does not enforce this, so the tool does). A split parent carries no category of its own: put categories on the children. `actual_transactions_update`
|
|
343
|
+
> **Split transactions:** pass a `subtransactions` array (each child needs an `amount` in integer cents; `category` and `notes` are optional). The child amounts must sum to the parent `amount` (the server does not enforce this, so the tool does). A split parent carries no category of its own: put categories on the children. `actual_transactions_update` with `subtransactions` edits the children of a transaction that is already a split, or splits a plain transaction IN PLACE: it keeps its id, `imported_id` and cleared state, so a bank-synced transaction can be split without breaking import matching. Its own category is cleared, and a child that names no category inherits it. A split child cannot itself be split. Note: `actual_transactions_import` forwards a `subtransactions` array to the API but does NOT apply the sum check, so use `actual_transactions_create` for guaranteed-balanced splits.
|
|
344
344
|
|
|
345
345
|
**Utility (2)**
|
|
346
346
|
|
|
@@ -453,12 +453,13 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
453
453
|
|
|
454
454
|
`actual_budget_updates_batch`: batch multiple budget updates in one call
|
|
455
455
|
|
|
456
|
-
### Server Information & Lookup (
|
|
456
|
+
### Server Information & Lookup (5)
|
|
457
457
|
|
|
458
458
|
| Tool | Description |
|
|
459
459
|
|------|-------------|
|
|
460
460
|
| `actual_server_info` | Server status, version, build info |
|
|
461
461
|
| `actual_server_get_version` | Actual Budget server version |
|
|
462
|
+
| `actual_get_context` | Retrieve open accounts, category groups with nested categories, and payees (name-sorted, capped by `payeeLimit`) in a single call |
|
|
462
463
|
| `actual_get_id_by_name` | Resolve an exact name → UUID for accounts, categories, payees |
|
|
463
464
|
| `actual_entities_search` | Find accounts/categories/payees by a name pattern (contains/startsWith/endsWith/exact/fuzzy). Fixes "payee not found" from a partial or mistyped name |
|
|
464
465
|
|
|
@@ -508,11 +509,12 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
508
509
|
| `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
|
|
509
510
|
| `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
511
|
| `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 |
|
|
512
|
+
| `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
513
|
| `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
514
|
| `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
|
|
515
|
+
| `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` |
|
|
516
|
+
| `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) |
|
|
517
|
+
| `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
518
|
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
|
|
517
519
|
| `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
520
|
| `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 |
|
|
@@ -600,7 +602,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
|
|
|
600
602
|
- No auth token. Process ownership is the security boundary.
|
|
601
603
|
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
|
|
602
604
|
- The process exits when stdin closes (Claude Desktop shutting down)
|
|
603
|
-
- All
|
|
605
|
+
- All 82 tools are available, identical to HTTP mode
|
|
604
606
|
|
|
605
607
|
**Start manually to verify:**
|
|
606
608
|
|
|
@@ -666,14 +668,15 @@ AUTH_PROVIDER=oidc
|
|
|
666
668
|
OIDC_ISSUER=https://sso.yourdomain.com
|
|
667
669
|
OIDC_RESOURCE=https://actual-mcp.yourdomain.com/http # this server's public MCP URL (the expected 'aud')
|
|
668
670
|
OIDC_ACCEPTED_AUDIENCES=your-client-id # only if your IdP puts the client id in 'aud' (#245)
|
|
669
|
-
OIDC_SCOPES= # leave empty for Casdoor
|
|
671
|
+
OIDC_SCOPES= # leave empty for Casdoor / Cloudflare Access for SaaS
|
|
672
|
+
# OIDC_SCOPES_SUPPORTED=openid,offline_access # extra scopes advertised in discovery without enforcement (#472)
|
|
670
673
|
```
|
|
671
674
|
|
|
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
|
|
675
|
+
`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
676
|
|
|
674
677
|
**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
678
|
|
|
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
|
|
679
|
+
- `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
680
|
- `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
681
|
|
|
679
682
|
**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 +696,7 @@ Three things to know before enabling it:
|
|
|
693
696
|
|
|
694
697
|
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
698
|
|
|
696
|
-
See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) for `AUTH_BUDGET_ACL` format and
|
|
699
|
+
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
700
|
|
|
698
701
|
---
|
|
699
702
|
|
|
@@ -702,7 +705,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
|
|
|
702
705
|
| Command | What It Tests | Requires Live Server |
|
|
703
706
|
|---------|---------------|---------------------|
|
|
704
707
|
| `npm run build` | TypeScript compilation | No |
|
|
705
|
-
| `npm run test:unit-js` |
|
|
708
|
+
| `npm run test:unit-js` | 82-tool smoke, schema validation, auth ACL | No |
|
|
706
709
|
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
|
|
707
710
|
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
|
|
708
711
|
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
|
|
@@ -786,7 +789,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
786
789
|
|
|
787
790
|
---
|
|
788
791
|
|
|
789
|
-
**Version:** 0.22.
|
|
792
|
+
**Version:** 0.22.8 | **Tool Count:** 82 (verified LibreChat-compatible)
|
|
790
793
|
|
|
791
794
|
## Sponsor
|
|
792
795
|
|
|
@@ -85,6 +85,7 @@ const IMPLEMENTED_TOOLS = [
|
|
|
85
85
|
'actual_budgets_export',
|
|
86
86
|
'actual_budgets_import',
|
|
87
87
|
'actual_preferences_get',
|
|
88
|
+
'actual_get_context',
|
|
88
89
|
];
|
|
89
90
|
// 🔑 Mapping of Actual API function names → your MCP tool names
|
|
90
91
|
// This allows us to compare what exists in the API vs. what it has been wrapped.
|
package/dist/src/auth/setup.js
CHANGED
|
@@ -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
|
|
45
|
-
|
|
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: ${
|
|
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
|
{
|
package/dist/src/config.js
CHANGED
|
@@ -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":["*"]}
|
|
@@ -760,7 +760,7 @@ async function shutdownActualApi(opts) {
|
|
|
760
760
|
setApiInitialized(false);
|
|
761
761
|
}
|
|
762
762
|
}
|
|
763
|
-
import { BANK_SYNC_SETTLE_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
|
|
763
|
+
import { BANK_SYNC_SETTLE_MS, SPLIT_VERIFY_INTERVAL_MS, SPLIT_VERIFY_TIMEOUT_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
|
|
764
764
|
// Concurrency limiter extracted to ./actual-adapter/concurrency.ts (#166).
|
|
765
765
|
// Imported for internal use (every method wraps its raw call in withConcurrency)
|
|
766
766
|
// and setMaxConcurrency is re-exported.
|
|
@@ -1646,6 +1646,81 @@ export async function getPayees() {
|
|
|
1646
1646
|
return await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
|
|
1647
1647
|
});
|
|
1648
1648
|
}
|
|
1649
|
+
/**
|
|
1650
|
+
* #484: accounts, category groups and payees in ONE withActualApi session, so one api lock cycle
|
|
1651
|
+
* and one consistent snapshot. The reads are raw and sequential: an `adapter.get*` call here would
|
|
1652
|
+
* nest a session inside this one and deadlock until ACTUAL_OP_TIMEOUT_MS, and Promise.all would buy
|
|
1653
|
+
* nothing because every call serialises on the one api singleton anyway.
|
|
1654
|
+
*/
|
|
1655
|
+
export async function getContext(options) {
|
|
1656
|
+
return withActualApi(async () => {
|
|
1657
|
+
observability.incrementToolCall('actual.context.get').catch(() => { });
|
|
1658
|
+
const includeAccounts = options?.includeAccounts ?? true;
|
|
1659
|
+
const includeCategories = options?.includeCategories ?? true;
|
|
1660
|
+
const includePayees = options?.includePayees ?? true;
|
|
1661
|
+
const includeClosed = options?.includeClosed ?? false;
|
|
1662
|
+
const payeeLimit = options?.payeeLimit ?? 500;
|
|
1663
|
+
const result = {};
|
|
1664
|
+
// Payees need the account listing even when accounts were not requested: upstream getPayees()
|
|
1665
|
+
// drops the transfer payees of DELETED accounts but not of CLOSED ones, so hiding closed
|
|
1666
|
+
// accounts means hiding their transfer payees here too.
|
|
1667
|
+
const closedIds = new Set();
|
|
1668
|
+
if (includeAccounts || (includePayees && !includeClosed)) {
|
|
1669
|
+
const rawAccounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
1670
|
+
const all = Array.isArray(rawAccounts) ? rawAccounts : [];
|
|
1671
|
+
for (const a of all)
|
|
1672
|
+
if (a.closed)
|
|
1673
|
+
closedIds.add(String(a.id));
|
|
1674
|
+
if (includeAccounts) {
|
|
1675
|
+
result.accounts = all
|
|
1676
|
+
.filter((a) => includeClosed || !a.closed)
|
|
1677
|
+
.map((a) => ({
|
|
1678
|
+
id: String(a.id),
|
|
1679
|
+
name: String(a.name ?? ''),
|
|
1680
|
+
offbudget: Boolean(a.offbudget),
|
|
1681
|
+
closed: Boolean(a.closed),
|
|
1682
|
+
}));
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
if (includeCategories) {
|
|
1686
|
+
// No options: upstream then returns hidden groups and categories too, flagged by `hidden`.
|
|
1687
|
+
const rawGroups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
|
|
1688
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1689
|
+
result.categoryGroups = (Array.isArray(rawGroups) ? rawGroups : []).map((g) => ({
|
|
1690
|
+
id: String(g.id),
|
|
1691
|
+
name: String(g.name ?? ''),
|
|
1692
|
+
is_income: Boolean(g.is_income),
|
|
1693
|
+
hidden: Boolean(g.hidden),
|
|
1694
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1695
|
+
categories: (Array.isArray(g.categories) ? g.categories : []).map((c) => ({
|
|
1696
|
+
id: String(c.id),
|
|
1697
|
+
name: String(c.name ?? ''),
|
|
1698
|
+
hidden: Boolean(c.hidden),
|
|
1699
|
+
})),
|
|
1700
|
+
}));
|
|
1701
|
+
}
|
|
1702
|
+
if (includePayees) {
|
|
1703
|
+
const rawPayees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
|
|
1704
|
+
// Name order, case-insensitive, id as the tie-break, so the cut is deterministic. Transfer
|
|
1705
|
+
// payees count toward the cap. Recency is not an option: payees carry no last-used field.
|
|
1706
|
+
const list = (Array.isArray(rawPayees) ? rawPayees : [])
|
|
1707
|
+
.map((p) => ({
|
|
1708
|
+
id: String(p.id),
|
|
1709
|
+
name: String(p.name ?? ''),
|
|
1710
|
+
transfer_acct: p.transfer_acct != null ? String(p.transfer_acct) : null,
|
|
1711
|
+
}))
|
|
1712
|
+
.filter((p) => includeClosed || p.transfer_acct === null || !closedIds.has(p.transfer_acct))
|
|
1713
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1714
|
+
result.payees = list.slice(0, payeeLimit);
|
|
1715
|
+
result.payeesTruncated = list.length > payeeLimit;
|
|
1716
|
+
result.payeeTotal = list.length;
|
|
1717
|
+
if (result.payeesTruncated) {
|
|
1718
|
+
result.hint = `Showing ${result.payees.length} of ${list.length} payees. Use actual_entities_search or actual_get_id_by_name to find the rest.`;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
return result;
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1649
1724
|
/**
|
|
1650
1725
|
* #388: turn an optional FILTER id that is actually a NAME into a refusal that names the id.
|
|
1651
1726
|
*
|
|
@@ -1875,7 +1950,7 @@ export async function updateTransaction(id, fields) {
|
|
|
1875
1950
|
// so an update that changed nothing would otherwise be reported as success. A
|
|
1876
1951
|
// targeted ActualQL query by id keeps this cheap (indexed lookup). #305 extends
|
|
1877
1952
|
// the select to is_parent + amount so the split guards below can run off the
|
|
1878
|
-
// same read (single source; no second out-of-queue read).
|
|
1953
|
+
// same read (single source; no second out-of-queue read); #489 adds is_child.
|
|
1879
1954
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1880
1955
|
const { q } = (await import('@actual-app/api'));
|
|
1881
1956
|
const rows = await withConcurrency(() => retry(async () => {
|
|
@@ -1885,34 +1960,80 @@ export async function updateTransaction(id, fields) {
|
|
|
1885
1960
|
// (which is exactly what broke editing a split). `splits: 'all'` returns
|
|
1886
1961
|
// regular transactions, split parents, and split children as flat rows, so
|
|
1887
1962
|
// the existence + is_parent + amount read is correct for every kind.
|
|
1888
|
-
const res = (await rawRunQuery(q('transactions').options({ splits: 'all' }).filter({ id }).select(['id', 'is_parent', 'amount'])));
|
|
1963
|
+
const res = (await rawRunQuery(q('transactions').options({ splits: 'all' }).filter({ id }).select(['id', 'is_parent', 'is_child', 'amount', 'category'])));
|
|
1889
1964
|
return Array.isArray(res?.data) ? res.data : [];
|
|
1890
1965
|
}, { retries: 2, backoffMs: 200 }));
|
|
1891
1966
|
if (rows.length === 0) {
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
//
|
|
1896
|
-
//
|
|
1897
|
-
//
|
|
1898
|
-
// is
|
|
1899
|
-
//
|
|
1900
|
-
//
|
|
1901
|
-
//
|
|
1967
|
+
// #489: a typed refusal (nothing was written), wording kept byte-identical to what callers saw.
|
|
1968
|
+
throw new NotFoundRefusal('Transaction', id, 'actual_transactions_get', undefined, `Transaction "${id}" not found. Use actual_transactions_get to list transactions.`);
|
|
1969
|
+
}
|
|
1970
|
+
// Split guards, BEFORE the raw write:
|
|
1971
|
+
// (a) a CHILD cannot itself be split: upstream nests one level only.
|
|
1972
|
+
// (b) child amounts must sum to the effective parent amount. The API does not
|
|
1973
|
+
// enforce this; the ground truth is the caller's new `amount` if supplied,
|
|
1974
|
+
// else the stored amount read above.
|
|
1975
|
+
// #489 lifted #305's third rule, which refused a PLAIN target: "converting a plain
|
|
1976
|
+
// transaction into a split via updateTransaction is broken in 26.7.0 (it strands orphan
|
|
1977
|
+
// children)". That is no longer true. Upstream #8207 (`shared/transactions.ts`) converts in
|
|
1978
|
+
// place through makeChild, and a live probe at 26.9.0 confirmed it persists across a fresh
|
|
1979
|
+
// download with the SAME id and `imported_id`, and keeps a transfer balanced by replacing the
|
|
1980
|
+
// single counterpart with one per child. Reusing that path is why #489 is not a create plus
|
|
1981
|
+
// delete: raw `addTransactions` does not run transfers while `deleteTransaction` does, so the
|
|
1982
|
+
// orchestration deleted a transfer's counterpart, and it re-ran rules on the new parent.
|
|
1902
1983
|
const subs = fields?.subtransactions;
|
|
1984
|
+
const row = rows[0];
|
|
1985
|
+
const converting = Boolean(subs) && row.is_parent !== true;
|
|
1903
1986
|
if (subs) {
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
throw new Error(`Transaction "${id}" is not a split. Converting a plain transaction into a split is not supported here; create the split with actual_transactions_create instead.`);
|
|
1987
|
+
if (row.is_child === true) {
|
|
1988
|
+
throw new OutOfRangeRefusal(`Transaction "${id}" is a split child and cannot itself be split. Edit its parent's subtransactions with actual_transactions_update instead.`, id);
|
|
1907
1989
|
}
|
|
1908
1990
|
const providedAmount = fields.amount;
|
|
1909
1991
|
const parentAmount = providedAmount != null ? providedAmount : row.amount;
|
|
1910
1992
|
const sum = subtransactionsSum(subs);
|
|
1911
1993
|
if (sum !== parentAmount) {
|
|
1912
|
-
throw new
|
|
1994
|
+
throw new OutOfRangeRefusal(`Subtransactions must sum to the parent amount. Expected ${parentAmount}, got ${sum}.`, String(sum));
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
// #489: a converted parent keeps its old category upstream (the update path nulls a parent's
|
|
1998
|
+
// category only when `account` is in the diff), while the Actual UI shows a split parent with
|
|
1999
|
+
// none. Clear it in the SAME write, and hand its category to every child that names none, so
|
|
2000
|
+
// the children still inherit it exactly as upstream's makeChild would have done.
|
|
2001
|
+
let payload = fields;
|
|
2002
|
+
if (converting) {
|
|
2003
|
+
const f = fields;
|
|
2004
|
+
const inherited = 'category' in f ? f.category : (row.category ?? null);
|
|
2005
|
+
payload = {
|
|
2006
|
+
...f,
|
|
2007
|
+
category: null,
|
|
2008
|
+
subtransactions: f.subtransactions.map((c) => ('category' in c ? c : { ...c, category: inherited })),
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
await withConcurrency(() => retry(() => rawUpdateTransaction(id, payload), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2012
|
+
if (!converting)
|
|
2013
|
+
return {};
|
|
2014
|
+
// #489: verify AFTER the write. Upstream resolves before the conversion lands (see
|
|
2015
|
+
// SPLIT_VERIFY_TIMEOUT_MS), so success is only claimed once the parent reads as a split
|
|
2016
|
+
// with every requested child. Polling here, inside the drain, also guarantees the change is
|
|
2017
|
+
// local before the drain's api.sync(). A retry after a timeout is safe: the row is then a
|
|
2018
|
+
// split, so a second call EDITS its children rather than creating anything new.
|
|
2019
|
+
const expected = subs.length;
|
|
2020
|
+
const deadline = Date.now() + SPLIT_VERIFY_TIMEOUT_MS;
|
|
2021
|
+
for (;;) {
|
|
2022
|
+
const res = (await rawRunQuery(q('transactions').options({ splits: 'all' })
|
|
2023
|
+
.filter({ $or: [{ id }, { parent_id: id }] })
|
|
2024
|
+
.select(['id', 'is_parent', 'parent_id'])));
|
|
2025
|
+
const data = Array.isArray(res?.data) ? res.data : [];
|
|
2026
|
+
const parent = data.find((r) => r.id === id);
|
|
2027
|
+
const children = data.filter((r) => r.parent_id === id).map((r) => r.id);
|
|
2028
|
+
if (parent?.is_parent === true && children.length === expected) {
|
|
2029
|
+
return { split: { parent: id, children } };
|
|
2030
|
+
}
|
|
2031
|
+
if (Date.now() >= deadline) {
|
|
2032
|
+
throw new Error(`Transaction "${id}" was sent to be split into ${expected} parts, but the split was not visible after ${SPLIT_VERIFY_TIMEOUT_MS}ms (found ${children.length} children), so the outcome is unknown. ` +
|
|
2033
|
+
'Read it back with actual_transactions_get before retrying: if it is already a split, a retry edits its children rather than duplicating them.');
|
|
1913
2034
|
}
|
|
2035
|
+
await new Promise((resolve) => setTimeout(resolve, SPLIT_VERIFY_INTERVAL_MS));
|
|
1914
2036
|
}
|
|
1915
|
-
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1916
2037
|
});
|
|
1917
2038
|
}
|
|
1918
2039
|
export async function updateTransactionBatch(updates) {
|
|
@@ -3571,5 +3692,6 @@ export default {
|
|
|
3571
3692
|
exportBudget,
|
|
3572
3693
|
importBudget,
|
|
3573
3694
|
getPreferences,
|
|
3695
|
+
getContext,
|
|
3574
3696
|
notifications,
|
|
3575
3697
|
};
|
|
@@ -46,6 +46,18 @@ export const BANK_SYNC_SETTLE_MS = 30_000;
|
|
|
46
46
|
* session at the cost of slightly higher latency.
|
|
47
47
|
*/
|
|
48
48
|
export const WRITE_SESSION_DELAY_MS = 100;
|
|
49
|
+
/**
|
|
50
|
+
* #489: how long (ms) to wait for a plain-to-split conversion to become visible.
|
|
51
|
+
*
|
|
52
|
+
* Upstream `api/transaction-update` returns `handlers['transactions-batch-update'](diff)['updated']`
|
|
53
|
+
* without awaiting it (verified at @actual-app/api 26.9.0), so the write lands AFTER the call
|
|
54
|
+
* resolves. Measured live, a read issued immediately after still sees the plain row. The adapter
|
|
55
|
+
* therefore reads the split back, polling every SPLIT_VERIFY_INTERVAL_MS, and refuses to report
|
|
56
|
+
* success if it has not appeared within this bound. A local SQLite write, so milliseconds in
|
|
57
|
+
* practice; the bound only decides how long a genuinely lost write takes to be reported.
|
|
58
|
+
*/
|
|
59
|
+
export const SPLIT_VERIFY_TIMEOUT_MS = 2000;
|
|
60
|
+
export const SPLIT_VERIFY_INTERVAL_MS = 25;
|
|
49
61
|
// ============================================================================
|
|
50
62
|
// MCP SERVER
|
|
51
63
|
// ============================================================================
|
|
@@ -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
|
-
:
|
|
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
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common validation schemas used across MCP tools for Actual Budget.
|
|
5
5
|
* These schemas provide consistent validation, better error messages,
|
|
6
|
-
* and reduce duplication across the
|
|
6
|
+
* and reduce duplication across the 82 tool definitions.
|
|
7
7
|
*/
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { MAX_NAME_LENGTH, MAX_NOTES_LENGTH, DATE_PATTERN, MONTH_PATTERN, UUID_PATTERN } from '../constants.js';
|
|
@@ -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
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createTool } from '../lib/toolFactory.js';
|
|
3
|
+
import adapter from '../lib/actual-adapter.js';
|
|
4
|
+
/**
|
|
5
|
+
* actual_get_context (#484)
|
|
6
|
+
*
|
|
7
|
+
* One call that returns the ids an assistant needs before it can do anything else: accounts,
|
|
8
|
+
* category groups with their categories, and payees, read in a single api session.
|
|
9
|
+
*
|
|
10
|
+
* The name follows the actual_get_id_by_name precedent: a cross-entity snapshot has no single
|
|
11
|
+
* domain noun for actual_{domain}_{action}. Read-only.
|
|
12
|
+
*/
|
|
13
|
+
const schema = z
|
|
14
|
+
.object({
|
|
15
|
+
includeAccounts: z.boolean().optional().default(true)
|
|
16
|
+
.describe('Include accounts (default: true).'),
|
|
17
|
+
includeCategories: z.boolean().optional().default(true)
|
|
18
|
+
.describe('Include category groups with their categories, hidden ones flagged (default: true).'),
|
|
19
|
+
includePayees: z.boolean().optional().default(true)
|
|
20
|
+
.describe('Include payees (default: true).'),
|
|
21
|
+
includeClosed: z.boolean().optional().default(false)
|
|
22
|
+
.describe('Include closed accounts and their transfer payees (default: false).'),
|
|
23
|
+
payeeLimit: z.number().int().min(1).max(2000).optional().default(500)
|
|
24
|
+
.describe('Maximum payees to return, 1 to 2000 (default: 500). Payees are sorted by name; when more exist, payeesTruncated is true and payeeTotal gives the full count. Use actual_entities_search or actual_get_id_by_name for the rest.'),
|
|
25
|
+
})
|
|
26
|
+
.refine((v) => v.includeAccounts || v.includeCategories || v.includePayees, {
|
|
27
|
+
message: 'At least one of includeAccounts, includeCategories or includePayees must be true.',
|
|
28
|
+
path: ['includeAccounts'],
|
|
29
|
+
});
|
|
30
|
+
export default createTool({
|
|
31
|
+
name: 'actual_get_context',
|
|
32
|
+
description: 'Read the budget structure and entity UUIDs (accounts, category groups with nested categories, and payees) in a single call. ' +
|
|
33
|
+
'Call this first to bootstrap context so you have the ids needed for transactions, rules, or budget operations without several list calls. ' +
|
|
34
|
+
'A section you exclude is omitted from the response rather than returned empty. Read-only.',
|
|
35
|
+
schema,
|
|
36
|
+
handler: async (input) => {
|
|
37
|
+
return await adapter.getContext(input);
|
|
38
|
+
},
|
|
39
|
+
examples: [
|
|
40
|
+
{
|
|
41
|
+
description: 'Bootstrap budget context with open accounts, categories, and up to 500 payees',
|
|
42
|
+
input: {},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
description: 'Fetch accounts and categories without reading payees',
|
|
46
|
+
input: { includePayees: false },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
package/dist/src/tools/index.js
CHANGED
|
@@ -80,3 +80,4 @@ export { default as tags_delete } from './tags_delete.js';
|
|
|
80
80
|
export { default as notes_get } from './notes_get.js';
|
|
81
81
|
export { default as notes_update } from './notes_update.js';
|
|
82
82
|
export { default as preferences_get } from './preferences_get.js';
|
|
83
|
+
export { default as get_context } from './get_context.js';
|
|
@@ -24,24 +24,28 @@ const InputSchema = z.object({
|
|
|
24
24
|
transfer_id: z.string().nullable().optional().describe('Transfer transaction ID if this is a transfer'),
|
|
25
25
|
cleared: z.boolean().nullable().optional().describe('Whether transaction is cleared'),
|
|
26
26
|
reconciled: z.boolean().nullable().optional().describe('Whether transaction is reconciled'),
|
|
27
|
-
// #305: edit the children of an
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// the
|
|
31
|
-
//
|
|
27
|
+
// #305: edit the children of an existing split. #489: or split a PLAIN transaction in
|
|
28
|
+
// place (same id, so imported_id and bank-sync matching are kept). The child amounts must
|
|
29
|
+
// sum to the parent amount, and a split child cannot itself be split; both are enforced in
|
|
30
|
+
// the adapter pre-flight (it reads is_parent, is_child and amount), because the stored
|
|
31
|
+
// amount is not part of this input.
|
|
32
32
|
subtransactions: CommonSchemas.subtransactions
|
|
33
33
|
.optional()
|
|
34
|
-
.describe('
|
|
34
|
+
.describe('Split children. On a plain transaction this splits it in place (same id, import fields kept); on a split it replaces the children. Amounts must sum to the parent amount.'),
|
|
35
35
|
}).describe('Fields to update'),
|
|
36
36
|
});
|
|
37
37
|
const tool = {
|
|
38
38
|
name: 'actual_transactions_update',
|
|
39
|
-
description: 'Update an existing transaction in Actual Budget. Provide the transaction ID and the fields you want to update.'
|
|
39
|
+
description: 'Update an existing transaction in Actual Budget. Provide the transaction ID and the fields you want to update. ' +
|
|
40
|
+
'To split an existing transaction across categories in one call, pass fields.subtransactions (amounts summing to the transaction amount): ' +
|
|
41
|
+
'it is split in place, keeping its id and import fields, and the result lists the new child ids. ' +
|
|
42
|
+
'The parent\'s own category is cleared; a child that names no category inherits it.',
|
|
40
43
|
inputSchema: InputSchema,
|
|
41
44
|
call: async (args, _meta) => {
|
|
42
45
|
const input = InputSchema.parse(args || {});
|
|
43
|
-
await adapter.updateTransaction(input.id, input.fields);
|
|
44
|
-
|
|
46
|
+
const split = (await adapter.updateTransaction(input.id, input.fields))?.split;
|
|
47
|
+
// #489: name the children only when a split was CREATED, so the common edit keeps its shape.
|
|
48
|
+
return split ? { success: true, split } : { success: true };
|
|
45
49
|
},
|
|
46
50
|
};
|
|
47
51
|
export default tool;
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.22.
|
|
4
|
+
"version": "0.22.8",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
8
8
|
},
|
|
9
|
-
"description": "MCP server with
|
|
9
|
+
"description": "MCP server with 82 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
|
|
10
10
|
"homepage": "https://github.com/agigante80/actual-mcp-server#readme",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -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/transactions_update_split_convert.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 && node tests/unit/get_context.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",
|