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:
1 parent
082b6081b7
commit
439a15264b
11 files changed
+1632
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""fin-mcp — MCP server wrapping FinClient for the fin personal finance app."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
+5
-1
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user