Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/canary.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Dependency canary

# Resolves from the declared constraints against live PyPI on a schedule, so an
# upstream major release surfaces here within a week instead of in a user's install.
# mcp 2.0.0 landed 2026-07-28 and was reported by a user on 07-31; this job is what
# should have caught it first.

on:
schedule:
- cron: "0 6 * * 1" # Mondays, 06:00 UTC
workflow_dispatch:

permissions:
contents: read

jobs:
resolve:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# highest: what a user installing today gets.
# lowest-direct: proves the floors we declare are actually installable.
resolution: [highest, lowest-direct]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: python scripts/smoke_test.py --resolution ${{ matrix.resolution }}

report:
needs: resolve
if: failure()
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v7
with:
script: |
const title = 'Dependency canary failed';
const url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = [
'The scheduled dependency canary failed, which means a fresh resolution',
'from the constraints in `pyproject.toml` no longer produces a working',
'install. This usually means an upstream release broke us.',
'',
`Run: ${url}`,
'',
'Check which leg failed. `highest` means a new upstream version broke us',
'and users installing today are affected. `lowest-direct` means a declared',
'floor is no longer installable.',
].join('\n');

const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const open = existing.data.find(i => i.title === title && !i.pull_request);

if (open) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: open.number,
body,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
});
}
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
# Locked environment. Catches code regressions.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync --group dev
- run: uv run pytest
env:
YNAB_API_KEY: test-dummy-key

# Fresh resolution from pyproject constraints, no lockfile. Catches the world
# moving underneath us, which is what the locked job structurally cannot see.
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: python scripts/smoke_test.py
16 changes: 15 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,22 @@ jobs:
env:
YNAB_API_KEY: test-dummy-key

# Fresh resolution from the released tag. The `test` job above installs from
# uv.lock, which is exactly the check that let 1.0.3 publish broken (see #21).
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- uses: astral-sh/setup-uv@v6
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: python scripts/smoke_test.py

publish:
needs: test
needs: [test, smoke]
runs-on: ubuntu-latest
permissions:
id-token: write
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ This repo contains two things:

### Python MCP server (`src/`)

- **`server/`** — FastMCP server package, run standalone via `python -m src.server`. Tool definitions are split by domain across submodules (`accounts.py`, `transactions.py`, `categories.py`, etc.) and registered through `src/server/__init__.py`. Shared infrastructure (`mcp`, `cache`, `client`, `handle_errors`, `serialize` helpers, `DEFAULT_EXCLUDES`) lives in `src/server/_shared.py`. All tools use the `@handle_errors` decorator for uniform YNAB/HTTP error handling and lazy DB init.
- **`server/`** — MCPServer (mcp SDK 2.x) server package, run standalone via `python -m src.server`. Tool definitions are split by domain across submodules (`accounts.py`, `transactions.py`, `categories.py`, etc.) and registered through `src/server/__init__.py`. Shared infrastructure (`mcp`, `cache`, `client`, `handle_errors`, `serialize` helpers, `DEFAULT_EXCLUDES`) lives in `src/server/_shared.py`. All tools use the `@handle_errors` decorator for uniform YNAB/HTTP error handling and lazy DB init.
- **`ynab_client.py`** — Async httpx client for YNAB API v1.
- **`cache/`** — 4-tier caching: TTL-based response cache, delta sync (server knowledge tracking), retry with exponential backoff, SQLite persistence.
- **`models/`** — Pydantic models, each module exports a `*_DEFAULT_EXCLUDE` set defining the fields hidden from MCP responses by default. The registry in `src/server/_shared.py` (`DEFAULT_EXCLUDES`) maps model classes to their default exclude sets. Every MCP tool accepts an optional `exclude_fields: list[str]` param that, when provided, fully replaces the default. See `FIELDS.md` for the per-model field reference.
Expand All @@ -33,3 +33,4 @@ This repo contains two things:

- **Milliunits:** YNAB stores money as milliunits (1000 = $1.00). The Python server accepts dollars in tool parameters and converts to milliunits internally.
- **Month format:** YNAB months use first-of-month dates (`2026-03-01` for March 2026).
- **Dependency bounds:** runtime deps in `pyproject.toml` carry upper bounds. An unbounded `mcp[cli]>=1.26.0` broke every fresh install when the SDK shipped 2.0.0 (#21). `uv run pytest` uses `uv.lock` and cannot catch this class of break; `python scripts/smoke_test.py` resolves fresh from the declared constraints and can. Run it before releasing, and after touching any dependency.
20 changes: 14 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mcp-ynab"
version = "1.0.3"
version = "1.1.0"
description = "MCP server that connects AI assistants to your YNAB budget"
readme = "README.md"
license = "AGPL-3.0-only"
Expand All @@ -14,11 +14,16 @@ classifiers = [
"Topic :: Office/Business :: Financial",
]
dependencies = [
"httpx>=0.28.1",
"mcp[cli]>=1.26.0",
"pydantic>=2.11.3",
"pydantic-settings>=2.9.1",
"sqlalchemy[asyncio]>=2.0.41",
# Upper bounds are deliberate: an unbounded mcp[cli]>=1.26.0 broke every fresh
# install when the SDK shipped 2.0.0 and removed mcp.server.fastmcp (see #21).
# aiosqlite is intentionally left unbounded; it is never imported directly and
# SQLAlchemy constrains it. Lifting a cap needs a canary/smoke run, not a guess.
"httpx>=0.28.1,<1.0",
"mcp[cli]>=2.0.0,<3.0.0",
# Floor tracks mcp 2.x, which itself requires pydantic>=2.12.0.
"pydantic>=2.12.0,<3.0.0",
"pydantic-settings>=2.9.1,<3.0.0",
"sqlalchemy[asyncio]>=2.0.41,<3.0.0",
"aiosqlite>=0.21.0",
]

Expand All @@ -37,6 +42,9 @@ dev = [
]

[tool.hatch.build.targets.wheel]
# TODO: this installs a top-level `src` package into site-packages, which collides
# with any other distribution doing the same. Rename to `mcp_ynab` (breaking import
# change, needs its own major/minor release, not a fix release).
packages = ["src"]

[build-system]
Expand Down
Loading
Loading