Project: Actual MCP Server
Purpose: Step-by-step checklist for every new MCP tool added to the project
Last Updated: 2026-06-07
Use this file every time a new tool is added. Print or open it alongside your editor and tick each box before committing. The checklist is ordered so that each step builds on the previous one.
- Tool file created:
src/tools/<domain>_<action>.ts - Exported from
src/tools/index.ts - Added to
IMPLEMENTED_TOOLSinsrc/actualToolsManager.ts - Classified in
src/lib/tool-annotations.ts(#379): put it in exactly one ofREAD_ONLY,DESTRUCTIVEorADDITIVE, and if it writes, decide idempotence.npm run build && node tests/unit/tool_annotations.test.jsFAILS if you skip this (the guard readsdist/, so build first), and it checks the read-only claim against the adapter call graph - Adapter function(s) exist (or created) in
src/lib/actual-adapter.ts -
npm run buildpasses with zero errors
- Input example added to
tests/unit/generated_tools.smoke.test.js(required fields + stub response) - Output shape asserted in smoke test: tool added to
resultWrappers[],successTools[], or a customif (n === '...')block withshapeErr() - Negative-path / schema validation in
tests/unit/schema_validation.test.js(if schema is complex) - Error message quality: for each applicable Zod-layer scenario (wrong date format, unknown field, missing required field, wrong type, invalid enum, out-of-range), assert the error message is actionable, not just that it throws
-
npm run test:unit-jspasses
- Test block added to the appropriate
tests/manual/tests/*.jsmodule - Positive test: call with valid input, assert required output fields are present with correct types and values
- Read-back verification: after every create/update, call the corresponding
_getor_listtool and assert the entity exists with the values you supplied - Optional fields: each optional input that changes output is exercised in at least one positive call, and the response is asserted to reflect it
- Negative test: call with a name / ID that does not exist; assert error is returned AND that the response contains useful context (e.g., list of available values). If the tool silently accepts the bad input (no error returned), record a
// GAP(error-messages): actual_<tool> with bad input: [observed behavior]comment in the test file instead of asserting, and log a⚠warning line. - Error scenario coverage: for each API-layer scenario (invalid/non-existent ID, conflicting fields, duplicate/already-exists, dependency not found) that applies to this tool, add a negative-path block asserting the error message is actionable (not generic). Use
// GAP(error-messages):comments for any silent failures discovered. -
tests/manual/README.mdtest-module table updated if a new file was added -
npm run test:integration:fullpasses against a real server
- New tool added to the correct prompt file in
tests/manual-prompt/(see Step 6 for which file) - Phase header tool count updated in that prompt file
-
tests/manual-prompt/README.mdPhase Overview table total updated - Both a positive scenario and a not-found/negative scenario described in the prompt instructions
- Tool's happy-path call added to
tests/e2e/docker-all-tools.e2e.spec.ts, and confirmed collected withnpx playwright test --list --config playwright.config.docker.ts(#366: the oldtests/e2e/suites/path never executed) -
EXPECTED_TOOL_COUNTdefault bumped intests/manual/tests/sanity.js(the only place that asserts an exact tool count) -
describe('Docker E2E - ALL <N> TOOLS', ...)block-name count updated intests/e2e/docker-all-tools.e2e.spec.ts(cosmetic label only; the spec does not assert a count, andmcp-client.playwright.spec.tsonly checkstools.length > 0) -
npm run test:e2epasses
-
README.md: new tool row added in Available Tools table -
docs/ARCHITECTURE.md: tool listed in domain section (if relevant) -
docs/TESTING_AND_RELIABILITY.md: test file entries updated if new test module added -
npm run docs:syncrun to batch-update all Tool Count: markers
-
npm run build✅ -
npm run test:adapter✅ -
npm run test:unit-js✅ -
npm run verify-tools✅ (confirms tool name matches registered name) -
npm audit --audit-level=moderate✅ - Commit message uses
feat(tools): add <tool_name>format
Location: src/tools/<domain>_<action>.ts
Naming convention: actual_{domain}_{action} (snake_case). Use the exact same name for:
- The file name (
<domain>_<action>.ts) - The
namefield inside the tool definition - The entry in
IMPLEMENTED_TOOLS
Template:
import { z } from 'zod';
import type { ToolDefinition } from '../../types/tool.d.js';
import adapter from '../lib/actual-adapter.js';
import { CommonSchemas } from '../lib/schemas/common.js';
const InputSchema = z.object({
// Use CommonSchemas for shared types: accountId, amountCents, date, etc.
name: z.string().describe('Human-readable name to look up'),
type: z.enum(['accounts', 'categories', 'payees']).describe('Entity type'),
});
const tool: ToolDefinition = {
name: 'actual_example_tool',
description: 'One-line description. Amount in cents (negative=expense). Dates: YYYY-MM-DD.',
inputSchema: InputSchema,
call: async (args: unknown) => {
const input = InputSchema.parse(args);
return await adapter.someMethod(input);
},
};
export default tool;Preferred for new tools: use
createTool()fromsrc/lib/toolFactory.tsinstead of a rawToolDefinition. It wires up error handling, logging, and per-tool observability counters automatically. The legacy template above still works and matches most existing tools, but prefer the factory for anything new. See the "Tool Structure Pattern" section in CLAUDE.md.
Checklist for the tool file:
- Amounts always in cents (integer), never decimal dollars
- Dates always
YYYY-MM-DD - UUIDs validated with
UUID_PATTERNfromsrc/lib/constants.tswhen needed - Error messages are user-facing; make them actionable
- When a lookup returns no results, include a list of available values in the error response (see Negative Test section below)
Export from src/tools/index.ts:
export { default as example_tool } from './example_tool.js';Register in src/actualToolsManager.ts by adding the tool name to IMPLEMENTED_TOOLS:
const IMPLEMENTED_TOOLS = [
// ...existing tools...
'actual_example_tool', // ← add here, alphabetical within domain
];Verify with:
npm run verify-toolsAdd the tool to the right sets in src/lib/tool-annotations.ts. This is what the server
PUBLISHES to clients as MCP annotations, so a model can tell a read from a delete before
calling it.
- If it changes nothing at all, add it to
READ_ONLY. Claim this only when you are certain: a wrongreadOnlyHint: trueis worse than no annotation, because clients use it to decide what needs confirming. - If it writes, decide two things. Is it destructive (removes or replaces data:
the
*_deletefamily,payees_merge,budgets_import, andaccounts_close, which REMOVES a zero-transaction account)? Is it idempotent (does calling it twice with the same arguments leave the same state)? Creates are not idempotent;budgets_holdForNextMonthis not either, because upstream ADDS to the buffer. -
openWorldHintis handled for you: everything is a closed world exceptactual_bank_sync. Only touchOPEN_WORLDif your tool reaches a third-party service. - Run
npm run build && node tests/unit/tool_annotations.test.js. The guard reads the compiled table fromdist/, so an unbuilt tree validates the PREVIOUS classification. It derives read-versus-write from the adapter call graph and will tell you if your classification disagrees with your code.
Do not make any code in src/ branch on an annotation. The MCP spec says clients must
treat annotations as untrusted, so they can never carry an authorisation or safety decision.
Keep those in budget-acl.ts and the adapter guards.
If the tool calls an Actual API method that is not yet wrapped in src/lib/actual-adapter.ts, add a new exported function following the withActualApi / withConcurrency / retry pattern:
export async function exampleMethod(input: unknown): Promise<unknown> {
return withActualApi(async () => {
return withConcurrency(() =>
retry(() => rawExampleMethod(input) as Promise<unknown>, { retries: 2, backoffMs: 200 })
);
});
}Then export the function from the adapter's default export object at the bottom of actual-adapter.ts.
This file monkeypatches the adapter with predefined stub responses and runs every registered tool with a minimal valid input. It asserts both that the tool does not throw and that the response shape matches what the tool documents.
The file has no test framework, and assert is not imported. Use the file's own shapeErr() helper inside if (n === '...') blocks, or add the tool to one of the existing membership arrays.
-
Add a
stubResponsesentry if the tool calls an adapter method not already listed:// In the stubResponses map at the top of the file: exampleMethod: { id: 'example-id-001', name: 'Example' },
Without a stub entry the adapter returns
undefined, which will cause shape assertions to fail with a confusing error. -
Add a required-fields input example if the tool has non-trivial required fields:
if (name.includes('example_tool')) { inputExample.name = 'SomeExistingName'; inputExample.type = 'accounts'; }
-
Assert the output shape using one of these approaches; pick whichever fits:
Return type Approach Returns { result: ... }wrapperAdd tool name to resultWrappers[]arrayReturns { success: true }Add tool name to successTools[]arrayUnique shape (custom fields) Add if (n === 'example_tool') { ... }block withshapeErr()// Option A: add to the relevant array (preferred when response matches existing pattern): const resultWrappers = ['accounts_list', /* ... */ 'example_tool']; // Option B: custom block for unique shapes: if (n === 'example_tool') { if (typeof res?.id !== 'string') shapeErr('expected id string'); if (typeof res?.name !== 'string') shapeErr('expected name string'); }
The minimum assertion by return type:
Return type What to assert Single entity create typeof res?.id === 'string'List Array.isArray(res?.result)orArray.isArray(res?.items)Count + list typeof res?.count === 'number'and array field presentMutation (update/delete) res?.success === true→ add tosuccessTools[]Summary/total typeof res?.totalAmount === 'number'
Add negative-path tests for any schema that has:
- Enums (wrong value should fail)
- UUID fields (non-UUID string should fail)
- Numeric ranges (out-of-range should fail)
- Required fields (missing should fail)
it('actual_example_tool rejects invalid type', () => {
assert.throws(
() => InputSchema.parse({ type: 'invalid', name: 'foo' }),
/invalid_enum_value/,
);
});Error message quality (Zod-layer scenarios, apply where relevant):
For each scenario below that applies to this tool's schema, add a dedicated it() block asserting both that the error is thrown and that the message is actionable:
| # | Scenario | Applies if tool has… | What to assert |
|---|---|---|---|
| 1 | Wrong date format | any date field |
message contains YYYY-MM-DD and the received value |
| 2 | Unknown / disallowed field | strict schema (no passthrough) | message contains Unknown field or lists allowed fields |
| 4 | Missing required field | any required field | message names the missing field |
| 5 | Wrong type | amount, limit, or numeric field |
message contains integer or cents |
| 6 | Invalid enum value | any z.enum(...) field |
message lists allowed values |
| 10 | Value out of range | limit, amount constraints |
message contains positive or the valid range |
Scenarios 3, 7, 8, 9 are caught at API response time; cover those in Step 5b instead.
If you add a new file under
tests/unit/(rather than extendinggenerated_tools.smoke.test.jsorschema_validation.test.js), append&& node tests/unit/<your_file>.test.jsto thetest:unit-jsscript inpackage.json. That script is an explicit chain, not a glob, so an unregistered file never runs.
Run with:
npm run test:unit-jsManual tests run against a real, live Actual Budget instance over HTTP. They live in tests/manual/tests/.
Code quality reminder: Before writing or editing any file under
tests/manual/, read the Technical Guidelines section intests/manual/README.md. Key rules that are easy to miss:
- Hard limit of 400 lines per file (evaluate a split before reaching 300)
- Each test file exports exactly one primary function
fooTests(client, context)- Always use
client.callTool(); never callfetch()directly- Wrap checks in
try/catch; log✓/❌/⚠but only rethrow fatal errors- Re-fetch and assert after every create/update (read-back verification)
- New entities must follow the
MCP-{Type}-{timestamp}naming pattern socleanupcan find them- Never add shared mutable state outside the
contextobject
Choose the right module based on the tool's domain:
| Domain | File |
|---|---|
| Server info, version, SQL, sessions | sanity.js or advanced.js |
| Accounts | account.js |
| Categories / Category Groups | category.js / category-group.js |
| Payees | payee.js |
| Rules | rules.js |
| Transactions | transaction.js |
| Budgets | budget.js |
| Lookup helpers, bank sync, advanced queries | advanced.js |
Call the tool with a valid input drawn from data already present in the budget (use client.callTool() on a known entity first to get its name/id).
Output-field assertions; "expected shape" means:
| Field type | Assert |
|---|---|
id |
non-empty string (UUID format preferred) |
amount |
integer, not decimal (Number.isInteger(res.amount)) |
date |
string matching /^\d{4}-\d{2}-\d{2}$/ |
name |
matches the value you supplied |
| list result | Array.isArray(res) && res.length >= 0 |
| optional field supplied | field present in response with the value you set |
| optional field omitted | field absent, null, or undefined |
// Positive: resolve an account by its real name
try {
const accts = await client.callTool("actual_accounts_list", {});
const first = Array.isArray(accts) && accts.length > 0 ? accts[0] : null;
if (first?.name) {
const res = await client.callTool("actual_example_tool", { type: 'accounts', name: first.name });
if (res?.id === first.id) {
console.log(` ✓ example_tool [accounts]: "${first.name}" → ${res.id}`);
} else {
console.log(` ❌ example_tool [accounts]: expected ${first.id}, got ${JSON.stringify(res).slice(0,120)}`);
}
} else {
console.log(" ℹ example_tool: no accounts found, skipped");
}
} catch (err) {
console.log(" ❌ example_tool:", err.message);
}Read-back verification (mandatory for create/update tools):
After any call that writes data, immediately call the corresponding _get or _list tool and assert the entity persists with the values you supplied. This catches tools that report success but do not actually write:
// Read-back: verify the entity persisted with correct field values
try {
const created = await client.callTool("actual_accounts_create", { name: accountName, type: 'checking' });
const accounts = await client.callTool("actual_accounts_list", {});
const found = accounts.find(a => a.id === created?.id);
if (found?.name === accountName && found?.type === 'checking') {
console.log(` ✓ accounts_create [read-back]: persisted with correct name and type`);
} else {
console.log(` ❌ accounts_create [read-back]: entity not found or fields wrong after create: ${JSON.stringify(found).slice(0,120)}`);
}
} catch (err) {
console.log(" ❌ accounts_create [read-back]:", err.message);
}Optional-input field coverage:
For each optional input that meaningfully changes the response (e.g. note, cleared, category_id): include one positive call with the field supplied and assert it appears in the result; include one positive call without it and assert the tool still succeeds and the field is absent or null.
Call the tool with a name or ID that is guaranteed not to exist. Assert:
- The tool returns an error (not a crash)
- The error message (or response body) includes a list of available values, so the AI can self-correct
Error scenario coverage (API-layer scenarios, apply where relevant):
For each scenario below that applies to this tool's API behaviour, add a matching negative-path block:
| # | Scenario | Applies if tool… | What to assert |
|---|---|---|---|
| 3 | Invalid / non-existent ID | accepts a UUID and looks it up | error contains not found AND names a list tool (e.g. actual_accounts_list) |
| 7 | Conflicting fields | can close/delete/mutate state | error explains the conflict and suggests a remedy |
| 8 | Duplicate / already-exists | creates a named entity | error mentions the existing entity's ID or suggests _update tool |
| 9 | Dependency not found | accepts a foreign-key ID (e.g. group_id, category_id) |
error names the missing dependency and a list tool |
Scenarios 1, 2, 4, 5, 6, 10 are caught at Zod parse time; cover those in Step 4b instead.
Use a sentinel name like "__nonexistent_MCP_test_value__" which can never collide with real data:
// Negative: name that does not exist; response must include available list
try {
const res = await client.callTool("actual_example_tool", {
type: 'accounts',
name: '__nonexistent_MCP_test_value__',
});
const text = JSON.stringify(res);
if (text.includes('not found') || text.includes('available') || res?.error) {
console.log(` ✓ example_tool [negative]: correctly returned not-found (with context)`);
} else {
// Tool silently accepted bad input; record a GAP comment and log a warning
// GAP(error-messages): actual_example_tool with non-existent name silently returns [observed value]
console.log(` ⚠ example_tool [negative]: accepted non-existent name without error: ${text.slice(0,200)}`);
console.log(` // GAP(error-messages): actual_example_tool bad name accepted silently`);
}
} catch (err) {
// If tool throws cleanly with a descriptive message, that also counts
if (err.message.includes('not found') || err.message.includes('available')) {
console.log(` ✓ example_tool [negative]: threw with useful message`);
} else {
console.log(` ❌ example_tool [negative]: error message not helpful: ${err.message}`);
}
}Design expectation: Tools that look up by name (e.g.,
actual_get_id_by_name,actual_categories_get,actual_payees_get) should return anot_foundresponse that lists available values. This allows an AI to immediately retry without needing a separate list call. Example response shape:{ "error": "No account named '__nonexistent_MCP_test_value__' found.", "available": ["Checking", "Savings", "Credit Card"] }
If you added a new test module file, add it to the "Directory Layout" table. If you added tests to an existing file, update its description comment.
Folder: tests/manual-prompt/ holds three sequentially-pasted prompt files.
See tests/manual-prompt/README.md for usage.
Which file to edit (based on domain):
| Domain | File |
|---|---|
Server info, read-only lists, actual_get_id_by_name |
prompt-1-smoke.txt (Phase 1) |
| Accounts, Categories, Payees, Rules, Transactions, Schedules | prompt-2-core.txt (Phases 2 to 6b) |
| Budgets, Summaries, Query, Session Management, Cleanup | prompt-3-advanced.txt (Phases 7 to 12) |
- Find the correct phase in the right prompt file (by domain; see table above)
- Add a positive scenario describing what the AI should call and what constitutes a pass
- Add a negative scenario explicitly instructing the AI to test not-found / invalid inputs:
- actual_example_tool, test positive + negative: * Positive: pick first account name from actual_accounts_list → call actual_example_tool with type='accounts' and that name → verify returned id matches account id (✓ pass) * Negative: call actual_example_tool with type='accounts' and name='__nonexistent_MCP_test_value__' → verify response contains 'not found' or an 'available' list (✓ pass); if response contains no error context → ✗ fail - Update the phase header tool count in the prompt file:
Phase N: Domain (X tools)→Phase N: Domain (X+1 tools) - Update
tests/manual-prompt/README.mdPhase Overview table: increment the count for the affected phase row and the Total row
Run npm run docs:sync to update all **Tool Count:** markers across README.md, every file under docs/, and .github/copilot-instructions.md in one pass. Do not edit these counts manually.
Then run npm run tool-count -- --fix to update the PROSE total-count literals that docs:sync does not touch: the "all N tools" phrasings, the docker descriptions (long.md / short.md), src/lib/constants.ts, the EXPECTED_TOOL_COUNT defaults, and the test-doc totals. This script keys off IMPLEMENTED_TOOLS (the same canonical source as verify-tools), and rewrites only allowlisted TOTAL anchors, so it never corrupts a per-domain subset count like ### Categories (4 tools). npm run tool-count (check mode) is enforced in CI via tests/unit/tool_count_sync.test.js, so a stale total will fail the build; running --fix here keeps the build green.
- Add a row for the new tool in the Available Tools table (tool name, description, key parameters)
- Add the tool to the domain section table if the domain section lists tools explicitly
- If this tool closes a planned-capability issue, reference it in the commit (
(#N)) so the release step closes it; there is no roadmap file to update. - Every issue should carry an area label and a priority label (
P0toP3, plusiceboxfor not-yet-committed ideas). Apply the priority on triage so the backlog is filterable.
- If you added a new test module file to
tests/manual/tests/, add it to the test-file table
Run the full pre-commit suite:
npm run build # Zero TypeScript errors
npm run test:adapter # Adapter smoke tests
npm run test:unit-js # Unit tests (includes schema_validation)
npm run verify-tools # All IMPLEMENTED_TOOLS resolve correctly
npm audit --audit-level=moderate # No new vulnerabilitiesOptional (requires Docker + real Actual Budget):
npm run test:integration:full # Full live integration test suite
npm run test:e2e # Playwright MCP protocol testsgit add .
git commit -m "feat(tools): add actual_<tool_name>
- <one-line description of what the tool does>
- Adapter method: adapter.<methodName>()
- Tests: unit smoke + negative path in tests/unit/
- Manual tests: positive + negative in tests/manual/tests/<module>.js
- Prompt: tests/manual-prompt/prompt-{1|2|3}-*.txt Phase N updated + README total updated
- Docs: README tool row added; docs:sync run
- Total tools: N → N+1 (XX% API coverage)"The table below summarises the negative test patterns for existing tools that already implemented available list responses. Use these as reference when implementing the same pattern for new lookup/search tools.
| Tool | Not-Found Response Pattern |
|---|---|
actual_get_id_by_name |
{ error: "No <type> named '<name>' found.", available: ["Name1", "Name2", ...] } |
actual_accounts_get_balance |
⚠ GAP: silently returns { balance: 0 } for any non-existent id (expected: not-found error) |
actual_query_run |
{ error: "Unknown field '<field>'" } with field suggestions |
When implementing a new lookup tool, the call function should:
- Try to find the entity by name/id
- If not found, fetch the full list
- Return
{ error: "Not found", available: list.map(e => e.name) }; never justnullor an empty object
| File | What to change |
|---|---|
src/tools/<domain>_<action>.ts |
Create the tool definition + Zod schema |
src/tools/index.ts |
Add export line |
src/actualToolsManager.ts |
Add tool name to IMPLEMENTED_TOOLS |
src/lib/actual-adapter.ts |
Add adapter method if new API call needed |
tests/unit/generated_tools.smoke.test.js |
Add stub response to stubResponses map (if new adapter method); add input example; add to resultWrappers[], successTools[], or custom if (n === '...') shape assertion |
tests/unit/schema_validation.test.js |
Add negative schema tests for complex schemas |
tests/e2e/docker-all-tools.e2e.spec.ts |
Add happy-path call for the new tool (the only E2E file that runs) |
tests/manual/tests/sanity.js |
Update EXPECTED_TOOL_COUNT default (the only exact-count gate) |
tests/e2e/docker-all-tools.e2e.spec.ts |
Update the describe(...) block-name count label (cosmetic) |
tests/manual/tests/<module>.js |
Add positive + negative test block |
tests/manual/README.md |
Update module table if new file added |
tests/manual-prompt/prompt-{1|2|3}-*.txt |
Add tool to the correct phase; update phase count |
tests/manual-prompt/README.md |
Update Phase Overview table total |
README.md |
Add tool table row in Available Tools section |
docs/ARCHITECTURE.md |
Update domain table if applicable |
docs/TESTING_AND_RELIABILITY.md |
Update if new test module added |
README.md, docs/*.md, .github/copilot-instructions.md |
Run npm run docs:sync to batch-update all Tool Count: markers (docker/description/* carry no marker; update by hand) |