feat: fin-mcp as a deployable HTTP service, with typed tools and name resolution

Makes the MCP server runnable as a network service and hardens its tool
surface. Previously it was stdio-only and had never been executed — the
test file had never run, and 3 of its 5 tests failed on first invocation.

Transport and deployment

  FIN_MCP_TRANSPORT selects stdio (default, unchanged) or streamable-http,
  which binds FIN_MCP_HOST:FIN_MCP_PORT and serves MCP at FIN_MCP_PATH
  (default /mcp) with stateless_http so no session affinity is needed.

  Ships as its own image (cli/Dockerfile) and its own Deployment +
  ClusterIP Service (deploy/k8s/fin-mcp.yaml) rather than being mounted
  into the API app — matching how the other MCP servers in the cluster are
  deployed, and keeping the mcp dependency out of the api image. The
  manifest is a reference to copy into the Flux repo; it is not applied
  from here.

  The manifest deliberately has no HTTPRoute. fin-mcp has no auth of its
  own and the API runs with FIN_API_TOKEN unset, so with writes enabled
  the only thing keeping it safe is being unreachable from outside the
  cluster.

Correctness

  create_transaction accepted a free-form type string while
  POST /api/transactions accepts all six transaction types. A caller
  passing type=transfer would write a single leg with no
  linked_account_id — an orphan that corrupts balances and that
  paired-delete can never clean up. All eight enum-valued parameters are
  now Literal-typed, so the client rejects such calls before they are
  made. TestInvariantGuards locks this down.

  FinClient was constructed eagerly in create_server(), so a missing
  FIN_API_URL killed the process before the MCP handshake and surfaced to
  the client only as "server failed to start". It is now built lazily on
  first use, turning the same misconfiguration into a readable error.

Tool surface

  Adds find_account / find_category, which map a name to the integer id
  every other tool requires — exact match wins, otherwise all substring
  matches are returned so the caller can disambiguate. Publishes accounts
  and categories as the resources fin://accounts and fin://categories.
  Drops get_openapi_spec: the tools already cover the API and the spec is
  too large to justify a tool slot.

  The three update_* tools took an untyped dict[str, Any], which produced
  a schema with no properties at all; they now take explicit optional
  parameters, plus clear_* flags so the ability to null a field survives
  the change.

  Removes ~48 lines of write-warning boilerplate duplicated across 24
  tools — it lives in the server instructions once, and no longer claims
  refresh_prices can double-book. Fixes fetch_ticker_price claiming it
  blocks for minutes when the client times out at 30s, and
  deactivate_account flagging itself destructive while documenting itself
  as reversible.

Tests: 37 passing, up from 27 passing / 3 failing. The failures were
harness bugs — mcp >=1.12 returns (content, structured) from call_tool
where the helper assumed a list. Verified against a live API over both
transports: handshake, reads, writes, name resolution, resource reads,
and rejection of the transfer type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
This commit is contained in:
EislM0203andClaude Opus 5 committed 2026-08-22 21:20:39 +00:00
1 parent 082b6081b7
commit 439a15264b
11 files changed
+1632 -2

No files matched your search

+5
View File
@@ -5,6 +5,11 @@ description: Interact with the user's fin personal-finance app (net worth, accou
# fin CLI
For MCP-capable agents: `fin-mcp` (install: `pip install -e 'cli/[mcp]'`) exposes the
full fin API as structured MCP tools without subprocess overhead. Read-only by default;
set `FIN_MCP_ALLOW_WRITES=1` for write access. The CLI (`fin`) remains the fallback for
environments without the `mcp` extra or for one-off interactive queries.
`fin` talks HTTP to the fin API. Setup (usually already exported):
```bash
+10
View File
@@ -52,6 +52,16 @@ cli/
`fin` is a typer CLI that talks HTTP to the deployed API. Configure with `FIN_API_URL` (required, no default) and `FIN_API_TOKEN` (only if the API enforces auth — the API checks it only when its own `FIN_API_TOKEN` env var is set; `/api/health` is always open). **Warning**: enabling `FIN_API_TOKEN` on the API also locks out the React frontend — `web/src/api/client.ts` sends no `Authorization` header — until nginx injects the header or the frontend learns the token. Install with `pip install -e cli/`; every command supports `--json`. `fin_cli/client.py` is kept free of typer/rich imports so a future MCP server can reuse it as-is. Agent-facing usage docs live in `.claude/skills/fin/SKILL.md`.
`cli/fin_mcp/` wraps `FinClient` as an MCP server (`fin-mcp` console script), exposing the full API as 39 tools. Install the extra with `pip install -e 'cli/[mcp]'`.
- **Mode**: read-only (15 tools) by default; `FIN_MCP_ALLOW_WRITES=1` adds the 24 write tools.
- **Name resolution**: `find_account` / `find_category` map a name to the integer id every other tool takes (exact match wins, else substring). Accounts and categories are also published as the resources `fin://accounts` and `fin://categories`. There is no `get_openapi_spec` tool — the tools already cover the API and the spec is too large to be worth a tool slot.
- **Transport**: `FIN_MCP_TRANSPORT=stdio` (default, for clients that spawn the process) or `streamable-http`, which binds `FIN_MCP_HOST:FIN_MCP_PORT` and serves MCP at `FIN_MCP_PATH` (default `/mcp`). HTTP mode sets `stateless_http` so no session affinity is required.
- **Deployment**: its own image (`cli/Dockerfile`) and its own Deployment + ClusterIP Service in k8s — it is not mounted into the API app. Reference manifest: `deploy/k8s/fin-mcp.yaml`. In-cluster endpoint is `http://fin-mcp.apps.svc.cluster.local:8000/mcp`.
- **Tool params are typed**: enum-valued arguments use `Literal`, so the MCP client rejects bad values before the call. In particular `create_transaction` accepts only `expense`/`income` — the API itself accepts all six transaction types, and writing a `transfer` row through it would create an orphan leg with no `linked_account_id` (invariant #1). Two-leg writes must go through `create_transfer` / `create_settlement` / `create_split_expense` / `create_splitwise_i_paid`.
**Security**: `fin-mcp` has no auth of its own — it inherits whatever `FinClient` uses. With writes enabled it must stay unreachable from outside the cluster (no HTTPRoute) unless `FIN_API_TOKEN` is set on both the API and the MCP server.
## Backend conventions
- **Sync only**: SQLAlchemy is configured with a synchronous engine (`create_engine`, `SessionLocal`). Never introduce `async def` endpoints or `async with Session`. FastAPI handles the thread pool.
+76 -1
View File
@@ -30,8 +30,83 @@ Set in `docker-compose.yml` under the `api` service:
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | `sqlite:////data/fin.db` | SQLite path inside the container |
| `FIN_API_TOKEN` | _(unset — open)_ | Bearer token the API requires; leave unset for no auth |
Data is persisted in a named Docker volume (`sqlite_data`). No other configuration is required.
CLI / MCP environment variables (set in the shell or MCP client config):
| Variable | Default | Description |
|---|---|---|
| `FIN_API_URL` | _(required)_ | Base URL of the fin API, e.g. `https://fin.example.com` |
| `FIN_API_TOKEN` | _(unset)_ | Bearer token for the API (only if the API enforces auth) |
| `FIN_MCP_ALLOW_WRITES` | _(unset — read-only)_ | Set to `1` to enable the 24 write tools in the MCP server |
| `FIN_MCP_TRANSPORT` | `stdio` | `stdio`, or `streamable-http` to run as a network service |
| `FIN_MCP_HOST` / `FIN_MCP_PORT` | `0.0.0.0` / `8000` | Bind address, `streamable-http` only |
| `FIN_MCP_PATH` | `/mcp` | Path MCP is served at, `streamable-http` only |
Data is persisted in a named Docker volume (`sqlite_data`).
## MCP server
`fin-mcp` exposes the full fin API as structured tools. Default mode is
**read-only** (15 tools). Set `FIN_MCP_ALLOW_WRITES=1` to additionally enable
24 write tools (create/update/delete transactions, accounts, stock trades, etc.).
Enum-valued arguments are typed, so the client rejects invalid values before the
call. `create_transaction` deliberately accepts only `expense` and `income`:
two-leg writes must go through `create_transfer`, `create_settlement`,
`create_split_expense` or `create_splitwise_i_paid`, or the ledger ends up with
an orphan leg.
Use `find_account` / `find_category` to turn a name into the integer id the
other tools take. Both lists are also published as the resources
`fin://accounts` and `fin://categories`.
**Transports.** `stdio` (default) suits clients that spawn the process.
`streamable-http` runs it as a service — it binds `FIN_MCP_HOST:FIN_MCP_PORT`
and serves MCP at `FIN_MCP_PATH`:
```bash
FIN_API_URL=http://localhost:8000 FIN_MCP_TRANSPORT=streamable-http fin-mcp
```
In Kubernetes it runs as its own Deployment and ClusterIP Service — see
`deploy/k8s/fin-mcp.yaml`. That manifest has no HTTPRoute on purpose: the MCP
server has no auth of its own, so with writes enabled it must stay unreachable
from outside the cluster unless `FIN_API_TOKEN` is set on both the API and the
MCP server.
**Install:**
```bash
pip install -e 'cli/[mcp]'
```
**Run manually:**
```bash
export FIN_API_URL=https://fin.example.com
fin-mcp # stdio; connect via your MCP client
```
**Claude Desktop / Claude Code config** (`~/.claude/mcp.json` or equivalent):
```json
{
"mcpServers": {
"fin": {
"command": "fin-mcp",
"env": {
"FIN_API_URL": "https://fin.example.com",
"FIN_API_TOKEN": "...",
"FIN_MCP_ALLOW_WRITES": "0"
}
}
}
}
```
Remove `FIN_API_TOKEN` if the API runs without auth. Change `FIN_MCP_ALLOW_WRITES` to `"1"`
to permit write operations (confirm amounts, accounts, and dates with the user before each call).
## Architecture
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.12-slim
WORKDIR /app
# fin_cli/client.py is the whole dependency surface; the mcp extra pulls the
# server SDK. No build toolchain is needed, so this stays a single stage.
COPY pyproject.toml ./
COPY fin_cli/ ./fin_cli/
COPY fin_mcp/ ./fin_mcp/
RUN pip install --no-cache-dir '.[mcp]'
RUN useradd -m -u 1000 app
USER app
# Served over the network rather than spawned by a local client.
ENV FIN_MCP_TRANSPORT=streamable-http \
FIN_MCP_HOST=0.0.0.0 \
FIN_MCP_PORT=8000 \
FIN_MCP_PATH=/mcp
EXPOSE 8000
CMD ["fin-mcp"]
+3
View File
@@ -0,0 +1,3 @@
"""fin-mcp — MCP server wrapping FinClient for the fin personal finance app."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .server import main
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -9,12 +9,16 @@ dependencies = [
"rich>=13",
]
[project.optional-dependencies]
mcp = ["mcp[cli]>=1.10,<2"]
[project.scripts]
fin = "fin_cli.main:app"
fin-mcp = "fin_mcp.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["fin_cli"]
packages = ["fin_cli", "fin_mcp"]
+288
View File
@@ -0,0 +1,288 @@
"""MCP server tests.
Skipped entirely on machines where `mcp` is not installed, so the existing
25-test suite stays green without the optional extra.
"""
import asyncio
import pytest
pytest.importorskip("mcp")
from fin_mcp.server import create_server # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _tool_names(server):
return {t.name for t in asyncio.run(server.list_tools())}
def _call(server, name, arguments=None):
"""Call a tool and return the raw FastMCP result list."""
return asyncio.run(server.call_tool(name, arguments or {}))
def _unwrap(result):
"""Extract the {'result': ...} payload from a FastMCP call_tool response.
The return shape is SDK-version dependent:
mcp >=1.10 -> list[Content]
mcp >=1.12 -> tuple(list[Content], structured_dict)
Both are handled so the suite does not pin a patch release.
"""
import json
# Newer SDKs return (content, structured_content); the second element is
# already the deserialised return value.
if isinstance(result, tuple):
content, structured = result
if isinstance(structured, dict):
return structured
result = content
item = result[0]
text = getattr(item, "text", None)
if text is None:
text = item.content[0].text
return json.loads(text)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestReadOnlyMode:
def test_read_only_by_default(self, client, monkeypatch):
"""Server created without flag exposes exactly 15 read tools."""
monkeypatch.delenv("FIN_MCP_ALLOW_WRITES", raising=False)
server = create_server(client=client)
names = _tool_names(server)
# Required read tools present
assert "get_net_worth" in names
assert "list_accounts" in names
assert "list_transactions" in names
assert "get_spending_month" in names
assert "get_fx_rate" in names
assert "list_rsu_grants" in names
assert "preview_rsu_schedule" in names
assert "health" in names
assert "find_account" in names
assert "find_category" in names
assert "get_net_worth_history" in names
assert "list_categories" in names
assert "get_holdings" in names
assert "list_stock_transactions" in names
assert "get_spending_summary" in names
# No write tools
write_names = {n for n in names if n.startswith(("create_", "delete_", "update_", "deactivate_", "adjust_", "vest_", "generate_", "reconcile", "refresh_", "fetch_", "funded_"))}
assert write_names == set(), f"Unexpected write tools in read-only mode: {write_names}"
assert len(names) == 15
def test_env_flag_enables_writes(self, client, monkeypatch):
"""FIN_MCP_ALLOW_WRITES=1 adds 24 write tools for a total of 39."""
monkeypatch.setenv("FIN_MCP_ALLOW_WRITES", "1")
server = create_server(client=client)
names = _tool_names(server)
assert "create_transfer" in names
assert "delete_transaction" in names
assert "refresh_prices" in names
assert len(names) == 39
class TestReadParity:
def test_read_parity(self, client):
"""Tool results match FinClient results for three read tools (B1 criterion)."""
# Seed some data
acct = client.create_account({"name": "Parity Bank", "type": "bank", "currency": "EUR"})
account_id = acct["id"]
import datetime
today = datetime.date.today().isoformat()
client.create_transaction({
"account_id": account_id,
"date": today,
"amount": -25.0,
"type": "expense",
"currency": "EUR",
})
server = create_server(client=client)
# list_accounts parity
tool_result = _unwrap(_call(server, "list_accounts"))
client_result = client.list_accounts()
assert tool_result["result"] == client_result
# list_transactions parity
tool_txns = _unwrap(_call(server, "list_transactions", {"account_id": account_id}))
client_txns = client.list_transactions(account_id=account_id)
assert tool_txns["result"] == client_txns
# get_net_worth parity
tool_nw = _unwrap(_call(server, "get_net_worth"))
client_nw = client.get_net_worth()
assert tool_nw["result"] == client_nw
class TestWriteRoundTrip:
def test_write_round_trip(self, client):
"""Writes-enabled server: create account + expense, then verify via FinClient."""
server = create_server(client=client, allow_writes=True)
# Create account via tool
result = _unwrap(_call(server, "create_account", {
"name": "Write Test",
"type": "bank",
"currency": "EUR",
}))
account_id = result["result"]["id"]
# Create expense via tool
import datetime
today = datetime.date.today().isoformat()
_call(server, "create_transaction", {
"account_id": account_id,
"date": today,
"amount": -50.0,
"type": "expense",
"currency": "EUR",
})
# Verify via FinClient
txns = client.list_transactions(account_id=account_id)
assert len(txns) == 1
assert txns[0]["amount"] == -50.0
class TestWriteErrors:
def test_write_error_surfaces(self, client):
"""Deleting a non-existent transaction surfaces the API error, not a crash.
Depending on SDK version the failure either raises ToolError or comes
back as an error-flagged result; both must carry the upstream 404 so
the caller can tell what went wrong.
"""
server = create_server(client=client, allow_writes=True)
try:
result = _call(server, "delete_transaction", {"transaction_id": 99999})
except Exception as exc: # ToolError on mcp >=1.12
assert "404" in str(exc), f"Error lost the upstream status: {exc}"
return
content = result[0] if isinstance(result, tuple) else result
item = content[0]
is_error = getattr(item, "isError", False) or getattr(item, "type", "") == "error"
text = getattr(item, "text", "")
assert is_error or "404" in text, (
f"Expected an error for missing id 99999, got: {text}"
)
class TestInvariantGuards:
"""The MCP surface must not be able to break ledger invariants."""
def test_create_transaction_rejects_two_leg_types(self, client):
"""create_transaction cannot write a single leg of a transfer.
POST /api/transactions accepts all six transaction types, so a bare
`type: str` would let a caller create a `transfer` row with no
linked_account_id — an orphan leg that corrupts balances and that the
paired-delete logic can never clean up. The tool schema must refuse it.
"""
server = create_server(client=client, allow_writes=True)
schema = None
for tool in asyncio.run(server.list_tools()):
if tool.name == "create_transaction":
schema = tool.inputSchema
break
assert schema is not None, "create_transaction not registered"
# Resolve the enum, which may sit behind a $ref/$defs indirection.
prop = schema["properties"]["type"]
allowed = prop.get("enum")
if allowed is None:
ref = prop.get("$ref", "").rsplit("/", 1)[-1]
allowed = schema.get("$defs", {}).get(ref, {}).get("enum")
assert allowed is not None, f"type is not constrained by an enum: {prop}"
assert set(allowed) == {"expense", "income"}, (
f"create_transaction must only allow expense/income, got: {allowed}"
)
class TestNameResolution:
"""find_account / find_category turn a human name into the id tools need."""
def test_exact_match_beats_substring(self, client):
"""An exact name must not be drowned out by longer names containing it."""
client.create_account({"name": "Food", "type": "bank", "currency": "EUR"})
client.create_account({"name": "Fast Food Fund", "type": "bank", "currency": "EUR"})
server = create_server(client=client)
hits = _unwrap(_call(server, "find_account", {"name": "food"}))["result"]
assert [h["name"] for h in hits] == ["Food"]
def test_substring_returns_every_candidate(self, client):
"""An ambiguous name returns all matches so the caller can disambiguate."""
client.create_account({"name": "Savings A", "type": "bank", "currency": "EUR"})
client.create_account({"name": "Savings B", "type": "bank", "currency": "EUR"})
server = create_server(client=client)
hits = _unwrap(_call(server, "find_account", {"name": "savings"}))["result"]
assert sorted(h["name"] for h in hits) == ["Savings A", "Savings B"]
def test_no_match_is_empty_not_an_error(self, client):
"""A miss must return [] rather than raising, so the model can ask the user."""
server = create_server(client=client)
hits = _unwrap(_call(server, "find_account", {"name": "no such account"}))["result"]
assert hits == []
def test_find_category_returns_usable_id(self, client):
client.create_category({"name": "Groceries", "color": "#22c55e", "icon": "🛒"})
server = create_server(client=client)
hits = _unwrap(_call(server, "find_category", {"name": "groceries"}))["result"]
assert len(hits) == 1
assert hits[0]["name"] == "Groceries"
assert isinstance(hits[0]["id"], int)
class TestResources:
"""Accounts and categories are also exposed as resources, not just tools."""
def test_reference_resources_are_listed(self, client):
server = create_server(client=client)
uris = {str(r.uri) for r in asyncio.run(server.list_resources())}
assert "fin://accounts" in uris
assert "fin://categories" in uris
def test_accounts_resource_serves_live_json(self, client):
import json
client.create_account({"name": "Resource Bank", "type": "bank", "currency": "EUR"})
server = create_server(client=client)
contents = list(asyncio.run(server.read_resource("fin://accounts")))
assert len(contents) == 1
assert contents[0].mime_type == "application/json"
payload = json.loads(contents[0].content)
assert [a["name"] for a in payload] == ["Resource Bank"]
assert payload == client.list_accounts()
+110
View File
@@ -0,0 +1,110 @@
# fin-mcp — MCP server for the fin API.
#
# This file is NOT applied from this repo. The cluster is reconciled by Flux
# from the GitOps repo (Kustomization `fin-app`, namespace flux-system); copy
# this alongside the existing fin-api / fin-web manifests there.
#
# Deliberately ClusterIP-only, with no HTTPRoute. Writes are enabled, and the
# fin API has no authentication (FIN_API_TOKEN is unset on fin-api), so the
# only thing keeping this safe is that nothing outside the cluster can reach
# it. Do not add an HTTPRoute for this Service without turning on auth first.
# This matches how karakeep-mcp, paperless-mcp and searxng-mcp are deployed.
#
# In-cluster endpoint: http://fin-mcp.apps.svc.cluster.local:8000/mcp
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fin-mcp
namespace: apps
labels:
app: fin
component: mcp
spec:
replicas: 1
selector:
matchLabels:
app: fin
component: mcp
template:
metadata:
labels:
app: fin
component: mcp
spec:
securityContext:
runAsUser: 1000
runAsGroup: 1000
containers:
- name: mcp
image: harbor.cloud.traunseenet.com/library/fin-mcp:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8000
protocol: TCP
env:
# Talks to the API over the cluster Service, not the public route.
- name: FIN_API_URL
value: http://fin-api.apps.svc.cluster.local:8000
- name: FIN_MCP_TRANSPORT
value: streamable-http
- name: FIN_MCP_HOST
value: 0.0.0.0
- name: FIN_MCP_PORT
value: "8000"
- name: FIN_MCP_PATH
value: /mcp
# 38 tools instead of 14. Safe only while this stays unreachable
# from outside the cluster — see the header note.
- name: FIN_MCP_ALLOW_WRITES
value: "1"
# Uncomment together with FIN_API_TOKEN on fin-api.
# - name: FIN_API_TOKEN
# valueFrom:
# secretKeyRef:
# name: fin-api-token
# key: token
# MCP has no unauthenticated health route, so probe the TCP socket
# rather than an HTTP path — GET /mcp answers 406 without the MCP
# Accept headers, which would read as a failure.
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: fin-mcp
namespace: apps
labels:
app: fin
component: mcp
spec:
type: ClusterIP
selector:
app: fin
component: mcp
ports:
- name: http
port: 8000
targetPort: http
protocol: TCP
+18
View File
@@ -13,6 +13,24 @@ services:
timeout: 10s
retries: 3
mcp:
image: harbor.cloud.traunseenet.com/library/fin-mcp:latest
build: ./cli
environment:
- FIN_API_URL=http://api:8000
- FIN_MCP_TRANSPORT=streamable-http
- FIN_MCP_HOST=0.0.0.0
- FIN_MCP_PORT=8000
- FIN_MCP_PATH=/mcp
- FIN_MCP_ALLOW_WRITES=1
# No ports mapping on purpose: the API has no auth, so the MCP surface
# stays reachable only from inside the compose network. Add a mapping
# only if you also set FIN_API_TOKEN.
depends_on:
api:
condition: service_healthy
restart: unless-stopped
web:
image: harbor.cloud.traunseenet.com/library/fin-web:latest
build: ./web