Fungible is a terminal-based personal finance app that runs entirely on your local machine. It integrates with the Model Context Protocol (MCP) to provide AI-powered transaction categorization, budget analysis, and financial queries without sending your data to the cloud. The architecture solves three hard problems: managing MCP server lifecycle in a client-side process, maintaining transaction state consistency without a backend database, and enforcing tool boundaries when bank APIs and categorization agents share the same memory space.
This is not a cloud-native fintech app. It is a local-first architecture that treats your filesystem as the source of truth and MCP as a protocol for tool orchestration, not a remote service.
Architecture Overview
Fungible runs as a single process on your machine. It spawns MCP servers as child processes, communicates with them over stdio, and persists all state to local SQLite databases. The user interacts through a terminal UI built with Textual (Python TUI framework). The agent layer sits between the UI and the data layer, calling MCP tools for categorization, querying, and analysis.
The key components:
- Terminal UI: Textual-based interface for transaction review, budget tracking, and agent queries
- Agent orchestrator: Manages MCP server lifecycle, routes tool calls, handles retries
- MCP servers: Separate processes that expose tools for bank API access, transaction categorization, and financial analysis
- State layer: SQLite databases for transactions, categories, budgets, and reconciliation logs
- Bank connectors: Plaid or manual CSV import for transaction ingestion
The data flow:
- User imports transactions via Plaid or CSV
- Transactions are written to SQLite with
pending_categorizationstatus - Agent orchestrator spawns an MCP server for categorization
- MCP server reads transactions, calls LLM for category inference, returns results
- Agent orchestrator writes categorized transactions back to SQLite
- User reviews categorizations in terminal UI, approves or overrides
- Approved transactions are marked
reconciledand used for budget calculations
No transaction data leaves the machine unless the user explicitly exports it.
MCP Server Lifecycle in a Client-Side Process
MCP servers are typically long-running services that multiple clients connect to. Fungible inverts this: the client spawns MCP servers on demand and kills them when the task is complete. This avoids the overhead of a persistent server process but requires careful lifecycle management.
The orchestrator uses Python’s subprocess module to spawn MCP servers:
import subprocess
import json
class MCPOrchestrator:
def __init__(self, server_path: str):
self.server_path = server_path
self.process = None
def start_server(self):
self.process = subprocess.Popen(
[self.server_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
def call_tool(self, tool_name: str, params: dict) -> dict:
request = {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": params
},
"id": 1
}
self.process.stdin.write(json.dumps(request) + "\n")
self.process.stdin.flush()
response = self.process.stdout.readline()
return json.loads(response)
def shutdown(self):
if self.process:
self.process.terminate()
self.process.wait(timeout=5)
The lifecycle constraints:
- Startup latency: Spawning a new Python process takes 200-500ms. For interactive queries, this is noticeable. Fungible mitigates this by keeping a pool of pre-spawned servers for common tools (categorization, budget analysis).
- Crash recovery: If an MCP server crashes mid-transaction, the orchestrator must detect the failure, log the error, and retry with a fresh server. The retry logic checks for
BrokenPipeErroror empty stdout and respawns the server up to 3 times before failing the task. - Resource cleanup: Zombie processes accumulate if the orchestrator does not explicitly terminate servers. Fungible uses
atexithandlers to kill all child processes on shutdown.
The trade-off: spawning servers on demand keeps memory usage low (no idle servers), but adds latency to the first tool call. For batch operations (categorizing 500 transactions), this is acceptable. For interactive queries, the pool pattern is necessary.
State Persistence Without a Backend Database
Fungible uses SQLite as the single source of truth for all financial state. There is no backend API, no sync service, no conflict resolution. The database schema is designed for deterministic reconciliation: every transaction has a reconciliation_status field that tracks whether it has been reviewed and approved by the user.
The schema:
CREATE TABLE transactions (
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL,
account_id TEXT NOT NULL,
category_id TEXT,
reconciliation_status TEXT CHECK(reconciliation_status IN ('pending', 'categorized', 'reconciled')) DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT,
is_system BOOLEAN DEFAULT 0
);
CREATE TABLE reconciliation_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_id TEXT NOT NULL,
old_category_id TEXT,
new_category_id TEXT,
changed_by TEXT CHECK(changed_by IN ('agent', 'user')),
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
The reconciliation flow:
- Import: Transactions are inserted with
reconciliation_status = 'pending' - Categorization: Agent updates
category_idand setsreconciliation_status = 'categorized' - Review: User reviews categorized transactions in the UI
- Approval: User approves or overrides categories, sets
reconciliation_status = 'reconciled' - Audit: All category changes are logged in
reconciliation_logwithchanged_byfield
The key constraint: only transactions with reconciliation_status = 'reconciled' are included in budget calculations. This prevents the agent from affecting financial reports until the user has reviewed its work.
The failure mode: if the agent crashes mid-categorization, some transactions will be stuck in pending status. The UI shows a warning if there are pending transactions older than 24 hours, prompting the user to re-run categorization or manually categorize them.
Tool Boundary Enforcement in a Shared Process
Fungible’s agent orchestrator, MCP servers, and bank API connectors all run in the same process. This creates a risk: a buggy MCP server could directly modify the SQLite database, bypassing the reconciliation flow. Fungible enforces tool boundaries using filesystem permissions and read-only database connections.
The boundary rules:
| Component | Database Access | Filesystem Access | Network Access |
|---|---|---|---|
| Terminal UI | Read/write (all tables) | Read/write (export only) | None |
| Agent orchestrator | Read/write (transactions, reconciliation_log) | Read-only (MCP server binaries) | None |
| MCP servers | Read-only (transactions, categories) | None | None (unless bank connector) |
| Bank connectors | None | None | HTTPS (Plaid API only) |
The enforcement mechanism:
- Database permissions: MCP servers receive a read-only SQLite connection string (
file:transactions.db?mode=ro). Attempts to write raisesqlite3.OperationalError. - Filesystem isolation: MCP servers run in a chroot jail (on Linux) or with restricted file descriptors (on macOS). They cannot read or write files outside their designated directories.
- Network isolation: Only the bank connector process has network access. MCP servers for categorization and analysis run with
network=nonein their process environment.
The code for read-only database access:
import sqlite3
def get_readonly_connection(db_path: str) -> sqlite3.Connection:
uri = f"file:{db_path}?mode=ro"
conn = sqlite3.connect(uri, uri=True)
conn.execute("PRAGMA query_only = ON")
return conn
The failure mode: if an MCP server attempts to write to the database, it crashes with an exception. The orchestrator catches this, logs the error, and marks the transaction as failed_categorization. The user sees a notification in the UI and can retry with a different MCP server or manually categorize the transaction.
Transaction Reconciliation Without Server Round-Trips
Fungible’s reconciliation logic is deterministic and runs entirely on the client. There is no server to query for the “correct” category or to resolve conflicts between the agent’s suggestion and the user’s override. The reconciliation algorithm is simple:
- Agent suggests a category based on transaction description and historical patterns
- User reviews the suggestion in the UI
- If the user approves, the transaction is marked
reconciledwith the agent’s category - If the user overrides, the transaction is marked
reconciledwith the user’s category - The override is logged in
reconciliation_logwithchanged_by = 'user'
The agent learns from overrides by querying reconciliation_log for similar transactions:
def get_user_overrides(description: str, limit: int = 10) -> list[dict]:
conn = get_readonly_connection("transactions.db")
cursor = conn.execute("""
SELECT t.description, r.old_category_id, r.new_category_id
FROM reconciliation_log r
JOIN transactions t ON r.transaction_id = t.id
WHERE r.changed_by = 'user'
AND t.description LIKE ?
ORDER BY r.timestamp DESC
LIMIT ?
""", (f"%{description}%", limit))
return [{"description": row[0], "old_category": row[1], "new_category": row[2]} for row in cursor.fetchall()]
This query runs in the MCP server (with read-only access) and returns historical overrides. The agent includes these in the LLM prompt to improve future categorizations.
The trade-off table:
| Reconciliation Strategy | Latency | Consistency | Offline Support | Conflict Resolution |
|---|---|---|---|---|
| Server-side (traditional fintech) | 200-500ms | Strong (ACID) | No | Server decides |
| Client-side (Fungible) | <10ms | Eventual (user approval) | Yes | User decides |
| Hybrid (local cache + sync) | 50-200ms | Weak (last-write-wins) | Partial | Conflict UI required |
Fungible chooses client-side reconciliation because offline support and user control are more important than strong consistency. The user is the source of truth, not the server.
Observability for Local-First Financial Agents
Fungible logs all agent activity to a local JSON file. The log includes:
- Tool calls (name, parameters, response time)
- LLM requests (prompt, response, token count)
- Database queries (SQL, execution time)
- Errors (exception type, stack trace, retry count)
The log format:
{
"timestamp": "2026-11-07T00:08:58Z",
"event": "tool_call",
"tool_name": "categorize_transaction",
"params": {"transaction_id": "tx_123", "description": "STARBUCKS #1234"},
"response": {"category_id": "cat_food_dining", "confidence": 0.92},
"duration_ms": 1250
}
The UI includes a log viewer that filters by event type, tool name, or error status. This is critical for debugging categorization failures or understanding why the agent suggested a particular category.
The observability constraints:
- No telemetry: Fungible does not send logs to a remote service. All observability is local.
- No cost tracking: Because the agent runs locally (or uses a self-hosted LLM), there is no per-request cost to track. Users who use OpenAI or Anthropic APIs must track costs manually.
- No distributed tracing: Because there is no backend, there is no need for trace IDs or span correlation. The log is a flat sequence of events.
The failure mode: if the log file grows too large (>100MB), the UI becomes slow. Fungible rotates logs daily and archives old logs to a compressed format.
Technical Verdict
Use Fungible’s local-first MCP architecture when:
- You need offline-first transaction reconciliation without dependency on cloud services or network availability
- You want deterministic financial state where the user is the final authority on categorization and budgets
- You require privacy guarantees that transaction data never leaves the local machine (no sync, no telemetry, no cloud backup)
- You can tolerate eventual consistency where agent suggestions are reviewed and approved by the user before affecting financial reports
- You are building for a single-user context where multi-device sync and collaborative budgeting are not requirements
Avoid this architecture when:
- You need real-time multi-account sync across devices (Fungible’s local-first design does not support distributed state)
- You require strong consistency for financial reports (server-side reconciliation with ACID guarantees is more appropriate)
- You want automatic categorization without user review (the reconciliation flow assumes human approval)
- You need to integrate with third-party financial planning tools that expect a REST API or webhook interface
- You are building for teams or families where multiple users need concurrent access to the same budget (local SQLite does not support multi-writer concurrency)
The local-first pattern works because personal finance is inherently single-user and benefits from offline access. The MCP integration provides AI capabilities without sacrificing privacy or control. The reconciliation flow ensures that the agent is a suggestion engine, not a decision-maker.