From 3d009ba216863f8c6d8b0e54caf32114af988d72 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 12 Oct 2026 22:32:18 +0200 Subject: [PATCH] u --- docs/api/README.md | 7 + docs/api/routes-all.md | 32 +- docs/api/routes.md | 32 +- .../.well-known/oauth-authorization-server.md | 15 + .../.well-known/oauth-protected-resource.md | 15 + docs/api/routes/mcp.md | 21 + docs/api/routes/oauth/authorize.md | 32 + docs/api/routes/oauth/introspect.md | 15 + docs/api/routes/oauth/register.md | 31 + docs/api/routes/oauth/token.md | 30 + package-lock.json | 212 ++++- package.json | 4 +- src/api/index.js | 14 +- src/api/middleware/auth.js | 10 +- src/api/routes/mcp.js | 761 ++++++++++++++++++ src/db/mcp.js | 51 ++ src/db/store.js | 1 + 17 files changed, 1272 insertions(+), 11 deletions(-) create mode 100644 docs/api/routes/.well-known/oauth-authorization-server.md create mode 100644 docs/api/routes/.well-known/oauth-protected-resource.md create mode 100644 docs/api/routes/mcp.md create mode 100644 docs/api/routes/oauth/authorize.md create mode 100644 docs/api/routes/oauth/introspect.md create mode 100644 docs/api/routes/oauth/register.md create mode 100644 docs/api/routes/oauth/token.md create mode 100644 src/api/routes/mcp.js create mode 100644 src/db/mcp.js diff --git a/docs/api/README.md b/docs/api/README.md index a716984..5bde1dd 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -32,6 +32,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | File | Endpoint | |------|----------| +| [`routes/.well-known/oauth-authorization-server.md`](routes/.well-known/oauth-authorization-server.md) | `GET /.well-known/oauth-authorization-server` | +| [`routes/.well-known/oauth-protected-resource.md`](routes/.well-known/oauth-protected-resource.md) | `GET /.well-known/oauth-protected-resource` | | [`routes/account.get.md`](routes/account.get.md) | `GET /account` | | [`routes/account.post.md`](routes/account.post.md) | `POST /account` | | [`routes/account/_id.md`](routes/account/_id.md) | `DELETE /account/{id}` | @@ -155,6 +157,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/ledger.post.md`](routes/ledger.post.md) | `POST /ledger` | | [`routes/ledger/manifestarticle/_manifestarticleId.md`](routes/ledger/manifestarticle/_manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` | | [`routes/ledger/sku/_sku.md`](routes/ledger/sku/_sku.md) | `GET /ledger/sku/{sku}` | +| [`routes/mcp.md`](routes/mcp.md) | `POST /mcp` | | [`routes/modelconversation.get.md`](routes/modelconversation.get.md) | `GET /modelconversation` | | [`routes/modelconversation.post.md`](routes/modelconversation.post.md) | `POST /modelconversation` | | [`routes/modelconversation/_id.delete.md`](routes/modelconversation/_id.delete.md) | `DELETE /modelconversation/{id}` | @@ -164,6 +167,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/modelconversation/_id/stream.md`](routes/modelconversation/_id/stream.md) | `GET /modelconversation/{id}/stream` | | [`routes/number/list.md`](routes/number/list.md) | `GET /number` | | [`routes/number/update.md`](routes/number/update.md) | `PATCH /number/{id}` | +| [`routes/oauth/authorize.md`](routes/oauth/authorize.md) | `GET /oauth/authorize` | +| [`routes/oauth/introspect.md`](routes/oauth/introspect.md) | `POST /oauth/introspect` | +| [`routes/oauth/register.md`](routes/oauth/register.md) | `POST /oauth/register` | +| [`routes/oauth/token.md`](routes/oauth/token.md) | `POST /oauth/token` | | [`routes/order/list.md`](routes/order/list.md) | `GET /order` | | [`routes/order/create.md`](routes/order/create.md) | `POST /order` | | [`routes/order/delete.md`](routes/order/delete.md) | `DELETE /order/{id}` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index db0ce4c..2793599 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 216 API endpoints across all modules. +Overview of all 223 API endpoints across all modules. ### Notation @@ -63,6 +63,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). ### Routes +### Section: /.well-known + +#### OAuth 2.0 Authorization Server Metadata (RFC 8414) — `GET /.well-known/oauth-authorization-server` +_none_ +- Returns: `200` +#### OAuth 2.0 Protected Resource Metadata (RFC 9728) — `GET /.well-known/oauth-protected-resource` +_none_ +- Returns: `200` + ### Section: /account #### List accounts — `GET /account` @@ -505,6 +514,12 @@ _none_ - Params: `sku: str, query: transaction_type?: str` - Returns: `200 T16` +### Section: /mcp + +#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp` +_none_ +- Returns: `200` + ### Section: /modelconversation #### List conversations — `GET /modelconversation` @@ -538,6 +553,21 @@ _none_ - Params: `id: int, body: { prefix?: str, padding?: int, next?: int }` - Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }` +### Section: /oauth + +#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize` +- Params: `query: client_id: str, redirect_uri: str, response_type: str, code_challenge: str, code_challenge_method: str, state?: str` +- Returns: `200` +#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect` +_none_ +- Returns: `200` +#### Dynamic Client Registration (RFC 7591) — `POST /oauth/register` +- Params: `body: { redirect_uris: str[], client_name?: str }` +- Returns: `201` +#### OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) — `POST /oauth/token` +- Params: `application/x-www-form-urlencoded` +- Returns: `200` + ### Section: /order #### List orders — `GET /order` diff --git a/docs/api/routes.md b/docs/api/routes.md index 476b7aa..eaae012 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 169 API endpoints across all modules. +Overview of all 176 API endpoints across all modules. ### Notation @@ -58,6 +58,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). ### Routes +### Section: /.well-known + +#### OAuth 2.0 Authorization Server Metadata (RFC 8414) — `GET /.well-known/oauth-authorization-server` +_none_ +- Returns: `200` +#### OAuth 2.0 Protected Resource Metadata (RFC 9728) — `GET /.well-known/oauth-protected-resource` +_none_ +- Returns: `200` + ### Section: /account #### List accounts — `GET /account` @@ -430,6 +439,12 @@ _none_ - Params: `sku: str, query: transaction_type?: str` - Returns: `200 T15` +### Section: /mcp + +#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp` +_none_ +- Returns: `200` + ### Section: /number #### List number sequences — `GET /number` @@ -439,6 +454,21 @@ _none_ - Params: `id: int, body: { prefix?: str, padding?: int, next?: int }` - Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }` +### Section: /oauth + +#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize` +- Params: `query: client_id: str, redirect_uri: str, response_type: str, code_challenge: str, code_challenge_method: str, state?: str` +- Returns: `200` +#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect` +_none_ +- Returns: `200` +#### Dynamic Client Registration (RFC 7591) — `POST /oauth/register` +- Params: `body: { redirect_uris: str[], client_name?: str }` +- Returns: `201` +#### OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) — `POST /oauth/token` +- Params: `application/x-www-form-urlencoded` +- Returns: `200` + ### Section: /order #### List orders — `GET /order` diff --git a/docs/api/routes/.well-known/oauth-authorization-server.md b/docs/api/routes/.well-known/oauth-authorization-server.md new file mode 100644 index 0000000..1853dde --- /dev/null +++ b/docs/api/routes/.well-known/oauth-authorization-server.md @@ -0,0 +1,15 @@ +# `GET /.well-known/oauth-authorization-server` + +Public. No cookie required. + +OAuth 2.0 Authorization Server Metadata (RFC 8414) + +Returns OAuth 2.0 authorization server endpoints and capabilities. + +## Response `200` + +Authorization Server Metadata + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/.well-known/oauth-protected-resource.md b/docs/api/routes/.well-known/oauth-protected-resource.md new file mode 100644 index 0000000..91eb557 --- /dev/null +++ b/docs/api/routes/.well-known/oauth-protected-resource.md @@ -0,0 +1,15 @@ +# `GET /.well-known/oauth-protected-resource` + +Public. No cookie required. + +OAuth 2.0 Protected Resource Metadata (RFC 9728) + +Returns protected resource metadata discovering the authorization server for the MCP endpoint. + +## Response `200` + +Protected Resource Metadata + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/mcp.md b/docs/api/routes/mcp.md new file mode 100644 index 0000000..99bbe99 --- /dev/null +++ b/docs/api/routes/mcp.md @@ -0,0 +1,21 @@ +# `POST /mcp` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Model Context Protocol (MCP) Streamable HTTP endpoint + +Handles MCP JSON-RPC protocol requests and streamable sessions. Protected by Bearer token authorization. + +## Response `200` + +MCP JSON-RPC response + +## Errors + +| Status | Description | +|--------|-------------| +| 401 | Missing or invalid authorization token | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/oauth/authorize.md b/docs/api/routes/oauth/authorize.md new file mode 100644 index 0000000..6487644 --- /dev/null +++ b/docs/api/routes/oauth/authorize.md @@ -0,0 +1,32 @@ +# `GET /oauth/authorize` + +Public. No cookie required. + +OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) + +Authorization prompt for end-users to grant access to an MCP client. + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `client_id` | yes | string | | +| `redirect_uri` | yes | string | | +| `response_type` | yes | string | | +| `code_challenge` | yes | string | | +| `code_challenge_method` | yes | string | | +| `state` | no | string | | + +## Response `200` + +Authorization / Consent HTML form + +## Errors + +| Status | Description | +|--------|-------------| +| 302 | Redirect back to client callback | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/oauth/introspect.md b/docs/api/routes/oauth/introspect.md new file mode 100644 index 0000000..9696881 --- /dev/null +++ b/docs/api/routes/oauth/introspect.md @@ -0,0 +1,15 @@ +# `POST /oauth/introspect` + +Public. No cookie required. + +OAuth 2.0 Token Introspection (RFC 7662) + +Verifies the validity of an issued access token. + +## Response `200` + +Token status and metadata + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/oauth/register.md b/docs/api/routes/oauth/register.md new file mode 100644 index 0000000..5ce656f --- /dev/null +++ b/docs/api/routes/oauth/register.md @@ -0,0 +1,31 @@ +# `POST /oauth/register` + +Public. No cookie required. + +Dynamic Client Registration (RFC 7591) + +Registers a new OAuth MCP client dynamically. + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `client_name` | no | string | | +| `redirect_uris` | yes | string[] | | + +```json +{ + "client_name": "…", + "redirect_uris": [ + "…" + ] +} +``` + +## Response `201` + +Registered client credentials + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/oauth/token.md b/docs/api/routes/oauth/token.md new file mode 100644 index 0000000..1f978ba --- /dev/null +++ b/docs/api/routes/oauth/token.md @@ -0,0 +1,30 @@ +# `POST /oauth/token` + +Public. No cookie required. + +OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) + +Exchanges authorization code with PKCE for an access token, or refreshes an expired token. + +## Body + +`application/x-www-form-urlencoded` + +```json +{ + "grant_type": "authorization_code", + "code": "…", + "redirect_uri": "…", + "client_id": "…", + "code_verifier": "…", + "refresh_token": "…" +} +``` + +## Response `200` + +Issued access token and refresh token + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/package-lock.json b/package-lock.json index 50efd90..c9705d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@modelcontextprotocol/sdk": "^1.32.1", "better-sqlite3": "^13.0.3", "chalk": "^6.0.1", "cookie-parser": "^1.4.7", @@ -32,7 +33,8 @@ "sharp": "^0.35.5", "skelehash-wasm": "^0.1.0", "swagger-ui-dist": "^5.33.1", - "vm2": "^3.12.2" + "vm2": "^3.12.2", + "zod": "^4.6.7" }, "devDependencies": { "acorn": "^8.19.0", @@ -198,6 +200,18 @@ "lodash.uniq": "^4.5.0" } }, + "node_modules/@hono/node-server": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz", + "integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -756,6 +770,55 @@ "node": ">=18" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -1046,7 +1109,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1074,6 +1136,23 @@ } } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1887,6 +1966,23 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -2458,6 +2554,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -2530,6 +2647,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -2568,7 +2704,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, "funding": [ { "type": "github", @@ -3082,6 +3217,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3294,6 +3438,15 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3704,6 +3857,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -3746,9 +3908,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -4354,6 +4521,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4598,6 +4774,15 @@ "png-js": "^2.0.0" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkijs": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", @@ -4966,7 +5151,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6443,6 +6627,24 @@ "engines": { "node": ">= 10" } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 59949e8..be9a7f1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.32.1", "better-sqlite3": "^13.0.3", "chalk": "^6.0.1", "cookie-parser": "^1.4.7", @@ -39,7 +40,8 @@ "sharp": "^0.35.5", "skelehash-wasm": "^0.1.0", "swagger-ui-dist": "^5.33.1", - "vm2": "^3.12.2" + "vm2": "^3.12.2", + "zod": "^4.6.7" }, "devDependencies": { "acorn": "^8.19.0", diff --git a/src/api/index.js b/src/api/index.js index 26dd1dd..bf1f00b 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -72,6 +72,17 @@ function isAllowedCorsOrigin(origin) { function corsMiddleware(req, res, next) { const origin = req.headers.origin; + if (req.path === '/mcp' || req.path.startsWith('/.well-known') || req.path.startsWith('/oauth')) { + res.setHeader('Access-Control-Allow-Origin', origin || '*'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id'); + res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); + if (req.method === 'OPTIONS') { + return res.sendStatus(204); + } + return next(); + } + if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) { res.setHeader('Access-Control-Allow-Origin', origin || '*'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); @@ -121,7 +132,7 @@ function corsMiddleware(req, res, next) { next(); } -async function buildApiApp() { +export async function buildApiApp() { const app = express(); app.use((req, res, next) => { @@ -133,6 +144,7 @@ async function buildApiApp() { }); app.use(corsMiddleware); app.use(express.json({ limit: env.bodyLimit })); + app.use(express.urlencoded({ extended: true, limit: env.bodyLimit })); app.use(cookieParser()); app.use(loggerMiddleware); app.use(authMiddleware); diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index 7869b83..e3d948c 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -63,6 +63,12 @@ export const authMiddleware = async (req, res, next) => { return next(); } + if (req.path === '/mcp' || req.path.startsWith('/.well-known') || req.path === '/oauth/register' || req.path === '/oauth/token' || req.path === '/oauth/introspect') { + return next(); + } + + const isOAuthAuthorize = req.path === '/oauth/authorize'; + const bearerToken = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7).trim() : null; @@ -70,7 +76,7 @@ export const authMiddleware = async (req, res, next) => { const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs'); if (!token) { - if (isDocs) return next(); + if (isDocs || isOAuthAuthorize) return next(); return respondWithAuthPayload(res, req, 401, { error: 'Unauthorized' }); } @@ -118,7 +124,7 @@ export const authMiddleware = async (req, res, next) => { next(); } catch { if (req.cookies.token) clearTokenCookie(res); - if (isDocs) return next(); + if (isDocs || isOAuthAuthorize) return next(); return respondWithAuthPayload(res, req, 401, { error: 'Invalid token' }); } }; diff --git a/src/api/routes/mcp.js b/src/api/routes/mcp.js new file mode 100644 index 0000000..30064e6 --- /dev/null +++ b/src/api/routes/mcp.js @@ -0,0 +1,761 @@ +import { randomUUID, randomBytes, createHash } from 'node:crypto'; +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; +import env from '../../config/env.js'; +import * as mcpDb from '../../db/mcp.js'; +import * as googleAuthDb from '../../db/googleAuth.js'; +import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from '../../tools/prompts.js'; + +const MAX_RESPONSE_CHARS = 20_000; +const REQUEST_TIMEOUT_MS = 30_000; +const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + +function getBaseUrl(req) { + const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; + const host = req.get('host') || `127.0.0.1:${env.port}`; + return `${proto}://${host}`; +} + +const tokenVerifier = { + verifyAccessToken: async (token) => { + let decoded; + try { + decoded = jwt.verify(token, env.jwtSecret); + } catch (err) { + throw new Error(`Invalid access token: ${err.message}`); + } + + const userId = Number(decoded.id || decoded.sub); + if (!Number.isInteger(userId) || userId <= 0) { + throw new Error('Invalid token subject'); + } + + const user = await googleAuthDb.getById(userId); + if (!user) { + throw new Error('User is no longer authorized'); + } + + const scopes = typeof decoded.scope === 'string' + ? decoded.scope.split(' ') + : Array.isArray(decoded.scope) + ? decoded.scope + : ['mcp:tools']; + + return { + token, + clientId: decoded.client_id || 'unknown', + scopes, + expiresAt: decoded.exp, + resource: decoded.aud, + user: { + id: user.id, + email: user.email, + selectedCompanyId: user.selectedCompanyId, + }, + }; + }, +}; + +function createDashbackMcpServer({ user }) { + const server = new McpServer({ + name: 'dashback-mcp', + version: '1.0.0', + }); + + server.registerTool( + 'dashback_request', + { + title: 'Dashback API Request', + description: TOOL_DESCRIPTIONS.dashback_request || 'Perform HTTP requests against the Dashback API', + inputSchema: { + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.method), + path: z.string().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.path), + query: z.record(z.string(), z.any()).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.query), + body: z.union([z.string(), z.record(z.string(), z.any())]).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.body), + note: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.note), + }, + }, + async ({ method = 'GET', path, query = null, body = null, note }) => { + if (!METHODS.includes(method)) throw new Error(`Unsupported method: ${method}`); + if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) { + throw new Error('path must start with a single "/"'); + } + + const token = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '5m' }); + const headers = { Authorization: `Bearer ${token}` }; + const init = { method, headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }; + if (body != null && method !== 'GET' && method !== 'DELETE') { + headers['Content-Type'] = 'application/json'; + init.body = typeof body === 'string' ? body : JSON.stringify(body); + } + + const qs = query ? `?${new URLSearchParams(query).toString()}` : ''; + const url = `${path.startsWith('/docs') || path.startsWith('/api/') ? path : `/api${path}`}${qs}`; + console.log(`[mcp] Tool invoke dashback_request: ${method} ${url}`); + const res = await fetch(`http://127.0.0.1:${env.port}${url}`, init); + let text = await res.text(); + if (text.length > MAX_RESPONSE_CHARS) { + text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`; + } + return { + content: [{ type: 'text', text: JSON.stringify({ status: res.status, body: text }) }], + }; + } + ); + + return server; +} + +const transports = {}; + +function mcpAuthMiddleware(req, res, next) { + const baseUrl = getBaseUrl(req); + const middleware = requireBearerAuth({ + verifier: tokenVerifier, + requiredScopes: [], + resourceMetadataUrl: `${baseUrl}/.well-known/oauth-protected-resource`, + }); + return middleware(req, res, next); +} + +/** + * Registers MCP server and OAuth 2.1 endpoints. + * @param {import('express').Application} app + * @param {string} [_basePath=''] + */ +export function register(app, _basePath = '') { + /** + * @openapi + * /.well-known/oauth-protected-resource: + * get: + * tags: [MCP] + * summary: OAuth 2.0 Protected Resource Metadata (RFC 9728) + * description: Returns protected resource metadata discovering the authorization server for the MCP endpoint. + * security: [] + * responses: + * 200: + * description: Protected Resource Metadata + */ + const handleProtectedResourceMetadata = (req, res) => { + const baseUrl = getBaseUrl(req); + res.json({ + resource: `${baseUrl}/mcp`, + authorization_servers: [baseUrl], + scopes_supported: ['mcp:tools'], + resource_name: 'Dashback MCP Server', + }); + }; + app.get('/.well-known/oauth-protected-resource', handleProtectedResourceMetadata); + app.get('/.well-known/oauth-protected-resource/mcp', handleProtectedResourceMetadata); + + /** + * @openapi + * /.well-known/oauth-authorization-server: + * get: + * tags: [MCP] + * summary: OAuth 2.0 Authorization Server Metadata (RFC 8414) + * description: Returns OAuth 2.0 authorization server endpoints and capabilities. + * security: [] + * responses: + * 200: + * description: Authorization Server Metadata + */ + const handleAuthServerMetadata = (req, res) => { + const baseUrl = getBaseUrl(req); + res.json({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/oauth/authorize`, + token_endpoint: `${baseUrl}/oauth/token`, + registration_endpoint: `${baseUrl}/oauth/register`, + introspection_endpoint: `${baseUrl}/oauth/introspect`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + scopes_supported: ['mcp:tools'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'], + }); + }; + app.get('/.well-known/oauth-authorization-server', handleAuthServerMetadata); + app.get('/.well-known/openid-configuration', handleAuthServerMetadata); + + /** + * @openapi + * /oauth/register: + * post: + * tags: [MCP] + * summary: Dynamic Client Registration (RFC 7591) + * description: Registers a new OAuth MCP client dynamically. + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [redirect_uris] + * properties: + * client_name: + * type: string + * redirect_uris: + * type: array + * items: + * type: string + * responses: + * 201: + * description: Registered client credentials + */ + app.post('/oauth/register', async (req, res) => { + try { + const body = req.body || {}; + const clientName = body.client_name || 'MCP Client'; + const redirectUris = body.redirect_uris; + + if (!redirectUris || (!Array.isArray(redirectUris) && typeof redirectUris !== 'string')) { + return res.status(400).json({ + error: 'invalid_client_metadata', + error_description: 'redirect_uris is required and must be an array or string', + }); + } + + const clientId = randomUUID(); + const clientSecret = randomBytes(32).toString('hex'); + const grantTypes = body.grant_types || ['authorization_code', 'refresh_token']; + const responseTypes = body.response_types || ['code']; + const scope = body.scope || 'mcp:tools'; + + const record = await mcpDb.registerClient({ + clientId, + clientSecret, + clientName, + redirectUris, + grantTypes, + responseTypes, + scope, + }); + + res.status(201).json({ + client_id: record.clientId, + client_secret: record.clientSecret, + client_name: record.clientName, + redirect_uris: Array.isArray(redirectUris) ? redirectUris : [redirectUris], + grant_types: Array.isArray(grantTypes) ? grantTypes : [grantTypes], + response_types: Array.isArray(responseTypes) ? responseTypes : [responseTypes], + scope: record.scope, + }); + } catch (err) { + console.error('[oauth] Client registration error:', err); + res.status(500).json({ error: 'server_error', error_description: err.message }); + } + }); + + /** + * @openapi + * /oauth/authorize: + * get: + * tags: [MCP] + * summary: OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) + * description: Authorization prompt for end-users to grant access to an MCP client. + * security: [] + * parameters: + * - in: query + * name: client_id + * required: true + * schema: + * type: string + * - in: query + * name: redirect_uri + * required: true + * schema: + * type: string + * - in: query + * name: response_type + * required: true + * schema: + * type: string + * enum: [code] + * - in: query + * name: code_challenge + * required: true + * schema: + * type: string + * - in: query + * name: code_challenge_method + * required: true + * schema: + * type: string + * enum: [S256] + * - in: query + * name: state + * schema: + * type: string + * responses: + * 200: + * description: Authorization / Consent HTML form + * 302: + * description: Redirect back to client callback + */ + app.get('/oauth/authorize', async (req, res) => { + const { + client_id, + redirect_uri, + response_type, + code_challenge, + code_challenge_method = 'S256', + state = '', + scope = 'mcp:tools', + } = req.query; + + if (!client_id) { + return res.status(400).send('Missing client_id'); + } + + const client = await mcpDb.getClient(client_id); + if (!client) { + return res.status(400).send(`Unknown client_id: ${client_id}`); + } + + let registeredUris = []; + try { + registeredUris = JSON.parse(client.redirectUris); + } catch { + registeredUris = [client.redirectUris]; + } + + if (!redirect_uri || !registeredUris.includes(redirect_uri)) { + return res.status(400).send('Invalid or unregistered redirect_uri'); + } + + if (response_type !== 'code') { + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set('error', 'unsupported_response_type'); + if (state) redirectUrl.searchParams.set('state', state); + return res.redirect(redirectUrl.toString()); + } + + if (!code_challenge || code_challenge_method !== 'S256') { + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set('error', 'invalid_request'); + redirectUrl.searchParams.set('error_description', 'PKCE code_challenge with method S256 is required'); + if (state) redirectUrl.searchParams.set('state', state); + return res.redirect(redirectUrl.toString()); + } + + // If user is logged in + if (req.user) { + const html = ` + + + + Authorize MCP Client + + + +
+

Authorize Application

+

${escapeHtml(client.clientName)} would like access to your Dashback MCP tools.

+
+
Authorized Account
+
${escapeHtml(req.user.email)}
+
+
+ + + + + + +
+ + +
+
+
+ +`; + return res.setHeader('Content-Type', 'text/html').send(html); + } + + // If user is not logged in: display login form with allowlisted email input + const html = ` + + + + Sign in to Authorize + + + +
+

Dashback Sign In

+

Sign in with your authorized Dashback email to grant ${escapeHtml(client.clientName)} access.

+
+ + + + + + + + +
+
+ +`; + return res.setHeader('Content-Type', 'text/html').send(html); + }); + + app.post('/oauth/authorize', async (req, res) => { + const { + client_id, + redirect_uri, + code_challenge, + code_challenge_method = 'S256', + state = '', + scope = 'mcp:tools', + action, + email, + } = req.body; + + const redirectUrl = new URL(redirect_uri); + + if (action === 'deny') { + redirectUrl.searchParams.set('error', 'access_denied'); + if (state) redirectUrl.searchParams.set('state', state); + return res.redirect(redirectUrl.toString()); + } + + let userId = req.user?.id; + if (!userId && email) { + const user = await googleAuthDb.getAllowedEmail(email.trim()); + if (!user) { + return res.status(403).send('Email is not authorized in Dashback'); + } + userId = user.id; + } + + if (!userId) { + return res.status(401).send('Not authenticated'); + } + + const code = randomBytes(32).toString('hex'); + await mcpDb.createAuthCode({ + code, + clientId: client_id, + googleAuthId: userId, + redirectUri: redirect_uri, + codeChallenge: code_challenge, + codeChallengeMethod: code_challenge_method, + scope: scope || 'mcp:tools', + expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes + }); + + redirectUrl.searchParams.set('code', code); + if (state) redirectUrl.searchParams.set('state', state); + return res.redirect(redirectUrl.toString()); + }); + + /** + * @openapi + * /oauth/token: + * post: + * tags: [MCP] + * summary: OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) + * description: Exchanges authorization code with PKCE for an access token, or refreshes an expired token. + * security: [] + * requestBody: + * required: true + * content: + * application/x-www-form-urlencoded: + * schema: + * type: object + * required: [grant_type, client_id] + * properties: + * grant_type: + * type: string + * enum: [authorization_code, refresh_token] + * code: + * type: string + * redirect_uri: + * type: string + * client_id: + * type: string + * code_verifier: + * type: string + * refresh_token: + * type: string + * responses: + * 200: + * description: Issued access token and refresh token + */ + app.post('/oauth/token', async (req, res) => { + try { + const body = req.body || {}; + const grantType = body.grant_type; + const clientId = body.client_id; + const baseUrl = getBaseUrl(req); + + if (grantType === 'authorization_code') { + const { code, redirect_uri, code_verifier } = body; + if (!code || !redirect_uri || !code_verifier || !clientId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing code, redirect_uri, client_id, or code_verifier', + }); + } + + const authCode = await mcpDb.getAuthCode(code); + if (!authCode || authCode.used || new Date(authCode.expiresAt) < new Date()) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'Authorization code is invalid or has expired', + }); + } + + if (authCode.clientId !== clientId) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'Client ID does not match authorization code', + }); + } + + if (authCode.redirectUri !== redirect_uri) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'Redirect URI does not match authorization code', + }); + } + + // PKCE S256 verification + const calculatedChallenge = createHash('sha256').update(code_verifier).digest('base64url'); + if (calculatedChallenge !== authCode.codeChallenge) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'PKCE verification failed', + }); + } + + await mcpDb.consumeAuthCode(code); + + const scope = authCode.scope || 'mcp:tools'; + const accessToken = jwt.sign( + { + sub: String(authCode.googleAuthId), + id: authCode.googleAuthId, + client_id: clientId, + scope, + aud: `${baseUrl}/mcp`, + iss: baseUrl, + }, + env.jwtSecret, + { expiresIn: '1h' } + ); + + const refreshToken = randomBytes(32).toString('hex'); + await mcpDb.createRefreshToken({ + refreshToken, + clientId, + googleAuthId: authCode.googleAuthId, + scope, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days + }); + + return res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: refreshToken, + scope, + }); + } else if (grantType === 'refresh_token') { + const { refresh_token } = body; + if (!refresh_token || !clientId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing refresh_token or client_id', + }); + } + + const tokenRow = await mcpDb.getRefreshToken(refresh_token); + if (!tokenRow || tokenRow.revoked || new Date(tokenRow.expiresAt) < new Date()) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'Refresh token is invalid or expired', + }); + } + + if (tokenRow.clientId !== clientId) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'Client ID does not match refresh token', + }); + } + + const scope = tokenRow.scope || 'mcp:tools'; + const accessToken = jwt.sign( + { + sub: String(tokenRow.googleAuthId), + id: tokenRow.googleAuthId, + client_id: clientId, + scope, + aud: `${baseUrl}/mcp`, + iss: baseUrl, + }, + env.jwtSecret, + { expiresIn: '1h' } + ); + + return res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token, + scope, + }); + } else { + return res.status(400).json({ + error: 'unsupported_grant_type', + error_description: `Grant type ${grantType} is not supported`, + }); + } + } catch (err) { + console.error('[oauth] Token endpoint error:', err); + res.status(500).json({ error: 'server_error', error_description: err.message }); + } + }); + + /** + * @openapi + * /oauth/introspect: + * post: + * tags: [MCP] + * summary: OAuth 2.0 Token Introspection (RFC 7662) + * description: Verifies the validity of an issued access token. + * security: [] + * responses: + * 200: + * description: Token status and metadata + */ + app.post('/oauth/introspect', async (req, res) => { + const { token } = req.body || {}; + if (!token) { + return res.json({ active: false }); + } + + try { + const decoded = jwt.verify(token, env.jwtSecret); + res.json({ + active: true, + client_id: decoded.client_id, + sub: decoded.sub, + scope: decoded.scope, + exp: decoded.exp, + aud: decoded.aud, + iss: decoded.iss, + }); + } catch { + res.json({ active: false }); + } + }); + + /** + * @openapi + * /mcp: + * post: + * tags: [MCP] + * summary: Model Context Protocol (MCP) Streamable HTTP endpoint + * description: Handles MCP JSON-RPC protocol requests and streamable sessions. Protected by Bearer token authorization. + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: MCP JSON-RPC response + * 401: + * description: Missing or invalid authorization token + */ + app.post('/mcp', mcpAuthMiddleware, async (req, res) => { + const sessionId = req.headers['mcp-session-id']; + let transport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId].transport; + } else if (!sessionId && isInitializeRequest(req.body)) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true, + onsessioninitialized: (newSessionId) => { + transports[newSessionId] = { + transport, + user: req.auth?.user, + }; + }, + }); + + transport.onclose = () => { + if (transport.sessionId) { + delete transports[transport.sessionId]; + } + }; + + const server = createDashbackMcpServer({ user: req.auth.user }); + await server.connect(transport); + } else { + return res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + } + + await transport.handleRequest(req, res, req.body); + }); + + const handleSessionRequest = async (req, res) => { + const sessionId = req.headers['mcp-session-id']; + if (!sessionId || !transports[sessionId]) { + return res.status(400).send('Invalid or missing session ID'); + } + const { transport } = transports[sessionId]; + await transport.handleRequest(req, res); + }; + + app.get('/mcp', mcpAuthMiddleware, handleSessionRequest); + app.delete('/mcp', mcpAuthMiddleware, handleSessionRequest); +} + +function escapeHtml(str) { + if (typeof str !== 'string') return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/db/mcp.js b/src/db/mcp.js new file mode 100644 index 0000000..994189b --- /dev/null +++ b/src/db/mcp.js @@ -0,0 +1,51 @@ +import { getStore } from './store.js'; + +export async function registerClient(input) { + const store = await getStore(); + return store.mcp.registerClient(input); +} + +export async function getClient(clientId) { + const store = await getStore(); + return (await store.mcp.getClient(clientId)) ?? null; +} + +export async function listClients() { + const store = await getStore(); + return store.mcp.listClients(); +} + +export async function deleteClient(clientId) { + const store = await getStore(); + return store.mcp.deleteClient(clientId); +} + +export async function createAuthCode(input) { + const store = await getStore(); + return store.mcp.createAuthCode(input); +} + +export async function getAuthCode(code) { + const store = await getStore(); + return (await store.mcp.getAuthCode(code)) ?? null; +} + +export async function consumeAuthCode(code) { + const store = await getStore(); + return store.mcp.consumeAuthCode(code); +} + +export async function createRefreshToken(input) { + const store = await getStore(); + return store.mcp.createRefreshToken(input); +} + +export async function getRefreshToken(refreshToken) { + const store = await getStore(); + return (await store.mcp.getRefreshToken(refreshToken)) ?? null; +} + +export async function revokeRefreshToken(refreshToken) { + const store = await getStore(); + return store.mcp.revokeRefreshToken(refreshToken); +} diff --git a/src/db/store.js b/src/db/store.js index a1b6c22..5ab221f 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -54,6 +54,7 @@ const STORE_NAMESPACES = [ 'package', 'articlelist', 'optionslist', + 'mcp', ]; /**