We built 118 MCP tools for our SaaS. About 35 of them handle accounting: transactions, transfers, recurring rules, categories, currencies, financial accounts. We timed the same operations in our web forms and through Claude. The difference was larger than we expected - in both directions.
This isn't a "forms are dead" post. It's a timing comparison with code. Some operations are 4x faster in conversation. Some are still better in a form. The interesting part is figuring out which is which.
Operation 1: Recording a single expense
Form: ~45 seconds
Open the accounting page. Click "New Transaction." Select type: Expense. Type amount: 12.50. Type description: "Coffee beans." Click the category dropdown, scroll through 40+ categories, find "Food & Dining", expand it, pick "Groceries." Select account from another dropdown. Click Save.
Conversation: ~10 seconds
"12.50 coffee beans, groceries, wise"
Claude calls list-categories with search: "groceries" to find the deepest matching subcategory:
server.registerTool(
'list-categories',
{
description: 'Search expense/income categories by keyword. ALWAYS use the search ' +
'parameter to find the deepest matching subcategory (e.g. search "groceries" to ' +
'find "Food & Dining > Groceries" at depth 2, not "Food & Dining" at depth 0). ' +
'Each result includes depth (0=root, 1-3=subcategory) - prefer the deepest match.',
inputSchema: z.object({
type: z.enum(['INCOME', 'EXPENSE']).optional(),
search: z.string().max(200).optional(),
limit: z.coerce.number().int().min(1).max(50).optional(),
}),
annotations: { readOnlyHint: true },
},
// ...
);
Then it resolves "wise" to the correct financial account via list-accounts, and calls create-transaction:
server.registerTool(
'create-transaction',
{
description: 'Record a financial transaction. ' +
'Workflow: 1) Search category via list-categories with a keyword ' +
'2) Confirm category + account with user 3) Create transaction.',
inputSchema: z.object({
type: z.enum(['INCOME', 'EXPENSE']),
amount: coerceAmount(),
description: z.string().max(200),
accountId: z.string(),
categoryId: z.string().optional(),
date: coerceDateString().optional(),
}),
annotations: { destructiveHint: false, idempotentHint: false },
},
// ...
);
Before creating anything, Claude confirms: "Record $12.50 expense 'Coffee beans' in Food & Dining > Groceries from Wise USD?" You say yes. Done.
The time difference comes from category selection. In a form, you navigate a tree. In a conversation, you type a keyword and the AI searches for the deepest match. "Groceries" resolves to Food & Dining > Groceries (depth 2) without you knowing the tree structure.
Operation 2: A 6-item receipt
Form: ~4 minutes
Six separate transactions. Open form, fill fields, save. Repeat five more times. Each one requires the same category navigation, same account selection. Copy-paste the date six times.
Conversation: ~20 seconds
"Receipt from today: milk 3.20, bread 2.10, eggs 4.50, butter 3.80, cheese 6.90, yogurt 2.40 - all groceries, wise account"
Claude calls create-transactions - one batch operation:
server.registerTool(
'create-transactions',
{
description: 'Batch create multiple transactions in a single call. ' +
'Use for multi-item receipts. Always confirm the full list of items, ' +
'total amount, category, and account with the user before calling.',
inputSchema: z.object({
accountId: z.string(),
categoryId: z.string().optional(),
date: coerceDateString().optional(),
transactions: coerceJsonArray(
z.object({
type: z.enum(['INCOME', 'EXPENSE']),
amount: coerceAmount(),
description: z.string().max(200),
categoryId: z.string().optional(),
date: coerceDateString().optional(),
})
).describe('JSON array of 1-50 transactions'),
}),
annotations: { destructiveHint: false, idempotentHint: false },
},
// ...
);
The accountId, categoryId, and date are shared across all items - you specify them once. Individual transactions can override the category or date if needed. Claude confirms: "6 items, total $22.90, all Groceries, Wise USD, today. Create?" One tool call instead of six form submissions.
The batch endpoint accepts up to 50 transactions. The AI sends them as a JSON array inside a single parameter - parsed and validated by our coerceJsonArray helper (covered in post 2).
This is where the time gap is largest. 4 minutes vs 20 seconds is a 12x difference. And it's not because the form is badly designed - it's because the form treats each transaction as a separate operation.
Operation 3: Transfer between accounts
Form: ~30 seconds
Open transfers page. Select source account. Select destination account. If currencies differ, manually check today's exchange rate. Enter amount. Calculate the destination amount yourself. Save.
Conversation: ~10 seconds
"Transfer $500 from Wise to Mono"
Claude chains three operations: calls get-exchange-rates to find the USD/UAH rate, calculates the destination amount, then calls create-transfer:
server.registerTool(
'create-transfer',
{
description: 'Transfer money between two financial accounts. ' +
'Auto-fetches exchange rate for cross-currency transfers. ' +
'For loan payments: confirm source account, loan account, amount, and category.',
inputSchema: z.object({
fromAccountId: z.string(),
toAccountId: z.string(),
amount: coerceAmount(),
description: z.string().max(200).optional(),
date: coerceDateString().optional(),
}),
annotations: { destructiveHint: false, idempotentHint: false },
},
// ...
);
Claude confirms: "Transfer $500 from Wise USD to Mono UAH. Rate: 1 USD = 41.25 UAH. Destination: 20,625 UAH. Proceed?" The exchange rate lookup that takes you 15 seconds on Google takes Claude one tool call.
Operation 4: Setting up a recurring expense
Form: ~60 seconds
Navigate to recurring transactions. Click "New." Fill in all the same fields as a regular transaction, plus: frequency (monthly), day of month, start date, optional end date. Save. Wonder if it actually worked until the first one generates.
Conversation: ~15 seconds
"Every month on the 1st, record $50 expense 'Spotify Family' from Wise, subscriptions category"
Claude calls create-recurring-transaction:
server.registerTool(
'create-recurring-transaction',
{
description: 'Schedule a recurring transaction template. ' +
'Does NOT generate a transaction immediately - the daily cron handles generation.',
inputSchema: z.object({
type: z.enum(['INCOME', 'EXPENSE', 'TRANSFER']),
amount: coerceAmount(),
description: z.string().max(200),
accountId: z.string(),
categoryId: z.string().optional(),
targetAccountId: z.string().optional()
.describe('Required for TRANSFER type'),
intervalUnit: z.enum(['DAY', 'WEEK', 'MONTH']),
intervalValue: z.coerce.number().int().positive(),
dayOfMonth: z.coerce.number().int().min(1).max(31).optional()
.describe('Required when intervalUnit=MONTH'),
startDate: coerceDateString(),
endDate: coerceDateString().optional(),
}),
annotations: { destructiveHint: false, idempotentHint: false },
},
// ...
);
One detail worth noting: the tool description says "Does NOT generate a transaction immediately." This is important for user expectations. The recurring transaction is a template - a daily cron job generates the actual transactions. Claude relays this: "Created monthly recurring expense. First transaction will generate on the 1st."
You can also ask "What's my recurring forecast?" and Claude calls get-recurring-forecast to show projected expenses, income, and net for the next 30 days.
Operation 5: Financial overview
Form: ~90 seconds
Open dashboard. Read the summary cards. Click into category breakdown. Switch the period filter. Go back. Open a different report. Mentally combine the numbers.
Conversation: ~10 seconds
"How did this month go compared to last month?"
Claude calls get-dashboard-stats with period: "thisMonth", then again with period: "lastMonth", and summarizes:
"This month: $4,200 income, $3,100 expenses, $1,100 net profit. Last month: $3,800 income, $2,900 expenses, $900 net. Income up 10.5%, expenses up 6.9%, net profit up 22.2%."
Then you ask "Where did the extra spending go?" and Claude calls get-category-breakdown:
server.registerTool(
'get-category-breakdown',
{
description: 'Category-level spending or income breakdown for a period.',
inputSchema: z.object({
type: z.enum(['INCOME', 'EXPENSE']),
period: z.string().optional()
.describe('thisMonth, lastMonth, last3Months, etc.'),
dateFrom: coerceDateString().optional(),
dateTo: coerceDateString().optional(),
scope: z.enum(['ALL', 'COMPANY', 'PERSONAL']).optional(),
displayCurrency: z.string().optional(),
}),
annotations: { readOnlyHint: true },
},
// ...
);
"Top 3 expense categories this month: Rent $1,200 (38.7%), Food & Dining $680 (21.9%), Software & Subscriptions $420 (13.5%). The increase came from Food & Dining - up $180 vs last month."
The dashboard shows the same data. But the dashboard requires you to navigate between views and connect the dots yourself. The conversation connects them for you.
The timing table
| Operation | Form | Conversation | Speedup |
|---|---|---|---|
| Single expense | ~45s | ~10s | 4.5x |
| 6-item receipt | ~4min | ~20s | 12x |
| Cross-currency transfer | ~30s | ~10s | 3x |
| Recurring expense setup | ~60s | ~15s | 4x |
| Financial overview + drill-down | ~90s | ~10s | 9x |
| Edit a specific transaction field | ~15s | ~10s | 1.5x |
| Bulk categorize 20 transactions | ~10min | ~2min | 5x |
The pattern: conversation wins on discovery (finding categories, accounts, rates) and batching (multiple items in one operation). The speedup is largest when the form requires repetitive navigation.
Where forms still win
Not everything is faster in a conversation.
Complex table editing. Rearranging 20 line items on an invoice - drag and drop, adjust quantities, reorder rows. A conversation would be "move item 3 above item 1, change quantity of item 5 to 3, delete item 7." By the time you've described the changes, you could have done it in the UI.
Visual layouts. Designing a document template, choosing colors, positioning logos. Visual tasks need a visual interface.
Browsing and comparing. Scrolling through a list of 50 transactions to spot patterns. Your eyes are faster than describing what you're looking for - unless you know the exact filter, in which case the conversation wins again.
Precision editing. Changing an amount from 12.50 to 12.55. In a form: click, backspace twice, type 55. In a conversation: "Change the amount of transaction X to 12.55." Similar time, but the form gives you a visual confirmation that the cursor is in the right field.
The rule of thumb: if the task is about data entry, conversation wins. If the task is about spatial manipulation or visual comparison, forms win.
The confirmation flow
Every write operation in our MCP server has a confirmation step built into the AI's workflow. This isn't a technical feature of MCP - it's a convention enforced through tool descriptions.
Look at our create-transaction description:
Workflow: 1) Search category via list-categories with a keyword
2) Confirm category + account with user
3) Create transaction
4) Show full details
And create-transfer:
For loan payments: confirm source account, loan account,
amount, and category.
And delete-transaction:
NEVER call without explicit user confirmation.
Always show full transaction details first.
Deletion is permanent and cannot be undone.
The AI reads these instructions and follows them. It doesn't call create-transaction immediately after you say "12.50 coffee beans." It first resolves the category, resolves the account, and asks "Record $12.50 expense 'Coffee beans' in Food & Dining > Groceries from Wise USD?" Only after you confirm does it execute.
This is different from a form's "Save" button. The form shows you what you're about to save, but you filled in every field yourself - typos are your problem. The conversation shows you what the AI understood, which catches misinterpretations before they become data.
We've seen cases where the AI catches ambiguity that a form can't: "You said 'wise' - you have two Wise accounts: Wise USD and Wise EUR. Which one?" A form dropdown shows both, but it doesn't know which one you usually use for groceries. The AI does, and asks only when it's uncertain.
How we enforce confirmation in tool descriptions
The convention is simple: destructive or write tools include explicit workflow instructions in their description field. The AI treats these as guidelines for its behavior.
Three levels:
Suggest workflow: "Search category first, confirm with user, then create." The AI usually follows this but might skip if the context is clear.
Strong warning: "NEVER call without explicit user confirmation. Show current values and proposed changes first." The AI almost always asks before proceeding.
Hard constraint: "Deletion is permanent and cannot be undone." Combined with annotations: { destructiveHint: true }, some MCP clients will add their own confirmation dialog on top of the AI's confirmation.
This isn't bulletproof - the AI can still make mistakes. But the combination of tool descriptions, annotations, and the AI's natural tendency to confirm ambiguous operations catches most errors before they happen.
Multi-currency: the hidden complexity
PaperLink supports multiple currencies per team. This adds complexity that conversations handle better than forms.
In a form: you pick a source account (Wise USD), pick a destination account (Mono UAH), then manually look up the exchange rate, calculate the destination amount, and enter it. If the rate changes between your Google search and clicking Save, you're off by a few cents.
In a conversation: "Transfer $500 from Wise to Mono." The AI calls get-exchange-rates, gets the current rate, calculates automatically, and shows you the result for confirmation. If you have a manual rate override (set via set-manual-exchange-rate for internal transfers at a fixed rate), it uses that instead.
The currency tools also handle setup:
> "Add British pounds to our currencies"
Claude calls add-team-currency with currencyCode: "GBP".
Auto-fetches the exchange rate. Done.
> "What are our current rates?"
Claude calls list-team-currencies.
"USD (base), EUR (0.92), UAH (41.25), GBP (0.79). All rates auto-updated."
> "Set EUR rate to 0.95 for this month's invoices"
Claude calls set-manual-exchange-rate with base: "USD", target: "EUR", rate: 0.95.
"Manual rate set: 1 USD = 0.95 EUR. This overrides the auto rate until you remove it."
The one-liner
claude mcp add --transport http paperlink https://mcp.paperlink.online/api/mcp/mcp
Connect, grant accounting:read and accounting:write scopes, and try: "What did I spend on groceries last month?"
Top comments (0)