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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,45 @@ RATE_LIMIT_PER_HOUR=180
# When true, all write operations (create, update, delete) are blocked
# Set to false to enable write operations
YNAB_READ_ONLY=true

# ---------------------------------------------------------------------------
# Remote / HTTP mode (experimental)
# ---------------------------------------------------------------------------
# Transport: "stdio" (default, local) or "http" (remote, per-session isolation).
# MCP_TRANSPORT=stdio

# HTTP port (http mode only; default 3000)
# PORT=3000

# Public base URL of the deployment (used later by the OAuth flow)
# PUBLIC_URL=https://ynab-mcp.example.com

# DNS-rebinding protection (http mode). Requires ALLOWED_HOSTS/ALLOWED_ORIGINS.
# ENABLE_DNS_REBINDING_PROTECTION=true
# ALLOWED_HOSTS=ynab-mcp.example.com
# ALLOWED_ORIGINS=https://claude.ai

# INTERIM (header) AUTH: if the YNAB OAuth vars below are NOT all set, http mode
# takes the YNAB token per request via the `X-YNAB-Token` header (falling back to
# YNAB_ACCESS_TOKEN above for single-user HTTP). Serve behind TLS.

# ---------------------------------------------------------------------------
# Multi-user YNAB OAuth (http mode) — set ALL of the following to enable it.
# Register your own YNAB OAuth app at https://app.ynab.com/settings/developer
# and set its redirect URI to <PUBLIC_URL>/oauth/ynab/callback
# ---------------------------------------------------------------------------
# YNAB_OAUTH_CLIENT_ID=your_ynab_oauth_client_id
# YNAB_OAUTH_CLIENT_SECRET=your_ynab_oauth_client_secret
# ENCRYPTION_KEY=base64-encoded-32-byte-key # openssl rand -base64 32
# PUBLIC_URL=https://ynab-mcp.example.com
# YNAB_OAUTH_ALLOW_WRITE=true # offer read-write at consent (default true)

# Storage for users + encrypted tokens (oauth mode): memory (default, non-durable),
# sqlite, or postgres.
# STORAGE_DRIVER=sqlite
# SQLITE_PATH=/data/ynab-mcp.db
# DATABASE_URL=postgres://user:pass@host:5432/ynab_mcp

# Token lifetimes (optional)
# MCP_ACCESS_TOKEN_TTL_SEC=3600
# MCP_AUTH_CODE_TTL_SEC=600
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0]

### Added

- **Remote HTTP transport.** `MCP_TRANSPORT=http` runs the server over the MCP
Streamable HTTP transport (`POST /mcp`) with a plain `GET /health`. Each session
gets an isolated client / cache / rate limiter / audit log. stdio remains the
default and is unchanged.
- **Multi-user YNAB OAuth.** When the YNAB OAuth app credentials + `ENCRYPTION_KEY`
+ `PUBLIC_URL` are configured, the server acts as an OAuth 2.1 Authorization
Server (Dynamic Client Registration + PKCE) federated to YNAB. Each user connects
their own YNAB account; identity is their YNAB user id. Users choose read-only vs
read-write at a consent screen. YNAB refresh tokens are encrypted at rest
(AES-256-GCM) and rotated on refresh.
- **Pluggable storage** for users + tokens: `memory` (default), `sqlite`
(`better-sqlite3`), or `postgres` (`pg`); the durable drivers are optional
dependencies loaded on demand.
- Interim header auth for HTTP mode (`X-YNAB-Token`) to run single-user remote
before configuring OAuth.
- `docs/REMOTE_HOSTING.md` with deployer setup (registering a YNAB OAuth app, env,
TLS), and DNS-rebinding/Origin protections for the HTTP transport.

### Changed

- Internal: `createServer` generalized into `buildYnabClient` / `createServerForUser`
(per-user context); the audit log is now an injected instance (per user) rather
than a process-global singleton.

## [0.2.0]

### Added
Expand Down
13 changes: 10 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ WORKDIR /app
# Copy package files first for better layer caching
COPY package.json package-lock.json ./

# Install all dependencies (including dev for build)
RUN npm ci
# Install all dependencies (including dev for build). Build tools let the optional
# native `better-sqlite3` addon compile on musl; they're removed afterward. The
# SQLite driver stays optional — if the build fails the image still works with the
# memory/postgres drivers.
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& npm ci \
&& apk del .build-deps

# Copy source files
COPY tsconfig.json ./
Expand Down Expand Up @@ -51,6 +56,8 @@ LABEL org.opencontainers.image.source="https://github.com/auzroz/ynab-mcp"
LABEL org.opencontainers.image.vendor="auzroz"
LABEL org.opencontainers.image.licenses="MIT"

# MCP servers communicate via stdio
# Default transport is stdio. For remote/HTTP mode, run with MCP_TRANSPORT=http
# and publish the port (default 3000).
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,31 @@ The server is configured via environment variables:
| `YNAB_BUDGET_ID` | No | Default budget UUID (uses "last-used" if not set) |
| `YNAB_READ_ONLY` | No | Set to `false` to enable write operations (default: `true`) |

### Remote / HTTP mode (experimental)

By default the server speaks **stdio** (local). Set `MCP_TRANSPORT=http` to run it
as a **remote** server over the MCP Streamable HTTP transport at `POST /mcp`, with a
plain `GET /health` for load balancers. Each session gets an isolated
client/cache/rate-limiter/audit-log.

| Variable | Description |
|----------|-------------|
| `MCP_TRANSPORT` | `stdio` (default) or `http` |
| `PORT` | HTTP port (default `3000`) |
| `ALLOWED_HOSTS` / `ALLOWED_ORIGINS` | Comma-separated allowlists for DNS-rebinding protection |
| `ENABLE_DNS_REBINDING_PROTECTION` | Enable Origin/Host checks (needs an allowlist) |

> ⚠️ **Interim auth.** Until the YNAB-OAuth flow lands, HTTP mode takes the YNAB
> token per request via the `X-YNAB-Token` header (falling back to
> `YNAB_ACCESS_TOKEN` for single-user HTTP). **Serve behind TLS.** Full multi-user
> YNAB OAuth is planned in a later phase.

```bash
MCP_TRANSPORT=http PORT=3000 YNAB_ACCESS_TOKEN=… npm start
# connect an MCP client via the mcp-remote shim:
npx mcp-remote http://localhost:3000/mcp --header "X-YNAB-Token: <your-token>"
```

### Claude Desktop Integration

Add to your Claude Desktop configuration:
Expand Down
123 changes: 123 additions & 0 deletions docs/REMOTE_HOSTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Remote hosting (multi-user, YNAB OAuth)

The YNAB MCP server can run as a **remote, multi-user** server: you host one
instance, your users connect their own YNAB accounts via OAuth, and each user's
data stays isolated. This guide covers deploying it yourself.

> Prefer a quick single-user remote server without OAuth? See **[Interim header
> mode](#interim-header-mode-single-user)** at the bottom.

## How it works

There are two OAuth layers:

1. **MCP client ⇄ your server.** Your server is an OAuth 2.1 Authorization Server
(Dynamic Client Registration + PKCE, provided by the MCP SDK). MCP clients
(claude.ai, Claude Desktop) authenticate to it and receive an MCP access token.
2. **Your server ⇄ YNAB.** Your server is a confidential OAuth client of YNAB. When
a user connects, they pick **read-only** or **read-write**, are sent to YNAB to
authorize, and your server stores their (encrypted) YNAB refresh token.

Identity is the user's **YNAB user id** — no separate accounts or passwords.

## 1. Register a YNAB OAuth application

1. Go to <https://app.ynab.com/settings/developer> → **New OAuth Application**.
2. Set the **Redirect URI** to exactly:
```
https://YOUR_PUBLIC_URL/oauth/ynab/callback
```
3. Copy the **Client ID** and **Client Secret**.

## 2. Configure the server

Set these environment variables (see `.env.example`):

| Variable | Required | Description |
|----------|----------|-------------|
| `MCP_TRANSPORT` | yes | `http` |
| `PUBLIC_URL` | yes | Public HTTPS base URL, e.g. `https://ynab-mcp.example.com` |
| `YNAB_OAUTH_CLIENT_ID` | yes | From step 1 |
| `YNAB_OAUTH_CLIENT_SECRET` | yes | From step 1 |
| `ENCRYPTION_KEY` | yes | 32-byte key for at-rest token encryption — `openssl rand -base64 32` |
| `YNAB_OAUTH_ALLOW_WRITE` | no | Offer read-write at consent (default `true`; set `false` to force read-only) |
| `STORAGE_DRIVER` | no | `memory` (default, **non-durable**), `sqlite`, or `postgres` |
| `SQLITE_PATH` | if sqlite | e.g. `/data/ynab-mcp.db` |
| `DATABASE_URL` | if postgres | `postgres://user:pass@host:5432/db` |
| `ALLOWED_HOSTS` / `ALLOWED_ORIGINS` | recommended | Allowlists for DNS-rebinding/Origin checks |
| `ENABLE_DNS_REBINDING_PROTECTION` | recommended | `true` (needs an allowlist above) |

OAuth mode activates automatically once `YNAB_OAUTH_CLIENT_ID`,
`YNAB_OAUTH_CLIENT_SECRET`, `ENCRYPTION_KEY`, and `PUBLIC_URL` are all set. Use a
**durable** driver (`sqlite`/`postgres`) in production — `memory` loses all
sessions and connected accounts on restart.

## 3. Serve over TLS

OAuth requires HTTPS. Terminate TLS at a reverse proxy in front of the app. Example
with Caddy (automatic TLS):

```
ynab-mcp.example.com {
reverse_proxy localhost:3000
}
```

Docker Compose sketch:

```yaml
services:
ynab-mcp:
image: ghcr.io/auzroz/ynab-mcp:latest
environment:
MCP_TRANSPORT: http
PUBLIC_URL: https://ynab-mcp.example.com
YNAB_OAUTH_CLIENT_ID: ${YNAB_OAUTH_CLIENT_ID}
YNAB_OAUTH_CLIENT_SECRET: ${YNAB_OAUTH_CLIENT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
STORAGE_DRIVER: sqlite
SQLITE_PATH: /data/ynab-mcp.db
ENABLE_DNS_REBINDING_PROTECTION: "true"
ALLOWED_HOSTS: ynab-mcp.example.com
volumes: [ "ynab-data:/data" ]
caddy:
image: caddy:2
ports: [ "443:443" ]
# ... mount a Caddyfile as above
volumes: { ynab-data: {} }
```

## 4. Connect a client

- **claude.ai** — add a custom/remote connector pointing at
`https://YOUR_PUBLIC_URL/mcp`. Its OAuth flow discovers your AS metadata,
registers, and walks the user through the YNAB consent screen.
- **Claude Desktop / stdio-only clients** — use the `mcp-remote` shim, which drives
the same OAuth flow:
```bash
npx mcp-remote https://YOUR_PUBLIC_URL/mcp
```

## Security notes

- YNAB refresh tokens are encrypted at rest with AES-256-GCM (`ENCRYPTION_KEY`).
Keep that key secret and stable; rotating it invalidates stored connections.
- Access is read-only or read-write **per user**, per their consent choice; the
server also honors a global `YNAB_READ_ONLY=true` override.
- Always run behind TLS; enable DNS-rebinding protection with an allowlist.
- `memory` storage is for evaluation only — it is not durable and not shared across
replicas. For multiple replicas, use `postgres` (session/token cache is currently
in-process; a shared cache would be a future enhancement).

## Interim header mode (single-user)

If you don't set the OAuth variables, HTTP mode falls back to **header auth**: the
YNAB token is supplied per request via `X-YNAB-Token` (or the `YNAB_ACCESS_TOKEN`
env for a single user). This is handy for a personal remote instance:

```bash
MCP_TRANSPORT=http YNAB_ACCESS_TOKEN=… npm start
npx mcp-remote http://localhost:3000/mcp --header "X-YNAB-Token: <token>"
```

Serve behind TLS; this mode has no per-user isolation beyond the token you send.
Loading
Loading