Writing handlers
A handler is an async function that turns bank2ai tool inputs into bank2ai outputs. The library doesn't care how you do it, call REST APIs, query a database, hit a GraphQL endpoint, mock everything for tests. This page collects the patterns we've seen work.
Pattern: stateless handlers calling a backend client
The simplest shape, and what the reference real-bank guide uses. Handlers are top-level async functions; backend state lives in a client passed via closure.
from bank2ai import Account, register_tools
from fastmcp import FastMCP
def make_app(api: AcmeBankClient) -> FastMCP:
app = FastMCP("acme-bank")
async def get_accounts(*, only_withdrawal_accounts, account_type, status, usage):
rows = await api.list_accounts()
if only_withdrawal_accounts:
rows = [r for r in rows if r.can_withdraw]
if account_type:
rows = [r for r in rows if r.type == account_type]
if status:
rows = [r for r in rows if r.status == status]
if usage:
rows = [r for r in rows if r.usage == usage]
return [_to_bank2ai_account(r) for r in rows]
# … other handlers (all optional) …
register_tools(app, get_accounts=get_accounts, ...)
return app
Every keyword argument to register_tools is optional, so during development you can register only the handlers you've written, unimplemented tools are simply not exposed.
Pattern: per-request authentication
Handlers can read MCP context to forward an inbound bearer token, or call into a server-side auth flow. Use FastMCP's Context parameter:
from fastmcp import Context
async def get_accounts(ctx: Context, *, only_withdrawal_accounts, account_type, status, usage):
token = ctx.request_context.access_token # forwarded MCP access token
api = AcmeBankClient(token=token)
return await _list_accounts(api, only_withdrawal_accounts, account_type, status, usage)
If your backend requires an exchange (e.g. email/password → session token), do the exchange once at server startup and refresh as needed. See the real-bank guide for a worked example.
Pattern: mapping backend shapes to bank2ai
Banks rarely expose the exact bank2ai shape on the wire. Write small mappers and unit-test them, your spec compliance lives or dies in those mappers.
def _to_bank2ai_account(row: AcmeAccountRow) -> Account:
return Account(
id=row.id,
name=row.display_name,
accountNumber=row.formatted_number,
iban=row.iban, # optional, omit if your bank has no IBAN
bic=row.bic,
ownerName=row.holder_name,
product=row.product_name,
currency=row.currency,
balance=row.balance,
availableBalance=row.available_balance,
overdraftLimit=row.overdraft or 0,
status=row.is_closed and "Deleted" or row.is_blocked and "Blocked" or "Enabled",
usage="Business" if row.is_business else "Private",
isWithdrawalAccount=row.kind in {"checking", "savings"},
isDefaultAccount=row.is_primary,
accountType={"checking": "Current", "savings": "Savings", "credit": "Credit"}[row.kind],
# Credit-only fields. Omit on non-credit accounts.
statementBalance=row.statement_balance,
minimumPaymentDue=row.minimum_payment_due,
paymentDueDate=row.payment_due_date,
statementClosingDate=row.statement_closing_date,
)
Pattern: prepare → execute for transfers
The two-step transfer flow exists so the user can confirm a structured preview before money moves. The contract:
prepare_transfer, validate everything (debtor account, creditor identifier, amount, rail), then return aPrepareTransferResponsewith a populateditemcontaining a server-generatedtransferIntentIdand anexpiresAt5 minutes ahead. Cache the prepared transfer server-side keyed by the intent id.execute_transfer, look up the intent bytransfer_intent_id. Reject unknown or expired intents with the structuredcode: "intent_not_found"orcode: "intent_expired"so the client can branch without parsingcontent. On a fresh intent, call your backend's transfer API and return anExecuteTransferResponsewith the bank-issued receipt.
The intent's amount, creditor, debtor, and rail are immutable — any change requires a fresh prepare_transfer call. This is the single most important safety property of the surface; don't shortcut it.
Pattern: returning user-facing errors
For unrecoverable failures, raise ToolError (or let your client raise an MCP error). For user-facing conditions ("Insufficient funds", "Invalid recipient"), return a successful response with a content field that explains the situation, plus a code field with a structured value:
return PrepareTransferResponse(
content="Insufficient funds in your default account.",
code="insufficient_funds",
)
This keeps the AI client conversational instead of forcing it to interpret protocol errors.
What not to do
- Don't reshape responses. If the spec says
accountNumber, your output must sayaccountNumber, notaccount_number. The Account model has separate typed identifier fields (iban,bban,bic,maskedPan); pick the one that matches what your bank holds rather than overloadingaccountNumber. The library and FastMCP enforce field names; don't fight them. - Don't add a bank2ai-defined
authenticatetool. Earlier drafts of the spec described one; it has been removed. Authentication is a server concern. - Don't trust client-supplied
withdrawal_account_idblindly. Re-resolve the account on the server side and check it belongs to the authenticated user.