actual-mcp-server 0.6.33 → 0.6.35

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
@@ -33,14 +33,14 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
33
33
 
34
34
  Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
35
35
 
36
- - **63 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, rules, budgets, batch operations, bank sync, and more. Covers 84% of the Actual Budget API.
36
+ - **69 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers 87% of the Actual Budget API.
37
37
  - **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.
38
38
  - **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.
39
39
  - **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`.
40
40
  - **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
41
41
  - **Production-grade reliability.** Connection pooling (up to 15 concurrent sessions), automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
42
42
 
43
- > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 63 tools tested end-to-end. Any MCP-compatible client should work.
43
+ > **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 69 tools tested end-to-end. Any MCP-compatible client should work.
44
44
 
45
45
  ---
46
46
 
@@ -346,6 +346,26 @@ For Claude Desktop (stdio), restart Claude after upgrading.
346
346
 
347
347
  `actual_payees_get` · `actual_payees_create` · `actual_payees_update` · `actual_payees_delete` · `actual_payees_merge` · `actual_payee_rules_get`
348
348
 
349
+ ### Tags (4)
350
+
351
+ `actual_tags_list` · `actual_tags_create` · `actual_tags_update` · `actual_tags_delete`
352
+
353
+ | Tool | Description |
354
+ |------|-------------|
355
+ | `actual_tags_list` | List all tags (id, tag word, optional color and description) |
356
+ | `actual_tags_create` | Create or upsert a tag by name; returns the tag UUID |
357
+ | `actual_tags_update` | Update tag name, color, or description by UUID |
358
+ | `actual_tags_delete` | Soft-delete a tag by UUID |
359
+
360
+ ### Notes (2)
361
+
362
+ `actual_notes_get` · `actual_notes_update`
363
+
364
+ | Tool | Description |
365
+ |------|-------------|
366
+ | `actual_notes_get` | Get the note for any entity (account/category/category-group/payee UUID, or budget-YYYY-MM) |
367
+ | `actual_notes_update` | Set or clear the note for any entity; validates entity exists or matches budget-YYYY-MM pattern |
368
+
349
369
  ### Budgets (10)
350
370
 
351
371
  | Tool | Description |
@@ -730,4 +750,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
730
750
 
731
751
  ---
732
752
 
733
- **Version:** 0.6.33 | **Tool Count:** 63 (verified LibreChat-compatible)
753
+ **Version:** 0.6.35 | **Tool Count:** 69 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.6.33",
4
+ "version": "0.6.35",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
8
8
  },
9
- "description": "MCP server with 63 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
9
+ "description": "MCP server with 69 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/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js",
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",
@@ -66,6 +66,12 @@ const IMPLEMENTED_TOOLS = [
66
66
  'actual_session_list',
67
67
  'actual_get_id_by_name',
68
68
  'actual_server_get_version',
69
+ 'actual_tags_list',
70
+ 'actual_tags_create',
71
+ 'actual_tags_update',
72
+ 'actual_tags_delete',
73
+ 'actual_notes_get',
74
+ 'actual_notes_update',
69
75
  ];
70
76
  // 🔑 Mapping of Actual API function names → your MCP tool names
71
77
  // This allows us to compare what exists in the API vs. what it has been wrapped.
@@ -6,12 +6,13 @@ import api from '@actual-app/api';
6
6
  // cannot expose its named exports via static import syntax. At runtime the default import
7
7
  // IS module.exports, so all methods are accessible as properties.
8
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
- const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule,
9
+ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote,
10
10
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
11
  } = api;
12
12
  import { EventEmitter } from 'events';
13
13
  import observability from '../observability.js';
14
14
  import retry, { isRetryableError } from './retry.js';
15
+ import { notFoundMsg } from './errors.js';
15
16
  import logger from '../logger.js';
16
17
  import config from '../config.js';
17
18
  import { parseBudgetRegistry } from './budget-registry.js';
@@ -1749,6 +1750,60 @@ export async function getServerVersion() {
1749
1750
  return await withConcurrency(() => retry(() => rawGetServerVersion(), { retries: 2, backoffMs: 200 }));
1750
1751
  });
1751
1752
  }
1753
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1754
+ export async function getTags() {
1755
+ return withActualApi(async () => {
1756
+ observability.incrementToolCall('actual.tags.get').catch(() => { });
1757
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1758
+ return await withConcurrency(() => retry(() => rawGetTags(), { retries: 2, backoffMs: 200 }));
1759
+ });
1760
+ }
1761
+ export async function createTag(tag) {
1762
+ observability.incrementToolCall('actual.tags.create').catch(() => { });
1763
+ return queueWriteOperation(async () => {
1764
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1765
+ const raw = await withConcurrency(() => retry(() => rawCreateTag(tag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1766
+ return normalizeToId(raw);
1767
+ });
1768
+ }
1769
+ export async function updateTag(id, fields) {
1770
+ observability.incrementToolCall('actual.tags.update').catch(() => { });
1771
+ return queueWriteOperation(async () => {
1772
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1773
+ const tags = await withConcurrency(() => retry(() => rawGetTags(), { retries: 2, backoffMs: 200 }));
1774
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1775
+ const exists = tags.some((t) => t.id === id);
1776
+ if (!exists) {
1777
+ throw new Error(notFoundMsg('Tag', id, 'actual_tags_list'));
1778
+ }
1779
+ await withConcurrency(() => retry(() => rawUpdateTag(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1780
+ });
1781
+ }
1782
+ export async function deleteTag(id) {
1783
+ observability.incrementToolCall('actual.tags.delete').catch(() => { });
1784
+ return queueWriteOperation(async () => {
1785
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1786
+ const tags = await withConcurrency(() => retry(() => rawGetTags(), { retries: 2, backoffMs: 200 }));
1787
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1788
+ const exists = tags.some((t) => t.id === id);
1789
+ if (!exists) {
1790
+ throw new Error(notFoundMsg('Tag', id, 'actual_tags_list'));
1791
+ }
1792
+ await withConcurrency(() => retry(() => rawDeleteTag(id), { retries: 0, backoffMs: 200 }));
1793
+ });
1794
+ }
1795
+ export async function getNote(id) {
1796
+ return withActualApi(async () => {
1797
+ observability.incrementToolCall('actual.notes.get').catch(() => { });
1798
+ return await withConcurrency(() => retry(() => rawGetNote(id), { retries: 2, backoffMs: 200 }));
1799
+ });
1800
+ }
1801
+ export async function updateNote(id, note) {
1802
+ observability.incrementToolCall('actual.notes.update').catch(() => { });
1803
+ return queueWriteOperation(async () => {
1804
+ await withConcurrency(() => retry(() => rawUpdateNote(id, note), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1805
+ });
1806
+ }
1752
1807
  export default {
1753
1808
  getAccounts,
1754
1809
  getAccountsWithBalances,
@@ -1760,6 +1815,12 @@ export default {
1760
1815
  createCategory,
1761
1816
  getPayees,
1762
1817
  createPayee,
1818
+ getTags,
1819
+ createTag,
1820
+ updateTag,
1821
+ deleteTag,
1822
+ getNote,
1823
+ updateNote,
1763
1824
  getBudgetMonths,
1764
1825
  getBudgetMonth,
1765
1826
  setBudgetAmount,
@@ -58,6 +58,14 @@ export const ruleIdSchema = z
58
58
  .string()
59
59
  .regex(UUID_PATTERN, 'Invalid rule ID format (expected UUID)')
60
60
  .describe('Rule UUID');
61
+ /**
62
+ * Tag UUID validation
63
+ * Used for: tag management operations (update, delete)
64
+ */
65
+ export const tagIdSchema = z
66
+ .string()
67
+ .regex(UUID_PATTERN, 'Invalid tag ID format (expected UUID)')
68
+ .describe('Tag UUID');
61
69
  // ============================================================================
62
70
  // DATE SCHEMAS
63
71
  // ============================================================================
@@ -169,6 +177,7 @@ export const CommonSchemas = {
169
177
  categoryGroupId: categoryGroupIdSchema,
170
178
  payeeId: payeeIdSchema,
171
179
  ruleId: ruleIdSchema,
180
+ tagId: tagIdSchema,
172
181
  // Dates
173
182
  date: dateSchema,
174
183
  monthYear: monthYearSchema,
@@ -62,3 +62,9 @@ export { default as transactions_update_batch } from './transactions_update_batc
62
62
  export { default as transfers_create } from './transfers_create.js';
63
63
  export { default as session_close } from './session_close.js';
64
64
  export { default as session_list } from './session_list.js';
65
+ export { default as tags_list } from './tags_list.js';
66
+ export { default as tags_create } from './tags_create.js';
67
+ export { default as tags_update } from './tags_update.js';
68
+ export { default as tags_delete } from './tags_delete.js';
69
+ export { default as notes_get } from './notes_get.js';
70
+ export { default as notes_update } from './notes_update.js';
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import adapter from '../lib/actual-adapter.js';
4
+ export default createTool({
5
+ name: 'actual_notes_get',
6
+ description: 'Get the note attached to an entity in Actual Budget. ' +
7
+ 'The id can be any entity UUID (account, category, category-group, payee) ' +
8
+ 'or the synthetic budget-month id in the form "budget-YYYY-MM" ' +
9
+ '(e.g. "budget-2026-01" for January 2026). ' +
10
+ 'Returns the note text when one exists. ' +
11
+ 'Returns a clear "no note" result (not null) when no note has been set for the given id.',
12
+ schema: z.object({
13
+ id: z.string().min(1).describe('Entity id: a UUID for an account/category/category-group/payee, ' +
14
+ 'or "budget-YYYY-MM" for a budget month note'),
15
+ }),
16
+ handler: async ({ id }) => {
17
+ const note = await adapter.getNote(id);
18
+ if (note === null) {
19
+ return { found: false, id, note: null, message: `No note set for ${id}` };
20
+ }
21
+ return { found: true, id: note.id, note: note.note };
22
+ },
23
+ examples: [
24
+ {
25
+ description: 'Get note for an account',
26
+ input: { id: '00000000-0000-0000-0000-000000000001' },
27
+ },
28
+ {
29
+ description: 'Get note for a budget month',
30
+ input: { id: 'budget-2026-01' },
31
+ },
32
+ ],
33
+ });
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import adapter from '../lib/actual-adapter.js';
4
+ const BUDGET_MONTH_RE = /^budget-\d{4}-\d{2}$/;
5
+ export default createTool({
6
+ name: 'actual_notes_update',
7
+ description: 'Set or clear the note attached to an entity in Actual Budget. ' +
8
+ 'This is an upsert: creates the note if none exists, updates it if one does. ' +
9
+ 'Pass an empty string for note to clear it. ' +
10
+ 'The id must resolve to a known entity (account, category, category-group, payee) ' +
11
+ 'or match the pattern "budget-YYYY-MM" for a budget month note. ' +
12
+ 'Unknown ids are rejected to prevent orphan notes. ' +
13
+ 'Budget month notes support template directives such as "#template 250" and "#goal 1000".',
14
+ schema: z.object({
15
+ id: z.string().min(1).describe('Entity id: a UUID for an account/category/category-group/payee, ' +
16
+ 'or "budget-YYYY-MM" for a budget month note'),
17
+ note: z.string().describe('Note text to set. Pass an empty string to clear the note.'),
18
+ }),
19
+ handler: async ({ id, note }) => {
20
+ // Fast path: budget-YYYY-MM synthetic ids need no entity lookup.
21
+ if (!BUDGET_MONTH_RE.test(id)) {
22
+ // Validate that the id resolves to a known entity.
23
+ // Fetch in parallel to minimise latency.
24
+ const [accounts, categories, categoryGroups, payees] = await Promise.all([
25
+ adapter.getAccounts(),
26
+ adapter.getCategories(),
27
+ adapter.getCategoryGroups(),
28
+ adapter.getPayees(),
29
+ ]);
30
+ const known = (Array.isArray(accounts) && accounts.some((e) => e.id === id)) ||
31
+ (Array.isArray(categories) && categories.some((e) => e.id === id)) ||
32
+ (Array.isArray(categoryGroups) && categoryGroups.some((e) => e.id === id)) ||
33
+ (Array.isArray(payees) && payees.some((e) => e.id === id));
34
+ if (!known) {
35
+ return {
36
+ error: `Entity "${id}" not found. ` +
37
+ 'The id must be a UUID from actual_accounts_list, actual_categories_get, ' +
38
+ 'actual_category_groups_get, or actual_payees_get, ' +
39
+ 'or a budget month id like "budget-2026-01".',
40
+ };
41
+ }
42
+ }
43
+ await adapter.updateNote(id, note);
44
+ return {
45
+ success: true,
46
+ id,
47
+ note,
48
+ cleared: note === '',
49
+ };
50
+ },
51
+ examples: [
52
+ {
53
+ description: 'Set a budget template note for January 2026',
54
+ input: { id: 'budget-2026-01', note: '#template 250' },
55
+ },
56
+ {
57
+ description: 'Clear a note',
58
+ input: { id: 'budget-2026-01', note: '' },
59
+ },
60
+ {
61
+ description: 'Set a note on an account',
62
+ input: { id: '00000000-0000-0000-0000-000000000001', note: 'Reconcile monthly' },
63
+ },
64
+ ],
65
+ });
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import adapter from '../lib/actual-adapter.js';
4
+ export default createTool({
5
+ name: 'actual_tags_create',
6
+ description: 'Create a new tag in Actual Budget. The "tag" field is the raw word WITHOUT a leading "#" character ' +
7
+ '(e.g. use "groceries", not "#groceries"). ' +
8
+ 'IMPORTANT: this operation is an upsert on the tag word. If a tag with the same name already exists ' +
9
+ '(including previously deleted ones), the existing tag is updated and its id is returned. ' +
10
+ 'Creating the same tag name twice always returns the same id. ' +
11
+ 'Color convention is CSS hex (e.g. "#33aa33") but no format is enforced by the API.',
12
+ schema: z.object({
13
+ tag: z.string().min(1, 'tag must not be empty').describe('Tag word without "#" prefix (e.g. "groceries")'),
14
+ color: z.string().optional().describe('Optional color string (convention: CSS hex like "#33aa33")'),
15
+ description: z.string().optional().describe('Optional description of the tag'),
16
+ }),
17
+ handler: async (input) => {
18
+ return await adapter.createTag(input);
19
+ },
20
+ examples: [
21
+ {
22
+ description: 'Create a groceries tag with a green color',
23
+ input: { tag: 'groceries', color: '#33aa33', description: 'Food purchases' },
24
+ },
25
+ {
26
+ description: 'Create a minimal tag (no color or description)',
27
+ input: { tag: 'travel' },
28
+ },
29
+ ],
30
+ });
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import { CommonSchemas } from '../lib/schemas/common.js';
4
+ import adapter from '../lib/actual-adapter.js';
5
+ export default createTool({
6
+ name: 'actual_tags_delete',
7
+ description: 'Delete a tag from Actual Budget by its UUID. ' +
8
+ 'The tag is soft-deleted (tombstoned) and will no longer appear in actual_tags_list. ' +
9
+ 'If the id does not exist, a not-found error is returned. ' +
10
+ 'Use actual_tags_list to find valid tag UUIDs.',
11
+ schema: z.object({
12
+ id: CommonSchemas.tagId,
13
+ }),
14
+ handler: async ({ id }) => {
15
+ await adapter.deleteTag(id);
16
+ return { success: true };
17
+ },
18
+ examples: [
19
+ {
20
+ description: 'Delete a tag by its UUID',
21
+ input: { id: '00000000-0000-0000-0000-000000000001' },
22
+ },
23
+ ],
24
+ });
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import adapter from '../lib/actual-adapter.js';
4
+ export default createTool({
5
+ name: 'actual_tags_list',
6
+ description: 'List all tags defined in Actual Budget. Tags are stored without a leading "#" character ' +
7
+ '(e.g. "groceries", not "#groceries"). Returns id, tag (the word), and optional color and description fields.',
8
+ schema: z.object({}),
9
+ handler: async () => {
10
+ return await adapter.getTags();
11
+ },
12
+ examples: [
13
+ { description: 'List all tags', input: {} },
14
+ ],
15
+ });
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import { createTool } from '../lib/toolFactory.js';
3
+ import { CommonSchemas } from '../lib/schemas/common.js';
4
+ import adapter from '../lib/actual-adapter.js';
5
+ export default createTool({
6
+ name: 'actual_tags_update',
7
+ description: 'Update an existing tag in Actual Budget. At least one of tag/color/description must be provided. ' +
8
+ 'The id must be a valid UUID from actual_tags_list. ' +
9
+ 'If the id does not exist, a not-found error is returned (the API would silently no-op; this tool adds a pre-flight guard). ' +
10
+ 'The "tag" field is the raw word WITHOUT a leading "#" character.',
11
+ schema: z.object({
12
+ id: CommonSchemas.tagId,
13
+ tag: z.string().min(1, 'tag must not be empty').optional().describe('New tag word without "#" prefix'),
14
+ color: z.string().optional().describe('New color string (convention: CSS hex like "#112233")'),
15
+ description: z.string().optional().describe('New description'),
16
+ }).refine((data) => data.tag !== undefined || data.color !== undefined || data.description !== undefined, { message: 'At least one of tag, color, or description must be provided' }),
17
+ handler: async ({ id, tag, color, description }) => {
18
+ const fields = {};
19
+ if (tag !== undefined)
20
+ fields.tag = tag;
21
+ if (color !== undefined)
22
+ fields.color = color;
23
+ if (description !== undefined)
24
+ fields.description = description;
25
+ await adapter.updateTag(id, fields);
26
+ return { success: true };
27
+ },
28
+ examples: [
29
+ {
30
+ description: 'Rename a tag and change its color',
31
+ input: { id: '00000000-0000-0000-0000-000000000001', tag: 'food', color: '#112233' },
32
+ },
33
+ {
34
+ description: 'Update only the description',
35
+ input: { id: '00000000-0000-0000-0000-000000000001', description: 'Updated description' },
36
+ },
37
+ ],
38
+ });
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.6.33",
4
+ "version": "0.6.35",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
7
7
  "npm": ">=10.0.0"
8
8
  },
9
- "description": "MCP server with 63 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
9
+ "description": "MCP server with 69 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/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js",
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",