diff --git a/CLAUDE.md b/CLAUDE.md
index d4448d48..ccdc8f09 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,8 +12,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- `uv run pytest` — Run all tests
- `uv run pytest tests/test_foo.py::test_name` — Run a single test
- `uv run python -m src.server` — Run MCP server standalone (stdio transport)
+- `uv run python -m src.cli auth login|status|logout` — Manage the stored token
+- `uv run python -m src.cli cache purge` — Delete the cache DB and its key
-**Environment:** Requires `YNAB_API_KEY` in `.env.local`
+**Environment:** The token comes from the OS credential store (`mcp-ynab auth
+login`) or, with a deprecation warning, from `YNAB_API_KEY` in `.env.local`.
+Other flags: `YNAB_ALLOW_ENV_KEY=0` (credential store only), `YNAB_READ_ONLY=1`
+(write tools not registered), `YNAB_CACHE_ENCRYPTION=0` (plaintext cache,
+debugging only).
## Architecture
@@ -27,10 +33,26 @@ This repo contains two things:
- **`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.
-- **`config.py`** — `Settings` via pydantic-settings. Cache DB path is platform-specific (`~/Library/Application Support/ynab-mcp-server/cache.db` on macOS).
+- **`security/`** — `credentials.py` (keyring-backed store for the token and the
+ cache key), `redaction.py` (scrubs live secrets from every error path),
+ `crypto.py` (AES-256-GCM for the cache), `consent.py` + `diff.py` (human
+ confirmation with a real before/after diff). See `SECURITY.md` for the threat
+ model and its stated limits.
+- **`cli.py`** — the `mcp-ynab` entry point. A bare invocation starts the stdio
+ server; `auth`/`cache` subcommands manage credentials and the cache. Server
+ modules are imported lazily so `auth login` works before a token exists.
+- **`config.py`** — `Settings` via pydantic-settings. `ynab_api_key` is a
+ `SecretStr` resolved from the credential store first, then the environment.
+ Cache DB path is platform-specific (`~/Library/Application Support/ynab-mcp-server/cache.db` on macOS).
### Key conventions
- **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).
+- **Mutating tools require consent:** the 16 write tools take a `ctx: Context | None = None`
+ parameter (injected by the SDK, invisible in the tool's input schema) and call
+ `consent.confirm()` before writing. The pre-image must come from
+ `_shared.client`, never the cache — a stale row would show the user a false
+ "before". Register them with `_shared.mutating_tool()`, not `_shared.mcp.tool()`,
+ so `YNAB_READ_ONLY=1` can drop them at registration time.
- **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.
diff --git a/README.md b/README.md
index d36e1703..b52c3461 100644
--- a/README.md
+++ b/README.md
@@ -12,17 +12,41 @@ An MCP server that connects AI assistants to your [YNAB](https://www.ynab.com/)
- **Search & analytics** — text search across transactions, per-category spending breakdowns, Sankey flow data
- **Bulk operations** — create or update multiple transactions in a single call
- **Dollar amounts** — accepts dollars in parameters, converts to YNAB milliunits internally
+- **Token in the OS credential store** — not in a config file any agent can read
+- **Human confirmation for every write** — with a real before/after diff, read live from YNAB
+- **Encrypted cache** — cached budget data is AES-256-GCM at rest
## Quick Start
+Store your [YNAB personal access token](https://app.ynab.com/settings/developer)
+once, in your OS credential store:
+
+```bash
+uv tool run mcp-ynab auth login
```
-uv tool run mcp-ynab
+
+The token is read hidden, validated against the YNAB API, and then saved to
+Windows Credential Manager, macOS Keychain, or Secret Service. It does not go
+into any config file, and it is not read from `argv` or left in shell history.
+
+Nothing appears as you type or paste — that is intended. If your terminal will
+not paste into a hidden prompt (the Windows console sends Ctrl+V as a control
+character rather than pasting), feed it from a file instead:
+
+```bash
+uv tool run mcp-ynab auth login --stdin < token.txt
```
-Requires a [YNAB personal access token](https://app.ynab.com/settings/developer) set as `YNAB_API_KEY`.
+```bash
+uv tool run mcp-ynab auth status # which source is in use, plus a fingerprint
+uv tool run mcp-ynab auth logout # remove the stored token
+uv tool run mcp-ynab cache purge # delete the local cache and its encryption key
+```
## Configuration
+No API key goes in the config — `auth login` already stored it.
+
### Claude Desktop / ChatGPT
Add to your config file:
@@ -32,10 +56,7 @@ Add to your config file:
"mcpServers": {
"ynab": {
"command": "uv",
- "args": ["tool", "run", "mcp-ynab"],
- "env": {
- "YNAB_API_KEY": "your-api-key-here"
- }
+ "args": ["tool", "run", "mcp-ynab"]
}
}
}
@@ -44,11 +65,48 @@ Add to your config file:
### Claude Code
```bash
-claude mcp add-json ynab --scope user '{"type":"stdio","command":"uv","args":["tool","run","mcp-ynab"],"env":{"YNAB_API_KEY":"your-api-key-here"}}'
+claude mcp add-json ynab --scope user '{"type":"stdio","command":"uv","args":["tool","run","mcp-ynab"]}'
```
See [mcp-ynab.com](https://mcp-ynab.com) for config file locations and troubleshooting.
+### Environment variables
+
+| Variable | Default | Effect |
+|---|---|---|
+| `YNAB_API_KEY` | unset | Still honoured, with a deprecation warning on stderr. Putting it in an MCP client config writes your token in plaintext into a file any agent can read — prefer `auth login`. |
+| `YNAB_ALLOW_ENV_KEY` | `1` | Set to `0` to refuse `YNAB_API_KEY` entirely, so the credential store is the only accepted source. |
+| `YNAB_READ_ONLY` | `0` | Set to `1` to stop the 16 write tools being registered at all. They never reach the model's tool list. |
+| `YNAB_CACHE_ENCRYPTION` | `1` | Set to `0` to store the cache in plaintext. Debugging only. |
+
+## Writes require your confirmation
+
+Every tool that changes something in YNAB asks you first, showing the current
+state read live from the YNAB API alongside exactly what would change:
+
+```
+CONFIRM: Update transaction
+Plan: bud-1
+
+Current state, read from the YNAB API just now:
+ 2026-03-15 Chez Nowhere Groceries -$50.25 cleared=uncleared approved=no
+
+This will change:
+ amount : -$50.25 -> -$60.00
+ approved : no -> yes
+
+Confirm to apply exactly this. Nothing else will be sent.
+```
+
+Bulk operations list every row rather than summarising a count. Declining, or a
+client that cannot ask you, blocks the write — there is no fallback that applies
+it unconfirmed.
+
+This relies on your MCP client supporting elicitation. **On a client that does
+not, writes will refuse outright.** See [SECURITY.md](./SECURITY.md) for the full
+threat model, including what the credential store does and does not protect
+against.
+
## Available Tools
| Group | Tools |
@@ -80,11 +138,15 @@ uv sync
# Run tests
uv run pytest
+# Fresh-resolution smoke test (resolves from pyproject.toml, not uv.lock)
+python scripts/smoke_test.py
+
# Run server standalone
uv run python -m src.server
```
-Requires `YNAB_API_KEY` in `.env.local` for running the server.
+Running the server needs a token: `uv run python -m src.cli auth login`, or
+`YNAB_API_KEY` in `.env.local`.
## License
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..bef1a2e7
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,246 @@
+# Security
+
+This document describes what this server protects, what it deliberately does not,
+and how to operate it safely. Where a protection has a limit, the limit is stated
+here rather than left for you to discover.
+
+## Threat model
+
+### What was actually wrong
+
+The model never had access to your YNAB token. This server runs as a local
+subprocess; the LLM only ever sees tool *return values*, never process memory and
+never the environment block.
+
+The real leak was mundane. A setup line like:
+
+```bash
+claude mcp add-json ynab --scope user '{...,"env":{"YNAB_API_KEY":"..."}}'
+```
+
+writes the token **in plaintext into the MCP client's own config file** —
+`~/.claude.json`, `claude_desktop_config.json`, or a project `.env.local`. Any
+agent with a file-read or shell tool, which is most of them including Claude Code
+itself, can open that file and read the token. It also lands in shell history.
+
+That is the hole this closes.
+
+### What the fix achieves
+
+The token lives in the OS credential store (Windows Credential Manager, macOS
+Keychain, Secret Service on Linux) under the service name `mcp-ynab`. It is:
+
+- out of every config file an agent can read,
+- out of the process environment block,
+- out of shell history — `mcp-ynab auth login` reads it with `getpass`, never
+ from `argv`,
+- a `SecretStr` in the process, unwrapped at exactly one line (the `Authorization`
+ header in `YNABClient.__init__`) and never stored on the client afterwards,
+- scrubbed out of every error string a tool returns, as a backstop for YNAB error
+ bodies and `httpx` exception reprs that can echo request headers.
+
+That is meaningful and worth doing.
+
+### What the fix does not achieve
+
+**DPAPI, Keychain and Secret Service scope secrets to the logged-in *user*, not
+to a particular *program*.** Code running as the same user can ask the credential
+store for the same secret and get it. An agent with shell access as that user can
+still retrieve your YNAB token.
+
+Closing that gap requires a separate OS account, a hardware token, or a broker
+process that authenticates its callers — none of which a pip-installable MCP
+server can deliver. Do not read anything in this document as a claim otherwise.
+
+What you get is a large reduction in *casual* exposure: the token is no longer
+sitting in a plaintext file that any tool wanders into while doing something
+else. You do not get isolation from code running as you.
+
+## Human consent for writes
+
+Every one of the 16 mutating tools requires a person to approve the exact change
+before it is sent to YNAB:
+
+`create_account`, `create_transaction`, `create_transactions`,
+`update_transaction`, `update_transactions`, `delete_transaction`,
+`import_transactions`, `create_category`, `update_category`,
+`create_category_group`, `update_category_group`, `update_category_for_month`,
+`update_payee`, `create_scheduled_transaction`, `update_scheduled_transaction`,
+`delete_scheduled_transaction`.
+
+The security property is:
+
+> the "before" shown to the human comes from the YNAB API, not from anything the
+> model said, and the payload applied afterwards is the one that was shown.
+
+Two mechanisms hold that up.
+
+**The model cannot forge or suppress consent.** A tool parameter annotated
+`Context` is injected by the MCP SDK and does not appear in the tool's input
+schema. The model cannot supply it, cannot fake it, and cannot leave it out.
+Consent therefore cannot be satisfied by anything the model emits.
+
+**The "before" is read live.** Pre-images come from `_shared.client`, which
+bypasses the cache entirely. A stale cached row would show you a false "before",
+which would make the whole prompt worse than useless. Each call site then
+fingerprints the resolved payload before eliciting and asserts the fingerprint
+afterwards, so nothing can be re-resolved between the prompt and the write.
+
+Bulk operations itemise **every** row with its own diff. They are never
+summarised as "12 transactions" — that is exactly the prompt a person waves
+through without having read it. A long prompt is the correct outcome there.
+
+`import_transactions` has no meaningful diff, because YNAB decides what gets
+imported. It is gated anyway: the prompt names the linked accounts that may be
+affected and states plainly that the result is not knowable in advance.
+
+### If your client cannot elicit
+
+**Writes are refused outright.** There is no silent fallback that applies the
+change unconfirmed. If the client did not advertise MCP elicitation support, or
+the elicitation request fails, the tool returns an error explaining that the
+write was blocked and how to fix it.
+
+This is intended behaviour, but it is worth knowing before you hit it mid-task:
+confirm which of your MCP clients actually implement `elicitation/create`. On any
+that do not, this server becomes read-only in practice.
+
+## Cache encryption
+
+The local SQLite cache holds payees, memos, amounts and category names — a fairly
+complete picture of your finances — in a file under your user profile.
+
+The payload columns are encrypted with **AES-256-GCM**. The key is 256 bits from
+`os.urandom`, generated on first use and stored base64 in the OS credential store
+under the entry `cache_key`. The envelope is `nonce(12) || ciphertext || tag`,
+with a storage-layer framing byte in front so the format can be identified.
+
+SQLCipher would encrypt the whole file, but it needs a native build that is
+painful to install on Windows. This is a column-level scheme instead, and that
+choice has consequences, listed below.
+
+### What stays in plaintext
+
+These columns are needed for lookups and delta sync and are **not** encrypted:
+
+`plan_id`, `entity_type`, `entity_id`, `cache_key`, `is_deleted`, `cached_at`,
+`ttl_seconds`, `knowledge`, `last_synced_at`.
+
+This leaks **metadata**: how many entities of each type you have, which plans
+exist, and when the server last synced. It does not leak amounts, payees, memos
+or category names.
+
+### The AAD row-swap limitation
+
+The additional authenticated data is a fixed `mcp-ynab:
.`. That
+binds a ciphertext to its table and column, but **not to its row**. Someone with
+write access to the database file could swap ciphertexts between rows of the same
+table, and decryption would succeed.
+
+This is acceptable because it requires write access to the DB in the first place,
+at which point they can simply corrupt the cache — and the cache is disposable
+data that rebuilds itself from YNAB. It is documented rather than hidden.
+
+### File permissions
+
+On POSIX the DB file and its WAL sidecars are set to `0600`. On Windows there is
+no portable equivalent; the file inherits the user-profile ACL, which excludes
+other non-administrator users but not you or anything running as you.
+
+### Turning it off
+
+`YNAB_CACHE_ENCRYPTION=0` stores the cache in plaintext. This exists for
+debugging. If no credential store is available at all, the server **fails closed**
+with a message naming this flag, rather than quietly writing plaintext while you
+believe the cache is encrypted.
+
+## Read-only mode
+
+`YNAB_READ_ONLY=1` stops the 16 mutating tools being registered at all. They
+never appear in the model's tool list, so there is nothing to call and nothing to
+be talked out of — a stronger position than offering a tool that then refuses.
+
+It is **off by default**: writes stay enabled unless you opt in.
+
+## Operations
+
+### Store or rotate your token
+
+```bash
+mcp-ynab auth login
+```
+
+Reads the token with `getpass` (hidden, never in `argv` or shell history),
+validates it against `GET /user`, and only then stores it. An invalid token is
+refused rather than saved.
+
+Input is invisible as you type or paste — that is `getpass` working, not a hang.
+On the Windows console, Ctrl+V at a hidden prompt is delivered as a literal
+control character rather than pasting; right-click usually pastes instead. If
+your terminal will not cooperate, pipe the token from a file:
+
+```bash
+mcp-ynab auth login --stdin < token.txt && rm token.txt
+```
+
+In PowerShell, the clipboard works directly and leaves nothing on disk:
+
+```powershell
+Get-Clipboard | mcp-ynab auth login --stdin
+```
+
+`--stdin` is also the headless/CI path. It keeps the token out of `argv` and out
+of shell history, which is the property that matters; `echo | ...` does
+not, because the token lands in history. Running it again replaces the stored token, which is
+how you rotate: revoke the old one at
+, create a new one, run `auth login`.
+
+```bash
+mcp-ynab auth status # which source is in use, and a fingerprint — never the token
+mcp-ynab auth logout # delete the stored token
+```
+
+After migrating, **remove `YNAB_API_KEY` from your MCP client config and from
+`.env.local`**. The credential store takes precedence over the environment, so
+leaving a stale key there is not a functional problem — it is exactly the
+plaintext exposure this was meant to remove. `auth status` warns when it sees
+one.
+
+### Purge cached budget data
+
+```bash
+mcp-ynab cache purge
+```
+
+Deletes the cache database, its WAL sidecars, and the cache key. The cache
+rebuilds itself from YNAB on the next request.
+
+Do this if you suspect the cache file has been exposed, or after revoking a
+token.
+
+### Strict setup
+
+```
+YNAB_ALLOW_ENV_KEY=0
+```
+
+Refuses the `YNAB_API_KEY` environment variable outright, so the credential store
+is the only accepted source. Without this, an environment variable is still
+honoured — with a deprecation warning on stderr — so existing installs keep
+working.
+
+## Credential resolution order
+
+1. OS credential store (`mcp-ynab` / `api_key`)
+2. `YNAB_API_KEY` environment variable or `.env.local` — permitted, with a
+ deprecation warning on stderr, unless `YNAB_ALLOW_ENV_KEY=0`
+3. Otherwise the server refuses to start and tells you to run `mcp-ynab auth login`
+
+The credential store wins over the environment deliberately, so a fresh
+`auth login` takes effect even when a stale key is still in an MCP client config.
+
+## Reporting a vulnerability
+
+Open an issue at
+. Do not include your YNAB
+token, an account ID, or a cache file in the report.
diff --git a/pyproject.toml b/pyproject.toml
index 4e7d50ad..74e6dcf0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,10 +25,15 @@ dependencies = [
"pydantic-settings>=2.9.1,<3.0.0",
"sqlalchemy[asyncio]>=2.0.41,<3.0.0",
"aiosqlite>=0.21.0",
+ # Credential storage and cache encryption. keyring wraps the per-platform
+ # store (Windows Credential Manager, macOS Keychain, Secret Service);
+ # cryptography provides AES-256-GCM for the at-rest cache envelope.
+ "keyring>=25.0,<26.0",
+ "cryptography>=43.0,<47.0",
]
[project.scripts]
-mcp-ynab = "src.server:main"
+mcp-ynab = "src.cli:main"
[project.urls]
Homepage = "https://github.com/pragprogrammer/mcp-ynab"
diff --git a/src/cli.py b/src/cli.py
new file mode 100644
index 00000000..942615fa
--- /dev/null
+++ b/src/cli.py
@@ -0,0 +1,302 @@
+"""Command line entry point for `mcp-ynab`.
+
+A bare invocation starts the MCP stdio server, so existing client configs keep
+working untouched. Subcommands manage the credential store and the local cache.
+
+Writing to stdout is fine *here*: these subcommands are run by a human in a
+terminal, not over the MCP transport. The server path below prints nothing to
+stdout for exactly that reason.
+
+The server modules are imported lazily inside serve(), because importing them
+constructs Settings, which refuses to build without a token — `auth login` has
+to be runnable before a token exists.
+"""
+
+import argparse
+import asyncio
+import getpass
+import os
+import re
+import sys
+from pathlib import Path
+
+from src.security import credentials
+
+# A YNAB personal access token is 64 printable ASCII characters with no spaces.
+# The floor below is deliberately loose: the point is to catch input that cannot
+# be a token at all, not to encode YNAB's exact format and break when it changes.
+_TOKEN_MIN_LEN = 20
+_HAS_WHITESPACE = re.compile(r"\s")
+
+
+def _read_stdin_token() -> str:
+ """Read a token from stdin, tolerating a byte-order mark.
+
+ Windows PowerShell 5.1 prefixes anything it pipes to a native executable
+ with a UTF-8 BOM. A BOM is not whitespace, so .strip() leaves it in place
+ and it then reads as a control character — decoding as utf-8-sig consumes
+ it. Any stray BOM elsewhere in the input is dropped too, which clipboard
+ and editor round-trips occasionally introduce.
+ """
+ raw = getattr(sys.stdin, "buffer", None)
+ if raw is None: # stdin already wrapped, e.g. under a test harness
+ return sys.stdin.read().replace("", "").strip()
+ try:
+ text = raw.read().decode("utf-8-sig")
+ except UnicodeDecodeError:
+ return ""
+ return text.replace("", "").strip()
+
+
+def _why_not_a_token(token: str) -> str | None:
+ """Explain why `token` cannot be a YNAB token, or None if it might be.
+
+ Worth checking before the network call. A token containing a control
+ character builds a malformed Authorization header, and YNAB answers that
+ with a bodyless HTTP 400 that says nothing about the actual cause.
+ """
+ if any(not c.isprintable() for c in token):
+ return (
+ "the input contains control characters rather than a token.\n"
+ " On the Windows console, Ctrl+V at a hidden prompt is delivered as a\n"
+ " literal control character instead of pasting. Try right-clicking to\n"
+ " paste, or skip pasting entirely with: auth login --stdin"
+ )
+ if _HAS_WHITESPACE.search(token):
+ return "the input contains whitespace; a YNAB token has none."
+ if len(token) < _TOKEN_MIN_LEN:
+ return (
+ f"the input is {len(token)} character(s); a YNAB personal access token "
+ "is 64. A partial paste looks like this."
+ )
+ return None
+
+
+def _db_path() -> str:
+ """The cache DB path, matching what Settings would resolve."""
+ from src.config import _default_db_path
+
+ return os.environ.get("CACHE_DB_PATH") or _default_db_path()
+
+
+async def _validate_token(token: str) -> tuple[bool, str]:
+ """Check a token against the live API. Returns (ok, detail)."""
+ import httpx
+
+ from src.ynab_client import YNABClient, YNABError
+
+ client = YNABClient(token)
+ try:
+ user = await client.get_user()
+ return True, f"user id {user.id}"
+ except YNABError as e:
+ return False, f"YNAB rejected the token: {e.message}"
+ except httpx.HTTPStatusError as e:
+ # YNAB answered. Do not report that as a failure to reach it.
+ return False, f"YNAB rejected the request with HTTP {e.response.status_code}"
+ except httpx.RequestError as e:
+ return False, f"could not reach YNAB: {type(e).__name__}: {e}"
+ except Exception as e:
+ return False, f"unexpected failure validating the token: {type(e).__name__}: {e}"
+ finally:
+ await client.close()
+
+
+def auth_login(from_stdin: bool = False) -> int:
+ """Read a token, validate it, then store it."""
+ if from_stdin:
+ # For terminals where pasting into a hidden prompt does not work, and
+ # for headless setups. Piping from a file keeps the token out of argv
+ # and out of shell history, which is the property that matters.
+ token = _read_stdin_token()
+ else:
+ # Both streams, not just stdin. Under Git Bash/MSYS on Windows
+ # sys.stdin.isatty() reports True even when stdin is redirected, and
+ # getpass resolves to win_getpass, which reads the console directly and
+ # ignores the redirect — so a stdin-only check passes and the process
+ # then blocks forever waiting for a human who is not there. Requiring
+ # stdout to be a terminal too turns that hang into this message.
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
+ print(
+ "ERROR: `auth login` needs an interactive terminal. The token is "
+ "read with getpass so it never lands in argv or shell history.\n"
+ " To feed it from a file instead: auth login --stdin",
+ file=sys.stderr,
+ )
+ return 2
+
+ print("Paste your YNAB personal access token.")
+ print("Create one at https://app.ynab.com/settings/developer\n")
+ print("Input is HIDDEN: nothing appears as you type or paste. That is normal.")
+ print("If Ctrl+V does nothing, try right-click, or rerun with --stdin.\n")
+ token = getpass.getpass("Token: ").strip()
+
+ if not token:
+ print("ERROR: no token entered.", file=sys.stderr)
+ return 2
+
+ problem = _why_not_a_token(token)
+ if problem:
+ # Caught here so the user gets the real cause, rather than an opaque
+ # rejection from an API that never saw a well-formed request.
+ print(f"ERROR: {problem}\nNothing was stored.", file=sys.stderr)
+ return 2
+
+ print("Validating against the YNAB API...")
+ ok, detail = asyncio.run(_validate_token(token))
+ if not ok:
+ # Never store something that does not work; a bad token saved now becomes
+ # a confusing auth failure later, far from its cause.
+ print(f"ERROR: {detail}. Nothing was stored.", file=sys.stderr)
+ return 1
+
+ try:
+ credentials.store_token(token)
+ except credentials.CredentialStoreUnavailable as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ return 1
+
+ print(f"OK: token validated ({detail}) and stored in the OS credential store.")
+ print(f" fingerprint: {credentials.token_fingerprint(token)}")
+ print(
+ "\nYou can now remove YNAB_API_KEY from your MCP client config "
+ "(~/.claude.json, claude_desktop_config.json) and from .env.local."
+ )
+ return 0
+
+
+def auth_status() -> int:
+ """Report which credential source is live, without revealing the token."""
+ stored = credentials.load_token()
+ env = os.environ.get("YNAB_API_KEY")
+ allow_env = os.environ.get("YNAB_ALLOW_ENV_KEY", "1").lower() not in ("0", "false", "no")
+
+ if stored:
+ source, token = "OS credential store", stored
+ elif env and allow_env:
+ source, token = "YNAB_API_KEY environment variable (deprecated)", env
+ else:
+ print("No token available.")
+ if env and not allow_env:
+ print(" YNAB_API_KEY is set but YNAB_ALLOW_ENV_KEY=0 refuses it.")
+ print(" Run `mcp-ynab auth login` to store one.")
+ return 1
+
+ print(f"Token source: {source}")
+ print(f"Fingerprint: {credentials.token_fingerprint(token)}")
+ if stored and env:
+ print(
+ "Note: YNAB_API_KEY is also set in the environment and is being "
+ "ignored.\n Remove it so the token stops sitting in a "
+ "readable config file."
+ )
+ print(f"Cache key: {'present' if credentials.get_secret(credentials.CACHE_KEY_ENTRY) else 'not yet created'}")
+ print(f"Cache DB: {_db_path()}")
+ return 0
+
+
+def auth_logout() -> int:
+ """Delete the stored token."""
+ try:
+ removed = credentials.delete_token()
+ except credentials.CredentialStoreUnavailable as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ return 1
+ print("Token deleted." if removed else "No stored token to delete.")
+ if os.environ.get("YNAB_API_KEY"):
+ print(
+ "WARNING: YNAB_API_KEY is still set in the environment, so the server "
+ "will keep working from it. Remove it too."
+ )
+ return 0
+
+
+def cache_purge() -> int:
+ """Delete the cache database and the key that encrypts it."""
+ db = Path(_db_path())
+ removed = []
+ # -wal and -shm are SQLite's WAL sidecars; leaving them behind would leave
+ # committed pages of plaintext-adjacent data on disk after a "purge".
+ for path in (db, db.with_name(db.name + "-wal"), db.with_name(db.name + "-shm")):
+ if path.exists():
+ path.unlink()
+ removed.append(path.name)
+
+ key_removed = False
+ try:
+ key_removed = credentials.delete_secret(credentials.CACHE_KEY_ENTRY)
+ except credentials.CredentialStoreUnavailable as e:
+ print(f"WARNING: could not remove the cache key: {e}", file=sys.stderr)
+
+ print(f"Cache files removed: {', '.join(removed) if removed else 'none'}")
+ print(f"Cache key removed: {'yes' if key_removed else 'no (none stored)'}")
+ print("The cache rebuilds itself on the next request.")
+ return 0
+
+
+def serve() -> int:
+ """Start the MCP stdio server."""
+ from src.server import main as server_main
+
+ server_main()
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="mcp-ynab",
+ description="MCP server that connects AI assistants to your YNAB budget. "
+ "With no subcommand, starts the stdio server.",
+ )
+ sub = parser.add_subparsers(dest="command")
+
+ sub.add_parser("serve", help="start the MCP stdio server (the default)")
+
+ auth = sub.add_parser("auth", help="manage the stored YNAB token")
+ auth_sub = auth.add_subparsers(dest="auth_command")
+ login = auth_sub.add_parser("login", help="read a token, validate it, store it")
+ login.add_argument(
+ "--stdin",
+ action="store_true",
+ help="read the token from stdin instead of prompting, e.g. "
+ "`type token.txt | mcp-ynab auth login --stdin`",
+ )
+ auth_sub.add_parser("status", help="show which credential source is in use")
+ auth_sub.add_parser("logout", help="delete the stored token")
+
+ cache = sub.add_parser("cache", help="manage the local cache")
+ cache_sub = cache.add_subparsers(dest="cache_command")
+ cache_sub.add_parser("purge", help="delete the cache database and its key")
+
+ return parser
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+
+ if args.command in (None, "serve"):
+ return serve()
+
+ if args.command == "auth":
+ if args.auth_command == "login":
+ return auth_login(from_stdin=args.stdin)
+ handlers = {"status": auth_status, "logout": auth_logout}
+ handler = handlers.get(args.auth_command)
+ if handler is None:
+ parser.parse_args(["auth", "--help"])
+ return 2
+ return handler()
+
+ if args.command == "cache":
+ if args.cache_command != "purge":
+ parser.parse_args(["cache", "--help"])
+ return 2
+ return cache_purge()
+
+ parser.print_help()
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/config.py b/src/config.py
index 0de90bd7..92cebcd3 100644
--- a/src/config.py
+++ b/src/config.py
@@ -1,9 +1,11 @@
import sys
from pathlib import Path
-from pydantic import Field
+from pydantic import Field, PrivateAttr, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
+from src.security import credentials
+
def _default_db_path() -> str:
"""Platform-appropriate default cache DB path."""
@@ -17,6 +19,23 @@ def _default_db_path() -> str:
return str(base / "cache.db")
+class MissingCredentialsError(RuntimeError):
+ """Raised when no YNAB token could be resolved from any permitted source."""
+
+
+_ENV_DEPRECATION = (
+ "WARNING: using YNAB_API_KEY from the environment. Anything that can read "
+ "the MCP client's config file or this process's environment can read the "
+ "token. Run `mcp-ynab auth login` to move it into the OS credential store; "
+ "set YNAB_ALLOW_ENV_KEY=0 to refuse the environment variable outright."
+)
+
+_MISSING = (
+ "No YNAB token found. Run `mcp-ynab auth login` to store one in the OS "
+ "credential store."
+)
+
+
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env.local",
@@ -24,8 +43,19 @@ class Settings(BaseSettings):
extra="ignore",
)
- ynab_api_key: str = Field(alias="YNAB_API_KEY")
+ # Resolved in _resolve_api_key below, which prefers the credential store over
+ # this field. SecretStr so the token cannot land in a repr, a log line, or a
+ # pydantic validation error; .get_secret_value() is called at exactly one site.
+ ynab_api_key: SecretStr | None = Field(default=None, alias="YNAB_API_KEY")
+
+ # Set to 0/false for a strict setup that accepts the credential store only.
+ allow_env_key: bool = Field(default=True, alias="YNAB_ALLOW_ENV_KEY")
+
cache_db_path: str = Field(default_factory=_default_db_path)
+ # Off only for debugging: the cache holds payees, memos and amounts.
+ cache_encryption: bool = Field(default=True, alias="YNAB_CACHE_ENCRYPTION")
+ # Opt-in. Writes stay enabled by default; see mutating_tool() in _shared.py.
+ read_only: bool = Field(default=False, alias="YNAB_READ_ONLY")
http_timeout: float = 30.0
# TTL for non-delta endpoints (seconds)
@@ -41,3 +71,37 @@ class Settings(BaseSettings):
retry_max_attempts: int = 3
retry_base_delay: float = 1.0
retry_max_delay: float = 30.0
+
+ _api_key_source: str = PrivateAttr(default="none")
+
+ @model_validator(mode="after")
+ def _resolve_api_key(self) -> "Settings":
+ """Resolve the token: credential store, then environment, else fail.
+
+ The credential store wins so that `mcp-ynab auth login` takes effect even
+ when a stale YNAB_API_KEY is still sitting in an MCP client config.
+ """
+ stored = credentials.load_token()
+ if stored:
+ self.ynab_api_key = SecretStr(stored)
+ self._api_key_source = "keyring"
+ return self
+
+ if self.ynab_api_key is not None:
+ if not self.allow_env_key:
+ raise MissingCredentialsError(
+ "YNAB_API_KEY is set but YNAB_ALLOW_ENV_KEY=0 refuses it. "
+ "Run `mcp-ynab auth login` to store the token in the OS "
+ "credential store."
+ )
+ # stderr, never stdout: stdout is the MCP transport.
+ print(_ENV_DEPRECATION, file=sys.stderr)
+ self._api_key_source = "env"
+ return self
+
+ raise MissingCredentialsError(_MISSING)
+
+ @property
+ def api_key_source(self) -> str:
+ """Where the live token came from: 'keyring', 'env', or 'none'."""
+ return self._api_key_source
diff --git a/src/db/engine.py b/src/db/engine.py
index e02bcf36..a531b200 100644
--- a/src/db/engine.py
+++ b/src/db/engine.py
@@ -1,7 +1,13 @@
+import os
+import stat
+import sys
+from pathlib import Path
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy import event, inspect, text
from src.db.tables import Base
+from src.security import crypto
_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
@@ -13,11 +19,35 @@ def _set_wal_mode(dbapi_conn, connection_record):
cursor.close()
-async def init_db(db_path: str) -> None:
+def _restrict_permissions(db_path: str) -> None:
+ """Make the cache file owner-only where the OS lets us say so.
+
+ On Windows the file inherits the user profile ACL, which already excludes
+ other non-administrator users; there is no portable chmod equivalent worth
+ faking here.
+ """
+ if sys.platform == "win32":
+ return
+ path = Path(db_path)
+ for candidate in (path, path.with_name(path.name + "-wal"), path.with_name(path.name + "-shm")):
+ if candidate.exists():
+ try:
+ os.chmod(candidate, stat.S_IRUSR | stat.S_IWUSR)
+ except OSError:
+ pass
+
+
+async def init_db(db_path: str, *, encryption: bool = True) -> None:
global _engine, _session_factory
if _engine is not None:
return
+ crypto.configure(enabled=encryption)
+ if encryption:
+ # Resolve the key now so a missing credential store fails here, with a
+ # message naming the fix, rather than midway through a cache write.
+ crypto.load_or_create_key()
+
_engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", echo=False)
event.listen(_engine.sync_engine, "connect", _set_wal_mode)
@@ -35,10 +65,29 @@ def _migrate_cache_tables(connection):
columns = [c["name"] for c in insp.get_columns(table)]
if "budget_id" in columns:
connection.execute(text(f"DROP TABLE {table}"))
+ # Drop payload tables still holding a plaintext JSON/TEXT `data`
+ # column. It is now a BLOB carrying an encryption envelope, and the
+ # two formats cannot be read interchangeably.
+ for table in ("cached_entity", "response_cache"):
+ if not insp.has_table(table):
+ continue
+ data_column = next(
+ (c for c in insp.get_columns(table) if c["name"] == "data"), None
+ )
+ if data_column is None or "BLOB" in str(data_column["type"]).upper():
+ continue
+ connection.execute(text(f"DROP TABLE {table}"))
+ # Emptying cached_entity without resetting server_knowledge would
+ # leave delta sync believing it is current, so the next sync would
+ # fetch only changes since then and the entities dropped here would
+ # never come back. Reset the knowledge with it.
+ if table == "cached_entity" and insp.has_table("server_knowledge"):
+ connection.execute(text("DROP TABLE server_knowledge"))
await conn.run_sync(_migrate_cache_tables)
await conn.run_sync(Base.metadata.create_all)
+ _restrict_permissions(db_path)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
diff --git a/src/db/tables.py b/src/db/tables.py
index b1568e03..9100676f 100644
--- a/src/db/tables.py
+++ b/src/db/tables.py
@@ -2,9 +2,10 @@
from datetime import datetime, timezone
from typing import Any, TypeVar
-from sqlalchemy import JSON, Boolean, DateTime, Integer, String, UniqueConstraint
+from sqlalchemy import Boolean, DateTime, Integer, String, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+from src.db.types import EncryptedJSON
from src.models.common import YNABBaseModel
T = TypeVar("T", bound=YNABBaseModel)
@@ -22,7 +23,9 @@ class CachedEntity(Base):
plan_id: Mapped[str] = mapped_column(String, index=True)
entity_type: Mapped[str] = mapped_column(String, index=True)
entity_id: Mapped[str] = mapped_column(String)
- data: Mapped[Any] = mapped_column(JSON)
+ # Encrypted: this is the whole entity, amounts and payees and memos included.
+ # The columns above stay plaintext because lookups and delta sync need them.
+ data: Mapped[Any] = mapped_column(EncryptedJSON("cached_entity", "data"))
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
cached_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
@@ -68,7 +71,7 @@ class ResponseCache(Base):
id: Mapped[int] = mapped_column(primary_key=True)
cache_key: Mapped[str] = mapped_column(String, unique=True, index=True)
- data: Mapped[Any] = mapped_column(JSON)
+ data: Mapped[Any] = mapped_column(EncryptedJSON("response_cache", "data"))
cached_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
diff --git a/src/db/types.py b/src/db/types.py
new file mode 100644
index 00000000..7095564f
--- /dev/null
+++ b/src/db/types.py
@@ -0,0 +1,69 @@
+"""SQLAlchemy column type that encrypts JSON payloads at rest.
+
+Implemented as a TypeDecorator so encryption is transparent to
+`src/cache/service.py`: it keeps handing dicts and lists to the ORM and getting
+them back, and nothing at the call sites had to change.
+"""
+
+import json
+
+from sqlalchemy import LargeBinary
+from sqlalchemy.types import TypeDecorator
+
+from src.security import crypto
+
+# One framing byte in front of the stored blob, so a database written with
+# encryption off can still be read after it is turned on (and vice versa) rather
+# than decoding as garbage. crypto.encrypt()'s own envelope stays exactly
+# nonce || ciphertext || tag; this byte belongs to the storage layer.
+_PLAINTEXT = b"\x00"
+_AESGCM = b"\x01"
+
+
+class EncryptedJSON(TypeDecorator):
+ """JSON in Python, an encrypted BLOB in SQLite."""
+
+ impl = LargeBinary
+ cache_ok = True
+
+ def __init__(self, table: str, column: str, **kwargs):
+ super().__init__(**kwargs)
+ # Named to match the constructor parameters so SQLAlchemy's cache key
+ # introspection distinguishes two instances bound to different columns.
+ self.table = table
+ self.column = column
+
+ @property
+ def _aad(self) -> bytes:
+ return crypto.aad_for(self.table, self.column)
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return None
+ raw = json.dumps(value, separators=(",", ":")).encode("utf-8")
+ if not crypto.is_enabled():
+ return _PLAINTEXT + raw
+ return _AESGCM + crypto.encrypt(raw, self._aad)
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return None
+ version, body = value[:1], value[1:]
+ if version == _PLAINTEXT:
+ return json.loads(body)
+ if version != _AESGCM:
+ raise crypto.CacheDecryptionError(
+ f"Unrecognised cache envelope version {version!r}. "
+ "Run `mcp-ynab cache purge`."
+ )
+ try:
+ return json.loads(crypto.decrypt(body, self._aad))
+ except crypto.CacheEncryptionUnavailable:
+ raise
+ except Exception as e:
+ # Usually the key was removed while the DB survived. The cache is
+ # disposable, so say so instead of failing cryptically.
+ raise crypto.CacheDecryptionError(
+ "A cached row could not be decrypted with the current cache key. "
+ "Run `mcp-ynab cache purge` to discard the cache and start over."
+ ) from e
diff --git a/src/security/__init__.py b/src/security/__init__.py
new file mode 100644
index 00000000..2ef6b11e
--- /dev/null
+++ b/src/security/__init__.py
@@ -0,0 +1,5 @@
+"""Security primitives: credential storage, redaction, cache encryption, consent.
+
+Nothing in this package imports the server or the cache service, so it can be
+used from `src/cli.py` without pulling in a `Settings` that demands a token.
+"""
diff --git a/src/security/consent.py b/src/security/consent.py
new file mode 100644
index 00000000..a1f75668
--- /dev/null
+++ b/src/security/consent.py
@@ -0,0 +1,211 @@
+"""Human confirmation for every mutating tool.
+
+The security property this file exists to provide:
+
+ the "before" shown to the human comes from the YNAB API, not from anything
+ the model said, and the payload applied afterwards is byte-identical to the
+ one that was shown.
+
+Two things make that hold. First, a tool parameter annotated `Context` is
+injected by the MCP SDK and does not appear in the tool's input schema, so the
+model cannot supply, forge or suppress it; consent cannot be satisfied by
+anything the model emits. Second, call sites fingerprint the resolved payload
+before eliciting and assert the fingerprint afterwards, so a payload cannot be
+re-resolved or recomputed between the prompt and the write.
+
+Callers fetch the pre-image through `_shared.client`, which bypasses the cache.
+That is not optional: a stale cache would show the human a false "before".
+"""
+
+import hashlib
+import json
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+from src.security import diff
+
+
+class ConsentDecision(BaseModel):
+ """The elicitation schema. Primitive fields only, per the MCP spec."""
+
+ confirm: bool = Field(
+ description=(
+ "True applies the change exactly as shown above. Anything else cancels "
+ "it and nothing is sent to YNAB."
+ )
+ )
+
+
+class ConsentUnavailable(Exception):
+ """The client cannot ask a human, so the write must not happen."""
+
+
+class ConsentDenied(Exception):
+ """A human saw the change and said no."""
+
+
+class ConsentViolation(Exception):
+ """The payload changed between being shown and being sent. Never expected."""
+
+
+class ReadOnlyMode(Exception):
+ """Writes are disabled by configuration."""
+
+
+_UNAVAILABLE_HELP = (
+ "This write was blocked: the MCP client cannot obtain human confirmation. "
+ "Every mutating YNAB tool requires a person to approve the exact change "
+ "first, and this client does not support MCP elicitation "
+ "(elicitation/create), so there is no way to ask. "
+ "Fixes, in order of preference: use a client that supports elicitation; or "
+ "make the change in the YNAB app directly; or run the server with "
+ "YNAB_READ_ONLY=1 so the write tools stop being offered at all. "
+ "The change was NOT applied."
+)
+
+_read_only = False
+
+
+def set_read_only(value: bool) -> None:
+ """Configure read-only mode for this process."""
+ global _read_only
+ _read_only = value
+
+
+def is_read_only() -> bool:
+ return _read_only
+
+
+def payload_fingerprint(payload: Any) -> str:
+ """A stable fingerprint of the exact payload that will be sent."""
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+def assert_unchanged(payload: Any, fingerprint: str) -> None:
+ """Fail if the payload is not the one the human approved.
+
+ Cheap, and it turns "apply exactly what was shown" from a convention into
+ something a test can assert.
+ """
+ if payload_fingerprint(payload) != fingerprint:
+ raise ConsentViolation(
+ "The payload changed after it was approved; nothing was sent to YNAB. "
+ "This is a bug in the server, not something you did."
+ )
+
+
+def _can_elicit(ctx: Any) -> bool:
+ """Whether the connected client advertised elicitation support.
+
+ Fails closed: if the capability cannot be established, the write is blocked
+ rather than applied unconfirmed.
+ """
+ try:
+ from mcp.types import ClientCapabilities, ElicitationCapability
+
+ return bool(
+ ctx.session.check_client_capability(
+ ClientCapabilities(elicitation=ElicitationCapability())
+ )
+ )
+ except Exception:
+ return False
+
+
+def render_prompt(
+ *,
+ action: str,
+ plan_id: str | None,
+ subject: str | None,
+ before: dict | None,
+ payload: Any,
+ body: str | None,
+ note: str | None,
+) -> str:
+ """Build the text a human is asked to approve."""
+ lines = [f"CONFIRM: {action}"]
+ if plan_id:
+ lines.append(f"Plan: {plan_id}")
+ lines.append("")
+
+ if subject:
+ lines.append("Current state, read from the YNAB API just now:")
+ lines.append(f" {subject}")
+ lines.append("")
+
+ if body is not None:
+ lines.append(body)
+ elif before is not None and isinstance(payload, dict):
+ lines.append("This will change:")
+ lines.append(diff.render_change_block(diff.diff_fields(before, payload)))
+ elif isinstance(payload, dict):
+ lines.append("This will be created:")
+ lines.append(diff.render_new_object(payload))
+ else:
+ lines.append("Payload:")
+ lines.append(" " + json.dumps(payload, indent=2).replace("\n", "\n "))
+
+ if note:
+ lines.append("")
+ lines.append(note)
+
+ lines.append("")
+ lines.append("Confirm to proceed with exactly this, and nothing else.")
+ return "\n".join(lines)
+
+
+async def confirm(
+ ctx: Any,
+ *,
+ action: str,
+ payload: Any,
+ plan_id: str | None = None,
+ subject: str | None = None,
+ before: dict | None = None,
+ body: str | None = None,
+ note: str | None = None,
+) -> str:
+ """Ask a human to approve `payload`. Returns its fingerprint.
+
+ Raises ReadOnlyMode, ConsentUnavailable or ConsentDenied rather than ever
+ returning on a path where the write should not happen.
+ """
+ if _read_only:
+ raise ReadOnlyMode(
+ "This server is running in read-only mode (YNAB_READ_ONLY), so it "
+ "cannot change anything in YNAB."
+ )
+
+ if ctx is None or not _can_elicit(ctx):
+ raise ConsentUnavailable(_UNAVAILABLE_HELP)
+
+ fingerprint = payload_fingerprint(payload)
+ message = render_prompt(
+ action=action,
+ plan_id=plan_id,
+ subject=subject,
+ before=before,
+ payload=payload,
+ body=body,
+ note=note,
+ )
+
+ try:
+ result = await ctx.elicit(message=message, schema=ConsentDecision)
+ except Exception as e:
+ # No usable back-channel, a client that errored on the request, a
+ # malformed response. Never degrade to applying the change.
+ raise ConsentUnavailable(f"{_UNAVAILABLE_HELP} (client error: {e})") from e
+
+ if getattr(result, "action", None) != "accept":
+ raise ConsentDenied(
+ f"Not confirmed ({getattr(result, 'action', 'unknown')}). "
+ "Nothing was sent to YNAB."
+ )
+ data = getattr(result, "data", None)
+ if data is None or not data.confirm:
+ raise ConsentDenied("Declined by the user. Nothing was sent to YNAB.")
+
+ return fingerprint
diff --git a/src/security/credentials.py b/src/security/credentials.py
new file mode 100644
index 00000000..b1457fef
--- /dev/null
+++ b/src/security/credentials.py
@@ -0,0 +1,113 @@
+"""OS credential store access for the YNAB token and the cache encryption key.
+
+Secrets are held in the platform credential store via `keyring` — Windows
+Credential Manager, macOS Keychain, or Secret Service on Linux — instead of in a
+config file. The point is to keep the token out of every file an agent can read
+(`~/.claude.json`, `claude_desktop_config.json`, `.env.local`, shell history).
+
+It is *not* a sandbox: these stores scope secrets to the logged-in user, not to a
+particular program, so anything running as the same user can ask for the same
+secret. SECURITY.md states that limit plainly; do not imply otherwise here.
+"""
+
+import hashlib
+import sys
+
+SERVICE_NAME = "mcp-ynab"
+API_KEY_ENTRY = "api_key"
+CACHE_KEY_ENTRY = "cache_key"
+
+_NO_BACKEND_HINT = (
+ "No OS credential store is available to `keyring` on this machine.\n"
+ " - Linux: install a Secret Service provider (gnome-keyring, KWallet) or\n"
+ " `pip install keyrings.alt` for a file-backed store.\n"
+ " - Headless/CI: set YNAB_API_KEY in the environment instead.\n"
+ "See SECURITY.md for the trade-offs."
+)
+
+
+class CredentialStoreUnavailable(RuntimeError):
+ """Raised when no usable `keyring` backend is installed."""
+
+
+def _keyring():
+ """Import keyring lazily.
+
+ Importing it can be slow (it probes backends) and the server should not pay
+ that cost on a run that never touches the credential store.
+ """
+ import keyring
+
+ return keyring
+
+
+def _no_keyring_error() -> type[BaseException]:
+ from keyring.errors import NoKeyringError
+
+ return NoKeyringError
+
+
+def get_secret(entry: str) -> str | None:
+ """Read one secret, or None if it is absent.
+
+ A missing backend is reported as absence rather than an exception so the
+ server can fall through to the next credential source.
+ """
+ try:
+ return _keyring().get_password(SERVICE_NAME, entry)
+ except _no_keyring_error():
+ return None
+ except Exception as e: # backend present but unhappy (locked keyring, etc.)
+ print(f"WARNING: credential store read failed: {e}", file=sys.stderr)
+ return None
+
+
+def set_secret(entry: str, value: str) -> None:
+ """Write one secret. Raises CredentialStoreUnavailable if there is no backend."""
+ try:
+ _keyring().set_password(SERVICE_NAME, entry, value)
+ except _no_keyring_error() as e:
+ raise CredentialStoreUnavailable(_NO_BACKEND_HINT) from e
+
+
+def delete_secret(entry: str) -> bool:
+ """Delete one secret. Returns False if it was not there to begin with."""
+ keyring = _keyring()
+ try:
+ if keyring.get_password(SERVICE_NAME, entry) is None:
+ return False
+ keyring.delete_password(SERVICE_NAME, entry)
+ return True
+ except _no_keyring_error() as e:
+ raise CredentialStoreUnavailable(_NO_BACKEND_HINT) from e
+ except Exception:
+ return False
+
+
+# ── YNAB personal access token ───────────────────────────────
+
+
+def store_token(token: str) -> None:
+ set_secret(API_KEY_ENTRY, token)
+
+
+def load_token() -> str | None:
+ return get_secret(API_KEY_ENTRY)
+
+
+def delete_token() -> bool:
+ return delete_secret(API_KEY_ENTRY)
+
+
+def token_fingerprint(token: str | None) -> str:
+ """A safe-to-log identifier for a token.
+
+ Never returns the token. Short values get the digest alone, because first-4 +
+ last-4 of a short string is most of the string.
+ """
+ if not token:
+ return "(none)"
+ digest = hashlib.sha256(token.encode("utf-8")).hexdigest()[:12]
+ if len(token) < 16:
+ return f"sha256:{digest}"
+ return f"{token[:4]}...{token[-4:]} (sha256:{digest})"
diff --git a/src/security/crypto.py b/src/security/crypto.py
new file mode 100644
index 00000000..2765c729
--- /dev/null
+++ b/src/security/crypto.py
@@ -0,0 +1,120 @@
+"""AES-256-GCM for the local cache of budget data.
+
+The cache holds payees, memos, amounts and category names — a fairly complete
+picture of the user's finances — in a SQLite file under the user profile. This
+encrypts the payload columns so that file is not readable on its own.
+
+The key lives in the OS credential store next to the token, which means the
+protection is exactly as strong as that store: it defends against something
+reading the DB file (a backup, a synced folder, another account on the machine),
+not against code running as the same user. SECURITY.md spells that out.
+
+SQLCipher would encrypt the whole file but needs a native build, which is
+painful on Windows; this is a column-level scheme instead, so the columns used
+for lookups and delta sync stay in plaintext and still leak metadata.
+"""
+
+import base64
+import os
+
+from src.security import credentials
+
+KEY_BYTES = 32 # AES-256
+NONCE_BYTES = 12 # GCM standard nonce size
+
+
+class CacheEncryptionUnavailable(RuntimeError):
+ """Raised when cache encryption is on but no key can be stored or read."""
+
+
+class CacheDecryptionError(RuntimeError):
+ """Raised when a cached row cannot be decrypted with the current key."""
+
+
+_enabled = True
+_key: bytes | None = None
+
+
+def configure(*, enabled: bool) -> None:
+ """Turn cache encryption on or off for this process."""
+ global _enabled, _key
+ _enabled = enabled
+ if not enabled:
+ _key = None
+
+
+def is_enabled() -> bool:
+ return _enabled
+
+
+def reset_key_cache() -> None:
+ """Forget the in-memory key. For tests."""
+ global _key
+ _key = None
+
+
+def aad_for(table: str, column: str) -> bytes:
+ """Additional authenticated data binding a ciphertext to its table and column.
+
+ Deliberately not row-scoped: see SECURITY.md for the row-swap limitation this
+ leaves open, which is acceptable because it requires DB write access already.
+ """
+ return f"mcp-ynab:{table}.{column}".encode("utf-8")
+
+
+def load_or_create_key() -> bytes:
+ """Fetch the cache key from the credential store, creating it on first use."""
+ global _key
+ if _key is not None:
+ return _key
+
+ stored = credentials.get_secret(credentials.CACHE_KEY_ENTRY)
+ if stored:
+ try:
+ key = base64.b64decode(stored, validate=True)
+ except Exception as e:
+ raise CacheEncryptionUnavailable(
+ "The stored cache key is not valid base64. Run `mcp-ynab cache purge` "
+ "to discard the cache and its key."
+ ) from e
+ if len(key) != KEY_BYTES:
+ raise CacheEncryptionUnavailable(
+ f"The stored cache key is {len(key)} bytes, expected {KEY_BYTES}. "
+ "Run `mcp-ynab cache purge`."
+ )
+ _key = key
+ return key
+
+ key = os.urandom(KEY_BYTES)
+ try:
+ credentials.set_secret(credentials.CACHE_KEY_ENTRY, base64.b64encode(key).decode("ascii"))
+ except credentials.CredentialStoreUnavailable as e:
+ # Fail closed. Quietly writing plaintext instead would leave the user
+ # believing their cache is encrypted when it is not.
+ raise CacheEncryptionUnavailable(
+ f"{e}\nCache encryption needs somewhere to keep its key. Set "
+ "YNAB_CACHE_ENCRYPTION=0 to store the cache in plaintext instead - an "
+ "explicit choice, not a silent downgrade."
+ ) from e
+ _key = key
+ return key
+
+
+def encrypt(plaintext: bytes, aad: bytes, *, key: bytes | None = None) -> bytes:
+ """Encrypt to `nonce(12) || ciphertext || tag`."""
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+
+ key = key if key is not None else load_or_create_key()
+ nonce = os.urandom(NONCE_BYTES)
+ return nonce + AESGCM(key).encrypt(nonce, plaintext, aad)
+
+
+def decrypt(envelope: bytes, aad: bytes, *, key: bytes | None = None) -> bytes:
+ """Reverse encrypt(). Raises InvalidTag on tampering, wrong AAD, or wrong key."""
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+
+ key = key if key is not None else load_or_create_key()
+ if len(envelope) <= NONCE_BYTES:
+ raise ValueError("ciphertext envelope is too short to contain a nonce")
+ nonce, body = envelope[:NONCE_BYTES], envelope[NONCE_BYTES:]
+ return AESGCM(key).decrypt(nonce, body, aad)
diff --git a/src/security/diff.py b/src/security/diff.py
new file mode 100644
index 00000000..69015550
--- /dev/null
+++ b/src/security/diff.py
@@ -0,0 +1,126 @@
+"""Rendering for consent prompts.
+
+Everything a human is asked to approve is rendered here. The values that reach
+these functions come from the YNAB API (the "before") and from the payload the
+server is about to send (the "after") — never from prose the model wrote.
+
+Amounts are milliunits throughout, matching the payload that goes on the wire,
+so the number shown in the prompt is the number that gets sent.
+"""
+
+from typing import Any
+
+# Fields carrying YNAB milliunits, rendered as currency rather than as integers.
+MONEY_FIELDS: frozenset[str] = frozenset(
+ {
+ "amount",
+ "balance",
+ "cleared_balance",
+ "uncleared_balance",
+ "budgeted",
+ "activity",
+ "goal_target",
+ "goal_overall_funded",
+ "goal_overall_left",
+ "goal_under_funded",
+ }
+)
+
+_MISSING = object()
+
+
+def format_money(milliunits: int | None) -> str:
+ """Render milliunits as currency. 1000 milliunits = $1.00."""
+ if milliunits is None:
+ return "(none)"
+ negative = milliunits < 0
+ whole = abs(milliunits)
+ # YNAB amounts are normally cent-aligned; show the third digit only when a
+ # value actually uses it, rather than rounding it out of sight.
+ text = f"{whole / 1000:,.3f}" if whole % 10 else f"{whole / 1000:,.2f}"
+ return f"{'-' if negative else ''}${text}"
+
+
+def format_value(field: str, value: Any) -> str:
+ """Render one field value for display."""
+ if value is _MISSING:
+ return "(unchanged)"
+ if value is None:
+ return "(none)"
+ if field in MONEY_FIELDS and isinstance(value, (int, float)) and not isinstance(value, bool):
+ return format_money(int(value))
+ if isinstance(value, bool):
+ return "yes" if value else "no"
+ if isinstance(value, str):
+ return value if value else '""'
+ if isinstance(value, list):
+ return f"[{len(value)} item{'s' if len(value) != 1 else ''}]"
+ return str(value)
+
+
+def describe_transaction(txn: dict) -> str:
+ """One line describing a transaction, for itemising bulk operations."""
+ parts = [
+ str(txn.get("date") or "(no date)"),
+ str(txn.get("payee_name") or txn.get("payee_id") or "(no payee)"),
+ str(txn.get("category_name") or txn.get("category_id") or "(no category)"),
+ format_money(txn.get("amount")),
+ ]
+ if txn.get("memo"):
+ parts.append(f'memo "{txn["memo"]}"')
+ parts.append(f"cleared={txn.get('cleared', 'uncleared')}")
+ parts.append(f"approved={'yes' if txn.get('approved') else 'no'}")
+ if txn.get("subtransactions"):
+ parts.append(f"split into {len(txn['subtransactions'])}")
+ return " ".join(parts)
+
+
+def describe_scheduled_transaction(txn: dict) -> str:
+ """One line describing a scheduled transaction."""
+ parts = [
+ f"next {txn.get('date_next') or txn.get('date') or '(no date)'}",
+ str(txn.get("frequency") or "(no frequency)"),
+ str(txn.get("payee_name") or txn.get("payee_id") or "(no payee)"),
+ str(txn.get("category_name") or txn.get("category_id") or "(no category)"),
+ format_money(txn.get("amount")),
+ ]
+ if txn.get("memo"):
+ parts.append(f'memo "{txn["memo"]}"')
+ return " ".join(parts)
+
+
+def diff_fields(before: dict, after: dict) -> list[tuple[str, Any, Any]]:
+ """Fields the payload would change, as (field, before, after).
+
+ Only keys present in `after` are considered: a sparse update says nothing
+ about the fields it omits, and listing them as unchanged would pad the
+ prompt with noise a human then skims past.
+ """
+ changes: list[tuple[str, Any, Any]] = []
+ for field, new in after.items():
+ old = before.get(field, _MISSING)
+ if old is _MISSING or old != new:
+ changes.append((field, old, new))
+ return changes
+
+
+def render_change_block(changes: list[tuple[str, Any, Any]], indent: str = " ") -> str:
+ """Aligned `field: before -> after` lines."""
+ if not changes:
+ return f"{indent}(no fields would change)"
+ width = max(len(field) for field, _, _ in changes)
+ return "\n".join(
+ f"{indent}{field:<{width}} : {format_value(field, old)} -> {format_value(field, new)}"
+ for field, old, new in changes
+ )
+
+
+def render_new_object(payload: dict, indent: str = " ") -> str:
+ """Aligned `field: value` lines, for a create with no pre-image."""
+ if not payload:
+ return f"{indent}(empty)"
+ width = max(len(field) for field in payload)
+ return "\n".join(
+ f"{indent}{field:<{width}} : {format_value(field, value)}"
+ for field, value in payload.items()
+ )
diff --git a/src/security/redaction.py b/src/security/redaction.py
new file mode 100644
index 00000000..e2a136bd
--- /dev/null
+++ b/src/security/redaction.py
@@ -0,0 +1,42 @@
+"""Keep live secrets out of anything the model can read.
+
+The YNAB token travels in an Authorization header. httpx exception reprs and
+YNAB error bodies can echo request headers back, so every string that leaves a
+tool by an error path is filtered here first.
+
+This is a backstop, not the primary defence: the token is a SecretStr everywhere
+except the one line that builds the header. Backstops are cheap, and the failure
+mode they cover (a token in a tool result the model reads) is the one that
+matters.
+"""
+
+PLACEHOLDER = "***REDACTED***"
+
+# Below this length a "secret" is more likely to be a substring of ordinary text
+# than the real thing, and replacing it would corrupt messages without adding
+# protection. A YNAB personal access token is 64 characters.
+_MIN_SECRET_LEN = 8
+
+_secrets: set[str] = set()
+
+
+def register_secret(value: str | None) -> None:
+ """Register a live secret to be scrubbed from outbound text."""
+ if value and len(value) >= _MIN_SECRET_LEN:
+ _secrets.add(value)
+
+
+def forget_secrets() -> None:
+ """Drop every registered secret. For tests."""
+ _secrets.clear()
+
+
+def scrub(text: str) -> str:
+ """Replace every registered secret in `text` with a placeholder."""
+ if not text or not _secrets:
+ return text
+ # Longest first, so a secret that contains another is replaced whole.
+ for secret in sorted(_secrets, key=len, reverse=True):
+ if secret in text:
+ text = text.replace(secret, PLACEHOLDER)
+ return text
diff --git a/src/server/_shared.py b/src/server/_shared.py
index f07d89fe..db866815 100644
--- a/src/server/_shared.py
+++ b/src/server/_shared.py
@@ -40,6 +40,15 @@
Transaction,
)
from src.models.user import User
+from src.security import consent
+from src.security.consent import (
+ ConsentDenied,
+ ConsentUnavailable,
+ ConsentViolation,
+ ReadOnlyMode,
+)
+from src.security.crypto import CacheDecryptionError, CacheEncryptionUnavailable
+from src.security.redaction import scrub
from src.ynab_client import YNABClient, YNABError
try:
@@ -49,12 +58,30 @@
sys.exit(1)
mcp = MCPServer("ynab")
+# SecretStr in, unwrapped once inside the client when it builds the auth header.
client = YNABClient(settings.ynab_api_key, timeout=settings.http_timeout)
cache = CacheService(client, settings)
+# Belt and braces: mutating tools are not registered at all in read-only mode
+# (see mutating_tool below), so this guard should be unreachable. It costs
+# nothing and means a tool that somehow is reachable still refuses.
+consent.set_read_only(settings.read_only)
+
_db_initialized = False
+def mutating_tool():
+ """Register a tool that changes YNAB data, unless read-only mode is on.
+
+ Gating at registration rather than inside the tool body means the tool never
+ appears in the model's tool list: there is nothing to call and nothing to
+ talk the server out of, instead of an offered capability that then refuses.
+ """
+ if settings.read_only:
+ return lambda func: func
+ return mcp.tool()
+
+
# Default exclude sets per model. Used when a tool is called without an explicit
# `exclude_fields` argument. See FIELDS.md for the full list per model.
DEFAULT_EXCLUDES: dict[type[YNABBaseModel], set[str]] = {
@@ -149,10 +176,19 @@ def serialize_list(models, *, exclude_fields: list[str] | None = None) -> str:
async def _ensure_db():
global _db_initialized
if not _db_initialized:
- await init_db(settings.cache_db_path)
+ await init_db(settings.cache_db_path, encryption=settings.cache_encryption)
_db_initialized = True
+def _error(payload: dict) -> str:
+ """Serialize an error payload with every registered secret scrubbed out.
+
+ Scrubbing the rendered JSON rather than each field means a token that shows
+ up in a nested value, or in a field added here later, is still caught.
+ """
+ return scrub(json.dumps(payload))
+
+
def handle_errors(func):
"""Decorator that catches YNAB and HTTP errors and returns friendly messages."""
@@ -161,11 +197,21 @@ async def wrapper(*args, **kwargs):
try:
await _ensure_db()
return await func(*args, **kwargs)
+ except ConsentUnavailable as e:
+ return _error({"error": str(e), "applied": False, "reason": "consent_unavailable"})
+ except ConsentDenied as e:
+ return _error({"error": str(e), "applied": False, "reason": "consent_denied"})
+ except ReadOnlyMode as e:
+ return _error({"error": str(e), "applied": False, "reason": "read_only"})
+ except ConsentViolation as e:
+ return _error({"error": str(e), "applied": False, "reason": "consent_violation"})
+ except (CacheEncryptionUnavailable, CacheDecryptionError) as e:
+ return _error({"error": str(e)})
except YNABError as e:
- return json.dumps({"error": e.message, "error_id": e.error_id, "status_code": e.status_code})
+ return _error({"error": e.message, "error_id": e.error_id, "status_code": e.status_code})
except httpx.HTTPStatusError as e:
- return json.dumps({"error": f"HTTP {e.response.status_code}: {e.response.text}"})
+ return _error({"error": f"HTTP {e.response.status_code}: {e.response.text}"})
except httpx.RequestError as e:
- return json.dumps({"error": f"Request failed: {e}"})
+ return _error({"error": f"Request failed: {e}"})
return wrapper
diff --git a/src/server/accounts.py b/src/server/accounts.py
index f967fa79..d077c02e 100644
--- a/src/server/accounts.py
+++ b/src/server/accounts.py
@@ -1,3 +1,6 @@
+from mcp.server.mcpserver import Context
+
+from src.security import consent
from src.server import _shared
from src.server._shared import dollars_to_milliunits, serialize, serialize_list
@@ -19,7 +22,7 @@ async def list_accounts(
return serialize_list(accounts, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def create_account(
plan_id: str,
@@ -27,8 +30,9 @@ async def create_account(
type: str,
balance: float,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Create a new account.
+ """Create a new account. Requires human confirmation.
Args:
plan_id: The plan ID (use list_plans to find available IDs)
@@ -44,6 +48,10 @@ async def create_account(
"type": type,
"balance": dollars_to_milliunits(balance),
}
+ fingerprint = await consent.confirm(
+ ctx, action="Create account", plan_id=plan_id, payload=account
+ )
+ consent.assert_unchanged(account, fingerprint)
acct = await _shared.cache.create_account(account, plan_id)
return serialize(acct, exclude_fields=exclude_fields)
diff --git a/src/server/categories.py b/src/server/categories.py
index a9c49428..28ca430f 100644
--- a/src/server/categories.py
+++ b/src/server/categories.py
@@ -1,6 +1,24 @@
+from mcp.server.mcpserver import Context
+
+from src.security import consent, diff
from src.server import _shared
from src.server._shared import dollars_to_milliunits, serialize, serialize_list
+
+async def _category_group_preimage(category_group_id: str, plan_id: str) -> dict:
+ """Read a category group straight from the API, bypassing the cache.
+
+ There is no single-group endpoint, so this pulls the group list and picks the
+ one being changed. One extra call on a rare operation buys a "before" that is
+ actually true rather than one assembled from whatever the model asserted.
+ """
+ groups, _knowledge = await _shared.client.get_categories(plan_id)
+ for group in groups:
+ if group.id == category_group_id:
+ return group.model_dump(exclude={"categories"})
+ return {}
+
+
_FIELDS_DOC = (
"Optional list of field names to exclude from the response. "
"If omitted, the model's default exclude list is used (see FIELDS.md). "
@@ -43,7 +61,7 @@ async def get_category(
return serialize(cat, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def create_category(
plan_id: str,
@@ -53,8 +71,9 @@ async def create_category(
goal_target: float | None = None,
goal_target_date: str | None = None,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Create a new category in a category group.
+ """Create a new category in a category group. Requires human confirmation.
Args:
plan_id: The plan ID (use list_plans to find available IDs)
@@ -78,11 +97,15 @@ async def create_category(
if goal_target_date is not None:
category["goal_target_date"] = goal_target_date
+ fingerprint = await consent.confirm(
+ ctx, action="Create category", plan_id=plan_id, payload=category
+ )
+ consent.assert_unchanged(category, fingerprint)
cat = await _shared.cache.create_category(category, plan_id)
return serialize(cat, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def update_category(
plan_id: str,
@@ -93,8 +116,11 @@ async def update_category(
goal_target: float | None = None,
goal_target_date: str | None = None,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Update a category. Only provide the fields you want to change.
+ """Update a category. Requires human confirmation.
+
+ Only provide the fields you want to change.
Args:
plan_id: The plan ID (use list_plans to find available IDs)
@@ -120,18 +146,29 @@ async def update_category(
if goal_target_date is not None:
category["goal_target_date"] = goal_target_date
+ before = (await _shared.client.get_category(category_id, plan_id)).model_dump()
+ fingerprint = await consent.confirm(
+ ctx,
+ action="Update category",
+ plan_id=plan_id,
+ subject=f'category "{before.get("name")}" ({category_id})',
+ before=before,
+ payload=category,
+ )
+ consent.assert_unchanged(category, fingerprint)
cat = await _shared.cache.update_category(category_id, category, plan_id)
return serialize(cat, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def create_category_group(
plan_id: str,
name: str,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Create a new category group.
+ """Create a new category group. Requires human confirmation.
Args:
plan_id: The plan ID (use list_plans to find available IDs)
@@ -140,19 +177,25 @@ async def create_category_group(
If omitted, the model's default exclude list is used (see FIELDS.md).
Pass [] to return all fields. Pass a custom list to override the default.
"""
- group = await _shared.cache.create_category_group({"name": name}, plan_id)
+ payload = {"name": name}
+ fingerprint = await consent.confirm(
+ ctx, action="Create category group", plan_id=plan_id, payload=payload
+ )
+ consent.assert_unchanged(payload, fingerprint)
+ group = await _shared.cache.create_category_group(payload, plan_id)
return serialize(group, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def update_category_group(
plan_id: str,
category_group_id: str,
name: str,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Update a category group.
+ """Update a category group. Requires human confirmation.
Args:
plan_id: The plan ID (use list_plans to find available IDs)
@@ -162,8 +205,19 @@ async def update_category_group(
If omitted, the model's default exclude list is used (see FIELDS.md).
Pass [] to return all fields. Pass a custom list to override the default.
"""
+ payload = {"name": name}
+ before = await _category_group_preimage(category_group_id, plan_id)
+ fingerprint = await consent.confirm(
+ ctx,
+ action="Rename category group",
+ plan_id=plan_id,
+ subject=f'category group "{before.get("name", "(not found)")}" ({category_group_id})',
+ before=before,
+ payload=payload,
+ )
+ consent.assert_unchanged(payload, fingerprint)
group = await _shared.cache.update_category_group(
- category_group_id, {"name": name}, plan_id
+ category_group_id, payload, plan_id
)
return serialize(group, exclude_fields=exclude_fields)
@@ -190,7 +244,7 @@ async def get_category_for_month(
return serialize(cat, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def update_category_for_month(
category_id: str,
@@ -198,8 +252,9 @@ async def update_category_for_month(
budgeted: float,
plan_id: str,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Update the budgeted amount for a category in a specific month.
+ """Update the budgeted amount for a category in a month. Requires human confirmation.
Args:
category_id: The category ID
@@ -210,7 +265,25 @@ async def update_category_for_month(
If omitted, the model's default exclude list is used (see FIELDS.md).
Pass [] to return all fields. Pass a custom list to override the default.
"""
+ payload = {"budgeted": dollars_to_milliunits(budgeted)}
+ before = (
+ await _shared.client.get_category_for_month(month, category_id, plan_id)
+ ).model_dump()
+ fingerprint = await consent.confirm(
+ ctx,
+ action=f"Set budgeted amount for {month}",
+ plan_id=plan_id,
+ subject=(
+ f'category "{before.get("name")}" ({category_id}) - '
+ f"budgeted {diff.format_money(before.get('budgeted'))}, "
+ f"activity {diff.format_money(before.get('activity'))}, "
+ f"balance {diff.format_money(before.get('balance'))}"
+ ),
+ before=before,
+ payload=payload,
+ )
+ consent.assert_unchanged(payload, fingerprint)
cat = await _shared.cache.update_category_for_month(
- month, category_id, dollars_to_milliunits(budgeted), plan_id
+ month, category_id, payload["budgeted"], plan_id
)
return serialize(cat, exclude_fields=exclude_fields)
diff --git a/src/server/payees.py b/src/server/payees.py
index d12babc2..5a87e943 100644
--- a/src/server/payees.py
+++ b/src/server/payees.py
@@ -1,3 +1,6 @@
+from mcp.server.mcpserver import Context
+
+from src.security import consent
from src.server import _shared
from src.server._shared import serialize, serialize_list
@@ -37,15 +40,16 @@ async def get_payee(
return serialize(payee, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def update_payee(
plan_id: str,
payee_id: str,
name: str,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Update a payee.
+ """Update a payee. Requires human confirmation.
Args:
plan_id: The plan ID (use list_plans to find available IDs)
@@ -55,5 +59,17 @@ async def update_payee(
If omitted, the model's default exclude list is used (see FIELDS.md).
Pass [] to return all fields. Pass a custom list to override the default.
"""
- payee = await _shared.cache.update_payee(payee_id, {"name": name}, plan_id)
+ payload = {"name": name}
+ # Straight from the API, not the cache: a stale name would be a false "before".
+ before = (await _shared.client.get_payee(payee_id, plan_id)).model_dump()
+ fingerprint = await consent.confirm(
+ ctx,
+ action="Rename payee",
+ plan_id=plan_id,
+ subject=f'payee "{before.get("name")}" ({payee_id})',
+ before=before,
+ payload=payload,
+ )
+ consent.assert_unchanged(payload, fingerprint)
+ payee = await _shared.cache.update_payee(payee_id, payload, plan_id)
return serialize(payee, exclude_fields=exclude_fields)
diff --git a/src/server/scheduled.py b/src/server/scheduled.py
index 7846d646..f1060c82 100644
--- a/src/server/scheduled.py
+++ b/src/server/scheduled.py
@@ -1,7 +1,25 @@
+from mcp.server.mcpserver import Context
+
+from src.security import consent, diff
from src.server import _shared
from src.server._shared import dollars_to_milliunits, serialize, serialize_list
+async def _scheduled_preimage(scheduled_transaction_id: str, plan_id: str) -> dict:
+ """Read a scheduled transaction straight from the API, bypassing the cache.
+
+ The write payload calls the next occurrence "date" while the API reports it
+ as "date_next"; mirroring it under both names lets the diff line up the field
+ the caller is actually changing. Both values come from the API response.
+ """
+ txn = (
+ await _shared.client.get_scheduled_transaction(scheduled_transaction_id, plan_id)
+ ).model_dump()
+ if txn.get("date_next") is not None:
+ txn.setdefault("date", txn["date_next"])
+ return txn
+
+
@_shared.mcp.tool()
@_shared.handle_errors
async def list_scheduled_transactions(
@@ -39,7 +57,7 @@ async def get_scheduled_transaction(
return serialize(txn, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def create_scheduled_transaction(
plan_id: str,
@@ -53,8 +71,11 @@ async def create_scheduled_transaction(
flag_color: str | None = None,
frequency: str | None = None,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Create a scheduled transaction (a transaction with a future date).
+ """Create a scheduled transaction. Requires human confirmation.
+
+ A scheduled transaction is a transaction with a future date.
Splits are not supported on this endpoint. Date must be in the future, no more
than 5 years out.
@@ -95,11 +116,24 @@ async def create_scheduled_transaction(
if frequency is not None:
scheduled["frequency"] = frequency
+ fingerprint = await consent.confirm(
+ ctx,
+ action="Create scheduled transaction",
+ plan_id=plan_id,
+ payload=scheduled,
+ body=(
+ "This recurring transaction will be created:\n"
+ f" {diff.describe_scheduled_transaction(scheduled)}\n\n"
+ "Exact fields sent to YNAB:\n"
+ f"{diff.render_new_object(scheduled)}"
+ ),
+ )
+ consent.assert_unchanged(scheduled, fingerprint)
txn = await _shared.cache.create_scheduled_transaction(scheduled, plan_id)
return serialize(txn, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def update_scheduled_transaction(
plan_id: str,
@@ -114,8 +148,9 @@ async def update_scheduled_transaction(
flag_color: str | None = None,
frequency: str | None = None,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Update a scheduled transaction.
+ """Update a scheduled transaction. Requires human confirmation.
The API requires account_id and date on every update. Splits are not supported on
this endpoint. Date must be in the future, no more than 5 years out.
@@ -157,20 +192,31 @@ async def update_scheduled_transaction(
if frequency is not None:
scheduled["frequency"] = frequency
+ before = await _scheduled_preimage(scheduled_transaction_id, plan_id)
+ fingerprint = await consent.confirm(
+ ctx,
+ action="Update scheduled transaction",
+ plan_id=plan_id,
+ subject=diff.describe_scheduled_transaction(before),
+ before=before,
+ payload=scheduled,
+ )
+ consent.assert_unchanged(scheduled, fingerprint)
txn = await _shared.cache.update_scheduled_transaction(
scheduled_transaction_id, scheduled, plan_id
)
return serialize(txn, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def delete_scheduled_transaction(
scheduled_transaction_id: str,
plan_id: str,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Delete a scheduled transaction.
+ """Delete a scheduled transaction. Requires human confirmation.
Args:
scheduled_transaction_id: The scheduled transaction ID to delete
@@ -179,5 +225,21 @@ async def delete_scheduled_transaction(
If omitted, the model's default exclude list is used (see FIELDS.md).
Pass [] to return all fields. Pass a custom list to override the default.
"""
+ before = await _scheduled_preimage(scheduled_transaction_id, plan_id)
+ target = {
+ "scheduled_transaction_id": scheduled_transaction_id,
+ "plan_id": plan_id,
+ }
+ fingerprint = await consent.confirm(
+ ctx,
+ action="DELETE scheduled transaction",
+ plan_id=plan_id,
+ payload=target,
+ body=(
+ "This recurring transaction will be permanently deleted:\n"
+ f" {diff.describe_scheduled_transaction(before)}"
+ ),
+ )
+ consent.assert_unchanged(target, fingerprint)
txn = await _shared.cache.delete_scheduled_transaction(scheduled_transaction_id, plan_id)
return serialize(txn, exclude_fields=exclude_fields)
diff --git a/src/server/transactions.py b/src/server/transactions.py
index 5f2ace6b..98c37423 100644
--- a/src/server/transactions.py
+++ b/src/server/transactions.py
@@ -1,9 +1,22 @@
import json
+from mcp.server.mcpserver import Context
+
+from src.security import consent, diff
from src.server import _shared
from src.server._shared import dollars_to_milliunits, serialize, serialize_list
+async def _transaction_preimage(transaction_id: str, plan_id: str) -> dict:
+ """Read a transaction straight from the API, bypassing the cache.
+
+ Bypassing the cache is not optional. A stale cached row would show the human
+ a "before" that is not what YNAB actually holds, and the whole point of the
+ consent prompt is that the before is true.
+ """
+ return (await _shared.client.get_transaction(transaction_id, plan_id)).model_dump()
+
+
def _prepare_subtransaction(sub: dict) -> dict:
"""Convert a subtransaction dict from dollars to milliunits and filter optional keys."""
out: dict = {"amount": dollars_to_milliunits(sub["amount"])}
@@ -215,7 +228,7 @@ async def search_transactions(
return serialize_list(matches, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def create_transaction(
plan_id: str,
@@ -232,8 +245,11 @@ async def create_transaction(
import_id: str | None = None,
subtransactions: list[dict] | None = None,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Create a new transaction. Future-dated transactions are not permitted (use scheduled transactions instead).
+ """Create a new transaction. Requires human confirmation.
+
+ Future-dated transactions are not permitted (use scheduled transactions instead).
Args:
plan_id: The plan ID (use list_plans to find available IDs)
@@ -277,19 +293,35 @@ async def create_transaction(
if memo:
transaction["memo"] = memo
+ fingerprint = await consent.confirm(
+ ctx,
+ action="Create transaction",
+ plan_id=plan_id,
+ payload=transaction,
+ body=(
+ "This transaction will be created:\n"
+ f" {diff.describe_transaction(transaction)}\n\n"
+ "Exact fields sent to YNAB:\n"
+ f"{diff.render_new_object(transaction)}"
+ ),
+ )
+ consent.assert_unchanged(transaction, fingerprint)
txn = await _shared.cache.create_transaction(transaction, plan_id)
return serialize(txn, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def create_transactions(
plan_id: str,
account_id: str,
transactions: list[dict],
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Create multiple transactions in a single API call. Ideal for bulk imports.
+ """Create multiple transactions in a single API call. Requires human confirmation.
+
+ Ideal for bulk imports.
Future-dated transactions are not permitted. The response includes any duplicate_import_ids
that were rejected because of an existing import_id on the same account.
@@ -332,11 +364,26 @@ async def create_transactions(
]
prepared.append(txn)
+ # Every row is itemised, never summarised as a count. "12 transactions" is
+ # exactly the prompt a human waves through without having seen it. If that
+ # makes the prompt long, the prompt is long: truncating here would be a
+ # correctness bug, not a UX trade-off.
+ listing = "\n".join(
+ f" {i:>3}. {diff.describe_transaction(t)}" for i, t in enumerate(prepared, 1)
+ )
+ fingerprint = await consent.confirm(
+ ctx,
+ action=f"Create {len(prepared)} transaction(s)",
+ plan_id=plan_id,
+ payload=prepared,
+ body=f"All {len(prepared)} will be created:\n{listing}",
+ )
+ consent.assert_unchanged(prepared, fingerprint)
txns = await _shared.cache.create_transactions(prepared, plan_id)
return serialize_list(txns, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def update_transaction(
plan_id: str,
@@ -353,8 +400,11 @@ async def update_transaction(
flag_color: str | None = None,
subtransactions: list[dict] | None = None,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Update an existing transaction. Only provide the fields you want to change.
+ """Update an existing transaction. Requires human confirmation.
+
+ Only provide the fields you want to change.
Future-dated transactions are not permitted. Split transaction amounts and dates
cannot be changed. If an existing transaction is a split, category_id cannot be
@@ -404,18 +454,29 @@ async def update_transaction(
_prepare_subtransaction(sub) for sub in subtransactions
]
+ before = await _transaction_preimage(transaction_id, plan_id)
+ fingerprint = await consent.confirm(
+ ctx,
+ action="Update transaction",
+ plan_id=plan_id,
+ subject=diff.describe_transaction(before),
+ before=before,
+ payload=transaction,
+ )
+ consent.assert_unchanged(transaction, fingerprint)
txn = await _shared.cache.update_transaction(transaction_id, transaction, plan_id)
return serialize(txn, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def delete_transaction(
transaction_id: str,
plan_id: str,
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Delete a transaction.
+ """Delete a transaction. Requires human confirmation.
Args:
transaction_id: The transaction ID to delete
@@ -424,14 +485,27 @@ async def delete_transaction(
If omitted, the model's default exclude list is used (see FIELDS.md).
Pass [] to return all fields. Pass a custom list to override the default.
"""
+ before = await _transaction_preimage(transaction_id, plan_id)
+ target = {"transaction_id": transaction_id, "plan_id": plan_id}
+ fingerprint = await consent.confirm(
+ ctx,
+ action="DELETE transaction",
+ plan_id=plan_id,
+ payload=target,
+ body=(
+ "This transaction will be permanently deleted:\n"
+ f" {diff.describe_transaction(before)}"
+ ),
+ )
+ consent.assert_unchanged(target, fingerprint)
txn = await _shared.cache.delete_transaction(transaction_id, plan_id)
return serialize(txn, exclude_fields=exclude_fields)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
-async def import_transactions(plan_id: str) -> str:
- """Import available transactions on all linked accounts for the given plan.
+async def import_transactions(plan_id: str, ctx: Context | None = None) -> str:
+ """Import available transactions on all linked accounts. Requires human confirmation.
Equivalent to clicking 'Import' on each account in the YNAB web app or tapping
the 'New Transactions' banner in the mobile app. Returns the list of transaction
@@ -440,18 +514,41 @@ async def import_transactions(plan_id: str) -> str:
Args:
plan_id: The plan ID (use list_plans to find available IDs)
"""
+ # No diff is possible here: YNAB decides what gets imported, and the result
+ # is not knowable in advance. Name the accounts that will be touched and say
+ # so plainly rather than implying a preview that does not exist.
+ accounts, _ = await _shared.client.get_accounts(plan_id)
+ linked = [a for a in accounts if a.direct_import_linked and not a.closed and not a.deleted]
+ if linked:
+ affected = "\n".join(f" - {a.name} ({a.type})" for a in linked)
+ else:
+ affected = " (no accounts are currently linked for direct import)"
+ target = {"action": "import_transactions", "plan_id": plan_id}
+ fingerprint = await consent.confirm(
+ ctx,
+ action="Import transactions from linked accounts",
+ plan_id=plan_id,
+ payload=target,
+ body=f"Accounts that may receive imported transactions:\n{affected}",
+ note=(
+ "YNAB decides which transactions are imported, so what this creates "
+ "is NOT known in advance and cannot be previewed here."
+ ),
+ )
+ consent.assert_unchanged(target, fingerprint)
transaction_ids = await _shared.cache.import_transactions(plan_id)
return json.dumps({"transaction_ids": transaction_ids}, indent=2)
-@_shared.mcp.tool()
+@_shared.mutating_tool()
@_shared.handle_errors
async def update_transactions(
plan_id: str,
transactions: list[dict],
exclude_fields: list[str] | None = None,
+ ctx: Context | None = None,
) -> str:
- """Update multiple transactions in a single API call.
+ """Update multiple transactions in a single API call. Requires human confirmation.
Each transaction must include either 'id' or 'import_id' for lookup (not both).
Only provided fields are updated (sparse update). Ideal for bulk recategorization,
@@ -504,5 +601,34 @@ async def update_transactions(
]
prepared.append(txn)
+ # Every row gets its own before/after. A row looked up by import_id has no
+ # pre-image available — say so, rather than leaving a blank that reads as
+ # "nothing changes".
+ blocks = []
+ for i, txn in enumerate(prepared, 1):
+ changes = {k: v for k, v in txn.items() if k not in ("id", "import_id")}
+ if "id" in txn:
+ before = await _transaction_preimage(txn["id"], plan_id)
+ header = f" {i:>3}. {diff.describe_transaction(before)}"
+ else:
+ before = {}
+ header = (
+ f" {i:>3}. (looked up by import_id {txn['import_id']}; "
+ "no pre-image available)"
+ )
+ block = diff.render_change_block(
+ diff.diff_fields(before, changes), indent=" "
+ )
+ blocks.append(header + "\n" + block)
+ fingerprint = await consent.confirm(
+ ctx,
+ action=f"Update {len(prepared)} transaction(s)",
+ plan_id=plan_id,
+ payload=prepared,
+ body="All {} will be updated:\n{}".format(
+ len(prepared), "\n".join(blocks)
+ ),
+ )
+ consent.assert_unchanged(prepared, fingerprint)
txns = await _shared.cache.update_transactions(prepared, plan_id)
return serialize_list(txns, exclude_fields=exclude_fields)
diff --git a/src/ynab_client.py b/src/ynab_client.py
index 79a96e37..970175fc 100644
--- a/src/ynab_client.py
+++ b/src/ynab_client.py
@@ -1,6 +1,9 @@
import httpx
from typing import Any
+from pydantic import SecretStr
+
+from src.security import redaction
from src.models import (
Account,
PlanDetail,
@@ -34,10 +37,15 @@ def __init__(self, status_code: int, error_id: str, message: str):
class YNABClient:
BASE_URL = "https://api.ynab.com/v1"
- def __init__(self, api_key: str, timeout: float = 30.0):
+ def __init__(self, api_key: str | SecretStr, timeout: float = 30.0):
+ # The only place the token is unwrapped. It goes straight into the httpx
+ # header and is never stored on self, so no attribute of this client can
+ # leak it into a repr or a tool return value.
+ token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
+ redaction.register_secret(token)
self._client = httpx.AsyncClient(
base_url=self.BASE_URL,
- headers={"Authorization": f"Bearer {api_key}"},
+ headers={"Authorization": f"Bearer {token}"},
timeout=timeout,
)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..ab879513
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,145 @@
+"""Shared fixtures.
+
+The consent gate means every mutating tool now needs two things the old tests
+did not provide: an MCP `Context` it can elicit through, and a `_shared.client`
+it can read a pre-image from. Both live here so test_server.py and
+test_security.py use the same fakes.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from mcp.server.mcpserver import (
+ AcceptedElicitation,
+ CancelledElicitation,
+ DeclinedElicitation,
+)
+
+from src.models import Account, Category, CategoryGroup, Payee, ScheduledTransaction, Transaction
+
+
+class FakeSession:
+ """Just enough ServerSession to answer the elicitation capability check."""
+
+ def __init__(self, supports_elicitation: bool = True):
+ self.supports_elicitation = supports_elicitation
+
+ def check_client_capability(self, capability) -> bool:
+ return self.supports_elicitation
+
+
+class FakeContext:
+ """A Context stand-in that records what a human would have been shown.
+
+ `prompts` is the point of this class: tests assert on the text the human
+ would actually see, which is the only thing that makes the consent gate
+ meaningful.
+ """
+
+ def __init__(
+ self,
+ *,
+ action: str = "accept",
+ confirm: bool = True,
+ supports_elicitation: bool = True,
+ raises: Exception | None = None,
+ ):
+ self.session = FakeSession(supports_elicitation)
+ self.prompts: list[str] = []
+ self._action = action
+ self._confirm = confirm
+ self._raises = raises
+
+ @property
+ def prompt(self) -> str:
+ """The single prompt shown, for the common one-elicitation case."""
+ assert len(self.prompts) == 1, f"expected 1 elicitation, saw {len(self.prompts)}"
+ return self.prompts[0]
+
+ async def elicit(self, message: str, schema):
+ self.prompts.append(message)
+ if self._raises is not None:
+ raise self._raises
+ if self._action == "accept":
+ return AcceptedElicitation(data=schema(confirm=self._confirm))
+ if self._action == "decline":
+ return DeclinedElicitation()
+ return CancelledElicitation()
+
+
+# The server package creates `cache` and `client` at import time, so the tests
+# patch the module-level objects in server/_shared.py.
+CACHE_PATH = "src.server._shared.cache"
+CLIENT_PATH = "src.server._shared.client"
+DB_PATH = "src.server._shared._ensure_db"
+
+
+@pytest.fixture
+def mock_cache():
+ """Stand in for the CacheService, so no tool reaches a real YNAB API."""
+ with patch(CACHE_PATH) as mock, patch(DB_PATH, new_callable=AsyncMock):
+ yield mock
+
+
+@pytest.fixture
+def consenting_ctx() -> FakeContext:
+ """A client whose human approves the change."""
+ return FakeContext()
+
+
+@pytest.fixture
+def declining_ctx() -> FakeContext:
+ """A client whose human says no."""
+ return FakeContext(action="decline")
+
+
+@pytest.fixture
+def no_elicitation_ctx() -> FakeContext:
+ """A client that never advertised elicitation support."""
+ return FakeContext(supports_elicitation=False)
+
+
+@pytest.fixture
+def mock_client():
+ """Patch the YNAB client used for consent pre-images.
+
+ Pre-images are read through `_shared.client`, never through the cache, so a
+ stale cached row cannot produce a false "before". These defaults let a test
+ that does not care about the pre-image stay short.
+ """
+ with patch(CLIENT_PATH) as mock:
+ mock.get_transaction = AsyncMock(
+ return_value=Transaction(
+ id="txn-1", date="2026-03-15", amount=-50250, memo="original",
+ payee_name="Original Payee", cleared="uncleared", approved=False,
+ )
+ )
+ mock.get_account = AsyncMock(
+ return_value=Account(id="acc-1", name="Checking", type="checking")
+ )
+ mock.get_accounts = AsyncMock(
+ return_value=(
+ [Account(id="acc-1", name="Checking", type="checking", direct_import_linked=True)],
+ 1,
+ )
+ )
+ mock.get_category = AsyncMock(
+ return_value=Category(id="cat-1", category_group_id="grp-1", name="Food", budgeted=100000)
+ )
+ mock.get_category_for_month = AsyncMock(
+ return_value=Category(
+ id="cat-1", category_group_id="grp-1", name="Food",
+ budgeted=100000, activity=-25000, balance=75000,
+ )
+ )
+ mock.get_categories = AsyncMock(
+ return_value=([CategoryGroup(id="grp-1", name="Bills")], 1)
+ )
+ mock.get_payee = AsyncMock(return_value=Payee(id="pay-1", name="Amazon"))
+ mock.get_scheduled_transaction = AsyncMock(
+ return_value=ScheduledTransaction(
+ id="sch-1", date_next="2026-04-01", frequency="monthly",
+ amount=-20000, account_id="acc-1", payee_name="Gym",
+ )
+ )
+ yield mock
diff --git a/tests/test_security.py b/tests/test_security.py
new file mode 100644
index 00000000..87807e83
--- /dev/null
+++ b/tests/test_security.py
@@ -0,0 +1,614 @@
+"""Tests for the security hardening: crypto, credentials, redaction, consent.
+
+The consent tests are the ones that matter. They assert on what a human would
+have been shown and on whether a write actually happened — not on the string a
+tool returned, which is the easy thing to check and the useless one.
+"""
+
+import json
+import os
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from cryptography.exceptions import InvalidTag
+
+from src.models import Transaction
+from src.security import consent, credentials, crypto, diff, redaction
+from src.ynab_client import YNABError
+
+
+TOKEN = "ynab-pat-0123456789abcdef0123456789abcdef0123456789abcdef"
+
+
+def _applied() -> Transaction:
+ """What the cache service hands back once a write has gone through."""
+ return Transaction(id="txn-1", date="2026-03-15", amount=-75000)
+
+
+# ── Crypto ────────────────────────────────────────────────────
+
+
+class TestCrypto:
+ def test_round_trip(self):
+ key = os.urandom(crypto.KEY_BYTES)
+ aad = crypto.aad_for("cached_entity", "data")
+ envelope = crypto.encrypt(b'{"memo":"lunch"}', aad, key=key)
+ assert crypto.decrypt(envelope, aad, key=key) == b'{"memo":"lunch"}'
+
+ def test_plaintext_absent_from_envelope(self):
+ key = os.urandom(crypto.KEY_BYTES)
+ aad = crypto.aad_for("cached_entity", "data")
+ envelope = crypto.encrypt(b"Chez Nowhere", aad, key=key)
+ assert b"Chez Nowhere" not in envelope
+
+ def test_nonce_is_fresh_per_encryption(self):
+ key = os.urandom(crypto.KEY_BYTES)
+ aad = crypto.aad_for("cached_entity", "data")
+ first = crypto.encrypt(b"same plaintext", aad, key=key)
+ second = crypto.encrypt(b"same plaintext", aad, key=key)
+ assert first[: crypto.NONCE_BYTES] != second[: crypto.NONCE_BYTES]
+ assert first != second
+
+ def test_tampered_ciphertext_raises(self):
+ key = os.urandom(crypto.KEY_BYTES)
+ aad = crypto.aad_for("cached_entity", "data")
+ envelope = bytearray(crypto.encrypt(b"sensitive", aad, key=key))
+ # Flip one bit well past the nonce, inside the ciphertext proper.
+ envelope[crypto.NONCE_BYTES + 1] ^= 0x01
+ with pytest.raises(InvalidTag):
+ crypto.decrypt(bytes(envelope), aad, key=key)
+
+ def test_wrong_aad_fails(self):
+ key = os.urandom(crypto.KEY_BYTES)
+ envelope = crypto.encrypt(b"sensitive", crypto.aad_for("cached_entity", "data"), key=key)
+ with pytest.raises(InvalidTag):
+ crypto.decrypt(envelope, crypto.aad_for("response_cache", "data"), key=key)
+
+ def test_wrong_key_fails(self):
+ aad = crypto.aad_for("cached_entity", "data")
+ envelope = crypto.encrypt(b"sensitive", aad, key=os.urandom(crypto.KEY_BYTES))
+ with pytest.raises(InvalidTag):
+ crypto.decrypt(envelope, aad, key=os.urandom(crypto.KEY_BYTES))
+
+ def test_short_envelope_rejected(self):
+ with pytest.raises(ValueError):
+ crypto.decrypt(b"tooshort", crypto.aad_for("t", "c"), key=os.urandom(32))
+
+
+# ── Credential store ──────────────────────────────────────────
+
+
+class FakeKeyring:
+ """A keyring backend that lives in a dict."""
+
+ def __init__(self):
+ self.store: dict[tuple[str, str], str] = {}
+
+ def get_password(self, service, entry):
+ return self.store.get((service, entry))
+
+ def set_password(self, service, entry, value):
+ self.store[(service, entry)] = value
+
+ def delete_password(self, service, entry):
+ del self.store[(service, entry)]
+
+
+@pytest.fixture
+def fake_keyring():
+ fake = FakeKeyring()
+ with patch.object(credentials, "_keyring", lambda: fake):
+ yield fake
+
+
+class TestCredentialStore:
+ def test_store_load_delete(self, fake_keyring):
+ assert credentials.load_token() is None
+ credentials.store_token(TOKEN)
+ assert credentials.load_token() == TOKEN
+ assert credentials.delete_token() is True
+ assert credentials.load_token() is None
+
+ def test_delete_absent_token_is_not_an_error(self, fake_keyring):
+ assert credentials.delete_token() is False
+
+ def test_token_and_cache_key_are_separate_entries(self, fake_keyring):
+ credentials.store_token(TOKEN)
+ credentials.set_secret(credentials.CACHE_KEY_ENTRY, "a-cache-key")
+ assert credentials.load_token() == TOKEN
+ assert credentials.get_secret(credentials.CACHE_KEY_ENTRY) == "a-cache-key"
+ credentials.delete_token()
+ assert credentials.get_secret(credentials.CACHE_KEY_ENTRY) == "a-cache-key"
+
+ def test_fingerprint_never_contains_the_token(self):
+ fingerprint = credentials.token_fingerprint(TOKEN)
+ assert TOKEN not in fingerprint
+ # Nor any run of it long enough to be worth having.
+ assert not any(TOKEN[i : i + 12] in fingerprint for i in range(len(TOKEN) - 12))
+
+ def test_fingerprint_of_short_token_is_digest_only(self):
+ assert credentials.token_fingerprint("short") == "sha256:f9b0078b5df5"
+
+ def test_fingerprint_of_no_token(self):
+ assert credentials.token_fingerprint(None) == "(none)"
+
+
+# ── Redaction ─────────────────────────────────────────────────
+
+
+class TestRedaction:
+ def setup_method(self):
+ redaction.forget_secrets()
+
+ def teardown_method(self):
+ redaction.forget_secrets()
+
+ def test_scrub_replaces_registered_secret(self):
+ redaction.register_secret(TOKEN)
+ assert TOKEN not in redaction.scrub(f"Authorization: Bearer {TOKEN}")
+ assert redaction.PLACEHOLDER in redaction.scrub(f"Bearer {TOKEN}")
+
+ def test_scrub_is_a_no_op_without_registration(self):
+ assert redaction.scrub("nothing registered") == "nothing registered"
+
+ def test_short_values_are_not_registered(self):
+ redaction.register_secret("abc")
+ assert redaction.scrub("abc def") == "abc def"
+
+ @pytest.mark.asyncio
+ async def test_token_in_exception_does_not_survive_handle_errors(self):
+ from src.server import _shared
+
+ redaction.register_secret(TOKEN)
+
+ @_shared.handle_errors
+ async def leaky():
+ raise YNABError(401, "unauthorized", f"bad header: Bearer {TOKEN}")
+
+ with patch.object(_shared, "_ensure_db", AsyncMock()):
+ result = await leaky()
+
+ assert TOKEN not in result
+ assert redaction.PLACEHOLDER in json.loads(result)["error"]
+
+ @pytest.mark.asyncio
+ async def test_token_in_http_error_body_does_not_survive(self):
+ import httpx
+
+ from src.server import _shared
+
+ redaction.register_secret(TOKEN)
+
+ @_shared.handle_errors
+ async def leaky():
+ response = httpx.Response(500, text=f"upstream echoed Bearer {TOKEN}")
+ raise httpx.HTTPStatusError("boom", request=None, response=response)
+
+ with patch.object(_shared, "_ensure_db", AsyncMock()):
+ result = await leaky()
+
+ assert TOKEN not in result
+
+
+# ── Diff rendering ────────────────────────────────────────────
+
+
+class TestDiffRendering:
+ def test_format_money_uses_milliunits(self):
+ assert diff.format_money(-50250) == "-$50.25"
+ assert diff.format_money(1000) == "$1.00"
+ assert diff.format_money(0) == "$0.00"
+ assert diff.format_money(None) == "(none)"
+
+ def test_format_money_keeps_sub_cent_precision(self):
+ # Rounding this to $50.26 would show a number that is not what is sent.
+ assert diff.format_money(50255) == "$50.255"
+
+ def test_diff_only_reports_fields_the_payload_sets(self):
+ changes = diff.diff_fields(
+ {"amount": -50250, "memo": "old", "approved": False},
+ {"amount": -60000},
+ )
+ assert changes == [("amount", -50250, -60000)]
+
+ def test_diff_reports_a_field_absent_from_the_preimage(self):
+ changes = diff.diff_fields({}, {"flag_color": "red"})
+ assert len(changes) == 1 and changes[0][0] == "flag_color"
+
+ def test_change_block_renders_money_as_currency(self):
+ block = diff.render_change_block([("amount", -50250, -60000)])
+ assert "-$50.25" in block and "-$60.00" in block
+
+
+# ── Consent ───────────────────────────────────────────────────
+
+
+class TestConsentPrimitives:
+ def test_fingerprint_is_order_independent(self):
+ assert consent.payload_fingerprint({"a": 1, "b": 2}) == consent.payload_fingerprint(
+ {"b": 2, "a": 1}
+ )
+
+ def test_fingerprint_changes_with_a_value(self):
+ assert consent.payload_fingerprint({"amount": -50250}) != consent.payload_fingerprint(
+ {"amount": -50251}
+ )
+
+ def test_assert_unchanged_passes_for_the_approved_payload(self):
+ payload = {"amount": -50250}
+ consent.assert_unchanged(payload, consent.payload_fingerprint(payload))
+
+ def test_assert_unchanged_catches_a_payload_swapped_after_approval(self):
+ payload = {"amount": -50250}
+ fingerprint = consent.payload_fingerprint(payload)
+ payload["amount"] = -99999
+ with pytest.raises(consent.ConsentViolation):
+ consent.assert_unchanged(payload, fingerprint)
+
+
+class TestConsentGate:
+ """The gate itself, exercised through a real tool."""
+
+ @pytest.mark.asyncio
+ async def test_decline_sends_nothing_to_ynab(
+ self, mock_cache, mock_client, declining_ctx
+ ):
+ from src.server import update_transaction
+
+ mock_cache.update_transaction = AsyncMock()
+ await update_transaction(
+ plan_id="bud-1", transaction_id="txn-1", amount=-75.00, ctx=declining_ctx
+ )
+
+ mock_cache.update_transaction.assert_not_called()
+ mock_client.update_transaction.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_cancel_sends_nothing_to_ynab(self, mock_cache, mock_client):
+ from tests.conftest import FakeContext
+
+ from src.server import update_transaction
+
+ mock_cache.update_transaction = AsyncMock()
+ await update_transaction(
+ plan_id="bud-1",
+ transaction_id="txn-1",
+ amount=-75.00,
+ ctx=FakeContext(action="cancel"),
+ )
+
+ mock_cache.update_transaction.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_confirm_false_sends_nothing_to_ynab(self, mock_cache, mock_client):
+ from tests.conftest import FakeContext
+
+ from src.server import update_transaction
+
+ mock_cache.update_transaction = AsyncMock()
+ await update_transaction(
+ plan_id="bud-1",
+ transaction_id="txn-1",
+ amount=-75.00,
+ ctx=FakeContext(action="accept", confirm=False),
+ )
+
+ mock_cache.update_transaction.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_client_without_elicitation_sends_nothing_to_ynab(
+ self, mock_cache, mock_client, no_elicitation_ctx
+ ):
+ from src.server import update_transaction
+
+ mock_cache.update_transaction = AsyncMock()
+ result = json.loads(
+ await update_transaction(
+ plan_id="bud-1",
+ transaction_id="txn-1",
+ amount=-75.00,
+ ctx=no_elicitation_ctx,
+ )
+ )
+
+ mock_cache.update_transaction.assert_not_called()
+ assert no_elicitation_ctx.prompts == []
+ assert result["applied"] is False
+ assert result["reason"] == "consent_unavailable"
+
+ @pytest.mark.asyncio
+ async def test_missing_context_sends_nothing_to_ynab(self, mock_cache, mock_client):
+ from src.server import update_transaction
+
+ mock_cache.update_transaction = AsyncMock()
+ result = json.loads(
+ await update_transaction(plan_id="bud-1", transaction_id="txn-1", amount=-75.00)
+ )
+
+ mock_cache.update_transaction.assert_not_called()
+ assert result["reason"] == "consent_unavailable"
+
+ @pytest.mark.asyncio
+ async def test_elicit_raising_sends_nothing_to_ynab(self, mock_cache, mock_client):
+ from tests.conftest import FakeContext
+
+ from src.server import update_transaction
+
+ mock_cache.update_transaction = AsyncMock()
+ result = json.loads(
+ await update_transaction(
+ plan_id="bud-1",
+ transaction_id="txn-1",
+ amount=-75.00,
+ ctx=FakeContext(raises=RuntimeError("no back-channel")),
+ )
+ )
+
+ mock_cache.update_transaction.assert_not_called()
+ assert result["reason"] == "consent_unavailable"
+
+ @pytest.mark.asyncio
+ async def test_applied_payload_matches_the_one_shown(
+ self, mock_cache, mock_client, consenting_ctx
+ ):
+ from src.server import update_transaction
+
+ mock_cache.update_transaction = AsyncMock(return_value=_applied())
+ shown = {}
+ real_render = consent.render_prompt
+
+ def spy(**kwargs):
+ shown["payload"] = kwargs["payload"]
+ return real_render(**kwargs)
+
+ with patch.object(consent, "render_prompt", spy):
+ await update_transaction(
+ plan_id="bud-1",
+ transaction_id="txn-1",
+ amount=-75.00,
+ memo="new memo",
+ ctx=consenting_ctx,
+ )
+
+ sent = mock_cache.update_transaction.call_args[0][1]
+ assert consent.payload_fingerprint(sent) == consent.payload_fingerprint(shown["payload"])
+ assert sent == {"amount": -75000, "memo": "new memo"}
+ # And the human saw those values, not just a fingerprint of them.
+ assert "-$75.00" in consenting_ctx.prompt
+ assert "new memo" in consenting_ctx.prompt
+
+ @pytest.mark.asyncio
+ async def test_preimage_comes_from_the_api_not_the_cache(
+ self, mock_cache, mock_client, consenting_ctx
+ ):
+ from src.server import update_transaction
+
+ mock_cache.update_transaction = AsyncMock(return_value=_applied())
+ mock_cache.get_transaction = AsyncMock()
+
+ await update_transaction(
+ plan_id="bud-1", transaction_id="txn-1", amount=-75.00, ctx=consenting_ctx
+ )
+
+ # A stale cached row would show the human a false "before".
+ mock_client.get_transaction.assert_awaited_once_with("txn-1", "bud-1")
+ mock_cache.get_transaction.assert_not_called()
+ assert "Original Payee" in consenting_ctx.prompt
+ assert "-$50.25" in consenting_ctx.prompt
+
+ @pytest.mark.asyncio
+ async def test_bulk_update_itemises_every_row(
+ self, mock_cache, mock_client, consenting_ctx
+ ):
+ from src.server import update_transactions
+
+ rows = 12
+ mock_cache.update_transactions = AsyncMock(return_value=[])
+ await update_transactions(
+ plan_id="bud-1",
+ transactions=[{"id": f"t{i}", "memo": f"memo {i}"} for i in range(rows)],
+ ctx=consenting_ctx,
+ )
+
+ prompt = consenting_ctx.prompt
+ # Never summarised as "12 transactions": every row is spelled out.
+ for i in range(rows):
+ assert f"memo {i}" in prompt, f"row {i} was not shown to the human"
+ assert mock_client.get_transaction.await_count == rows
+
+ @pytest.mark.asyncio
+ async def test_bulk_create_itemises_every_row(
+ self, mock_cache, mock_client, consenting_ctx
+ ):
+ from src.server import create_transactions
+
+ mock_cache.create_transactions = AsyncMock(return_value=[])
+ await create_transactions(
+ plan_id="bud-1",
+ account_id="acc-1",
+ transactions=[
+ {"date": "2026-03-15", "amount": -25.50, "payee_name": "Alpha"},
+ {"date": "2026-03-16", "amount": -10.00, "payee_name": "Beta"},
+ {"date": "2026-03-17", "amount": -1.25, "payee_name": "Gamma"},
+ ],
+ ctx=consenting_ctx,
+ )
+
+ prompt = consenting_ctx.prompt
+ for payee, amount in (("Alpha", "-$25.50"), ("Beta", "-$10.00"), ("Gamma", "-$1.25")):
+ assert payee in prompt and amount in prompt
+
+ @pytest.mark.asyncio
+ async def test_delete_shows_what_is_being_destroyed(
+ self, mock_cache, mock_client, consenting_ctx
+ ):
+ from src.server import delete_transaction
+
+ mock_cache.delete_transaction = AsyncMock(return_value=_applied())
+ await delete_transaction(
+ transaction_id="txn-1", plan_id="bud-1", ctx=consenting_ctx
+ )
+
+ assert "Original Payee" in consenting_ctx.prompt
+ assert "-$50.25" in consenting_ctx.prompt
+ assert "deleted" in consenting_ctx.prompt.lower()
+
+ @pytest.mark.asyncio
+ async def test_import_names_affected_accounts_and_admits_no_preview(
+ self, mock_cache, mock_client, consenting_ctx
+ ):
+ from src.server import import_transactions
+
+ mock_cache.import_transactions = AsyncMock(return_value=[])
+ await import_transactions(plan_id="bud-1", ctx=consenting_ctx)
+
+ prompt = consenting_ctx.prompt
+ assert "Checking" in prompt
+ assert "NOT known in advance" in prompt
+
+ @pytest.mark.asyncio
+ async def test_every_mutating_tool_refuses_without_consent(self, mock_cache, mock_client):
+ """No gated tool may apply a change when consent cannot be obtained."""
+ from tests.conftest import FakeContext
+
+ import src.server as server
+
+ blocked = FakeContext(supports_elicitation=False)
+ calls = {
+ "create_account": dict(plan_id="b", name="n", type="checking", balance=1.0),
+ "create_transaction": dict(plan_id="b", account_id="a", date="2026-03-15", amount=-1.0),
+ "create_transactions": dict(plan_id="b", account_id="a", transactions=[{"date": "2026-03-15", "amount": -1.0}]),
+ "update_transaction": dict(plan_id="b", transaction_id="t", amount=-1.0),
+ "update_transactions": dict(plan_id="b", transactions=[{"id": "t", "memo": "m"}]),
+ "delete_transaction": dict(plan_id="b", transaction_id="t"),
+ "import_transactions": dict(plan_id="b"),
+ "create_category": dict(plan_id="b", category_group_id="g", name="n"),
+ "update_category": dict(plan_id="b", category_id="c", name="n"),
+ "create_category_group": dict(plan_id="b", name="n"),
+ "update_category_group": dict(plan_id="b", category_group_id="g", name="n"),
+ "update_category_for_month": dict(plan_id="b", category_id="c", month="2026-03-01", budgeted=1.0),
+ "update_payee": dict(plan_id="b", payee_id="p", name="n"),
+ "create_scheduled_transaction": dict(plan_id="b", account_id="a", date="2026-04-01"),
+ "update_scheduled_transaction": dict(plan_id="b", scheduled_transaction_id="s", account_id="a", date="2026-04-01"),
+ "delete_scheduled_transaction": dict(plan_id="b", scheduled_transaction_id="s"),
+ }
+ assert len(calls) == 16
+
+ for name, kwargs in calls.items():
+ mock_cache.reset_mock()
+ result = json.loads(await getattr(server, name)(ctx=blocked, **kwargs))
+ assert result.get("reason") == "consent_unavailable", f"{name} was not gated: {result}"
+ assert result["applied"] is False
+ # Nothing on the cache service was invoked to change anything.
+ for attr in dir(mock_cache):
+ if attr.startswith(("create_", "update_", "delete_", "import_")):
+ getattr(mock_cache, attr).assert_not_called()
+
+
+# ── Read-only mode ────────────────────────────────────────────
+
+# Registration happens at import time, so read-only mode has to be exercised in
+# a fresh interpreter. Re-importing in-process would not re-run the decorators.
+_LIST_TOOLS = """
+import asyncio, json, sys
+from src.server import mcp
+tools = asyncio.run(mcp.list_tools())
+sys.stdout.write("TOOLS:" + json.dumps(sorted(t.name for t in tools)))
+"""
+
+GATED_TOOLS = {
+ "create_account",
+ "create_transaction",
+ "create_transactions",
+ "update_transaction",
+ "update_transactions",
+ "delete_transaction",
+ "import_transactions",
+ "create_category",
+ "update_category",
+ "create_category_group",
+ "update_category_group",
+ "update_category_for_month",
+ "update_payee",
+ "create_scheduled_transaction",
+ "update_scheduled_transaction",
+ "delete_scheduled_transaction",
+}
+
+
+def _tool_names(**env) -> set[str]:
+ import subprocess
+ import sys
+
+ result = subprocess.run(
+ [sys.executable, "-c", _LIST_TOOLS],
+ capture_output=True,
+ text=True,
+ env={**os.environ, "YNAB_API_KEY": "dummy-key-for-tests", **env},
+ )
+ assert result.returncode == 0, result.stderr
+ marker = result.stdout.index("TOOLS:") + len("TOOLS:")
+ return set(json.loads(result.stdout[marker:]))
+
+
+class TestReadOnlyMode:
+ def test_write_tools_are_not_registered(self):
+ names = _tool_names(YNAB_READ_ONLY="1")
+ assert not (GATED_TOOLS & names), "read-only mode still offers write tools"
+
+ def test_read_tools_survive(self):
+ names = _tool_names(YNAB_READ_ONLY="1")
+ for name in ("get_user", "list_plans", "list_accounts", "list_transactions"):
+ assert name in names
+
+ def test_writes_are_enabled_by_default(self):
+ # Read-only is opt-in: the default install can still write.
+ assert GATED_TOOLS <= _tool_names()
+
+
+# ── Token input handling ──────────────────────────────────────
+
+
+class TestTokenInput:
+ """Every case here was hit while setting the server up for real on Windows."""
+
+ def test_control_character_is_rejected_with_the_real_cause(self):
+ from src.cli import _why_not_a_token
+
+ # The Windows console delivers Ctrl+V to a hidden getpass prompt as this
+ # character rather than pasting. Left unchecked it builds a malformed
+ # Authorization header and YNAB answers with a bodyless HTTP 400.
+ problem = _why_not_a_token("\x16")
+ assert problem is not None
+ assert "Ctrl+V" in problem
+
+ def test_whitespace_is_rejected(self):
+ from src.cli import _why_not_a_token
+
+ assert "whitespace" in _why_not_a_token("a" * 32 + " " + "b" * 32)
+
+ def test_partial_paste_is_rejected(self):
+ from src.cli import _why_not_a_token
+
+ assert "character(s)" in _why_not_a_token("abc123")
+
+ def test_plausible_token_is_accepted(self):
+ from src.cli import _why_not_a_token
+
+ assert _why_not_a_token("a" * 64) is None
+
+ def test_bom_prefixed_stdin_is_accepted(self):
+ """PowerShell 5.1 puts a UTF-8 BOM in front of anything it pipes."""
+ import io
+
+ from src.cli import _read_stdin_token, _why_not_a_token
+
+ token = "b" * 64
+ stream = io.TextIOWrapper(io.BytesIO(b"\xef\xbb\xbf" + token.encode() + b"\r\n"))
+ with patch("sys.stdin", stream):
+ read = _read_stdin_token()
+
+ # A BOM is not whitespace, so .strip() leaves it and it reads as a
+ # control character. utf-8-sig consumes it.
+ assert read == token
+ assert _why_not_a_token(read) is None
diff --git a/tests/test_server.py b/tests/test_server.py
index 68d434d1..151d9b42 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -77,20 +77,6 @@ def _make_scheduled_transaction(**overrides) -> ScheduledTransaction:
return ScheduledTransaction(**{**defaults, **overrides})
-# We need to patch the module-level objects in server/_shared.py.
-# The server package creates `cache` and `client` at import time,
-# so we patch `src.server._shared.cache` (the CacheService instance).
-
-CACHE_PATH = "src.server._shared.cache"
-DB_PATH = "src.server._shared._ensure_db"
-
-
-@pytest.fixture
-def mock_cache():
- with patch(CACHE_PATH) as mock, patch(DB_PATH, new_callable=AsyncMock):
- yield mock
-
-
# ── Plan Tools ──────────────────────────────────────────────
@@ -234,25 +220,27 @@ async def test_returns_filtered_transactions(self, mock_cache):
class TestCreateTransaction:
@pytest.mark.asyncio
- async def test_converts_dollars_to_milliunits(self, mock_cache):
+ async def test_converts_dollars_to_milliunits(self, mock_cache, mock_client, consenting_ctx):
from src.server import create_transaction
mock_cache.create_transaction = AsyncMock(return_value=_make_transaction(amount=-50250))
await create_transaction(
plan_id="bud-1", account_id="acc-1", date="2026-03-15", amount=-50.25,
+ ctx=consenting_ctx,
)
call_args = mock_cache.create_transaction.call_args
txn_dict = call_args[0][0]
assert txn_dict["amount"] == -50250
@pytest.mark.asyncio
- async def test_includes_optional_fields(self, mock_cache):
+ async def test_includes_optional_fields(self, mock_cache, mock_client, consenting_ctx):
from src.server import create_transaction
mock_cache.create_transaction = AsyncMock(return_value=_make_transaction())
await create_transaction(
plan_id="bud-1", account_id="acc-1", date="2026-03-15", amount=-10.0,
payee_name="Store", category_id="cat-1", memo="test",
+ ctx=consenting_ctx,
)
txn_dict = mock_cache.create_transaction.call_args[0][0]
assert txn_dict["payee_name"] == "Store"
@@ -260,12 +248,13 @@ async def test_includes_optional_fields(self, mock_cache):
assert txn_dict["memo"] == "test"
@pytest.mark.asyncio
- async def test_omits_none_optional_fields(self, mock_cache):
+ async def test_omits_none_optional_fields(self, mock_cache, mock_client, consenting_ctx):
from src.server import create_transaction
mock_cache.create_transaction = AsyncMock(return_value=_make_transaction())
await create_transaction(
plan_id="bud-1", account_id="acc-1", date="2026-03-15", amount=-10.0,
+ ctx=consenting_ctx,
)
txn_dict = mock_cache.create_transaction.call_args[0][0]
assert "payee_name" not in txn_dict
@@ -275,7 +264,7 @@ async def test_omits_none_optional_fields(self, mock_cache):
class TestCreateTransactions:
@pytest.mark.asyncio
- async def test_converts_all_amounts(self, mock_cache):
+ async def test_converts_all_amounts(self, mock_cache, mock_client, consenting_ctx):
from src.server import create_transactions
mock_cache.create_transactions = AsyncMock(
@@ -288,6 +277,7 @@ async def test_converts_all_amounts(self, mock_cache):
{"date": "2026-03-15", "amount": -25.50},
{"date": "2026-03-16", "amount": -10.00, "payee_name": "Cafe"},
],
+ ctx=consenting_ctx,
)
prepared = mock_cache.create_transactions.call_args[0][0]
assert prepared[0]["amount"] == -25500
@@ -295,7 +285,7 @@ async def test_converts_all_amounts(self, mock_cache):
assert prepared[1]["payee_name"] == "Cafe"
@pytest.mark.asyncio
- async def test_per_transaction_account_override(self, mock_cache):
+ async def test_per_transaction_account_override(self, mock_cache, mock_client, consenting_ctx):
from src.server import create_transactions
mock_cache.create_transactions = AsyncMock(return_value=[_make_transaction()])
@@ -303,12 +293,13 @@ async def test_per_transaction_account_override(self, mock_cache):
plan_id="bud-1",
account_id="acc-default",
transactions=[{"date": "2026-03-15", "amount": -5.0, "account_id": "acc-override"}],
+ ctx=consenting_ctx,
)
prepared = mock_cache.create_transactions.call_args[0][0]
assert prepared[0]["account_id"] == "acc-override"
@pytest.mark.asyncio
- async def test_uses_default_account_id(self, mock_cache):
+ async def test_uses_default_account_id(self, mock_cache, mock_client, consenting_ctx):
from src.server import create_transactions
mock_cache.create_transactions = AsyncMock(return_value=[_make_transaction()])
@@ -316,6 +307,7 @@ async def test_uses_default_account_id(self, mock_cache):
plan_id="bud-1",
account_id="acc-default",
transactions=[{"date": "2026-03-15", "amount": -5.0}],
+ ctx=consenting_ctx,
)
prepared = mock_cache.create_transactions.call_args[0][0]
assert prepared[0]["account_id"] == "acc-default"
@@ -323,38 +315,38 @@ async def test_uses_default_account_id(self, mock_cache):
class TestUpdateTransaction:
@pytest.mark.asyncio
- async def test_converts_amount_to_milliunits(self, mock_cache):
+ async def test_converts_amount_to_milliunits(self, mock_cache, mock_client, consenting_ctx):
from src.server import update_transaction
mock_cache.update_transaction = AsyncMock(return_value=_make_transaction())
- await update_transaction(plan_id="bud-1", transaction_id="txn-1", amount=-75.00)
+ await update_transaction(plan_id="bud-1", transaction_id="txn-1", amount=-75.00, ctx=consenting_ctx)
call_args = mock_cache.update_transaction.call_args
txn_dict = call_args[0][1]
assert txn_dict["amount"] == -75000
@pytest.mark.asyncio
- async def test_only_includes_provided_fields(self, mock_cache):
+ async def test_only_includes_provided_fields(self, mock_cache, mock_client, consenting_ctx):
from src.server import update_transaction
mock_cache.update_transaction = AsyncMock(return_value=_make_transaction())
- await update_transaction(plan_id="bud-1", transaction_id="txn-1", memo="updated")
+ await update_transaction(plan_id="bud-1", transaction_id="txn-1", memo="updated", ctx=consenting_ctx)
txn_dict = mock_cache.update_transaction.call_args[0][1]
assert txn_dict == {"memo": "updated"}
class TestDeleteTransaction:
@pytest.mark.asyncio
- async def test_deletes_and_returns(self, mock_cache):
+ async def test_deletes_and_returns(self, mock_cache, mock_client, consenting_ctx):
from src.server import delete_transaction
mock_cache.delete_transaction = AsyncMock(return_value=_make_transaction())
- result = json.loads(await delete_transaction(transaction_id="txn-1", plan_id="bud-1"))
+ result = json.loads(await delete_transaction(transaction_id="txn-1", plan_id="bud-1", ctx=consenting_ctx))
assert result["id"] == "txn-1"
class TestUpdateTransactions:
@pytest.mark.asyncio
- async def test_converts_amounts_to_milliunits(self, mock_cache):
+ async def test_converts_amounts_to_milliunits(self, mock_cache, mock_client, consenting_ctx):
from src.server import update_transactions
mock_cache.update_transactions = AsyncMock(
@@ -366,13 +358,14 @@ async def test_converts_amounts_to_milliunits(self, mock_cache):
{"id": "t1", "amount": -25.50},
{"id": "t2", "amount": 10.00},
],
+ ctx=consenting_ctx,
)
prepared = mock_cache.update_transactions.call_args[0][0]
assert prepared[0]["amount"] == -25500
assert prepared[1]["amount"] == 10000
@pytest.mark.asyncio
- async def test_only_includes_provided_fields(self, mock_cache):
+ async def test_only_includes_provided_fields(self, mock_cache, mock_client, consenting_ctx):
from src.server import update_transactions
mock_cache.update_transactions = AsyncMock(
@@ -381,17 +374,19 @@ async def test_only_includes_provided_fields(self, mock_cache):
await update_transactions(
plan_id="bud-1",
transactions=[{"id": "t1", "memo": "updated"}],
+ ctx=consenting_ctx,
)
prepared = mock_cache.update_transactions.call_args[0][0]
assert prepared[0] == {"id": "t1", "memo": "updated"}
@pytest.mark.asyncio
- async def test_requires_id_per_transaction(self, mock_cache):
+ async def test_requires_id_per_transaction(self, mock_cache, mock_client, consenting_ctx):
from src.server import update_transactions
result = json.loads(await update_transactions(
plan_id="bud-1",
transactions=[{"memo": "no id here"}],
+ ctx=consenting_ctx,
))
assert "error" in result
assert "id" in result["error"]
@@ -459,12 +454,13 @@ async def test_returns_category_with_goals(self, mock_cache):
class TestUpdateCategoryForMonth:
@pytest.mark.asyncio
- async def test_converts_dollars_to_milliunits(self, mock_cache):
+ async def test_converts_dollars_to_milliunits(self, mock_cache, mock_client, consenting_ctx):
from src.server import update_category_for_month
mock_cache.update_category_for_month = AsyncMock(return_value=_make_category(budgeted=500000))
await update_category_for_month(
- category_id="cat-1", month="2026-03-01", budgeted=500.00, plan_id="bud-1"
+ category_id="cat-1", month="2026-03-01", budgeted=500.00, plan_id="bud-1",
+ ctx=consenting_ctx,
)
call_args = mock_cache.update_category_for_month.call_args
assert call_args[0][2] == 500000 # third positional arg is budgeted in milliunits
diff --git a/uv.lock b/uv.lock
index 084e1d78..c9e3b0fe 100644
--- a/uv.lock
+++ b/uv.lock
@@ -159,52 +159,55 @@ wheels = [
[[package]]
name = "cryptography"
-version = "50.0.0"
+version = "46.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
- { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
- { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
- { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
- { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
- { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
- { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
- { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
- { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
- { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
- { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
- { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
- { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
- { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
- { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
- { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
- { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
- { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
- { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
- { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
- { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
- { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
- { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
- { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
- { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
- { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
- { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
- { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
- { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
- { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
- { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
- { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
- { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
- { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
- { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
- { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
- { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
- { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
- { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
+ { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
+ { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
+ { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
+ { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
+ { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
+ { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
+ { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
+ { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
+ { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
+ { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
+ { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
+ { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
+ { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
+ { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
]
[[package]]
@@ -347,6 +350,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "jaraco-classes"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
+]
+
+[[package]]
+name = "jaraco-context"
+version = "6.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
+]
+
+[[package]]
+name = "jaraco-functools"
+version = "4.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" },
+]
+
+[[package]]
+name = "jeepney"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
+]
+
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -374,6 +419,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
+[[package]]
+name = "keyring"
+version = "25.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jaraco-classes" },
+ { name = "jaraco-context" },
+ { name = "jaraco-functools" },
+ { name = "jeepney", marker = "sys_platform == 'linux'" },
+ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
+ { name = "secretstorage", marker = "sys_platform == 'linux'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
+]
+
[[package]]
name = "markdown-it-py"
version = "4.2.0"
@@ -436,7 +498,9 @@ version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
+ { name = "cryptography" },
{ name = "httpx" },
+ { name = "keyring" },
{ name = "mcp", extra = ["cli"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
@@ -452,7 +516,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.21.0" },
+ { name = "cryptography", specifier = ">=43.0,<47.0" },
{ name = "httpx", specifier = ">=0.28.1,<1.0" },
+ { name = "keyring", specifier = ">=25.0,<26.0" },
{ name = "mcp", extras = ["cli"], specifier = ">=2.0.0,<3.0.0" },
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.9.1,<3.0.0" },
@@ -474,6 +540,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
+[[package]]
+name = "more-itertools"
+version = "11.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
+]
+
[[package]]
name = "opentelemetry-api"
version = "1.44.0"
@@ -683,6 +758,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
]
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
+]
+
[[package]]
name = "referencing"
version = "0.37.0"
@@ -790,6 +874,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
]
+[[package]]
+name = "secretstorage"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "jeepney" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
+]
+
[[package]]
name = "shellingham"
version = "1.5.4"