ynab-mcp-bridge
by mossipcams
README.md
# ynab-mcp-bridge
`ynab-mcp-bridge` is a read-only Model Context Protocol (MCP) server for YNAB.
It gives MCP clients a shared YNAB backend over either:
- stateless HTTP for self-hosted or remote deployments
- optional OAuth-protected HTTP for remote browser-based MCP clients
- `stdio` for local desktop clients and debugging
## What You Get
- Read-only YNAB tools for plans, accounts, categories, payees, transactions, scheduled transactions, and summary views
- HTTP mode by default
- Stateless `POST /mcp` handling that works well with clients that do not keep durable MCP sessions
- Optional auth2-backed OAuth mode for remote clients such as Claude Web
- Automatic plan resolution when `YNAB_PLAN_ID` is not set
## Choose A Mode
| Mode | Use it when | Auth | Key settings |
| --- | --- | --- | --- |
| `http` + `authless` | Local use or a trusted self-hosted setup | None | `YNAB_API_TOKEN` |
| `http` + `oauth-single-tenant` | Remote client access behind an upstream IdP | MCP-side OAuth | `MCP_PUBLIC_URL` and `MCP_AUTH2_CONFIG_PATH` |
| `http` + `oauth-hardened` | Same as above, but fail closed unless origins are explicitly allowed | MCP-side OAuth | `MCP_PUBLIC_URL`, `MCP_AUTH2_CONFIG_PATH`, and `MCP_ALLOWED_ORIGINS` |
| `stdio` | Local desktop clients and debugging | None | `YNAB_API_TOKEN` |
`authless` is the default deployment mode. `http` is the default transport.
## Quick Start
### 1. Install and build
```bash
npm install
npm run build
npm run pr:ci
```
Build artifact policy:
- `dist/` remains tracked in this repository for now because the published package and CLI entrypoints resolve from built JavaScript under `dist/`.
- When a source change affects runtime output, keep the generated `dist/` artifacts in sync with the source change rather than treating them as disposable local-only files.
- CI enforces that policy with `verify:build-sync` after `npm run build`, and it also runs `verify:pack` to smoke-test the packed npm artifact before treating a build as release-ready.
- When CI passes on the exact release commit and your dependency lockfile is already installed locally, pulling the release should be a restart-first path rather than a rebuild-first path.
### 2. Run the default local HTTP server
```bash
export YNAB_API_TOKEN=your-token
npm start
```
Defaults:
- host: `127.0.0.1`
- port: `3000`
- path: `/mcp`
### 3. Run over `stdio`
```bash
export YNAB_API_TOKEN=your-token
npm run start:stdio
```
### Local CI Preflight
Run this before creating a PR when you want one command that mirrors the required CI gates instead of running individual test commands:
```bash
npm run pr:ci
```
`pr:ci` runs the required local checks from CI: `test:ci`, `test:coverage`, `lint:deps`, `lint`, `typecheck`, `lint:unused`, and `build`. It intentionally does not include the advisory-only `lint:oxlint` step because that CI job is non-blocking.
`preflight` remains available as a backward-compatible alias, but the default pre-PR workflow should be `npm run pr:ci`.
For advisory quality reporting outside the blocking preflight gate, you can also run:
```bash
npm run lint:duplicates
npm run tech-debt:report
```
`lint:duplicates` runs JSCPD across maintained production, tooling, and configuration files using the checked-in `.jscpd.json` settings. It excludes generated output, Markdown, specs, contracts, and local task docs, and it also ignores the auth2 harness so duplication reporting stays focused on the production module surface. `tech-debt:report` uses the same advisory boundary and prints the current duplication, dead-export, suppression, debt-marker, and dependency-update counts.
### 4. Expose authless HTTP intentionally
Use this only when the network path is already trusted.
```bash
MCP_TRANSPORT=http \
MCP_HOST=0.0.0.0 \
MCP_ALLOWED_ORIGINS=https://claude.ai \
YNAB_API_TOKEN=your-token \
npm run start:http
```
### 5. Enable OAuth for a remote client
This is the recommended remote setup.
```bash
MCP_TRANSPORT=http \
MCP_HOST=0.0.0.0 \
MCP_ALLOWED_ORIGINS=https://claude.ai \
MCP_DEPLOYMENT_MODE=oauth-single-tenant \
MCP_PUBLIC_URL=https://mcp.example.com/mcp \
MCP_AUTH2_CONFIG_PATH=./auth2.config.example.json \
YNAB_API_TOKEN=your-token \
npm run start:http
```
The checked-in [auth2.config.example.json](/Users/matt/Desktop/Projects/ynab-mcp-bridge/auth2.config.example.json) shows the canonical auth2 file shape loaded through `MCP_AUTH2_CONFIG_PATH`. Copy it to a private path, replace the placeholder provider values, and keep `/oauth/callback` as the callback path.
With an auth2 config file in place, the minimal remote OAuth env surface is:
- `MCP_PUBLIC_URL`
- `MCP_AUTH2_CONFIG_PATH`
Legacy `MCP_OAUTH_*` settings are still supported as compatibility overrides, but they are no longer required for the primary auth2 HTTP path.
## Environment Variables
### YNAB Backend
| Variable | Required | Notes |
| --- | --- | --- |
| `YNAB_API_TOKEN` | Yes | Shared backend YNAB token |
| `YNAB_PLAN_ID` | No | Default plan for tools that accept `planId` |
If `YNAB_PLAN_ID` is unset, the bridge tries YNAB's `default_plan` first, then the only available plan when exactly one exists. If a configured plan becomes stale, the bridge retries once with a fresh resolution.
### Transport and HTTP
| Variable | Default | Notes |
| --- | --- | --- |
| `MCP_TRANSPORT` | `http` | `http` or `stdio` |
| `MCP_HOST` | `127.0.0.1` | HTTP only |
| `MCP_PORT` | `3000` | HTTP only |
| `MCP_PATH` | `/mcp` | HTTP only |
| `MCP_ALLOWED_ORIGINS` | empty | Comma-separated browser origin allowlist |
| `MCP_ALLOWED_HOSTS` | empty | Optional comma-separated `Host` header allowlist |
| `MCP_DEPLOYMENT_MODE` | `authless` | `authless`, `oauth-single-tenant`, or `oauth-hardened` |
| `MCP_AUTH_MODE` | none | Legacy compatibility shim: `none` or `oauth` |
Notes:
- When an `Origin` header is present, HTTP mode validates it.
- Loopback origins are allowed automatically for loopback hosts.
- `oauth-hardened` refuses to start without `MCP_ALLOWED_ORIGINS`.
### OAuth
| Variable | Required | Notes |
| --- | --- | --- |
| `MCP_PUBLIC_URL` | Yes in OAuth modes | Public MCP URL, for example `https://mcp.example.com/mcp` |
| `MCP_AUTH2_CONFIG_PATH` | Yes in OAuth modes | Path to the auth2 JSON config file |
| `MCP_OAUTH_AUDIENCE` | No | Defaults to `MCP_PUBLIC_URL` |
| `MCP_OAUTH_STORE_PATH` | No | Defaults to `~/.ynab-mcp-bridge/oauth-store.json` |
| `MCP_OAUTH_TOKEN_SIGNING_SECRET` | No | Defaults to a stable derived secret |
| `MCP_OAUTH_CALLBACK_PATH` | No | Defaults to `/oauth/callback` |
| `MCP_OAUTH_SKIP_LOCAL_CONSENT` | No | Set to `true` to bypass the bridge-hosted approval screen for every OAuth client |
| `MCP_OAUTH_SCOPES` | No | Comma-separated runtime scope override; defaults to the auth2 config scopes |
| `MCP_OAUTH_CLOUDFLARE_DOMAIN` | Optional compatibility override | Shortcut for Cloudflare Access endpoint derivation |
| `MCP_OAUTH_ISSUER` | Optional compatibility override | Upstream issuer |
| `MCP_OAUTH_AUTHORIZATION_URL` | Optional compatibility override | Upstream authorization endpoint |
| `MCP_OAUTH_TOKEN_URL` | Optional compatibility override | Upstream token endpoint |
| `MCP_OAUTH_JWKS_URL` | Optional compatibility override | Upstream JWKS endpoint |
| `MCP_OAUTH_CLIENT_ID` | Optional compatibility override | Upstream confidential client ID |
| `MCP_OAUTH_CLIENT_SECRET` | Optional compatibility override | Upstream confidential client secret |
## How HTTP Mode Behaves
- The default HTTP transport is stateless. Clients should send `POST /mcp` and should not depend on durable MCP sessions.
- The bridge still exposes `Mcp-Session-Id` headers for compatibility, but session continuity is not the primary path.
- In OAuth modes, the bridge acts as the MCP authorization server and exposes:
- `/.well-known/oauth-authorization-server`
- `/register`
- `/authorize`
- `/token`
- `/.well-known/oauth-protected-resource/mcp`
- OAuth here protects access to a shared backend YNAB token. It does not do per-user YNAB OAuth delegation.
- `MCP_OAUTH_SKIP_LOCAL_CONSENT=true` is intended for owner-operated deployments where you want the bridge to forward every authorization request upstream without showing the local approval page.
## Cloudflare Access
For Cloudflare Access, the simplest setup is:
- `MCP_PUBLIC_URL`
- `MCP_AUTH2_CONFIG_PATH`
Put the Cloudflare issuer, authorization endpoint, token endpoint, JWKS URI, client ID, and client secret in the auth2 config file. The legacy `MCP_OAUTH_*` settings remain available if you need to override those values at runtime.
Important details:
- Register the callback built from `MCP_PUBLIC_URL` and `MCP_OAUTH_CALLBACK_PATH`, for example `https://mcp.example.com/oauth/callback`
- Keep `MCP_PUBLIC_URL` on the external HTTPS hostname, not the internal bind address
- Use the public MCP URL as the audience unless your Access app expects a different resource identifier
- Do not use the older tenant-wide `/cdn-cgi/access/sso/oauth2/*` endpoints for this flow
If Cloudflare injects `Cf-Access-Jwt-Assertion`, the bridge can translate that assertion into a bridge-local token as an explicit compatibility path.
## Tool Coverage
The server exposes a read-only YNAB toolset across:
- user and plan metadata
- plan settings and plan months
- accounts, categories, and payees
- transactions and scheduled transactions
- payee locations
- money movement and transfer summaries
- higher-level financial summaries such as spending, cash flow, income, goal progress, obligations, and budget health
For YNAB-style summaries, treat `assigned_vs_spent` as a timing and buffering signal, not a score for budget discipline. In buffered budgets it often reflects paycheck timing, category staging, or money reserved for future months rather than overspending or underspending by itself.
## CLI Examples
Start with the default HTTP settings:
```bash
node dist/index.js
```
Start over `stdio`:
```bash
node dist/index.js --transport stdio
```
Start over HTTP explicitly:
```bash
node dist/index.js --transport http --host 127.0.0.1 --port 3000 --path /mcp
```
Run the local HTTP reliability probe:
```bash
npm run reliability:http -- --requests 10 --concurrency 2
```
The reliability command uses a bounded authless HTTP scenario that:
- starts a local bridge unless you pass `--url`
- runs `initialize`, `tools/list`, and `ynab_get_mcp_version`
- prints attempts, failures, error rate, and latency percentiles
- exits non-zero when the error rate is above `--max-error-rate`
Useful flags:
- `--requests <n>`: number of reliability sequences to run. Each sequence performs three MCP operations.
- `--concurrency <n>`: number of sequences to run in parallel.
- `--max-error-rate <0..1>`: fail the run if the observed error rate exceeds this threshold.
- `--url <http-url>`: target an already running bridge instead of starting a local one.
- `--host`, `--port`, `--path`: override the local server bind address when `--url` is not used.
Write a machine-readable smoke artifact and compare against a prior run:
```bash
npm run reliability:http -- \
--requests 10 \
--concurrency 2 \
--json-out artifacts/reliability/smoke.json \
--baseline-artifact artifacts/reliability/baseline-smoke.json
```
Run the heavier reliability suite in dry-run mode:
```bash
npm run reliability:load -- \
--profile baseline \
--url http://127.0.0.1:3000/mcp \
--json-out artifacts/reliability/baseline.json \
--dry-run
```
The dedicated load suite is designed for named profiles instead of ad hoc request counts:
- `smoke`: fast local regression check using the built-in Node probe
- `baseline`: repeatable average-load run for comparisons
- `stress`: higher sustained load to expose overload behavior
- `spike`: sudden burst behavior
- `soak`: longer steady-state run to catch degradation over time
Recommended workflow:
- run `smoke` on local changes
- record `baseline` artifacts on a stable environment
- run `stress` and `spike` before higher-risk releases
- run `soak` on a scheduled cadence or before major rollout events
Thresholds should be evaluated with error rate and latency percentiles such as `p95` and `p99`, not averages alone. The smoke command and artifact comparison flow already follow that model, and the load-suite dry run prints the exact thresholds that would be enforced by the heavier external runner.
Allow specific browser origins:
```bash
node dist/index.js \
--transport http \
--host 0.0.0.0 \
--port 3000 \
--path /mcp \
--allowed-origins https://claude.ai,https://chat.openai.com
```
Lock down accepted host headers too:
```bash
node dist/index.js \
--transport http \
--host 0.0.0.0 \
--port 3000 \
--path /mcp \
--allowed-origins https://claude.ai \
--allowed-hosts mcp.example.com
```
Enable OAuth with explicit upstream endpoints:
```bash
node dist/index.js \
--transport http \
--host 0.0.0.0 \
--port 3000 \
--path /mcp \
--allowed-origins https://claude.ai \
--deployment-mode oauth-single-tenant \
--public-url https://mcp.example.com/mcp \
--oauth-issuer https://example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/client-123 \
--oauth-authorization-url https://example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/client-123/authorization \
--oauth-token-url https://example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/client-123/token \
--oauth-jwks-url https://example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/client-123/jwks \
--oauth-client-id cloudflare-access-client-id \
--oauth-client-secret cloudflare-access-client-secret \
--oauth-audience https://mcp.example.com/mcp \
--oauth-store-path /var/lib/ynab-mcp-bridge/oauth-store.json \
--oauth-token-signing-secret replace-with-a-long-random-secret \
--oauth-scopes openid,profile
```
For a stricter remote deployment, switch `oauth-single-tenant` to `oauth-hardened`.
## Docker
Build the image:
```bash
docker build -t ynab-mcp-bridge .
```
Run the default HTTP server:
```bash
docker run --rm \
-p 3000:3000 \
-e YNAB_API_TOKEN=your-token \
ynab-mcp-bridge
```
## Rate Limiting
YNAB documents a limit of 200 requests per rolling hour per access token. The bridge applies a shared per-token sliding-window limiter and retries `429 Too Many Requests` responses conservatively.
## Development
```bash
npm test
npm run build
npm run lint:duplicates
npm run tech-debt:report
```
`lint:duplicates` runs a JSCPD baseline across maintained production, tooling, and configuration files. It excludes generated/vendor paths, Markdown, specs, contracts, local task docs, and the auth2 harness so the duplicate baseline tracks the production-facing codebase.
`tech-debt:report` prints the current advisory duplicate-remediation baseline together with dead-export and suppression counts so local cleanup work has one repeatable snapshot command.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues