From 95968e74ef16a1c9fa32cdfa33fd3bc2ecbe575e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 19:44:36 +0000 Subject: [PATCH 1/2] feat(http): add remote Streamable HTTP transport (Phase 1 foundation) Groundwork for a multi-tenant remote MCP server. Adds an HTTP transport alongside stdio and the per-user isolation seams needed for later OAuth. - MCP_TRANSPORT=http runs an Express app exposing the MCP Streamable HTTP transport at POST /mcp (per-session Server) plus a plain GET /health. - Per-session isolation: generalize createServer into buildYnabClient/ createServerForUser so each session gets its own YnabClient, cache, rate limiter, and audit log. - De-singletonize the audit log: inject an AuditLog into YnabClient (defaults to the process singleton for stdio); the ynab_audit_log tool reads the per-request client's instance. Prevents cross-user leakage. - Interim auth: HTTP mode binds the YNAB token per session via the X-YNAB-Token header (falls back to YNAB_ACCESS_TOKEN); TLS required. Replaced by YNAB OAuth in a later phase. - Config: loadHttpConfig() (PORT, PUBLIC_URL, ALLOWED_HOSTS/ORIGINS, DNS- rebinding protection); loadConfig() (stdio) unchanged. - Docs: README HTTP section, .env.example, Dockerfile EXPOSE 3000. - Tests: HTTP wiring (health, auth gate, session lifecycle) + per-user isolation; full suite 627 passing, 80% coverage gate green. stdio path unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016PgWfscEzb25EXrgJg1Mt5 --- .env.example | 21 ++ Dockerfile | 4 +- README.md | 25 ++ package-lock.json | 374 ++++++++++++++++++++++++++++++ package.json | 4 + src/config/environment.ts | 50 ++++ src/http.ts | 136 +++++++++++ src/index.ts | 31 ++- src/server.ts | 90 ++++--- src/services/ynab-client.ts | 47 ++-- src/tools/system/audit-log.ts | 11 +- tests/unit/http.test.ts | 97 ++++++++ tests/unit/server-context.test.ts | 55 +++++ 13 files changed, 884 insertions(+), 61 deletions(-) create mode 100644 src/http.ts create mode 100644 tests/unit/http.test.ts create mode 100644 tests/unit/server-context.test.ts diff --git a/.env.example b/.env.example index 334fd98..fe164bc 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,24 @@ 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 AUTH (http mode): until OAuth lands, each request supplies its YNAB +# token via the `X-YNAB-Token` header (falls back to YNAB_ACCESS_TOKEN above for +# single-user HTTP). Serve behind TLS. Replaced by YNAB OAuth in a later phase. diff --git a/Dockerfile b/Dockerfile index dc3a251..82bc289 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,6 +51,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"] diff --git a/README.md b/README.md index c413088..e63d7d1 100644 --- a/README.md +++ b/README.md @@ -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: " +``` + ### Claude Desktop Integration Add to your Claude Desktop configuration: diff --git a/package-lock.json b/package-lock.json index 1c17388..c90a9fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "decimal.js": "^10.4.3", + "express": "^5.2.1", "ynab": "^4.4.0", "zod": "^3.23.8" }, @@ -19,11 +20,14 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", + "@types/express": "^5.0.6", "@types/node": "^20.11.0", + "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^1.2.0", "eslint": "^9.39.2", "eslint-config-prettier": "^9.1.0", "prettier": "^3.2.0", + "supertest": "^7.2.2", "tsx": "^4.7.0", "typescript": "^5.3.0", "typescript-eslint": "^8.53.1", @@ -915,6 +919,29 @@ } } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -1272,6 +1299,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1279,6 +1334,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1286,6 +1373,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", @@ -1296,6 +1390,65 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@typescript-eslint/project-service": { "version": "8.53.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.1.tgz", @@ -1585,6 +1738,13 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -1595,6 +1755,13 @@ "node": "*" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1776,6 +1943,29 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1830,6 +2020,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -1904,6 +2101,16 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1913,6 +2120,17 @@ "node": ">= 0.8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -1982,6 +2200,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2429,6 +2663,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -2526,6 +2767,64 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2754,6 +3053,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -3217,6 +3532,29 @@ "dev": true, "license": "MIT" }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -4117,6 +4455,42 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 2d00c5b..fbdd77e 100644 --- a/package.json +++ b/package.json @@ -44,16 +44,20 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "decimal.js": "^10.4.3", + "express": "^5.2.1", "ynab": "^4.4.0", "zod": "^3.23.8" }, "devDependencies": { "@eslint/js": "^9.39.2", + "@types/express": "^5.0.6", "@types/node": "^20.11.0", + "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^1.2.0", "eslint": "^9.39.2", "eslint-config-prettier": "^9.1.0", "prettier": "^3.2.0", + "supertest": "^7.2.2", "tsx": "^4.7.0", "typescript": "^5.3.0", "typescript-eslint": "^8.53.1", diff --git a/src/config/environment.ts b/src/config/environment.ts index 93aec55..169b22d 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -61,6 +61,56 @@ function parseInteger(value: string | undefined, defaultValue: number, varName?: return Number(value); } +/** Split a comma/space-separated env list into a trimmed string array (or undefined). */ +function parseList(value: string | undefined): string[] | undefined { + if (value === undefined || value.trim() === '') return undefined; + const items = value + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return items.length > 0 ? items : undefined; +} + +/** + * Configuration for HTTP (remote / multi-tenant) mode. The YNAB access token is + * optional here: in multi-user deployments each request supplies its own token + * (interim: via header; later: via OAuth), while single-user HTTP can still set + * a fallback `YNAB_ACCESS_TOKEN`. + */ +export interface HttpConfig { + port: number; + publicUrl: string | undefined; + allowedHosts: string[] | undefined; + allowedOrigins: string[] | undefined; + enableDnsRebindingProtection: boolean; + // Shared knobs, reused per user context. + fallbackAccessToken: string | undefined; + defaultBudgetId: string | undefined; + readOnly: boolean; + cacheTtlMs: number; + rateLimitPerHour: number; +} + +export function loadHttpConfig(): HttpConfig { + const allowedHosts = parseList(process.env['ALLOWED_HOSTS']); + const allowedOrigins = parseList(process.env['ALLOWED_ORIGINS']); + return { + port: parseInteger(process.env['PORT'], 3000, 'PORT'), + publicUrl: process.env['PUBLIC_URL'] || undefined, + allowedHosts, + allowedOrigins, + // Only meaningful when a host/origin allowlist is configured. + enableDnsRebindingProtection: + parseBoolean(process.env['ENABLE_DNS_REBINDING_PROTECTION'], false, 'ENABLE_DNS_REBINDING_PROTECTION') && + (allowedHosts !== undefined || allowedOrigins !== undefined), + fallbackAccessToken: process.env['YNAB_ACCESS_TOKEN'] || undefined, + defaultBudgetId: process.env['YNAB_BUDGET_ID'] || undefined, + readOnly: parseBoolean(process.env['YNAB_READ_ONLY'], true, 'YNAB_READ_ONLY'), + cacheTtlMs: parseInteger(process.env['CACHE_TTL_MS'], 300000, 'CACHE_TTL_MS'), + rateLimitPerHour: parseInteger(process.env['RATE_LIMIT_PER_HOUR'], 180, 'RATE_LIMIT_PER_HOUR'), + }; +} + export function loadConfig(): Config { const rawConfig = { accessToken: process.env['YNAB_ACCESS_TOKEN'] ?? '', diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..5b18cc1 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,136 @@ +/** + * HTTP (remote) transport for the YNAB MCP server. + * + * Exposes the MCP Streamable HTTP transport over Express with **per-session** + * server instances — each session gets its own YnabClient / cache / rate limiter / + * audit log, so nothing leaks between concurrent clients. + * + * PHASE 1 (interim auth): the YNAB access token is supplied per session via the + * `X-YNAB-Token` header (falling back to the `YNAB_ACCESS_TOKEN` env for + * single-user HTTP). This is a stopgap to validate the transport/data plane and + * MUST be used only over TLS; it is replaced by the YNAB-OAuth flow in a later + * phase. + */ + +import { randomUUID } from 'node:crypto'; +import express, { type Request, type Response } from 'express'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createServerForUser } from './server.js'; +import type { HttpConfig } from './config/environment.js'; + +function jsonRpcError(code: number, message: string): unknown { + return { jsonrpc: '2.0', error: { code, message }, id: null }; +} + +/** + * Build the Express app (exported for testing without binding a port). + */ +export function createHttpApp(config: HttpConfig): express.Express { + const app = express(); + app.use(express.json({ limit: '4mb' })); + + // Active sessions: session id -> transport. Each transport is wired to its own + // per-user MCP Server instance. + const transports = new Map(); + + // Plain HTTP health check for load balancers / reverse proxies. + app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', transport: 'http', sessions: transports.size }); + }); + + // Client -> server messages (and streamed responses). + app.post('/mcp', async (req: Request, res: Response) => { + const sessionId = req.header('mcp-session-id'); + let transport = sessionId ? transports.get(sessionId) : undefined; + + if (!transport) { + // Only a fresh `initialize` (with no session id) may open a new session. + if (sessionId !== undefined || !isInitializeRequest(req.body)) { + res + .status(400) + .json(jsonRpcError(-32000, 'Bad Request: no valid session for this request')); + return; + } + + // Bind this session to a YNAB token (interim: header, else env fallback). + const token = req.header('x-ynab-token') ?? config.fallbackAccessToken; + if (!token) { + res + .status(401) + .json(jsonRpcError(-32001, 'Unauthorized: provide a YNAB token via the X-YNAB-Token header')); + return; + } + + const newTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableDnsRebindingProtection: config.enableDnsRebindingProtection, + ...(config.allowedHosts ? { allowedHosts: config.allowedHosts } : {}), + ...(config.allowedOrigins ? { allowedOrigins: config.allowedOrigins } : {}), + onsessioninitialized: (sid: string) => { + transports.set(sid, newTransport); + }, + }); + newTransport.onclose = () => { + const sid = newTransport.sessionId; + if (sid) transports.delete(sid); + }; + + const server = createServerForUser({ + accessToken: token, + defaultBudgetId: config.defaultBudgetId, + readOnly: config.readOnly, + rateLimitPerHour: config.rateLimitPerHour, + cacheTtlMs: config.cacheTtlMs, + }); + // Cast bridges an exactOptionalPropertyTypes mismatch between the SDK's + // Transport interface (optional onclose) and the transport's accessor type. + await server.connect(newTransport as unknown as Parameters[0]); + transport = newTransport; + } + + await transport.handleRequest( + req as unknown as IncomingMessage, + res as unknown as ServerResponse, + req.body + ); + }); + + // Server -> client stream (GET) and explicit session teardown (DELETE). + const sessionRequest = async (req: Request, res: Response): Promise => { + const sessionId = req.header('mcp-session-id'); + const transport = sessionId ? transports.get(sessionId) : undefined; + if (!transport) { + res.status(400).json(jsonRpcError(-32000, 'Bad Request: unknown or missing session id')); + return; + } + await transport.handleRequest( + req as unknown as IncomingMessage, + res as unknown as ServerResponse + ); + }; + app.get('/mcp', sessionRequest); + app.delete('/mcp', sessionRequest); + + return app; +} + +/** + * Build and start the HTTP server. Returns the Node http.Server. + */ +export function startHttpServer(config: HttpConfig): ReturnType { + const app = createHttpApp(config); + const httpServer = app.listen(config.port, () => { + console.error(`YNAB MCP Server (HTTP) listening on port ${config.port}`); + console.error( + config.readOnly + ? 'READ-ONLY mode (write operations disabled)' + : 'WRITE operations ENABLED' + ); + console.error( + 'Interim auth: YNAB token via X-YNAB-Token header (TLS required; replaced by OAuth in a later phase)' + ); + }); + return httpServer; +} diff --git a/src/index.ts b/src/index.ts index 4aec17e..4095bb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,13 +7,16 @@ * for integration with Claude and other MCP-compatible clients. */ +import type { Server as HttpServer } from 'node:http'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { createServer } from './server.js'; -import { loadConfig } from './config/environment.js'; +import { startHttpServer } from './http.js'; +import { loadConfig, loadHttpConfig } from './config/environment.js'; // Store server reference for graceful shutdown let server: Server | null = null; +let httpServer: HttpServer | null = null; async function shutdown(): Promise { console.error('Shutting down YNAB MCP Server...'); @@ -27,23 +30,31 @@ async function shutdown(): Promise { console.error('[DEBUG] Error during server close:', message); } } + if (httpServer) { + try { + await new Promise((resolve) => httpServer?.close(() => resolve())); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[DEBUG] Error during HTTP server close:', message); + } + } process.exit(0); } async function main(): Promise { - // Load and validate configuration - const config = loadConfig(); + // Transport selection: `http` for the remote/multi-user server, otherwise stdio. + const transportMode = (process.env['MCP_TRANSPORT'] ?? 'stdio').toLowerCase(); - // Create the MCP server with all tools registered - server = createServer(config); + if (transportMode === 'http') { + httpServer = startHttpServer(loadHttpConfig()); + return; + } - // Create stdio transport for communication + // stdio (single-user) mode. + const config = loadConfig(); + server = createServer(config); const transport = new StdioServerTransport(); - - // Connect server to transport await server.connect(transport); - - // Log startup (to stderr to avoid interfering with stdio protocol) console.error(`YNAB MCP Server started (budget: ${config.defaultBudgetId ?? 'last-used'})`); } diff --git a/src/server.ts b/src/server.ts index 3427332..400f3f8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,29 +18,45 @@ const pkg = require('../package.json') as { version: string }; import { YnabClient } from './services/ynab-client.js'; import { RateLimiter } from './services/rate-limiter.js'; import { Cache } from './services/cache.js'; +import { AuditLog } from './services/audit-log.js'; import { tools, handleToolCall } from './tools/index.js'; import { formatErrorResponse } from './utils/errors.js'; -export function createServer(config: Config): Server { - // Initialize services - const rateLimiter = new RateLimiter(config.rateLimitPerHour); - const cache = new Cache(config.cacheTtlMs); - const ynabClient = new YnabClient( - config.accessToken, - config.defaultBudgetId, +/** + * Per-user context for building an isolated server instance. In multi-tenant + * (HTTP) mode one of these is built per authenticated user so each gets its own + * YNAB client, cache, rate limiter, and audit log — no cross-user leakage. + */ +export interface UserContext { + accessToken: string; + defaultBudgetId?: string | undefined; + readOnly: boolean; + rateLimitPerHour: number; + cacheTtlMs: number; +} + +/** + * Build a fully-isolated YnabClient (own rate limiter, cache, and audit log). + */ +export function buildYnabClient(ctx: UserContext): YnabClient { + const rateLimiter = new RateLimiter(ctx.rateLimitPerHour); + const cache = new Cache(ctx.cacheTtlMs); + const auditLog = new AuditLog(); + return new YnabClient( + ctx.accessToken, + ctx.defaultBudgetId, rateLimiter, cache, - config.readOnly + ctx.readOnly, + auditLog ); +} - // Log read-only mode status - if (config.readOnly) { - console.error('YNAB MCP Server running in READ-ONLY mode (write operations disabled)'); - } else { - console.error('YNAB MCP Server running with WRITE operations ENABLED'); - } - - // Create MCP server +/** + * Create an MCP `Server` that dispatches tool calls to the given YnabClient. + * Shared by the stdio (single-user) and HTTP (per-user) entrypoints. + */ +export function createServerFromClient(ynabClient: YnabClient): Server { const server = new Server( { name: 'ynab-mcp-server', @@ -53,33 +69,21 @@ export function createServer(config: Config): Server { } ); - // Register tool listing handler server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools }; }); - // Register tool call handler server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { const result = await handleToolCall(name, args ?? {}, ynabClient); return { - content: [ - { - type: 'text', - text: result, - }, - ], + content: [{ type: 'text', text: result }], }; } catch (error) { return { - content: [ - { - type: 'text', - text: formatErrorResponse(error), - }, - ], + content: [{ type: 'text', text: formatErrorResponse(error) }], isError: true, }; } @@ -87,3 +91,29 @@ export function createServer(config: Config): Server { return server; } + +/** + * Build a per-user MCP server (multi-tenant / HTTP mode). + */ +export function createServerForUser(ctx: UserContext): Server { + return createServerFromClient(buildYnabClient(ctx)); +} + +/** + * Create the single-user server from process config (stdio mode). + */ +export function createServer(config: Config): Server { + if (config.readOnly) { + console.error('YNAB MCP Server running in READ-ONLY mode (write operations disabled)'); + } else { + console.error('YNAB MCP Server running with WRITE operations ENABLED'); + } + + return createServerForUser({ + accessToken: config.accessToken, + defaultBudgetId: config.defaultBudgetId, + readOnly: config.readOnly, + rateLimitPerHour: config.rateLimitPerHour, + cacheTtlMs: config.cacheTtlMs, + }); +} diff --git a/src/services/ynab-client.ts b/src/services/ynab-client.ts index faa3161..bde789e 100644 --- a/src/services/ynab-client.ts +++ b/src/services/ynab-client.ts @@ -8,7 +8,7 @@ import * as ynab from 'ynab'; import type { RateLimiter } from './rate-limiter.js'; import type { Cache } from './cache.js'; import { ReadOnlyModeError } from '../utils/errors.js'; -import { getAuditLog } from './audit-log.js'; +import { getAuditLog, type AuditLog } from './audit-log.js'; import { sanitizeErrorMessage } from '../utils/sanitize.js'; export class YnabClient { @@ -17,6 +17,7 @@ export class YnabClient { private readonly cache: Cache; private readonly defaultBudgetId: string; private readonly readOnly: boolean; + private readonly auditLog: AuditLog; // Track server knowledge for delta sync private serverKnowledge: Map = new Map(); @@ -26,13 +27,18 @@ export class YnabClient { defaultBudgetId: string | undefined, rateLimiter: RateLimiter, cache: Cache, - readOnly = true + readOnly = true, + // Injected per-instance audit log. Defaults to the process-wide singleton so + // single-user (stdio) usage is unchanged; multi-tenant callers pass a + // per-user instance so one user's write history never leaks to another. + auditLog: AuditLog = getAuditLog() ) { this.api = new ynab.API(accessToken); this.rateLimiter = rateLimiter; this.cache = cache; this.defaultBudgetId = defaultBudgetId ?? 'last-used'; this.readOnly = readOnly; + this.auditLog = auditLog; } /** @@ -42,6 +48,13 @@ export class YnabClient { return this.readOnly; } + /** + * Get this client's audit log instance (per-user in multi-tenant mode). + */ + getAuditLog(): AuditLog { + return this.auditLog; + } + /** * Get rate limit status. */ @@ -169,7 +182,7 @@ export class YnabClient { // Only invalidate budgets:true since getBudgets(false) doesn't include account data this.cache.delete('budgets:true'); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.accounts.createAccount(budgetId, data); auditLog.log({ @@ -234,7 +247,7 @@ export class YnabClient { // Invalidate categories cache after updating this.cache.delete(`categories:${budgetId}`); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.categories.updateMonthCategory(budgetId, month, categoryId, data); auditLog.log({ @@ -271,7 +284,7 @@ export class YnabClient { // Invalidate categories cache after creating this.cache.delete(`categories:${budgetId}`); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.categories.createCategory(budgetId, data); auditLog.log({ @@ -308,7 +321,7 @@ export class YnabClient { // Invalidate categories cache after creating (groups are returned with categories) this.cache.delete(`categories:${budgetId}`); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.categories.createCategoryGroup(budgetId, data); auditLog.log({ @@ -345,7 +358,7 @@ export class YnabClient { // Invalidate categories cache after updating this.cache.delete(`categories:${budgetId}`); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.categories.updateCategoryGroup( budgetId, @@ -417,7 +430,7 @@ export class YnabClient { this.assertWriteAllowed('createTransaction'); await this.rateLimiter.acquire(); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; const txn = data.transaction; try { const response = await this.api.transactions.createTransaction(budgetId, data); @@ -466,7 +479,7 @@ export class YnabClient { this.assertWriteAllowed('updateTransaction'); await this.rateLimiter.acquire(); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; const txn = data.transaction; try { const response = await this.api.transactions.updateTransaction(budgetId, transactionId, data); @@ -513,7 +526,7 @@ export class YnabClient { this.assertWriteAllowed('updateTransactions'); await this.rateLimiter.acquire(); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.transactions.updateTransactions(budgetId, data); auditLog.log({ @@ -550,7 +563,7 @@ export class YnabClient { this.assertWriteAllowed('deleteTransaction'); await this.rateLimiter.acquire(); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.transactions.deleteTransaction(budgetId, transactionId); // Redact PII from audit logs - payee_name may contain personal information @@ -586,7 +599,7 @@ export class YnabClient { this.assertWriteAllowed('importTransactions'); await this.rateLimiter.acquire(); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.transactions.importTransactions(budgetId); auditLog.log({ @@ -664,7 +677,7 @@ export class YnabClient { this.assertWriteAllowed('createScheduledTransaction'); await this.rateLimiter.acquire(); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; const txn = data.scheduled_transaction; try { const response = await this.api.scheduledTransactions.createScheduledTransaction( @@ -716,7 +729,7 @@ export class YnabClient { this.assertWriteAllowed('updateScheduledTransaction'); await this.rateLimiter.acquire(); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; const txn = data.scheduled_transaction; try { const response = await this.api.scheduledTransactions.updateScheduledTransaction( @@ -769,7 +782,7 @@ export class YnabClient { this.assertWriteAllowed('deleteScheduledTransaction'); await this.rateLimiter.acquire(); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.scheduledTransactions.deleteScheduledTransaction( budgetId, @@ -831,7 +844,7 @@ export class YnabClient { // Invalidate payees cache after creating this.cache.delete(`payees:${budgetId}`); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.payees.createPayee(budgetId, data); auditLog.log({ @@ -869,7 +882,7 @@ export class YnabClient { // Invalidate payees cache after updating this.cache.delete(`payees:${budgetId}`); - const auditLog = getAuditLog(); + const auditLog = this.auditLog; try { const response = await this.api.payees.updatePayee(budgetId, payeeId, data); auditLog.log({ diff --git a/src/tools/system/audit-log.ts b/src/tools/system/audit-log.ts index b7cbb7b..f5e2abb 100644 --- a/src/tools/system/audit-log.ts +++ b/src/tools/system/audit-log.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { YnabClient } from '../../services/ynab-client.js'; import { getAuditLog } from '../../services/audit-log.js'; // Input schema @@ -81,13 +82,17 @@ Shows all create, update, and delete operations with timestamps.`, // Handler function /** * Handler for the ynab_audit_log tool. - * Note: client param intentionally omitted; this tool only uses the local AuditLog service. + * + * Reads the audit log from the per-request client when available (so multi-tenant + * deployments surface only the current user's write history), falling back to the + * process-wide singleton for the single-user/stdio path. */ export async function handleAuditLog( - args: Record + args: Record, + client?: YnabClient ): Promise { const validated = inputSchema.parse(args); - const auditLog = getAuditLog(); + const auditLog = client ? client.getAuditLog() : getAuditLog(); // Build filter options (applies to both summary_only and full response) const filterOptions: { diff --git a/tests/unit/http.test.ts b/tests/unit/http.test.ts new file mode 100644 index 0000000..06b82d4 --- /dev/null +++ b/tests/unit/http.test.ts @@ -0,0 +1,97 @@ +/** + * HTTP transport wiring: health, auth gate, and session validation. + * (Full MCP protocol round-trips are exercised by integration tests later.) + */ + +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import { createHttpApp } from '../../src/http.js'; +import type { HttpConfig } from '../../src/config/environment.js'; + +function makeConfig(overrides: Partial = {}): HttpConfig { + return { + port: 0, + publicUrl: undefined, + allowedHosts: undefined, + allowedOrigins: undefined, + enableDnsRebindingProtection: false, + fallbackAccessToken: undefined, + defaultBudgetId: undefined, + readOnly: true, + cacheTtlMs: 300000, + rateLimitPerHour: 180, + ...overrides, + }; +} + +const initializeBody = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + }, +}; + +describe('HTTP transport', () => { + it('GET /health returns ok', async () => { + const app = createHttpApp(makeConfig()); + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body.transport).toBe('http'); + }); + + it('POST /mcp with a non-initialize request and no session → 400', async () => { + const app = createHttpApp(makeConfig({ fallbackAccessToken: 'env-token' })); + const res = await request(app) + .post('/mcp') + .send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(res.status).toBe(400); + }); + + it('POST /mcp initialize with no YNAB token → 401', async () => { + const app = createHttpApp(makeConfig({ fallbackAccessToken: undefined })); + const res = await request(app).post('/mcp').send(initializeBody); + expect(res.status).toBe(401); + }); + + it('GET /mcp without a session id → 400', async () => { + const app = createHttpApp(makeConfig()); + const res = await request(app).get('/mcp'); + expect(res.status).toBe(400); + }); + + it('DELETE /mcp without a session id → 400', async () => { + const app = createHttpApp(makeConfig()); + const res = await request(app).delete('/mcp'); + expect(res.status).toBe(400); + }); + + it('POST /mcp initialize with a token opens a session (returns Mcp-Session-Id)', async () => { + const app = createHttpApp(makeConfig({ fallbackAccessToken: 'env-token' })); + const res = await request(app) + .post('/mcp') + .set('Accept', 'application/json, text/event-stream') + .send(initializeBody); + + // A successful initialize issues a session id header regardless of body framing. + expect(res.status).toBeLessThan(400); + expect(res.headers['mcp-session-id']).toBeDefined(); + }); + + it('DELETE /mcp with a valid session id tears the session down', async () => { + const app = createHttpApp(makeConfig({ fallbackAccessToken: 'env-token' })); + const init = await request(app) + .post('/mcp') + .set('Accept', 'application/json, text/event-stream') + .send(initializeBody); + const sessionId = init.headers['mcp-session-id'] as string; + expect(sessionId).toBeDefined(); + + const del = await request(app).delete('/mcp').set('mcp-session-id', sessionId); + expect(del.status).toBeLessThan(400); + }); +}); diff --git a/tests/unit/server-context.test.ts b/tests/unit/server-context.test.ts new file mode 100644 index 0000000..18d75e1 --- /dev/null +++ b/tests/unit/server-context.test.ts @@ -0,0 +1,55 @@ +/** + * Per-user server context isolation (multi-tenant foundation). + */ + +import { describe, it, expect } from 'vitest'; +import { buildYnabClient, createServerForUser, type UserContext } from '../../src/server.js'; + +const baseCtx: Omit = { + defaultBudgetId: undefined, + rateLimitPerHour: 180, + cacheTtlMs: 300000, +}; + +describe('buildYnabClient (per-user isolation)', () => { + it('gives each user its own audit log instance', () => { + const a = buildYnabClient({ ...baseCtx, accessToken: 'token-a', readOnly: false }); + const b = buildYnabClient({ ...baseCtx, accessToken: 'token-b', readOnly: false }); + + expect(a.getAuditLog()).not.toBe(b.getAuditLog()); + }); + + it('does not leak audit entries between users', () => { + const a = buildYnabClient({ ...baseCtx, accessToken: 'token-a', readOnly: false }); + const b = buildYnabClient({ ...baseCtx, accessToken: 'token-b', readOnly: false }); + + a.getAuditLog().log({ + operation: 'create', + tool: 'ynab_create_payee', + budgetId: 'bud-a', + resourceType: 'payee', + details: {}, + success: true, + }); + + expect(a.getAuditLog().getFiltered({}).length).toBe(1); + expect(b.getAuditLog().getFiltered({}).length).toBe(0); + }); + + it('honors per-context read-only setting', () => { + const ro = buildYnabClient({ ...baseCtx, accessToken: 't', readOnly: true }); + const rw = buildYnabClient({ ...baseCtx, accessToken: 't', readOnly: false }); + + expect(ro.isReadOnly()).toBe(true); + expect(rw.isReadOnly()).toBe(false); + }); +}); + +describe('createServerForUser', () => { + it('builds an MCP Server bound to a per-user client', () => { + const server = createServerForUser({ ...baseCtx, accessToken: 't', readOnly: true }); + // A Server exposes connect/close; enough to confirm we got a wired instance. + expect(typeof server.connect).toBe('function'); + expect(typeof server.close).toBe('function'); + }); +}); From 3d36777e0b469d703fa8266872b5b40792dc3571 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 20:16:28 +0000 Subject: [PATCH 2/2] feat(remote): multi-user YNAB OAuth for the HTTP transport Complete the remote MCP server: HTTP mode can now act as an OAuth 2.1 Authorization Server federated to YNAB, so many users connect their own YNAB accounts to one self-hosted instance with per-user isolation. - MCP Authorization Server (src/auth/mcp-provider.ts): OAuthServerProvider with Dynamic Client Registration + PKCE, a read-only/read-write consent screen, federated authorize/callback to YNAB, and token issuance/refresh/ revoke. Identity is the user's YNAB user id. - YNAB OAuth client (src/auth/ynab-oauth.ts): authorize URL, code exchange, refresh, and /user identity lookup; errors never leak tokens. - Per-user token resolution (src/auth/user-session.ts) with an in-memory access-token cache and transparent refresh + rotation. - Pluggable storage (src/storage): Storage interface with memory (default), sqlite (better-sqlite3), and postgres (pg) adapters; durable drivers are optional deps loaded on demand. - At-rest encryption (src/crypto.ts): AES-256-GCM for YNAB refresh tokens. - HTTP app (src/http.ts) wires mcpAuthRouter, the YNAB callback routes, and requireBearerAuth on /mcp; resolves the authenticated user to a per-user YnabClient. Interim header auth remains when OAuth vars are unset. - Per-user isolation: audit log de-singletonized and injected into YnabClient; per-user Cache/RateLimiter/AuditLog via createServerForUser. - docs/REMOTE_HOSTING.md deployer guide; .env.example, README, CHANGELOG updated; Dockerfile builds native deps and EXPOSEs the port. stdio single-user mode and the MCP tool surface are unchanged. Minor version bump to 0.3.0 (additive). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016PgWfscEzb25EXrgJg1Mt5 --- .env.example | 27 +- CHANGELOG.md | 28 ++ Dockerfile | 9 +- docs/REMOTE_HOSTING.md | 123 ++++++ package-lock.json | 639 ++++++++++++++++++++++++++- package.json | 8 +- server.json | 2 +- src/auth/mcp-provider.ts | 276 ++++++++++++ src/auth/user-session.ts | 60 +++ src/auth/ynab-oauth.ts | 177 ++++++++ src/config/environment.ts | 48 +- src/crypto.ts | 73 +++ src/http.ts | 204 +++++++-- src/index.ts | 2 +- src/storage/index.ts | 49 ++ src/storage/memory.ts | 102 +++++ src/storage/postgres.ts | 355 +++++++++++++++ src/storage/sqlite.ts | 374 ++++++++++++++++ src/storage/types.ts | 97 ++++ tests/unit/auth/mcp-provider.test.ts | 364 +++++++++++++++ tests/unit/auth/user-session.test.ts | 120 +++++ tests/unit/auth/ynab-oauth.test.ts | 173 ++++++++ tests/unit/crypto.test.ts | 52 +++ tests/unit/http-oauth.test.ts | 96 ++++ tests/unit/http.test.ts | 24 +- tests/unit/storage/storage.test.ts | 292 ++++++++++++ 26 files changed, 3709 insertions(+), 65 deletions(-) create mode 100644 docs/REMOTE_HOSTING.md create mode 100644 src/auth/mcp-provider.ts create mode 100644 src/auth/user-session.ts create mode 100644 src/auth/ynab-oauth.ts create mode 100644 src/crypto.ts create mode 100644 src/storage/index.ts create mode 100644 src/storage/memory.ts create mode 100644 src/storage/postgres.ts create mode 100644 src/storage/sqlite.ts create mode 100644 src/storage/types.ts create mode 100644 tests/unit/auth/mcp-provider.test.ts create mode 100644 tests/unit/auth/user-session.test.ts create mode 100644 tests/unit/auth/ynab-oauth.test.ts create mode 100644 tests/unit/crypto.test.ts create mode 100644 tests/unit/http-oauth.test.ts create mode 100644 tests/unit/storage/storage.test.ts diff --git a/.env.example b/.env.example index fe164bc..a064de4 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,27 @@ YNAB_READ_ONLY=true # ALLOWED_HOSTS=ynab-mcp.example.com # ALLOWED_ORIGINS=https://claude.ai -# INTERIM AUTH (http mode): until OAuth lands, each request supplies its YNAB -# token via the `X-YNAB-Token` header (falls back to YNAB_ACCESS_TOKEN above for -# single-user HTTP). Serve behind TLS. Replaced by YNAB OAuth in a later phase. +# 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 /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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6634c81..5efca02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile b/Dockerfile index 82bc289..7de3305 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ./ diff --git a/docs/REMOTE_HOSTING.md b/docs/REMOTE_HOSTING.md new file mode 100644 index 0000000..7283e60 --- /dev/null +++ b/docs/REMOTE_HOSTING.md @@ -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 → **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: " +``` + +Serve behind TLS; this mode has no per-user isolation beyond the token you send. diff --git a/package-lock.json b/package-lock.json index c90a9fb..4e5189b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,8 +20,10 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", + "@types/better-sqlite3": "^7.6.12", "@types/express": "^5.0.6", "@types/node": "^20.11.0", + "@types/pg": "^8.11.10", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^1.2.0", "eslint": "^9.39.2", @@ -35,6 +37,10 @@ }, "engines": { "node": ">=20.0.0" + }, + "optionalDependencies": { + "better-sqlite3": "^11.8.1", + "pg": "^8.13.1" } }, "node_modules/@ampproject/remapping": { @@ -1299,6 +1305,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1390,6 +1406,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -1769,6 +1797,61 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1816,6 +1899,31 @@ "balanced-match": "^1.0.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1923,6 +2031,13 @@ "node": "*" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2081,6 +2196,22 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -2094,6 +2225,16 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2120,6 +2261,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -2170,6 +2321,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2582,6 +2743,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -2708,6 +2879,13 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2843,6 +3021,13 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2947,6 +3132,13 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3143,6 +3335,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3198,6 +3411,13 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", + "optional": true + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -3593,6 +3813,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -3609,6 +3842,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -3654,6 +3904,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3670,6 +3927,19 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.93.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.93.0.tgz", + "integrity": "sha512-Cu6yUpX5Iavugm8BeX7c0wgU9CvOqfd1yM6A1d2q2ZMjym7GjpASv2GdRcTq3Fx+Sb5OgBkEEpw4VnAbY6Y5RA==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-fetch": { "version": "2.6.13", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", @@ -3905,6 +4175,102 @@ "node": "*" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "optional": true, + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "optional": true, + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "optional": true, + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3982,6 +4348,77 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4049,6 +4486,17 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4103,6 +4551,32 @@ "node": ">= 0.10" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -4110,6 +4584,21 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4200,6 +4689,27 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4210,7 +4720,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4383,6 +4893,53 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4393,6 +4950,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4416,6 +4983,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -4504,6 +5081,36 @@ "node": ">=8" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -4640,6 +5247,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4982,6 +5602,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -5634,6 +6261,16 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/ynab": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ynab/-/ynab-4.4.0.tgz", diff --git a/package.json b/package.json index fbdd77e..cf57f73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ynab-mcp", - "version": "0.2.0", + "version": "0.3.0", "description": "MCP server providing comprehensive YNAB API coverage for Claude integration", "mcpName": "io.github.auzroz/ynab-mcp", "type": "module", @@ -48,10 +48,16 @@ "ynab": "^4.4.0", "zod": "^3.23.8" }, + "optionalDependencies": { + "better-sqlite3": "^11.8.1", + "pg": "^8.13.1" + }, "devDependencies": { "@eslint/js": "^9.39.2", + "@types/better-sqlite3": "^7.6.12", "@types/express": "^5.0.6", "@types/node": "^20.11.0", + "@types/pg": "^8.11.10", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^1.2.0", "eslint": "^9.39.2", diff --git a/server.json b/server.json index ad4cd93..e7758c9 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "namespace": "io.github.auzroz", "name": "ynab-mcp", "display_name": "YNAB Budget Assistant", - "version": "0.2.0", + "version": "0.3.0", "description": "Comprehensive YNAB (You Need A Budget) API coverage for Claude. Manage budgets, transactions, accounts, categories, payees, and get spending analytics.", "repository": { "url": "https://github.com/auzroz/ynab-mcp", diff --git a/src/auth/mcp-provider.ts b/src/auth/mcp-provider.ts new file mode 100644 index 0000000..221f7b1 --- /dev/null +++ b/src/auth/mcp-provider.ts @@ -0,0 +1,276 @@ +/** + * MCP OAuth 2.1 Authorization Server provider, federated to YNAB. + * + * Implements the SDK's `OAuthServerProvider` so `mcpAuthRouter` can expose + * `/authorize`, `/token`, `/register` (Dynamic Client Registration) and `/revoke` + * to MCP clients (claude.ai / Claude Desktop) with PKCE. The *user authorization* + * step is delegated to YNAB: `authorize()` shows a read-only/read-write consent + * page, then the browser is sent to YNAB; the `/oauth/ynab/callback` route + * (wired in http.ts, which calls {@link McpOAuthProvider.handleYnabCallback}) + * exchanges the YNAB code, records the YNAB-identified user + encrypted refresh + * token, and issues our own MCP authorization code back to the client. + */ + +import { randomBytes, randomUUID } from 'node:crypto'; +import type { Response } from 'express'; +import type { + OAuthServerProvider, + AuthorizationParams, +} from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'; +import type { + OAuthClientInformationFull, + OAuthTokens, + OAuthTokenRevocationRequest, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { + InvalidGrantError, + InvalidTokenError, + ServerError, +} from '@modelcontextprotocol/sdk/server/auth/errors.js'; +import type { Storage, GrantedScope } from '../storage/types.js'; +import type { Encryptor } from '../crypto.js'; +import { + buildAuthorizeUrl, + exchangeCode, + getYnabUserId, + type YnabOAuthConfig, +} from './ynab-oauth.js'; + +export interface McpProviderOptions { + storage: Storage; + encryptor: Encryptor; + ynab: YnabOAuthConfig; + /** Public base URL (no trailing slash), e.g. https://ynab-mcp.example.com */ + publicUrl: string; + /** Whether read-write access may be offered at consent. */ + allowWrite: boolean; + /** Force read-only regardless of the user's choice (defense in depth). */ + globalReadOnly: boolean; + accessTokenTtlSec: number; + authCodeTtlSec: number; +} + +function newToken(): string { + return randomBytes(32).toString('base64url'); +} + +function htmlEscape(s: string): string { + return s.replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string + ); +} + +export class McpOAuthProvider implements OAuthServerProvider { + private readonly opts: McpProviderOptions; + // Bridges challengeForAuthorizationCode -> exchangeAuthorizationCode within a + // single token request (the code is consumed from storage at challenge time). + private readonly consumedCodes = new Map< + string, + { clientId: string; userId: string; redirectUri: string; codeChallenge: string; scopes: string[] } + >(); + + constructor(opts: McpProviderOptions) { + this.opts = opts; + } + + private get callbackUrl(): string { + return `${this.opts.publicUrl}/oauth/ynab/callback`; + } + + get clientsStore(): OAuthRegisteredClientsStore { + const storage = this.opts.storage; + return { + getClient: (clientId: string) => storage.getClient(clientId), + registerClient: async (client) => { + const full: OAuthClientInformationFull = { + ...client, + client_id: randomUUID(), + client_id_issued_at: Math.floor(Date.now() / 1000), + }; + await storage.saveClient(full); + return full; + }, + }; + } + + /** + * Begin authorization: render the read-only/read-write consent page. The links + * carry the pending-auth `state`; the chosen scope is applied at `/oauth/ynab/start`. + */ + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response + ): Promise { + const state = newToken(); + await this.opts.storage.savePendingAuth({ + state, + clientId: client.client_id, + redirectUri: params.redirectUri, + codeChallenge: params.codeChallenge, + clientState: params.state, + scopes: params.scopes ?? [], + grantedScope: 'read-only', + expiresAt: Date.now() + this.opts.authCodeTtlSec * 1000, + }); + + const startBase = `${this.opts.publicUrl}/oauth/ynab/start`; + const roLink = `${startBase}?state=${encodeURIComponent(state)}&scope=read-only`; + const rwLink = `${startBase}?state=${encodeURIComponent(state)}&scope=read-write`; + const offerWrite = this.opts.allowWrite && !this.opts.globalReadOnly; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(` + +Connect YNAB + + +

Connect your YNAB budget

+

Client ${htmlEscape(client.client_name ?? client.client_id)} is requesting access to your YNAB data. Choose the access level:

+Read-only
View budgets, accounts, and transactions. No changes.
+${offerWrite ? `Read & write
View and modify (create/update/delete) your budget data.
` : ''} +

You'll be sent to YNAB to authorize. You can disconnect at any time.

+`); + } + + /** + * Called by the `/oauth/ynab/start` route: set the chosen scope and return the + * YNAB authorize URL to redirect the browser to. + */ + async startYnabAuthorization(state: string, scope: GrantedScope): Promise { + const rec = await this.opts.storage.takePendingAuth(state); + if (!rec) throw new InvalidGrantError('Authorization request expired or invalid'); + const grantedScope: GrantedScope = + this.opts.globalReadOnly || !this.opts.allowWrite ? 'read-only' : scope; + await this.opts.storage.savePendingAuth({ ...rec, grantedScope }); + return buildAuthorizeUrl(this.opts.ynab, { + redirectUri: this.callbackUrl, + state, + readOnly: grantedScope === 'read-only', + }); + } + + /** + * Called by the `/oauth/ynab/callback` route once YNAB redirects back with a + * code. Returns the MCP client redirect URL (with our `code` + `state`). + */ + async handleYnabCallback(code: string, state: string): Promise { + const rec = await this.opts.storage.takePendingAuth(state); + if (!rec) throw new InvalidGrantError('Authorization request expired or invalid'); + + const tokens = await exchangeCode(this.opts.ynab, { + code, + redirectUri: this.callbackUrl, + }); + const userId = await getYnabUserId(tokens.accessToken, this.opts.ynab.endpoints); + + await this.opts.storage.upsertUser({ + userId, + grantedScope: rec.grantedScope, + encryptedRefreshToken: this.opts.encryptor.encrypt(tokens.refreshToken), + updatedAt: Date.now(), + }); + + const mcpCode = newToken(); + await this.opts.storage.saveAuthCode({ + code: mcpCode, + clientId: rec.clientId, + userId, + redirectUri: rec.redirectUri, + codeChallenge: rec.codeChallenge, + scopes: rec.scopes, + expiresAt: Date.now() + this.opts.authCodeTtlSec * 1000, + }); + + const url = new URL(rec.redirectUri); + url.searchParams.set('code', mcpCode); + if (rec.clientState !== undefined) url.searchParams.set('state', rec.clientState); + return url.toString(); + } + + async challengeForAuthorizationCode( + _client: OAuthClientInformationFull, + authorizationCode: string + ): Promise { + const rec = await this.opts.storage.takeAuthCode(authorizationCode); + if (!rec) throw new InvalidGrantError('Invalid or expired authorization code'); + this.consumedCodes.set(authorizationCode, { + clientId: rec.clientId, + userId: rec.userId, + redirectUri: rec.redirectUri, + codeChallenge: rec.codeChallenge, + scopes: rec.scopes, + }); + return rec.codeChallenge; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + _codeVerifier?: string, + redirectUri?: string + ): Promise { + const rec = this.consumedCodes.get(authorizationCode); + this.consumedCodes.delete(authorizationCode); + if (!rec) throw new InvalidGrantError('Invalid or expired authorization code'); + if (rec.clientId !== client.client_id) throw new InvalidGrantError('Client mismatch'); + if (redirectUri !== undefined && redirectUri !== rec.redirectUri) { + throw new InvalidGrantError('redirect_uri mismatch'); + } + return this.issueTokens(rec.clientId, rec.userId, rec.scopes); + } + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + scopes?: string[] + ): Promise { + const rec = await this.opts.storage.takeRefreshToken(refreshToken); + if (!rec) throw new InvalidGrantError('Invalid refresh token'); + if (rec.clientId !== client.client_id) throw new InvalidGrantError('Client mismatch'); + return this.issueTokens(rec.clientId, rec.userId, scopes && scopes.length ? scopes : rec.scopes); + } + + private async issueTokens(clientId: string, userId: string, scopes: string[]): Promise { + const accessToken = newToken(); + const refreshToken = newToken(); + const expiresAt = Date.now() + this.opts.accessTokenTtlSec * 1000; + await this.opts.storage.saveAccessToken({ token: accessToken, clientId, userId, scopes, expiresAt }); + await this.opts.storage.saveRefreshToken({ token: refreshToken, clientId, userId, scopes }); + return { + access_token: accessToken, + token_type: 'bearer', + expires_in: this.opts.accessTokenTtlSec, + refresh_token: refreshToken, + scope: scopes.join(' '), + }; + } + + async verifyAccessToken(token: string): Promise { + const rec = await this.opts.storage.getAccessToken(token); + if (!rec) throw new InvalidTokenError('Invalid or expired access token'); + return { + token, + clientId: rec.clientId, + scopes: rec.scopes, + expiresAt: Math.floor(rec.expiresAt / 1000), + extra: { userId: rec.userId }, + }; + } + + async revokeToken( + _client: OAuthClientInformationFull, + request: OAuthTokenRevocationRequest + ): Promise { + try { + await this.opts.storage.deleteAccessToken(request.token); + await this.opts.storage.deleteRefreshToken(request.token); + } catch (err) { + throw new ServerError(err instanceof Error ? err.message : 'revoke failed'); + } + } +} diff --git a/src/auth/user-session.ts b/src/auth/user-session.ts new file mode 100644 index 0000000..d183ff6 --- /dev/null +++ b/src/auth/user-session.ts @@ -0,0 +1,60 @@ +/** + * Resolves an authenticated user's current YNAB access token for a request. + * + * Given a YNAB user id (from the verified MCP access token), loads the user's + * encrypted YNAB refresh token, returns a cached access token if still fresh, or + * refreshes against YNAB — persisting the rotated refresh token, since YNAB issues + * a new refresh token on every refresh. + */ + +import type { Storage, GrantedScope } from '../storage/types.js'; +import type { Encryptor } from '../crypto.js'; +import { refreshAccessToken, type YnabOAuthConfig } from './ynab-oauth.js'; + +/** Refresh a bit early so an access token doesn't expire mid-request. */ +const REFRESH_SKEW_MS = 60_000; + +export interface ResolvedUser { + accessToken: string; + grantedScope: GrantedScope; +} + +export class YnabTokenResolver { + private readonly cache = new Map(); + + constructor( + private readonly storage: Storage, + private readonly encryptor: Encryptor, + private readonly ynab: YnabOAuthConfig + ) {} + + async resolve(userId: string): Promise { + const user = await this.storage.getUser(userId); + if (!user) { + throw new Error('Unknown user: re-authorize the YNAB connection'); + } + + const cached = this.cache.get(userId); + if (cached && cached.expiresAt > Date.now() + REFRESH_SKEW_MS) { + return { accessToken: cached.accessToken, grantedScope: user.grantedScope }; + } + + const refreshToken = this.encryptor.decrypt(user.encryptedRefreshToken); + const set = await refreshAccessToken(this.ynab, refreshToken); + + // YNAB rotates the refresh token on every refresh — persist the new one. + await this.storage.upsertUser({ + ...user, + encryptedRefreshToken: this.encryptor.encrypt(set.refreshToken), + updatedAt: Date.now(), + }); + this.cache.set(userId, { accessToken: set.accessToken, expiresAt: set.expiresAt }); + + return { accessToken: set.accessToken, grantedScope: user.grantedScope }; + } + + /** Drop any cached access token for a user (e.g. after disconnect). */ + invalidate(userId: string): void { + this.cache.delete(userId); + } +} diff --git a/src/auth/ynab-oauth.ts b/src/auth/ynab-oauth.ts new file mode 100644 index 0000000..89b1515 --- /dev/null +++ b/src/auth/ynab-oauth.ts @@ -0,0 +1,177 @@ +/** + * Dependency-free client for YNAB's OAuth 2.0 Authorization Code flow. + * + * Covers building the authorize URL, exchanging an authorization code for a + * token set, refreshing an access token, and resolving the YNAB user id. All + * token-bearing failures throw sanitized errors that never echo secrets. + */ + +/** Default YNAB OAuth/API endpoints. Overridable per config for tests. */ +export const YNAB_AUTHORIZE_URL = 'https://app.ynab.com/oauth/authorize'; +export const YNAB_TOKEN_URL = 'https://app.ynab.com/oauth/token'; +export const YNAB_API_BASE_URL = 'https://api.ynab.com/v1'; + +/** Endpoint overrides so tests can point at a mock server. */ +export interface YnabOAuthEndpoints { + authorizeUrl?: string; + tokenUrl?: string; + apiBaseUrl?: string; +} + +export interface YnabOAuthConfig { + clientId: string; + clientSecret: string; + endpoints?: YnabOAuthEndpoints; +} + +/** A resolved token set with the expiry converted to epoch milliseconds. */ +export interface YnabTokenSet { + accessToken: string; + refreshToken: string; + /** Absolute expiry as epoch milliseconds (Date.now() + expires_in * 1000). */ + expiresAt: number; +} + +/** Raw token response shape from YNAB's token endpoint. */ +interface YnabTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Build the authorize URL a user visits to grant access. */ +export function buildAuthorizeUrl( + cfg: YnabOAuthConfig, + params: { redirectUri: string; state: string; readOnly: boolean } +): string { + const base = cfg.endpoints?.authorizeUrl ?? YNAB_AUTHORIZE_URL; + const url = new URL(base); + url.searchParams.set('client_id', cfg.clientId); + url.searchParams.set('redirect_uri', params.redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('state', params.state); + if (params.readOnly) { + url.searchParams.set('scope', 'read-only'); + } + return url.toString(); +} + +/** + * Extract a sanitized error message from a non-2xx token/API response body. + * Never includes token or secret material — only status and any error fields. + */ +async function sanitizedError(res: Response, context: string): Promise { + let detail = ''; + try { + const body: unknown = await res.json(); + if (isRecord(body)) { + const err = typeof body['error'] === 'string' ? body['error'] : undefined; + const desc = + typeof body['error_description'] === 'string' ? body['error_description'] : undefined; + const parts = [err, desc].filter((p): p is string => p !== undefined); + if (parts.length > 0) { + detail = `: ${parts.join(' - ')}`; + } + } + } catch { + // Body was not JSON; fall back to status only. Never surface raw body text. + } + return new Error(`${context} failed with status ${res.status}${detail}`); +} + +/** Parse a token response defensively and compute the absolute expiry. */ +function parseTokenSet(body: unknown): YnabTokenSet { + if (!isRecord(body)) { + throw new Error('YNAB token response was not an object'); + } + const { access_token, refresh_token, expires_in } = body as Partial; + if (typeof access_token !== 'string' || access_token === '') { + throw new Error('YNAB token response missing access_token'); + } + if (typeof refresh_token !== 'string' || refresh_token === '') { + throw new Error('YNAB token response missing refresh_token'); + } + if (typeof expires_in !== 'number' || !Number.isFinite(expires_in)) { + throw new Error('YNAB token response missing expires_in'); + } + return { + accessToken: access_token, + refreshToken: refresh_token, + expiresAt: Date.now() + expires_in * 1000, + }; +} + +async function postToken(cfg: YnabOAuthConfig, form: URLSearchParams): Promise { + const tokenUrl = cfg.endpoints?.tokenUrl ?? YNAB_TOKEN_URL; + const res = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: form.toString(), + }); + if (!res.ok) { + throw await sanitizedError(res, 'YNAB token request'); + } + const body: unknown = await res.json(); + return parseTokenSet(body); +} + +/** Exchange an authorization code for a token set. */ +export function exchangeCode( + cfg: YnabOAuthConfig, + params: { code: string; redirectUri: string } +): Promise { + const form = new URLSearchParams({ + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + redirect_uri: params.redirectUri, + grant_type: 'authorization_code', + code: params.code, + }); + return postToken(cfg, form); +} + +/** Exchange a refresh token for a fresh token set. */ +export function refreshAccessToken( + cfg: YnabOAuthConfig, + refreshToken: string +): Promise { + const form = new URLSearchParams({ + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + return postToken(cfg, form); +} + +/** Resolve the YNAB user id for a given access token. */ +export async function getYnabUserId( + accessToken: string, + endpoints?: YnabOAuthEndpoints +): Promise { + const base = endpoints?.apiBaseUrl ?? YNAB_API_BASE_URL; + const res = await fetch(`${base}/user`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + throw await sanitizedError(res, 'YNAB user request'); + } + const body: unknown = await res.json(); + if (!isRecord(body)) { + throw new Error('YNAB user response was not an object'); + } + const data = body['data']; + if (!isRecord(data)) { + throw new Error('YNAB user response missing data'); + } + const user = data['user']; + if (!isRecord(user) || typeof user['id'] !== 'string' || user['id'] === '') { + throw new Error('YNAB user response missing user id'); + } + return user['id']; +} diff --git a/src/config/environment.ts b/src/config/environment.ts index 169b22d..2725a10 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -77,6 +77,12 @@ function parseList(value: string | undefined): string[] | undefined { * (interim: via header; later: via OAuth), while single-user HTTP can still set * a fallback `YNAB_ACCESS_TOKEN`. */ +/** Auth strategy for HTTP mode. */ +export type HttpAuthMode = 'header' | 'oauth'; + +/** Persistence driver for the OAuth server. */ +export type StorageDriver = 'memory' | 'sqlite' | 'postgres'; + export interface HttpConfig { port: number; publicUrl: string | undefined; @@ -89,14 +95,42 @@ export interface HttpConfig { readOnly: boolean; cacheTtlMs: number; rateLimitPerHour: number; + + // Auth mode: `oauth` when the YNAB OAuth app + encryption key + public URL are + // all configured, otherwise the interim `header` mode. + authMode: HttpAuthMode; + oauthClientId: string | undefined; + oauthClientSecret: string | undefined; + encryptionKey: string | undefined; + allowWrite: boolean; + accessTokenTtlSec: number; + authCodeTtlSec: number; + + // Storage (oauth mode) + storageDriver: StorageDriver; + sqlitePath: string | undefined; + databaseUrl: string | undefined; +} + +function parseStorageDriver(value: string | undefined): StorageDriver { + const v = (value ?? 'memory').toLowerCase(); + if (v === 'memory' || v === 'sqlite' || v === 'postgres') return v; + throw new Error(`Invalid STORAGE_DRIVER "${value}". Expected: memory, sqlite, or postgres.`); } export function loadHttpConfig(): HttpConfig { const allowedHosts = parseList(process.env['ALLOWED_HOSTS']); const allowedOrigins = parseList(process.env['ALLOWED_ORIGINS']); + const publicUrl = process.env['PUBLIC_URL'] || undefined; + const oauthClientId = process.env['YNAB_OAUTH_CLIENT_ID'] || undefined; + const oauthClientSecret = process.env['YNAB_OAUTH_CLIENT_SECRET'] || undefined; + const encryptionKey = process.env['ENCRYPTION_KEY'] || undefined; + + const oauthConfigured = Boolean(oauthClientId && oauthClientSecret && encryptionKey && publicUrl); + return { port: parseInteger(process.env['PORT'], 3000, 'PORT'), - publicUrl: process.env['PUBLIC_URL'] || undefined, + publicUrl: publicUrl ? publicUrl.replace(/\/+$/, '') : undefined, allowedHosts, allowedOrigins, // Only meaningful when a host/origin allowlist is configured. @@ -108,6 +142,18 @@ export function loadHttpConfig(): HttpConfig { readOnly: parseBoolean(process.env['YNAB_READ_ONLY'], true, 'YNAB_READ_ONLY'), cacheTtlMs: parseInteger(process.env['CACHE_TTL_MS'], 300000, 'CACHE_TTL_MS'), rateLimitPerHour: parseInteger(process.env['RATE_LIMIT_PER_HOUR'], 180, 'RATE_LIMIT_PER_HOUR'), + + authMode: oauthConfigured ? 'oauth' : 'header', + oauthClientId, + oauthClientSecret, + encryptionKey, + allowWrite: parseBoolean(process.env['YNAB_OAUTH_ALLOW_WRITE'], true, 'YNAB_OAUTH_ALLOW_WRITE'), + accessTokenTtlSec: parseInteger(process.env['MCP_ACCESS_TOKEN_TTL_SEC'], 3600, 'MCP_ACCESS_TOKEN_TTL_SEC'), + authCodeTtlSec: parseInteger(process.env['MCP_AUTH_CODE_TTL_SEC'], 600, 'MCP_AUTH_CODE_TTL_SEC'), + + storageDriver: parseStorageDriver(process.env['STORAGE_DRIVER']), + sqlitePath: process.env['SQLITE_PATH'] || undefined, + databaseUrl: process.env['DATABASE_URL'] || undefined, }; } diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..18232b4 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,73 @@ +/** + * At-rest encryption for stored secrets (YNAB refresh tokens, MCP refresh tokens). + * + * AES-256-GCM using a deployer-provided key. The key comes from `ENCRYPTION_KEY` + * as base64 (32 bytes) or a 64-char hex string. Ciphertext is serialized as + * `v1...` (each part base64url) so the format is versioned + * and self-describing. + */ + +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +const ALGO = 'aes-256-gcm'; +const IV_BYTES = 12; +const VERSION = 'v1'; + +/** Parse and validate the encryption key from an env value. */ +export function parseEncryptionKey(raw: string | undefined): Buffer { + if (!raw || raw.trim() === '') { + throw new Error('ENCRYPTION_KEY is required in HTTP/multi-user mode (32-byte base64 or 64-char hex)'); + } + const value = raw.trim(); + let key: Buffer; + if (/^[0-9a-fA-F]{64}$/.test(value)) { + key = Buffer.from(value, 'hex'); + } else { + key = Buffer.from(value, 'base64'); + } + if (key.length !== 32) { + throw new Error( + `ENCRYPTION_KEY must decode to 32 bytes (got ${key.length}); use a base64 32-byte or 64-char hex key` + ); + } + return key; +} + +/** Encrypt a UTF-8 string; returns a versioned, self-describing token. */ +export function encrypt(plaintext: string, key: Buffer): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGO, key, iv); + const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return [VERSION, iv.toString('base64url'), tag.toString('base64url'), ct.toString('base64url')].join('.'); +} + +/** Decrypt a token produced by {@link encrypt}. Throws on tampering/format errors. */ +export function decrypt(token: string, key: Buffer): string { + const parts = token.split('.'); + if (parts.length !== 4 || parts[0] !== VERSION) { + throw new Error('Invalid encrypted token format'); + } + const iv = Buffer.from(parts[1] as string, 'base64url'); + const tag = Buffer.from(parts[2] as string, 'base64url'); + const ct = Buffer.from(parts[3] as string, 'base64url'); + const decipher = createDecipheriv(ALGO, key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); +} + +/** + * Small helper bundling a key so callers don't pass the Buffer around. + */ +export class Encryptor { + private readonly key: Buffer; + constructor(rawKey: string | undefined) { + this.key = parseEncryptionKey(rawKey); + } + encrypt(plaintext: string): string { + return encrypt(plaintext, this.key); + } + decrypt(token: string): string { + return decrypt(token, this.key); + } +} diff --git a/src/http.ts b/src/http.ts index 5b18cc1..71ef40f 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,65 +1,182 @@ /** * HTTP (remote) transport for the YNAB MCP server. * - * Exposes the MCP Streamable HTTP transport over Express with **per-session** - * server instances — each session gets its own YnabClient / cache / rate limiter / - * audit log, so nothing leaks between concurrent clients. + * Two auth modes (selected by config): + * - `header` — interim: YNAB token supplied per session via `X-YNAB-Token` + * (or the `YNAB_ACCESS_TOKEN` fallback). TLS required. + * - `oauth` — multi-tenant: the server is an OAuth 2.1 Authorization Server + * (DCR + PKCE via the SDK) federated to YNAB. Each MCP access + * token maps to a YNAB-identified user; per request we resolve and + * refresh that user's YNAB token and build an isolated server. * - * PHASE 1 (interim auth): the YNAB access token is supplied per session via the - * `X-YNAB-Token` header (falling back to the `YNAB_ACCESS_TOKEN` env for - * single-user HTTP). This is a stopgap to validate the transport/data plane and - * MUST be used only over TLS; it is replaced by the YNAB-OAuth flow in a later - * phase. + * In both modes each MCP session gets its own YnabClient / cache / rate limiter / + * audit log — nothing leaks between users. */ import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import express, { type Request, type Response } from 'express'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { createServerForUser } from './server.js'; +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { createServerForUser, type UserContext } from './server.js'; import type { HttpConfig } from './config/environment.js'; +import { Encryptor } from './crypto.js'; +import { createStorage } from './storage/index.js'; +import { McpOAuthProvider } from './auth/mcp-provider.js'; +import { YnabTokenResolver } from './auth/user-session.js'; function jsonRpcError(code: number, message: string): unknown { return { jsonrpc: '2.0', error: { code, message }, id: null }; } +/** Select and initialize the configured storage driver. */ +async function buildStorage(config: HttpConfig): ReturnType { + switch (config.storageDriver) { + case 'sqlite': + if (!config.sqlitePath) throw new Error('SQLITE_PATH is required for the sqlite storage driver'); + return createStorage({ driver: 'sqlite', path: config.sqlitePath }); + case 'postgres': + if (!config.databaseUrl) throw new Error('DATABASE_URL is required for the postgres storage driver'); + return createStorage({ driver: 'postgres', connectionString: config.databaseUrl }); + default: + return createStorage({ driver: 'memory' }); + } +} + +/** Result of resolving a session's user context, or an HTTP error to return. */ +type ContextResult = + | { ok: true; ctx: UserContext } + | { ok: false; status: number; message: string }; + /** - * Build the Express app (exported for testing without binding a port). + * Build the Express app (async because oauth mode initializes storage). + * Exported for testing without binding a port. */ -export function createHttpApp(config: HttpConfig): express.Express { +export async function createHttpApp(config: HttpConfig): Promise { const app = express(); app.use(express.json({ limit: '4mb' })); - // Active sessions: session id -> transport. Each transport is wired to its own - // per-user MCP Server instance. const transports = new Map(); - // Plain HTTP health check for load balancers / reverse proxies. app.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'ok', transport: 'http', sessions: transports.size }); + res.json({ status: 'ok', transport: 'http', authMode: config.authMode, sessions: transports.size }); }); + // Resolves the per-session UserContext at `initialize` time. + let resolveInitContext: (req: Request) => Promise; + + if (config.authMode === 'oauth') { + // --- OAuth mode: AS endpoints + YNAB federation --- + const storage = await buildStorage(config); + const encryptor = new Encryptor(config.encryptionKey); + const ynab = { + clientId: config.oauthClientId as string, + clientSecret: config.oauthClientSecret as string, + }; + const publicUrl = config.publicUrl as string; + const provider = new McpOAuthProvider({ + storage, + encryptor, + ynab, + publicUrl, + allowWrite: config.allowWrite, + globalReadOnly: config.readOnly, + accessTokenTtlSec: config.accessTokenTtlSec, + authCodeTtlSec: config.authCodeTtlSec, + }); + const resolver = new YnabTokenResolver(storage, encryptor, ynab); + + // OAuth 2.1 AS endpoints (/authorize, /token, /register, /revoke, metadata). + app.use(mcpAuthRouter({ provider, issuerUrl: new URL(publicUrl) })); + + // YNAB federation legs. + app.get('/oauth/ynab/start', async (req: Request, res: Response) => { + try { + const state = String(req.query['state'] ?? ''); + const scope = req.query['scope'] === 'read-write' ? 'read-write' : 'read-only'; + const url = await provider.startYnabAuthorization(state, scope); + res.redirect(url); + } catch { + res.status(400).send('Authorization request expired or invalid. Please start over.'); + } + }); + app.get('/oauth/ynab/callback', async (req: Request, res: Response) => { + try { + const code = String(req.query['code'] ?? ''); + const state = String(req.query['state'] ?? ''); + if (!code || !state) { + res.status(400).send('Missing code or state.'); + return; + } + const redirect = await provider.handleYnabCallback(code, state); + res.redirect(redirect); + } catch { + res.status(400).send('Could not complete YNAB authorization. Please start over.'); + } + }); + + // Protect the MCP endpoint with the bearer verifier. + app.use('/mcp', requireBearerAuth({ verifier: provider })); + + resolveInitContext = async (req: Request): Promise => { + const auth = (req as unknown as { auth?: AuthInfo }).auth; + const userId = auth?.extra?.['userId']; + if (typeof userId !== 'string') { + return { ok: false, status: 401, message: 'Unauthorized: token missing user identity' }; + } + try { + const { accessToken, grantedScope } = await resolver.resolve(userId); + return { + ok: true, + ctx: { + accessToken, + defaultBudgetId: config.defaultBudgetId, + readOnly: config.readOnly || grantedScope === 'read-only', + rateLimitPerHour: config.rateLimitPerHour, + cacheTtlMs: config.cacheTtlMs, + }, + }; + } catch { + return { ok: false, status: 401, message: 'Unauthorized: could not resolve YNAB access — re-authorize' }; + } + }; + } else { + // --- Interim header mode --- + resolveInitContext = async (req: Request): Promise => { + const token = req.header('x-ynab-token') ?? config.fallbackAccessToken; + if (!token) { + return { ok: false, status: 401, message: 'Unauthorized: provide a YNAB token via the X-YNAB-Token header' }; + } + return { + ok: true, + ctx: { + accessToken: token, + defaultBudgetId: config.defaultBudgetId, + readOnly: config.readOnly, + rateLimitPerHour: config.rateLimitPerHour, + cacheTtlMs: config.cacheTtlMs, + }, + }; + }; + } + // Client -> server messages (and streamed responses). app.post('/mcp', async (req: Request, res: Response) => { const sessionId = req.header('mcp-session-id'); let transport = sessionId ? transports.get(sessionId) : undefined; if (!transport) { - // Only a fresh `initialize` (with no session id) may open a new session. if (sessionId !== undefined || !isInitializeRequest(req.body)) { - res - .status(400) - .json(jsonRpcError(-32000, 'Bad Request: no valid session for this request')); + res.status(400).json(jsonRpcError(-32000, 'Bad Request: no valid session for this request')); return; } - // Bind this session to a YNAB token (interim: header, else env fallback). - const token = req.header('x-ynab-token') ?? config.fallbackAccessToken; - if (!token) { - res - .status(401) - .json(jsonRpcError(-32001, 'Unauthorized: provide a YNAB token via the X-YNAB-Token header')); + const result = await resolveInitContext(req); + if (!result.ok) { + res.status(result.status).json(jsonRpcError(-32001, result.message)); return; } @@ -77,15 +194,7 @@ export function createHttpApp(config: HttpConfig): express.Express { if (sid) transports.delete(sid); }; - const server = createServerForUser({ - accessToken: token, - defaultBudgetId: config.defaultBudgetId, - readOnly: config.readOnly, - rateLimitPerHour: config.rateLimitPerHour, - cacheTtlMs: config.cacheTtlMs, - }); - // Cast bridges an exactOptionalPropertyTypes mismatch between the SDK's - // Transport interface (optional onclose) and the transport's accessor type. + const server = createServerForUser(result.ctx); await server.connect(newTransport as unknown as Parameters[0]); transport = newTransport; } @@ -105,10 +214,7 @@ export function createHttpApp(config: HttpConfig): express.Express { res.status(400).json(jsonRpcError(-32000, 'Bad Request: unknown or missing session id')); return; } - await transport.handleRequest( - req as unknown as IncomingMessage, - res as unknown as ServerResponse - ); + await transport.handleRequest(req as unknown as IncomingMessage, res as unknown as ServerResponse); }; app.get('/mcp', sessionRequest); app.delete('/mcp', sessionRequest); @@ -119,18 +225,20 @@ export function createHttpApp(config: HttpConfig): express.Express { /** * Build and start the HTTP server. Returns the Node http.Server. */ -export function startHttpServer(config: HttpConfig): ReturnType { - const app = createHttpApp(config); +export async function startHttpServer( + config: HttpConfig +): Promise> { + const app = await createHttpApp(config); const httpServer = app.listen(config.port, () => { - console.error(`YNAB MCP Server (HTTP) listening on port ${config.port}`); + console.error(`YNAB MCP Server (HTTP, ${config.authMode} auth) listening on port ${config.port}`); console.error( - config.readOnly - ? 'READ-ONLY mode (write operations disabled)' - : 'WRITE operations ENABLED' - ); - console.error( - 'Interim auth: YNAB token via X-YNAB-Token header (TLS required; replaced by OAuth in a later phase)' + config.readOnly ? 'READ-ONLY mode (write operations disabled)' : 'WRITE operations ENABLED' ); + if (config.authMode === 'header') { + console.error( + 'Interim auth: YNAB token via X-YNAB-Token header (TLS required; set the YNAB OAuth env vars to enable OAuth)' + ); + } }); return httpServer; } diff --git a/src/index.ts b/src/index.ts index 4095bb0..71f2aa5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,7 +46,7 @@ async function main(): Promise { const transportMode = (process.env['MCP_TRANSPORT'] ?? 'stdio').toLowerCase(); if (transportMode === 'http') { - httpServer = startHttpServer(loadHttpConfig()); + httpServer = await startHttpServer(loadHttpConfig()); return; } diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000..4b271e6 --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,49 @@ +/** + * Storage factory: selects and initializes a durable (or in-memory) driver. + * + * Drivers: + * - `memory` (default) — in-process, non-durable. Good for dev / single-node. + * - `sqlite` — durable, single-node. Requires `better-sqlite3`. + * - `postgres` — durable, multi-node. Requires `pg`. + * + * The concrete driver modules import their native dependencies lazily, so only + * the selected driver's dependency needs to be installed. + */ + +import type { Storage } from './types.js'; +import { MemoryStorage } from './memory.js'; + +export type StorageOptions = + | { driver?: 'memory' } + | { driver: 'sqlite'; path: string } + | { driver: 'postgres'; connectionString: string }; + +/** + * Construct and `init()` a Storage adapter for the requested driver. + * Defaults to the in-memory driver when none is specified. + */ +export async function createStorage(opts: StorageOptions = {}): Promise { + const storage = await buildStorage(opts); + await storage.init(); + return storage; +} + +async function buildStorage(opts: StorageOptions): Promise { + switch (opts.driver) { + case 'sqlite': { + const { SqliteStorage } = await import('./sqlite.js'); + return new SqliteStorage({ path: opts.path }); + } + case 'postgres': { + const { PostgresStorage } = await import('./postgres.js'); + return new PostgresStorage({ connectionString: opts.connectionString }); + } + case 'memory': + case undefined: + return new MemoryStorage(); + default: + return new MemoryStorage(); + } +} + +export type { Storage } from './types.js'; diff --git a/src/storage/memory.ts b/src/storage/memory.ts new file mode 100644 index 0000000..695d6a2 --- /dev/null +++ b/src/storage/memory.ts @@ -0,0 +1,102 @@ +/** + * In-memory storage adapter (zero dependencies). + * + * The default driver: works out of the box for single-node / development and is + * the target for the test suite. NOTE: state is lost on restart — use the SQLite + * or Postgres driver for durable multi-user deployments. + */ + +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { + Storage, + UserRecord, + AuthCodeRecord, + AccessTokenRecord, + RefreshTokenRecord, + PendingAuthRecord, +} from './types.js'; + +export class MemoryStorage implements Storage { + private users = new Map(); + private clients = new Map(); + private pending = new Map(); + private authCodes = new Map(); + private accessTokens = new Map(); + private refreshTokens = new Map(); + + async init(): Promise { + // no-op + } + async close(): Promise { + this.users.clear(); + this.clients.clear(); + this.pending.clear(); + this.authCodes.clear(); + this.accessTokens.clear(); + this.refreshTokens.clear(); + } + + async upsertUser(rec: UserRecord): Promise { + this.users.set(rec.userId, rec); + } + async getUser(userId: string): Promise { + return this.users.get(userId); + } + async deleteUser(userId: string): Promise { + this.users.delete(userId); + } + + async getClient(clientId: string): Promise { + return this.clients.get(clientId); + } + async saveClient(client: OAuthClientInformationFull): Promise { + this.clients.set(client.client_id, client); + } + + async savePendingAuth(rec: PendingAuthRecord): Promise { + this.pending.set(rec.state, rec); + } + async takePendingAuth(state: string): Promise { + const rec = this.pending.get(state); + this.pending.delete(state); + if (!rec || rec.expiresAt < Date.now()) return undefined; + return rec; + } + + async saveAuthCode(rec: AuthCodeRecord): Promise { + this.authCodes.set(rec.code, rec); + } + async takeAuthCode(code: string): Promise { + const rec = this.authCodes.get(code); + this.authCodes.delete(code); + if (!rec || rec.expiresAt < Date.now()) return undefined; + return rec; + } + + async saveAccessToken(rec: AccessTokenRecord): Promise { + this.accessTokens.set(rec.token, rec); + } + async getAccessToken(token: string): Promise { + const rec = this.accessTokens.get(token); + if (rec && rec.expiresAt < Date.now()) { + this.accessTokens.delete(token); + return undefined; + } + return rec; + } + async deleteAccessToken(token: string): Promise { + this.accessTokens.delete(token); + } + + async saveRefreshToken(rec: RefreshTokenRecord): Promise { + this.refreshTokens.set(rec.token, rec); + } + async takeRefreshToken(token: string): Promise { + const rec = this.refreshTokens.get(token); + this.refreshTokens.delete(token); + return rec; + } + async deleteRefreshToken(token: string): Promise { + this.refreshTokens.delete(token); + } +} diff --git a/src/storage/postgres.ts b/src/storage/postgres.ts new file mode 100644 index 0000000..9b9b99e --- /dev/null +++ b/src/storage/postgres.ts @@ -0,0 +1,355 @@ +/** + * PostgreSQL storage adapter (durable, multi-node). + * + * Backed by `pg`, imported lazily so the dependency is only required when this + * driver is selected. Uses a connection pool with parameterized queries. Scopes + * arrays and full client objects are stored as JSON TEXT. One-time records (auth + * codes, refresh tokens, pending auth) are deleted-and-returned atomically via a + * `DELETE ... RETURNING` statement. + */ + +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { Pool as PgPool, QueryResultRow } from 'pg'; +import type { + Storage, + UserRecord, + AuthCodeRecord, + AccessTokenRecord, + RefreshTokenRecord, + PendingAuthRecord, + GrantedScope, +} from './types.js'; + +export interface PostgresStorageOptions { + connectionString: string; +} + +interface UserRow extends QueryResultRow { + user_id: string; + granted_scope: string; + encrypted_refresh_token: string; + updated_at: string | number; +} + +interface ClientRow extends QueryResultRow { + client_json: string; +} + +interface PendingRow extends QueryResultRow { + state: string; + client_id: string; + redirect_uri: string; + code_challenge: string; + client_state: string | null; + scopes: string; + granted_scope: string; + expires_at: string | number; +} + +interface AuthCodeRow extends QueryResultRow { + code: string; + client_id: string; + user_id: string; + redirect_uri: string; + code_challenge: string; + scopes: string; + expires_at: string | number; +} + +interface AccessTokenRow extends QueryResultRow { + token: string; + client_id: string; + user_id: string; + scopes: string; + expires_at: string | number; +} + +interface RefreshTokenRow extends QueryResultRow { + token: string; + client_id: string; + user_id: string; + scopes: string; +} + +function parseScopes(json: string): string[] { + const parsed: unknown = JSON.parse(json); + if (!Array.isArray(parsed)) return []; + return parsed.filter((s): s is string => typeof s === 'string'); +} + +function toMillis(value: string | number): number { + return typeof value === 'number' ? value : Number(value); +} + +export class PostgresStorage implements Storage { + private pool: PgPool | undefined; + + constructor(private readonly options: PostgresStorageOptions) {} + + private get db(): PgPool { + if (!this.pool) throw new Error('PostgresStorage used before init()'); + return this.pool; + } + + async init(): Promise { + let Pool: typeof PgPool; + try { + const mod = await import('pg'); + // `pg` is a CommonJS module; the Pool constructor lives on the default export. + Pool = mod.default.Pool; + } catch { + throw new Error('Postgres driver selected but pg is not installed'); + } + + const pool = new Pool({ connectionString: this.options.connectionString }); + + await pool.query(` + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + granted_scope TEXT NOT NULL, + encrypted_refresh_token TEXT NOT NULL, + updated_at BIGINT NOT NULL + ); + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS pending_auth ( + state TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + client_state TEXT, + scopes TEXT NOT NULL, + granted_scope TEXT NOT NULL, + expires_at BIGINT NOT NULL + ); + CREATE TABLE IF NOT EXISTS auth_codes ( + code TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at BIGINT NOT NULL + ); + CREATE TABLE IF NOT EXISTS access_tokens ( + token TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at BIGINT NOT NULL + ); + CREATE TABLE IF NOT EXISTS refresh_tokens ( + token TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scopes TEXT NOT NULL + ); + `); + + this.pool = pool; + } + + async close(): Promise { + if (this.pool) { + await this.pool.end(); + this.pool = undefined; + } + } + + // Users + YNAB tokens + + async upsertUser(rec: UserRecord): Promise { + await this.db.query( + `INSERT INTO users (user_id, granted_scope, encrypted_refresh_token, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id) DO UPDATE SET + granted_scope = EXCLUDED.granted_scope, + encrypted_refresh_token = EXCLUDED.encrypted_refresh_token, + updated_at = EXCLUDED.updated_at`, + [rec.userId, rec.grantedScope, rec.encryptedRefreshToken, rec.updatedAt] + ); + } + + async getUser(userId: string): Promise { + const res = await this.db.query('SELECT * FROM users WHERE user_id = $1', [userId]); + const row = res.rows[0]; + if (!row) return undefined; + return { + userId: row.user_id, + grantedScope: row.granted_scope as GrantedScope, + encryptedRefreshToken: row.encrypted_refresh_token, + updatedAt: toMillis(row.updated_at), + }; + } + + async deleteUser(userId: string): Promise { + await this.db.query('DELETE FROM users WHERE user_id = $1', [userId]); + } + + // MCP clients + + async getClient(clientId: string): Promise { + const res = await this.db.query( + 'SELECT client_json FROM oauth_clients WHERE client_id = $1', + [clientId] + ); + const row = res.rows[0]; + if (!row) return undefined; + return JSON.parse(row.client_json) as OAuthClientInformationFull; + } + + async saveClient(client: OAuthClientInformationFull): Promise { + await this.db.query( + `INSERT INTO oauth_clients (client_id, client_json) + VALUES ($1, $2) + ON CONFLICT (client_id) DO UPDATE SET client_json = EXCLUDED.client_json`, + [client.client_id, JSON.stringify(client)] + ); + } + + // Pending federated authorization (one-time) + + async savePendingAuth(rec: PendingAuthRecord): Promise { + await this.db.query( + `INSERT INTO pending_auth + (state, client_id, redirect_uri, code_challenge, client_state, scopes, granted_scope, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + rec.state, + rec.clientId, + rec.redirectUri, + rec.codeChallenge, + rec.clientState ?? null, + JSON.stringify(rec.scopes), + rec.grantedScope, + rec.expiresAt, + ] + ); + } + + async takePendingAuth(state: string): Promise { + const res = await this.db.query( + 'DELETE FROM pending_auth WHERE state = $1 RETURNING *', + [state] + ); + const row = res.rows[0]; + if (!row) return undefined; + const expiresAt = toMillis(row.expires_at); + if (expiresAt < Date.now()) return undefined; + return { + state: row.state, + clientId: row.client_id, + redirectUri: row.redirect_uri, + codeChallenge: row.code_challenge, + clientState: row.client_state ?? undefined, + scopes: parseScopes(row.scopes), + grantedScope: row.granted_scope as GrantedScope, + expiresAt, + }; + } + + // MCP authorization codes (one-time) + + async saveAuthCode(rec: AuthCodeRecord): Promise { + await this.db.query( + `INSERT INTO auth_codes + (code, client_id, user_id, redirect_uri, code_challenge, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + rec.code, + rec.clientId, + rec.userId, + rec.redirectUri, + rec.codeChallenge, + JSON.stringify(rec.scopes), + rec.expiresAt, + ] + ); + } + + async takeAuthCode(code: string): Promise { + const res = await this.db.query( + 'DELETE FROM auth_codes WHERE code = $1 RETURNING *', + [code] + ); + const row = res.rows[0]; + if (!row) return undefined; + const expiresAt = toMillis(row.expires_at); + if (expiresAt < Date.now()) return undefined; + return { + code: row.code, + clientId: row.client_id, + userId: row.user_id, + redirectUri: row.redirect_uri, + codeChallenge: row.code_challenge, + scopes: parseScopes(row.scopes), + expiresAt, + }; + } + + // MCP access tokens + + async saveAccessToken(rec: AccessTokenRecord): Promise { + await this.db.query( + `INSERT INTO access_tokens (token, client_id, user_id, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + [rec.token, rec.clientId, rec.userId, JSON.stringify(rec.scopes), rec.expiresAt] + ); + } + + async getAccessToken(token: string): Promise { + const res = await this.db.query( + 'SELECT * FROM access_tokens WHERE token = $1', + [token] + ); + const row = res.rows[0]; + if (!row) return undefined; + const expiresAt = toMillis(row.expires_at); + if (expiresAt < Date.now()) { + await this.db.query('DELETE FROM access_tokens WHERE token = $1', [token]); + return undefined; + } + return { + token: row.token, + clientId: row.client_id, + userId: row.user_id, + scopes: parseScopes(row.scopes), + expiresAt, + }; + } + + async deleteAccessToken(token: string): Promise { + await this.db.query('DELETE FROM access_tokens WHERE token = $1', [token]); + } + + // MCP refresh tokens (one-time, rotated on use) + + async saveRefreshToken(rec: RefreshTokenRecord): Promise { + await this.db.query( + `INSERT INTO refresh_tokens (token, client_id, user_id, scopes) + VALUES ($1, $2, $3, $4)`, + [rec.token, rec.clientId, rec.userId, JSON.stringify(rec.scopes)] + ); + } + + async takeRefreshToken(token: string): Promise { + const res = await this.db.query( + 'DELETE FROM refresh_tokens WHERE token = $1 RETURNING *', + [token] + ); + const row = res.rows[0]; + if (!row) return undefined; + return { + token: row.token, + clientId: row.client_id, + userId: row.user_id, + scopes: parseScopes(row.scopes), + }; + } + + async deleteRefreshToken(token: string): Promise { + await this.db.query('DELETE FROM refresh_tokens WHERE token = $1', [token]); + } +} diff --git a/src/storage/sqlite.ts b/src/storage/sqlite.ts new file mode 100644 index 0000000..7e6415b --- /dev/null +++ b/src/storage/sqlite.ts @@ -0,0 +1,374 @@ +/** + * SQLite storage adapter (durable, single-node). + * + * Backed by `better-sqlite3`, which is imported lazily so the dependency is only + * required when this driver is actually selected. Scopes arrays and full client + * objects are stored as JSON TEXT. One-time records (auth codes, refresh tokens, + * pending auth) are deleted-and-returned atomically inside a transaction. + */ + +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type BetterSqlite3 from 'better-sqlite3'; +import type { + Storage, + UserRecord, + AuthCodeRecord, + AccessTokenRecord, + RefreshTokenRecord, + PendingAuthRecord, + GrantedScope, +} from './types.js'; + +export interface SqliteStorageOptions { + path: string; +} + +interface UserRow { + user_id: string; + granted_scope: string; + encrypted_refresh_token: string; + updated_at: number; +} + +interface ClientRow { + client_id: string; + client_json: string; +} + +interface PendingRow { + state: string; + client_id: string; + redirect_uri: string; + code_challenge: string; + client_state: string | null; + scopes: string; + granted_scope: string; + expires_at: number; +} + +interface AuthCodeRow { + code: string; + client_id: string; + user_id: string; + redirect_uri: string; + code_challenge: string; + scopes: string; + expires_at: number; +} + +interface AccessTokenRow { + token: string; + client_id: string; + user_id: string; + scopes: string; + expires_at: number; +} + +interface RefreshTokenRow { + token: string; + client_id: string; + user_id: string; + scopes: string; +} + +function parseScopes(json: string): string[] { + const parsed: unknown = JSON.parse(json); + if (!Array.isArray(parsed)) return []; + return parsed.filter((s): s is string => typeof s === 'string'); +} + +export class SqliteStorage implements Storage { + private db: BetterSqlite3.Database | undefined; + + constructor(private readonly options: SqliteStorageOptions) {} + + private get database(): BetterSqlite3.Database { + if (!this.db) throw new Error('SqliteStorage used before init()'); + return this.db; + } + + async init(): Promise { + let Database: typeof BetterSqlite3; + try { + const mod = await import('better-sqlite3'); + Database = mod.default; + } catch { + throw new Error('SQLite driver selected but better-sqlite3 is not installed'); + } + + const db = new Database(this.options.path); + db.pragma('journal_mode = WAL'); + + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + granted_scope TEXT NOT NULL, + encrypted_refresh_token TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS pending_auth ( + state TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + client_state TEXT, + scopes TEXT NOT NULL, + granted_scope TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS auth_codes ( + code TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS access_tokens ( + token TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS refresh_tokens ( + token TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scopes TEXT NOT NULL + ); + `); + + this.db = db; + } + + async close(): Promise { + if (this.db) { + this.db.close(); + this.db = undefined; + } + } + + // Users + YNAB tokens + + async upsertUser(rec: UserRecord): Promise { + this.database + .prepare( + `INSERT INTO users (user_id, granted_scope, encrypted_refresh_token, updated_at) + VALUES (@userId, @grantedScope, @encryptedRefreshToken, @updatedAt) + ON CONFLICT(user_id) DO UPDATE SET + granted_scope = excluded.granted_scope, + encrypted_refresh_token = excluded.encrypted_refresh_token, + updated_at = excluded.updated_at` + ) + .run({ + userId: rec.userId, + grantedScope: rec.grantedScope, + encryptedRefreshToken: rec.encryptedRefreshToken, + updatedAt: rec.updatedAt, + }); + } + + async getUser(userId: string): Promise { + const row = this.database + .prepare('SELECT * FROM users WHERE user_id = ?') + .get(userId) as UserRow | undefined; + if (!row) return undefined; + return { + userId: row.user_id, + grantedScope: row.granted_scope as GrantedScope, + encryptedRefreshToken: row.encrypted_refresh_token, + updatedAt: row.updated_at, + }; + } + + async deleteUser(userId: string): Promise { + this.database.prepare('DELETE FROM users WHERE user_id = ?').run(userId); + } + + // MCP clients + + async getClient(clientId: string): Promise { + const row = this.database + .prepare('SELECT * FROM oauth_clients WHERE client_id = ?') + .get(clientId) as ClientRow | undefined; + if (!row) return undefined; + return JSON.parse(row.client_json) as OAuthClientInformationFull; + } + + async saveClient(client: OAuthClientInformationFull): Promise { + this.database + .prepare( + `INSERT INTO oauth_clients (client_id, client_json) + VALUES (@clientId, @clientJson) + ON CONFLICT(client_id) DO UPDATE SET client_json = excluded.client_json` + ) + .run({ clientId: client.client_id, clientJson: JSON.stringify(client) }); + } + + // Pending federated authorization (one-time) + + async savePendingAuth(rec: PendingAuthRecord): Promise { + this.database + .prepare( + `INSERT INTO pending_auth + (state, client_id, redirect_uri, code_challenge, client_state, scopes, granted_scope, expires_at) + VALUES (@state, @clientId, @redirectUri, @codeChallenge, @clientState, @scopes, @grantedScope, @expiresAt)` + ) + .run({ + state: rec.state, + clientId: rec.clientId, + redirectUri: rec.redirectUri, + codeChallenge: rec.codeChallenge, + clientState: rec.clientState ?? null, + scopes: JSON.stringify(rec.scopes), + grantedScope: rec.grantedScope, + expiresAt: rec.expiresAt, + }); + } + + async takePendingAuth(state: string): Promise { + const take = this.database.transaction((s: string): PendingRow | undefined => { + const row = this.database.prepare('SELECT * FROM pending_auth WHERE state = ?').get(s) as + | PendingRow + | undefined; + if (row) this.database.prepare('DELETE FROM pending_auth WHERE state = ?').run(s); + return row; + }); + const row = take(state); + if (!row || row.expires_at < Date.now()) return undefined; + return { + state: row.state, + clientId: row.client_id, + redirectUri: row.redirect_uri, + codeChallenge: row.code_challenge, + clientState: row.client_state ?? undefined, + scopes: parseScopes(row.scopes), + grantedScope: row.granted_scope as GrantedScope, + expiresAt: row.expires_at, + }; + } + + // MCP authorization codes (one-time) + + async saveAuthCode(rec: AuthCodeRecord): Promise { + this.database + .prepare( + `INSERT INTO auth_codes + (code, client_id, user_id, redirect_uri, code_challenge, scopes, expires_at) + VALUES (@code, @clientId, @userId, @redirectUri, @codeChallenge, @scopes, @expiresAt)` + ) + .run({ + code: rec.code, + clientId: rec.clientId, + userId: rec.userId, + redirectUri: rec.redirectUri, + codeChallenge: rec.codeChallenge, + scopes: JSON.stringify(rec.scopes), + expiresAt: rec.expiresAt, + }); + } + + async takeAuthCode(code: string): Promise { + const take = this.database.transaction((c: string): AuthCodeRow | undefined => { + const row = this.database.prepare('SELECT * FROM auth_codes WHERE code = ?').get(c) as + | AuthCodeRow + | undefined; + if (row) this.database.prepare('DELETE FROM auth_codes WHERE code = ?').run(c); + return row; + }); + const row = take(code); + if (!row || row.expires_at < Date.now()) return undefined; + return { + code: row.code, + clientId: row.client_id, + userId: row.user_id, + redirectUri: row.redirect_uri, + codeChallenge: row.code_challenge, + scopes: parseScopes(row.scopes), + expiresAt: row.expires_at, + }; + } + + // MCP access tokens + + async saveAccessToken(rec: AccessTokenRecord): Promise { + this.database + .prepare( + `INSERT INTO access_tokens (token, client_id, user_id, scopes, expires_at) + VALUES (@token, @clientId, @userId, @scopes, @expiresAt)` + ) + .run({ + token: rec.token, + clientId: rec.clientId, + userId: rec.userId, + scopes: JSON.stringify(rec.scopes), + expiresAt: rec.expiresAt, + }); + } + + async getAccessToken(token: string): Promise { + const row = this.database + .prepare('SELECT * FROM access_tokens WHERE token = ?') + .get(token) as AccessTokenRow | undefined; + if (!row) return undefined; + if (row.expires_at < Date.now()) { + this.database.prepare('DELETE FROM access_tokens WHERE token = ?').run(token); + return undefined; + } + return { + token: row.token, + clientId: row.client_id, + userId: row.user_id, + scopes: parseScopes(row.scopes), + expiresAt: row.expires_at, + }; + } + + async deleteAccessToken(token: string): Promise { + this.database.prepare('DELETE FROM access_tokens WHERE token = ?').run(token); + } + + // MCP refresh tokens (one-time, rotated on use) + + async saveRefreshToken(rec: RefreshTokenRecord): Promise { + this.database + .prepare( + `INSERT INTO refresh_tokens (token, client_id, user_id, scopes) + VALUES (@token, @clientId, @userId, @scopes)` + ) + .run({ + token: rec.token, + clientId: rec.clientId, + userId: rec.userId, + scopes: JSON.stringify(rec.scopes), + }); + } + + async takeRefreshToken(token: string): Promise { + const take = this.database.transaction((t: string): RefreshTokenRow | undefined => { + const row = this.database.prepare('SELECT * FROM refresh_tokens WHERE token = ?').get(t) as + | RefreshTokenRow + | undefined; + if (row) this.database.prepare('DELETE FROM refresh_tokens WHERE token = ?').run(t); + return row; + }); + const row = take(token); + if (!row) return undefined; + return { + token: row.token, + clientId: row.client_id, + userId: row.user_id, + scopes: parseScopes(row.scopes), + }; + } + + async deleteRefreshToken(token: string): Promise { + this.database.prepare('DELETE FROM refresh_tokens WHERE token = ?').run(token); + } +} diff --git a/src/storage/types.ts b/src/storage/types.ts new file mode 100644 index 0000000..2461e7d --- /dev/null +++ b/src/storage/types.ts @@ -0,0 +1,97 @@ +/** + * Persistence interface for the multi-tenant OAuth server. + * + * Holds: users (identified by their YNAB user id), each user's encrypted YNAB + * refresh token + granted scope, dynamically-registered MCP clients, short-lived + * MCP authorization codes, MCP access/refresh tokens, and the short-lived + * "pending authorization" records that bridge the MCP-authorize and YNAB-callback + * legs of the federated flow. + * + * All secrets (YNAB refresh tokens) are stored already-encrypted by the caller. + */ + +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; + +export type GrantedScope = 'read-only' | 'read-write'; + +export interface UserRecord { + /** YNAB user id — the identity in this system. */ + userId: string; + grantedScope: GrantedScope; + /** AES-GCM-encrypted YNAB refresh token. */ + encryptedRefreshToken: string; + updatedAt: number; +} + +export interface AuthCodeRecord { + code: string; + clientId: string; + userId: string; + redirectUri: string; + codeChallenge: string; + scopes: string[]; + expiresAt: number; +} + +export interface AccessTokenRecord { + token: string; + clientId: string; + userId: string; + scopes: string[]; + expiresAt: number; +} + +export interface RefreshTokenRecord { + token: string; + clientId: string; + userId: string; + scopes: string[]; +} + +/** Bridges the MCP `/authorize` leg to the YNAB `/oauth/ynab/callback` leg. */ +export interface PendingAuthRecord { + /** Opaque state we send to YNAB and expect back. */ + state: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + /** The MCP client's own `state`, echoed back to it at the end. */ + clientState: string | undefined; + /** Scopes the MCP client requested. */ + scopes: string[]; + /** The YNAB scope chosen at consent ("read-only" or full). */ + grantedScope: GrantedScope; + expiresAt: number; +} + +export interface Storage { + init(): Promise; + close(): Promise; + + // Users + YNAB tokens + upsertUser(rec: UserRecord): Promise; + getUser(userId: string): Promise; + deleteUser(userId: string): Promise; + + // MCP clients (Dynamic Client Registration) + getClient(clientId: string): Promise; + saveClient(client: OAuthClientInformationFull): Promise; + + // Pending federated authorization (state -> record), one-time + savePendingAuth(rec: PendingAuthRecord): Promise; + takePendingAuth(state: string): Promise; + + // MCP authorization codes, one-time + saveAuthCode(rec: AuthCodeRecord): Promise; + takeAuthCode(code: string): Promise; + + // MCP access tokens + saveAccessToken(rec: AccessTokenRecord): Promise; + getAccessToken(token: string): Promise; + deleteAccessToken(token: string): Promise; + + // MCP refresh tokens, one-time (rotated on use) + saveRefreshToken(rec: RefreshTokenRecord): Promise; + takeRefreshToken(token: string): Promise; + deleteRefreshToken(token: string): Promise; +} diff --git a/tests/unit/auth/mcp-provider.test.ts b/tests/unit/auth/mcp-provider.test.ts new file mode 100644 index 0000000..554f0c2 --- /dev/null +++ b/tests/unit/auth/mcp-provider.test.ts @@ -0,0 +1,364 @@ +/** + * Unit tests for the federated OAuth core `McpOAuthProvider`. + * + * Uses a real MemoryStorage and a real Encryptor; only the YNAB network calls + * (exchangeCode / getYnabUserId / refreshAccessToken) are mocked. buildAuthorizeUrl + * is kept real so the produced YNAB authorize URL is genuinely exercised. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import type { Response } from 'express'; +import type { + OAuthClientInformationFull, + OAuthClientMetadata, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js'; + +vi.mock('../../../src/auth/ynab-oauth.js', async () => { + const actual = + await vi.importActual( + '../../../src/auth/ynab-oauth.js' + ); + return { + // Keep buildAuthorizeUrl real so we test the actual YNAB authorize URL. + ...actual, + exchangeCode: vi.fn(), + getYnabUserId: vi.fn(), + refreshAccessToken: vi.fn(), + }; +}); + +import { MemoryStorage } from '../../../src/storage/memory.js'; +import { Encryptor } from '../../../src/crypto.js'; +import { McpOAuthProvider, type McpProviderOptions } from '../../../src/auth/mcp-provider.js'; +import * as ynabOauth from '../../../src/auth/ynab-oauth.js'; + +const exchangeCodeMock = vi.mocked(ynabOauth.exchangeCode); +const getYnabUserIdMock = vi.mocked(ynabOauth.getYnabUserId); +const refreshAccessTokenMock = vi.mocked(ynabOauth.refreshAccessToken); + +/** A fake express Response capturing setHeader / send. */ +interface FakeRes { + setHeader: ReturnType; + send: ReturnType; +} + +function makeRes(): FakeRes { + return { setHeader: vi.fn(), send: vi.fn() }; +} + +function makeProvider(overrides: Partial = {}): { + provider: McpOAuthProvider; + storage: MemoryStorage; + encryptor: Encryptor; +} { + const storage = new MemoryStorage(); + const encryptor = new Encryptor(randomBytes(32).toString('base64')); + const provider = new McpOAuthProvider({ + storage, + encryptor, + ynab: { clientId: 'cid', clientSecret: 'sec' }, + publicUrl: 'https://mcp.example.com', + allowWrite: true, + globalReadOnly: false, + accessTokenTtlSec: 3600, + authCodeTtlSec: 600, + ...overrides, + }); + return { provider, storage, encryptor }; +} + +const clientMetadata: OAuthClientMetadata = { + redirect_uris: ['https://client/cb'], + client_name: 'Test Client', +}; + +const authParams: AuthorizationParams = { + redirectUri: 'https://client/cb', + codeChallenge: 'chal', + state: 'clientState', + scopes: ['ynab'], +}; + +/** Extract the pending state from the read-only link in the consent HTML. */ +function extractState(html: string): string { + // The href is HTML-escaped, so `&` appears as `&`. + const m = /state=([^&"]+)&scope=read-only/.exec(html); + if (!m || m[1] === undefined) throw new Error('no read-only state link in HTML'); + return decodeURIComponent(m[1]); +} + +beforeEach(() => { + vi.clearAllMocks(); + exchangeCodeMock.mockResolvedValue({ + accessToken: 'ya', + refreshToken: 'yr', + expiresAt: Date.now() + 7200_000, + }); + getYnabUserIdMock.mockResolvedValue('ynab-user-1'); + refreshAccessTokenMock.mockResolvedValue({ + accessToken: 'ya2', + refreshToken: 'yr2', + expiresAt: Date.now() + 7200_000, + }); +}); + +/** Register a client and drive the full flow up to (and returning) an MCP auth code. */ +async function driveToAuthCode( + provider: McpOAuthProvider, + scope: 'read-only' | 'read-write' = 'read-write' +): Promise<{ client: OAuthClientInformationFull; code: string; redirectUrl: string }> { + const client = await provider.clientsStore.registerClient(clientMetadata); + const res = makeRes(); + await provider.authorize(client, authParams, res as unknown as Response); + const html = res.send.mock.calls[0]?.[0] as string; + const state = extractState(html); + await provider.startYnabAuthorization(state, scope); + const redirectUrl = await provider.handleYnabCallback('ynabcode', state); + const code = new URL(redirectUrl).searchParams.get('code'); + if (code === null) throw new Error('no code in redirect'); + return { client, code, redirectUrl }; +} + +describe('McpOAuthProvider clientsStore', () => { + it('registers a client with a generated id and round-trips getClient', async () => { + const { provider } = makeProvider(); + const client = await provider.clientsStore.registerClient(clientMetadata); + expect(client.client_id).toBeTruthy(); + expect(typeof client.client_id).toBe('string'); + expect(client.redirect_uris).toEqual(['https://client/cb']); + expect(client.client_id_issued_at).toBeTypeOf('number'); + + const fetched = await provider.clientsStore.getClient(client.client_id); + expect(fetched).toBeDefined(); + expect(fetched?.client_id).toBe(client.client_id); + }); + + it('getClient returns undefined for an unknown client', async () => { + const { provider } = makeProvider(); + expect(await provider.clientsStore.getClient('nope')).toBeUndefined(); + }); +}); + +describe('McpOAuthProvider.authorize', () => { + it('renders the consent page with a read-only and (allowWrite) read-write link, and saves a pending auth', async () => { + const { provider } = makeProvider(); + const client = await provider.clientsStore.registerClient(clientMetadata); + const res = makeRes(); + + await provider.authorize(client, authParams, res as unknown as Response); + + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); + const html = res.send.mock.calls[0]?.[0] as string; + expect(html).toContain('scope=read-only'); + expect(html).toContain('scope=read-write'); + expect(html).toContain('Read-only'); + + // A pending auth now exists and is usable (observed via startYnabAuthorization). + const state = extractState(html); + // Proof the pending auth was persisted: it can be started (and after start, + // startYnabAuthorization consumed+re-saved it, so a fresh unknown state fails). + await expect(provider.startYnabAuthorization(state, 'read-only')).resolves.toBeTypeOf('string'); + await expect(provider.startYnabAuthorization('never-saved', 'read-only')).rejects.toThrow(); + }); + + it('omits the read-write link when allowWrite is false', async () => { + const { provider } = makeProvider({ allowWrite: false }); + const client = await provider.clientsStore.registerClient(clientMetadata); + const res = makeRes(); + await provider.authorize(client, authParams, res as unknown as Response); + const html = res.send.mock.calls[0]?.[0] as string; + expect(html).toContain('scope=read-only'); + expect(html).not.toContain('scope=read-write'); + }); +}); + +describe('McpOAuthProvider.startYnabAuthorization', () => { + it('returns a real YNAB authorize URL for the chosen scope', async () => { + const { provider } = makeProvider(); + const client = await provider.clientsStore.registerClient(clientMetadata); + const res = makeRes(); + await provider.authorize(client, authParams, res as unknown as Response); + const state = extractState(res.send.mock.calls[0]?.[0] as string); + + const url = await provider.startYnabAuthorization(state, 'read-write'); + expect(typeof url).toBe('string'); + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe('https://app.ynab.com/oauth/authorize'); + expect(parsed.searchParams.get('client_id')).toBe('cid'); + expect(parsed.searchParams.get('state')).toBe(state); + // read-write => no scope restriction sent to YNAB. + expect(parsed.searchParams.has('scope')).toBe(false); + }); + + it('forces read-only when globalReadOnly is true even if read-write is requested', async () => { + const { provider } = makeProvider({ globalReadOnly: true }); + const client = await provider.clientsStore.registerClient(clientMetadata); + const res = makeRes(); + await provider.authorize(client, authParams, res as unknown as Response); + const state = extractState(res.send.mock.calls[0]?.[0] as string); + + const url = await provider.startYnabAuthorization(state, 'read-write'); + expect(new URL(url).searchParams.get('scope')).toBe('read-only'); + }); + + it('forces read-only when allowWrite is false even if read-write is requested', async () => { + const { provider } = makeProvider({ allowWrite: false }); + const client = await provider.clientsStore.registerClient(clientMetadata); + const res = makeRes(); + await provider.authorize(client, authParams, res as unknown as Response); + const state = extractState(res.send.mock.calls[0]?.[0] as string); + + const url = await provider.startYnabAuthorization(state, 'read-write'); + expect(new URL(url).searchParams.get('scope')).toBe('read-only'); + }); + + it('throws on a bogus/expired state', async () => { + const { provider } = makeProvider(); + await expect(provider.startYnabAuthorization('bogus', 'read-only')).rejects.toThrow(); + }); +}); + +describe('McpOAuthProvider.handleYnabCallback', () => { + it('redirects back to the client with code + client state and upserts an encrypted user', async () => { + const { provider, storage, encryptor } = makeProvider(); + const { redirectUrl } = await driveToAuthCode(provider, 'read-write'); + + expect(redirectUrl.startsWith('https://client/cb')).toBe(true); + const parsed = new URL(redirectUrl); + expect(parsed.searchParams.get('code')).toBeTruthy(); + expect(parsed.searchParams.get('state')).toBe('clientState'); + + // A user was upserted with the chosen scope and an ENCRYPTED refresh token. + const user = await storage.getUser('ynab-user-1'); + expect(user).toBeDefined(); + expect(user?.grantedScope).toBe('read-write'); + expect(user?.encryptedRefreshToken).not.toBe('yr'); + expect(encryptor.decrypt(user?.encryptedRefreshToken ?? '')).toBe('yr'); + + // The YNAB calls were made with the callback URL / access token. + expect(exchangeCodeMock).toHaveBeenCalledWith( + { clientId: 'cid', clientSecret: 'sec' }, + { code: 'ynabcode', redirectUri: 'https://mcp.example.com/oauth/ynab/callback' } + ); + expect(getYnabUserIdMock).toHaveBeenCalledWith('ya', undefined); + }); + + it('records the granted scope from the read-only consent choice', async () => { + const { provider, storage } = makeProvider(); + await driveToAuthCode(provider, 'read-only'); + const user = await storage.getUser('ynab-user-1'); + expect(user?.grantedScope).toBe('read-only'); + }); + + it('throws on a bogus/expired state', async () => { + const { provider } = makeProvider(); + await expect(provider.handleYnabCallback('c', 'bogus')).rejects.toThrow(); + }); +}); + +describe('McpOAuthProvider token issuance', () => { + it('challenge + exchange issues bearer tokens and the code is single-use', async () => { + const { provider } = makeProvider(); + const { client, code } = await driveToAuthCode(provider); + + const challenge = await provider.challengeForAuthorizationCode(client, code); + expect(challenge).toBe('chal'); + + const tokens = await provider.exchangeAuthorizationCode(client, code); + expect(tokens.access_token).toBeTruthy(); + expect(tokens.refresh_token).toBeTruthy(); + expect(tokens.token_type).toBe('bearer'); + expect(tokens.expires_in).toBe(3600); + + // Reusing the same code fails. + await expect(provider.exchangeAuthorizationCode(client, code)).rejects.toThrow(); + }); + + it('challengeForAuthorizationCode throws for an unknown code', async () => { + const { provider } = makeProvider(); + const client = await provider.clientsStore.registerClient(clientMetadata); + await expect(provider.challengeForAuthorizationCode(client, 'nope')).rejects.toThrow(); + }); + + it('exchangeAuthorizationCode rejects a client mismatch', async () => { + const { provider } = makeProvider(); + const { code } = await driveToAuthCode(provider); + await provider.challengeForAuthorizationCode( + await provider.clientsStore.registerClient(clientMetadata), + code + ); + const other = await provider.clientsStore.registerClient(clientMetadata); + await expect(provider.exchangeAuthorizationCode(other, code)).rejects.toThrow(); + }); + + it('exchangeAuthorizationCode rejects a redirect_uri mismatch', async () => { + const { provider } = makeProvider(); + const { client, code } = await driveToAuthCode(provider); + await provider.challengeForAuthorizationCode(client, code); + await expect( + provider.exchangeAuthorizationCode(client, code, undefined, 'https://evil/cb') + ).rejects.toThrow(); + }); +}); + +describe('McpOAuthProvider.verifyAccessToken', () => { + it('returns AuthInfo with clientId and userId, and throws for unknown tokens', async () => { + const { provider } = makeProvider(); + const { client, code } = await driveToAuthCode(provider); + await provider.challengeForAuthorizationCode(client, code); + const tokens = await provider.exchangeAuthorizationCode(client, code); + + const info = await provider.verifyAccessToken(tokens.access_token); + expect(info.clientId).toBe(client.client_id); + expect(info.extra?.['userId']).toBe('ynab-user-1'); + expect(info.token).toBe(tokens.access_token); + + await expect(provider.verifyAccessToken('unknown-token')).rejects.toThrow(); + }); +}); + +describe('McpOAuthProvider.exchangeRefreshToken', () => { + it('issues new tokens and rotates (old refresh token cannot be reused)', async () => { + const { provider } = makeProvider(); + const { client, code } = await driveToAuthCode(provider); + await provider.challengeForAuthorizationCode(client, code); + const tokens = await provider.exchangeAuthorizationCode(client, code); + const refresh = tokens.refresh_token; + if (refresh === undefined) throw new Error('expected a refresh token'); + + const rotated = await provider.exchangeRefreshToken(client, refresh); + expect(rotated.access_token).toBeTruthy(); + expect(rotated.refresh_token).toBeTruthy(); + expect(rotated.refresh_token).not.toBe(refresh); + + // The consumed refresh token is single-use. + await expect(provider.exchangeRefreshToken(client, refresh)).rejects.toThrow(); + }); + + it('rejects a refresh token belonging to another client', async () => { + const { provider } = makeProvider(); + const { client, code } = await driveToAuthCode(provider); + await provider.challengeForAuthorizationCode(client, code); + const tokens = await provider.exchangeAuthorizationCode(client, code); + const refresh = tokens.refresh_token; + if (refresh === undefined) throw new Error('expected a refresh token'); + + const other = await provider.clientsStore.registerClient(clientMetadata); + await expect(provider.exchangeRefreshToken(other, refresh)).rejects.toThrow(); + }); +}); + +describe('McpOAuthProvider.revokeToken', () => { + it('revokes an access token so verifyAccessToken then throws', async () => { + const { provider } = makeProvider(); + const { client, code } = await driveToAuthCode(provider); + await provider.challengeForAuthorizationCode(client, code); + const tokens = await provider.exchangeAuthorizationCode(client, code); + + await expect(provider.verifyAccessToken(tokens.access_token)).resolves.toBeDefined(); + await provider.revokeToken(client, { token: tokens.access_token }); + await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow(); + }); +}); diff --git a/tests/unit/auth/user-session.test.ts b/tests/unit/auth/user-session.test.ts new file mode 100644 index 0000000..68dad2b --- /dev/null +++ b/tests/unit/auth/user-session.test.ts @@ -0,0 +1,120 @@ +/** + * Unit tests for `YnabTokenResolver`: caching, refresh-token rotation persistence, + * unknown-user handling, and cache invalidation. Only refreshAccessToken (the YNAB + * network call) is mocked. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { randomBytes } from 'node:crypto'; + +vi.mock('../../../src/auth/ynab-oauth.js', async () => { + const actual = + await vi.importActual( + '../../../src/auth/ynab-oauth.js' + ); + return { ...actual, refreshAccessToken: vi.fn() }; +}); + +import { MemoryStorage } from '../../../src/storage/memory.js'; +import { Encryptor } from '../../../src/crypto.js'; +import { YnabTokenResolver } from '../../../src/auth/user-session.js'; +import * as ynabOauth from '../../../src/auth/ynab-oauth.js'; + +const refreshAccessTokenMock = vi.mocked(ynabOauth.refreshAccessToken); + +function setup(): { + storage: MemoryStorage; + encryptor: Encryptor; + resolver: YnabTokenResolver; +} { + const storage = new MemoryStorage(); + const encryptor = new Encryptor(randomBytes(32).toString('base64')); + const resolver = new YnabTokenResolver(storage, encryptor, { + clientId: 'cid', + clientSecret: 'sec', + }); + return { storage, encryptor, resolver }; +} + +async function seedUser(storage: MemoryStorage, encryptor: Encryptor): Promise { + await storage.upsertUser({ + userId: 'u1', + grantedScope: 'read-only', + encryptedRefreshToken: encryptor.encrypt('rt0'), + updatedAt: Date.now(), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + refreshAccessTokenMock.mockResolvedValue({ + accessToken: 'access-fresh', + refreshToken: 'rt-rotated', + expiresAt: Date.now() + 3600_000, + }); +}); + +describe('YnabTokenResolver.resolve', () => { + it('refreshes once and returns the access token and granted scope', async () => { + const { storage, encryptor, resolver } = setup(); + await seedUser(storage, encryptor); + + const resolved = await resolver.resolve('u1'); + expect(resolved.accessToken).toBe('access-fresh'); + expect(resolved.grantedScope).toBe('read-only'); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + // Refresh was called with the DECRYPTED seed refresh token. + expect(refreshAccessTokenMock).toHaveBeenCalledWith( + { clientId: 'cid', clientSecret: 'sec' }, + 'rt0' + ); + }); + + it('uses the cache on a second immediate resolve (no second refresh)', async () => { + const { storage, encryptor, resolver } = setup(); + await seedUser(storage, encryptor); + + await resolver.resolve('u1'); + const again = await resolver.resolve('u1'); + expect(again.accessToken).toBe('access-fresh'); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + }); + + it('persists the rotated refresh token (encrypted) on refresh', async () => { + const { storage, encryptor, resolver } = setup(); + await seedUser(storage, encryptor); + + await resolver.resolve('u1'); + const user = await storage.getUser('u1'); + expect(user).toBeDefined(); + // Stored value is not the plaintext, and decrypts to the rotated token. + expect(user?.encryptedRefreshToken).not.toBe('rt-rotated'); + expect(encryptor.decrypt(user?.encryptedRefreshToken ?? '')).toBe('rt-rotated'); + }); + + it('throws for an unknown user', async () => { + const { resolver } = setup(); + await expect(resolver.resolve('nobody')).rejects.toThrow(/Unknown user/); + expect(refreshAccessTokenMock).not.toHaveBeenCalled(); + }); +}); + +describe('YnabTokenResolver.invalidate', () => { + it('forces a refresh on the next resolve after invalidation', async () => { + const { storage, encryptor, resolver } = setup(); + await seedUser(storage, encryptor); + + await resolver.resolve('u1'); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + + resolver.invalidate('u1'); + + await resolver.resolve('u1'); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + // The second refresh used the rotated token persisted by the first. + expect(refreshAccessTokenMock).toHaveBeenLastCalledWith( + { clientId: 'cid', clientSecret: 'sec' }, + 'rt-rotated' + ); + }); +}); diff --git a/tests/unit/auth/ynab-oauth.test.ts b/tests/unit/auth/ynab-oauth.test.ts new file mode 100644 index 0000000..ac8593a --- /dev/null +++ b/tests/unit/auth/ynab-oauth.test.ts @@ -0,0 +1,173 @@ +/** + * Unit tests for the YNAB OAuth client. `global.fetch` is mocked so no real + * network calls occur; endpoints are overridden to a mock base. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + buildAuthorizeUrl, + exchangeCode, + refreshAccessToken, + getYnabUserId, + type YnabOAuthConfig, +} from '../../../src/auth/ynab-oauth.js'; + +const cfg: YnabOAuthConfig = { + clientId: 'client-abc', + clientSecret: 'secret-xyz', + endpoints: { + authorizeUrl: 'https://mock.ynab.test/oauth/authorize', + tokenUrl: 'https://mock.ynab.test/oauth/token', + apiBaseUrl: 'https://mock.ynab.test/v1', + }, +}; + +const originalFetch = global.fetch; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +beforeEach(() => { + global.fetch = vi.fn() as unknown as typeof fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe('buildAuthorizeUrl', () => { + it('includes required params and omits scope for full access', () => { + const url = new URL( + buildAuthorizeUrl(cfg, { redirectUri: 'https://app/cb', state: 's-1', readOnly: false }) + ); + expect(url.origin + url.pathname).toBe('https://mock.ynab.test/oauth/authorize'); + expect(url.searchParams.get('client_id')).toBe('client-abc'); + expect(url.searchParams.get('redirect_uri')).toBe('https://app/cb'); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('state')).toBe('s-1'); + expect(url.searchParams.has('scope')).toBe(false); + }); + + it('includes scope=read-only when readOnly is true', () => { + const url = new URL( + buildAuthorizeUrl(cfg, { redirectUri: 'https://app/cb', state: 's-2', readOnly: true }) + ); + expect(url.searchParams.get('scope')).toBe('read-only'); + }); +}); + +describe('exchangeCode', () => { + it('POSTs the form fields and returns a token set with a future expiry', async () => { + const mock = global.fetch as unknown as ReturnType; + mock.mockResolvedValue( + jsonResponse({ + access_token: 'access-1', + token_type: 'bearer', + expires_in: 7200, + refresh_token: 'refresh-1', + }) + ); + + const before = Date.now(); + const tokens = await exchangeCode(cfg, { code: 'auth-code', redirectUri: 'https://app/cb' }); + + expect(tokens.accessToken).toBe('access-1'); + expect(tokens.refreshToken).toBe('refresh-1'); + expect(tokens.expiresAt).toBeGreaterThan(before); + expect(tokens.expiresAt).toBeLessThanOrEqual(Date.now() + 7200 * 1000); + + expect(mock).toHaveBeenCalledTimes(1); + const [calledUrl, init] = mock.mock.calls[0] as [string, RequestInit]; + expect(calledUrl).toBe('https://mock.ynab.test/oauth/token'); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['Content-Type']).toBe( + 'application/x-www-form-urlencoded' + ); + const form = new URLSearchParams(init.body as string); + expect(form.get('client_id')).toBe('client-abc'); + expect(form.get('client_secret')).toBe('secret-xyz'); + expect(form.get('redirect_uri')).toBe('https://app/cb'); + expect(form.get('grant_type')).toBe('authorization_code'); + expect(form.get('code')).toBe('auth-code'); + }); +}); + +describe('refreshAccessToken', () => { + it('POSTs the refresh grant and returns a new token set', async () => { + const mock = global.fetch as unknown as ReturnType; + mock.mockResolvedValue( + jsonResponse({ + access_token: 'access-2', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'refresh-2', + }) + ); + + const before = Date.now(); + const tokens = await refreshAccessToken(cfg, 'old-refresh'); + + expect(tokens.accessToken).toBe('access-2'); + expect(tokens.refreshToken).toBe('refresh-2'); + expect(tokens.expiresAt).toBeGreaterThan(before); + + const [, init] = mock.mock.calls[0] as [string, RequestInit]; + const form = new URLSearchParams(init.body as string); + expect(form.get('grant_type')).toBe('refresh_token'); + expect(form.get('refresh_token')).toBe('old-refresh'); + expect(form.get('client_id')).toBe('client-abc'); + expect(form.get('client_secret')).toBe('secret-xyz'); + }); +}); + +describe('getYnabUserId', () => { + it('returns the user id and sends a bearer token', async () => { + const mock = global.fetch as unknown as ReturnType; + mock.mockResolvedValue(jsonResponse({ data: { user: { id: 'user-42' } } })); + + const id = await getYnabUserId('access-token-123', cfg.endpoints); + expect(id).toBe('user-42'); + + const [calledUrl, init] = mock.mock.calls[0] as [string, RequestInit]; + expect(calledUrl).toBe('https://mock.ynab.test/v1/user'); + expect((init.headers as Record).Authorization).toBe('Bearer access-token-123'); + }); +}); + +describe('sanitized errors', () => { + it('throws a sanitized error on non-2xx without leaking the token', async () => { + const mock = global.fetch as unknown as ReturnType; + mock.mockResolvedValue( + jsonResponse({ error: 'invalid_grant', error_description: 'bad code' }, 400) + ); + + await expect( + exchangeCode(cfg, { code: 'secret-code-value', redirectUri: 'https://app/cb' }) + ).rejects.toThrow(/status 400/); + + let message = ''; + try { + mock.mockResolvedValue( + jsonResponse({ error: 'invalid_grant', error_description: 'bad code' }, 400) + ); + await refreshAccessToken(cfg, 'super-secret-refresh-token'); + } catch (e) { + message = (e as Error).message; + } + expect(message).toContain('invalid_grant'); + expect(message).toContain('bad code'); + expect(message).not.toContain('super-secret-refresh-token'); + expect(message).not.toContain('secret-xyz'); + }); + + it('throws when the user response is missing the id', async () => { + const mock = global.fetch as unknown as ReturnType; + mock.mockResolvedValue(jsonResponse({ data: { user: {} } })); + await expect(getYnabUserId('tok', cfg.endpoints)).rejects.toThrow(/user id/); + }); +}); diff --git a/tests/unit/crypto.test.ts b/tests/unit/crypto.test.ts new file mode 100644 index 0000000..f27d070 --- /dev/null +++ b/tests/unit/crypto.test.ts @@ -0,0 +1,52 @@ +/** + * At-rest encryption (AES-256-GCM) round-trip + validation. + */ + +import { describe, it, expect } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { Encryptor, encrypt, decrypt, parseEncryptionKey } from '../../src/crypto.js'; + +const keyB64 = randomBytes(32).toString('base64'); + +describe('crypto', () => { + it('round-trips a secret', () => { + const enc = new Encryptor(keyB64); + const secret = 'ynab-refresh-token-abc123'; + const token = enc.encrypt(secret); + expect(token).not.toContain(secret); + expect(token.startsWith('v1.')).toBe(true); + expect(enc.decrypt(token)).toBe(secret); + }); + + it('produces distinct ciphertexts for the same plaintext (random IV)', () => { + const enc = new Encryptor(keyB64); + expect(enc.encrypt('same')).not.toBe(enc.encrypt('same')); + }); + + it('accepts a 64-char hex key', () => { + const hex = randomBytes(32).toString('hex'); + const enc = new Encryptor(hex); + expect(enc.decrypt(enc.encrypt('x'))).toBe('x'); + }); + + it('rejects a missing or wrong-length key', () => { + expect(() => parseEncryptionKey(undefined)).toThrow(/required/i); + expect(() => parseEncryptionKey(Buffer.from('short').toString('base64'))).toThrow(/32 bytes/); + }); + + it('fails to decrypt tampered ciphertext', () => { + const key = parseEncryptionKey(keyB64); + const token = encrypt('secret', key); + const parts = token.split('.'); + // Flip a byte in the ciphertext segment. + const ctBuf = Buffer.from(parts[3] as string, 'base64url'); + ctBuf[0] = ctBuf[0]! ^ 0xff; + const tampered = [parts[0], parts[1], parts[2], ctBuf.toString('base64url')].join('.'); + expect(() => decrypt(tampered, key)).toThrow(); + }); + + it('rejects malformed tokens', () => { + const key = parseEncryptionKey(keyB64); + expect(() => decrypt('not-a-token', key)).toThrow(/format/i); + }); +}); diff --git a/tests/unit/http-oauth.test.ts b/tests/unit/http-oauth.test.ts new file mode 100644 index 0000000..2ac83fa --- /dev/null +++ b/tests/unit/http-oauth.test.ts @@ -0,0 +1,96 @@ +/** + * HTTP transport in OAuth mode: health advertises the mode, the /mcp endpoint is + * bearer-protected, and the AS metadata endpoint is served. Full MCP tool calls + * are out of scope here. The YNAB network fns are mocked so no real calls occur. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import request from 'supertest'; + +vi.mock('../../src/auth/ynab-oauth.js', async () => { + const actual = + await vi.importActual( + '../../src/auth/ynab-oauth.js' + ); + return { + ...actual, + exchangeCode: vi.fn(async () => ({ + accessToken: 'ya', + refreshToken: 'yr', + expiresAt: Date.now() + 7200_000, + })), + getYnabUserId: vi.fn(async () => 'ynab-user-1'), + refreshAccessToken: vi.fn(async () => ({ + accessToken: 'ya2', + refreshToken: 'yr2', + expiresAt: Date.now() + 7200_000, + })), + }; +}); + +import { createHttpApp } from '../../src/http.js'; +import type { HttpConfig } from '../../src/config/environment.js'; + +function makeConfig(overrides: Partial = {}): HttpConfig { + return { + port: 0, + publicUrl: 'https://mcp.example.com', + allowedHosts: undefined, + allowedOrigins: undefined, + enableDnsRebindingProtection: false, + fallbackAccessToken: undefined, + defaultBudgetId: undefined, + readOnly: false, + cacheTtlMs: 300000, + rateLimitPerHour: 180, + authMode: 'oauth', + oauthClientId: 'cid', + oauthClientSecret: 'sec', + encryptionKey: randomBytes(32).toString('base64'), + allowWrite: true, + accessTokenTtlSec: 3600, + authCodeTtlSec: 600, + storageDriver: 'memory', + sqlitePath: undefined, + databaseUrl: undefined, + ...overrides, + }; +} + +const initializeBody = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + }, +}; + +describe('HTTP transport (oauth mode)', () => { + it('GET /health advertises authMode oauth', async () => { + const app = await createHttpApp(makeConfig()); + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body.authMode).toBe('oauth'); + }); + + it('POST /mcp initialize without a bearer token → 401', async () => { + const app = await createHttpApp(makeConfig()); + const res = await request(app) + .post('/mcp') + .set('Accept', 'application/json, text/event-stream') + .send(initializeBody); + expect(res.status).toBe(401); + }); + + it('GET /.well-known/oauth-authorization-server returns metadata with an issuer', async () => { + const app = await createHttpApp(makeConfig()); + const res = await request(app).get('/.well-known/oauth-authorization-server'); + expect(res.status).toBe(200); + expect(res.body.issuer).toBeTruthy(); + }); +}); diff --git a/tests/unit/http.test.ts b/tests/unit/http.test.ts index 06b82d4..38520ea 100644 --- a/tests/unit/http.test.ts +++ b/tests/unit/http.test.ts @@ -20,6 +20,16 @@ function makeConfig(overrides: Partial = {}): HttpConfig { readOnly: true, cacheTtlMs: 300000, rateLimitPerHour: 180, + authMode: 'header', + oauthClientId: undefined, + oauthClientSecret: undefined, + encryptionKey: undefined, + allowWrite: true, + accessTokenTtlSec: 3600, + authCodeTtlSec: 600, + storageDriver: 'memory', + sqlitePath: undefined, + databaseUrl: undefined, ...overrides, }; } @@ -37,7 +47,7 @@ const initializeBody = { describe('HTTP transport', () => { it('GET /health returns ok', async () => { - const app = createHttpApp(makeConfig()); + const app = await createHttpApp(makeConfig()); const res = await request(app).get('/health'); expect(res.status).toBe(200); expect(res.body.status).toBe('ok'); @@ -45,7 +55,7 @@ describe('HTTP transport', () => { }); it('POST /mcp with a non-initialize request and no session → 400', async () => { - const app = createHttpApp(makeConfig({ fallbackAccessToken: 'env-token' })); + const app = await createHttpApp(makeConfig({ fallbackAccessToken: 'env-token' })); const res = await request(app) .post('/mcp') .send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); @@ -53,25 +63,25 @@ describe('HTTP transport', () => { }); it('POST /mcp initialize with no YNAB token → 401', async () => { - const app = createHttpApp(makeConfig({ fallbackAccessToken: undefined })); + const app = await createHttpApp(makeConfig({ fallbackAccessToken: undefined })); const res = await request(app).post('/mcp').send(initializeBody); expect(res.status).toBe(401); }); it('GET /mcp without a session id → 400', async () => { - const app = createHttpApp(makeConfig()); + const app = await createHttpApp(makeConfig()); const res = await request(app).get('/mcp'); expect(res.status).toBe(400); }); it('DELETE /mcp without a session id → 400', async () => { - const app = createHttpApp(makeConfig()); + const app = await createHttpApp(makeConfig()); const res = await request(app).delete('/mcp'); expect(res.status).toBe(400); }); it('POST /mcp initialize with a token opens a session (returns Mcp-Session-Id)', async () => { - const app = createHttpApp(makeConfig({ fallbackAccessToken: 'env-token' })); + const app = await createHttpApp(makeConfig({ fallbackAccessToken: 'env-token' })); const res = await request(app) .post('/mcp') .set('Accept', 'application/json, text/event-stream') @@ -83,7 +93,7 @@ describe('HTTP transport', () => { }); it('DELETE /mcp with a valid session id tears the session down', async () => { - const app = createHttpApp(makeConfig({ fallbackAccessToken: 'env-token' })); + const app = await createHttpApp(makeConfig({ fallbackAccessToken: 'env-token' })); const init = await request(app) .post('/mcp') .set('Accept', 'application/json, text/event-stream') diff --git a/tests/unit/storage/storage.test.ts b/tests/unit/storage/storage.test.ts new file mode 100644 index 0000000..76c48d4 --- /dev/null +++ b/tests/unit/storage/storage.test.ts @@ -0,0 +1,292 @@ +/** + * Shared conformance suite for the Storage interface. + * + * The `runStorageConformance` function is parameterized by a Storage factory and + * run against every durable and in-memory adapter, so all drivers are held to + * identical semantics: user CRUD, client persistence, one-time take semantics for + * pending-auth / auth-codes / refresh-tokens, and access-token expiry. + */ + +import { describe, it, expect, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { + Storage, + UserRecord, + AuthCodeRecord, + AccessTokenRecord, + RefreshTokenRecord, + PendingAuthRecord, +} from '../../../src/storage/types.js'; +import { MemoryStorage } from '../../../src/storage/memory.js'; + +function makeUser(overrides: Partial = {}): UserRecord { + return { + userId: `user-${randomUUID()}`, + grantedScope: 'read-write', + encryptedRefreshToken: 'enc-refresh-token', + updatedAt: Date.now(), + ...overrides, + }; +} + +function makeClient(overrides: Partial = {}): OAuthClientInformationFull { + return { + client_id: `client-${randomUUID()}`, + client_name: 'Test Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code', 'refresh_token'], + ...overrides, + } as OAuthClientInformationFull; +} + +function makePending(overrides: Partial = {}): PendingAuthRecord { + return { + state: `state-${randomUUID()}`, + clientId: 'client-1', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + clientState: 'client-echo-state', + scopes: ['read', 'write'], + grantedScope: 'read-write', + expiresAt: Date.now() + 60_000, + ...overrides, + }; +} + +function makeAuthCode(overrides: Partial = {}): AuthCodeRecord { + return { + code: `code-${randomUUID()}`, + clientId: 'client-1', + userId: 'user-1', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + scopes: ['read'], + expiresAt: Date.now() + 60_000, + ...overrides, + }; +} + +function makeAccessToken(overrides: Partial = {}): AccessTokenRecord { + return { + token: `at-${randomUUID()}`, + clientId: 'client-1', + userId: 'user-1', + scopes: ['read', 'write'], + expiresAt: Date.now() + 60_000, + ...overrides, + }; +} + +function makeRefreshToken(overrides: Partial = {}): RefreshTokenRecord { + return { + token: `rt-${randomUUID()}`, + clientId: 'client-1', + userId: 'user-1', + scopes: ['read', 'write'], + ...overrides, + }; +} + +export function runStorageConformance(name: string, factory: () => Promise): void { + describe(`Storage conformance: ${name}`, () => { + let storage: Storage; + + beforeEach(async () => { + storage = await factory(); + }); + + afterAll(async () => { + await storage.close(); + }); + + describe('users', () => { + it('upserts, gets, updates, and deletes a user', async () => { + const user = makeUser(); + await storage.upsertUser(user); + + const fetched = await storage.getUser(user.userId); + expect(fetched).toEqual(user); + + const updated = makeUser({ + userId: user.userId, + grantedScope: 'read-only', + encryptedRefreshToken: 'new-token', + updatedAt: user.updatedAt + 1000, + }); + await storage.upsertUser(updated); + expect(await storage.getUser(user.userId)).toEqual(updated); + + await storage.deleteUser(user.userId); + expect(await storage.getUser(user.userId)).toBeUndefined(); + }); + + it('returns undefined for an unknown user', async () => { + expect(await storage.getUser('nope')).toBeUndefined(); + }); + }); + + describe('clients', () => { + it('saves and retrieves a client object faithfully', async () => { + const client = makeClient(); + await storage.saveClient(client); + expect(await storage.getClient(client.client_id)).toEqual(client); + }); + + it('returns undefined for an unknown client', async () => { + expect(await storage.getClient('nope')).toBeUndefined(); + }); + }); + + describe('pending auth (one-time)', () => { + it('takes a pending record exactly once', async () => { + const rec = makePending(); + await storage.savePendingAuth(rec); + + expect(await storage.takePendingAuth(rec.state)).toEqual(rec); + expect(await storage.takePendingAuth(rec.state)).toBeUndefined(); + }); + + it('returns undefined for an expired pending record', async () => { + const rec = makePending({ expiresAt: Date.now() - 1000 }); + await storage.savePendingAuth(rec); + expect(await storage.takePendingAuth(rec.state)).toBeUndefined(); + }); + + it('preserves undefined clientState', async () => { + const rec = makePending({ clientState: undefined }); + await storage.savePendingAuth(rec); + const taken = await storage.takePendingAuth(rec.state); + expect(taken?.clientState).toBeUndefined(); + }); + }); + + describe('auth codes (one-time)', () => { + it('takes an auth code exactly once', async () => { + const rec = makeAuthCode(); + await storage.saveAuthCode(rec); + + expect(await storage.takeAuthCode(rec.code)).toEqual(rec); + expect(await storage.takeAuthCode(rec.code)).toBeUndefined(); + }); + + it('returns undefined for an expired auth code', async () => { + const rec = makeAuthCode({ expiresAt: Date.now() - 1000 }); + await storage.saveAuthCode(rec); + expect(await storage.takeAuthCode(rec.code)).toBeUndefined(); + }); + }); + + describe('access tokens', () => { + it('saves and gets a valid token repeatedly', async () => { + const rec = makeAccessToken(); + await storage.saveAccessToken(rec); + expect(await storage.getAccessToken(rec.token)).toEqual(rec); + // still available on a second read (not one-time) + expect(await storage.getAccessToken(rec.token)).toEqual(rec); + }); + + it('returns undefined for an expired token', async () => { + const rec = makeAccessToken({ expiresAt: Date.now() - 1000 }); + await storage.saveAccessToken(rec); + expect(await storage.getAccessToken(rec.token)).toBeUndefined(); + }); + + it('deletes a token', async () => { + const rec = makeAccessToken(); + await storage.saveAccessToken(rec); + await storage.deleteAccessToken(rec.token); + expect(await storage.getAccessToken(rec.token)).toBeUndefined(); + }); + }); + + describe('refresh tokens (one-time / rotation)', () => { + it('takes a refresh token exactly once', async () => { + const rec = makeRefreshToken(); + await storage.saveRefreshToken(rec); + + expect(await storage.takeRefreshToken(rec.token)).toEqual(rec); + expect(await storage.takeRefreshToken(rec.token)).toBeUndefined(); + }); + + it('supports rotation: old token invalid, new token valid', async () => { + const oldToken = makeRefreshToken(); + await storage.saveRefreshToken(oldToken); + + const taken = await storage.takeRefreshToken(oldToken.token); + expect(taken).toEqual(oldToken); + + // rotate in a new token + const newToken = makeRefreshToken({ token: `rt-${randomUUID()}` }); + await storage.saveRefreshToken(newToken); + + expect(await storage.takeRefreshToken(oldToken.token)).toBeUndefined(); + expect(await storage.takeRefreshToken(newToken.token)).toEqual(newToken); + }); + + it('deletes a refresh token', async () => { + const rec = makeRefreshToken(); + await storage.saveRefreshToken(rec); + await storage.deleteRefreshToken(rec.token); + expect(await storage.takeRefreshToken(rec.token)).toBeUndefined(); + }); + }); + }); +} + +// --- MemoryStorage (always) --- +runStorageConformance('MemoryStorage', async () => { + const s = new MemoryStorage(); + await s.init(); + return s; +}); + +// --- SqliteStorage (skipped if better-sqlite3 can't be loaded/built) --- +// Detect availability synchronously at collection time (no top-level await, which +// would change vitest's file scheduling) so the describe below can be skipped. +const sqliteAvailable = ((): boolean => { + try { + // Actually load (not just resolve) so an unbuildable native addon → skip. + createRequire(import.meta.url)('better-sqlite3'); + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!sqliteAvailable)('SqliteStorage suite', () => { + const sqliteFiles: string[] = []; + + runStorageConformance('SqliteStorage', async () => { + const { SqliteStorage } = await import('../../../src/storage/sqlite.js'); + const file = join(tmpdir(), `ynab-mcp-sqlite-${randomUUID()}.db`); + sqliteFiles.push(file); + const s = new SqliteStorage({ path: file }); + await s.init(); + return s; + }); + + afterAll(() => { + for (const f of sqliteFiles) { + rmSync(f, { force: true }); + rmSync(`${f}-wal`, { force: true }); + rmSync(`${f}-shm`, { force: true }); + } + }); +}); + +// --- PostgresStorage (only when DATABASE_URL is set) --- +describe.skipIf(!process.env.DATABASE_URL)('PostgresStorage suite', () => { + runStorageConformance('PostgresStorage', async () => { + const { PostgresStorage } = await import('../../../src/storage/postgres.js'); + const connectionString = process.env.DATABASE_URL; + if (!connectionString) throw new Error('DATABASE_URL not set'); + const s = new PostgresStorage({ connectionString }); + await s.init(); + return s; + }); +});