This commit is contained in:
seb
2026-10-12 22:32:18 +02:00
parent f743ace6af
commit 3d009ba216
17 changed files with 1272 additions and 11 deletions

View File

@@ -32,6 +32,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| File | Endpoint | | 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.get.md`](routes/account.get.md) | `GET /account` |
| [`routes/account.post.md`](routes/account.post.md) | `POST /account` | | [`routes/account.post.md`](routes/account.post.md) | `POST /account` |
| [`routes/account/_id.md`](routes/account/_id.md) | `DELETE /account/{id}` | | [`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.post.md`](routes/ledger.post.md) | `POST /ledger` |
| [`routes/ledger/manifestarticle/_manifestarticleId.md`](routes/ledger/manifestarticle/_manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` | | [`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/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.get.md`](routes/modelconversation.get.md) | `GET /modelconversation` |
| [`routes/modelconversation.post.md`](routes/modelconversation.post.md) | `POST /modelconversation` | | [`routes/modelconversation.post.md`](routes/modelconversation.post.md) | `POST /modelconversation` |
| [`routes/modelconversation/_id.delete.md`](routes/modelconversation/_id.delete.md) | `DELETE /modelconversation/{id}` | | [`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/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/list.md`](routes/number/list.md) | `GET /number` |
| [`routes/number/update.md`](routes/number/update.md) | `PATCH /number/{id}` | | [`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/list.md`](routes/order/list.md) | `GET /order` |
| [`routes/order/create.md`](routes/order/create.md) | `POST /order` | | [`routes/order/create.md`](routes/order/create.md) | `POST /order` |
| [`routes/order/delete.md`](routes/order/delete.md) | `DELETE /order/{id}` | | [`routes/order/delete.md`](routes/order/delete.md) | `DELETE /order/{id}` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 216 API endpoints across all modules. Overview of all 223 API endpoints across all modules.
### Notation ### Notation
@@ -63,6 +63,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Routes ### 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 ### Section: /account
#### List accounts — `GET /account` #### List accounts — `GET /account`
@@ -505,6 +514,12 @@ _none_
- Params: `sku: str, query: transaction_type?: str` - Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T16` - Returns: `200 T16`
### Section: /mcp
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
_none_
- Returns: `200`
### Section: /modelconversation ### Section: /modelconversation
#### List conversations — `GET /modelconversation` #### List conversations — `GET /modelconversation`
@@ -538,6 +553,21 @@ _none_
- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }` - Params: `id: int, body: { prefix?: str, padding?: int, next?: int }`
- Returns: `200 { number: { id: int, type: str, 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 ### Section: /order
#### List orders — `GET /order` #### List orders — `GET /order`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview # Dash API Routes Overview
Overview of all 169 API endpoints across all modules. Overview of all 176 API endpoints across all modules.
### Notation ### Notation
@@ -58,6 +58,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Routes ### 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 ### Section: /account
#### List accounts — `GET /account` #### List accounts — `GET /account`
@@ -430,6 +439,12 @@ _none_
- Params: `sku: str, query: transaction_type?: str` - Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T15` - Returns: `200 T15`
### Section: /mcp
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
_none_
- Returns: `200`
### Section: /number ### Section: /number
#### List number sequences — `GET /number` #### List number sequences — `GET /number`
@@ -439,6 +454,21 @@ _none_
- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }` - Params: `id: int, body: { prefix?: str, padding?: int, next?: int }`
- Returns: `200 { number: { id: int, type: str, 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 ### Section: /order
#### List orders — `GET /order` #### List orders — `GET /order`

View File

@@ -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`._

View File

@@ -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`._

21
docs/api/routes/mcp.md Normal file
View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

212
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.32.1",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"chalk": "^6.0.1", "chalk": "^6.0.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@@ -32,7 +33,8 @@
"sharp": "^0.35.5", "sharp": "^0.35.5",
"skelehash-wasm": "^0.1.0", "skelehash-wasm": "^0.1.0",
"swagger-ui-dist": "^5.33.1", "swagger-ui-dist": "^5.33.1",
"vm2": "^3.12.2" "vm2": "^3.12.2",
"zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
"acorn": "^8.19.0", "acorn": "^8.19.0",
@@ -198,6 +200,18 @@
"lodash.uniq": "^4.5.0" "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": { "node_modules/@img/colour": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -756,6 +770,55 @@
"node": ">=18" "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": { "node_modules/@noble/ciphers": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
@@ -1046,7 +1109,6 @@
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "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": { "node_modules/ansi-escapes": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
@@ -1887,6 +1966,23 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT" "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": { "node_modules/crc-32": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -2458,6 +2554,27 @@
"node": ">= 0.6" "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": { "node_modules/exceljs": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
@@ -2530,6 +2647,25 @@
"url": "https://opencollective.com/express" "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": { "node_modules/express/node_modules/cookie-signature": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -2568,7 +2704,6 @@
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -3082,6 +3217,15 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3294,6 +3438,15 @@
"node": ">= 0.4" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -3704,6 +3857,15 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/js-yaml": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
@@ -3746,9 +3908,14 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT" "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": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -4354,6 +4521,15 @@
"node": ">=0.10.0" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4598,6 +4774,15 @@
"png-js": "^2.0.0" "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": { "node_modules/pkijs": {
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz",
@@ -4966,7 +5151,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "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==", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6443,6 +6627,24 @@
"engines": { "engines": {
"node": ">= 10" "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"
}
} }
} }
} }

View File

@@ -16,6 +16,7 @@
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.32.1",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"chalk": "^6.0.1", "chalk": "^6.0.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@@ -39,7 +40,8 @@
"sharp": "^0.35.5", "sharp": "^0.35.5",
"skelehash-wasm": "^0.1.0", "skelehash-wasm": "^0.1.0",
"swagger-ui-dist": "^5.33.1", "swagger-ui-dist": "^5.33.1",
"vm2": "^3.12.2" "vm2": "^3.12.2",
"zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
"acorn": "^8.19.0", "acorn": "^8.19.0",

View File

@@ -72,6 +72,17 @@ function isAllowedCorsOrigin(origin) {
function corsMiddleware(req, res, next) { function corsMiddleware(req, res, next) {
const origin = req.headers.origin; 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')) { if (req.path.startsWith('/api/public') || req.path.startsWith('/public')) {
res.setHeader('Access-Control-Allow-Origin', origin || '*'); res.setHeader('Access-Control-Allow-Origin', origin || '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
@@ -121,7 +132,7 @@ function corsMiddleware(req, res, next) {
next(); next();
} }
async function buildApiApp() { export async function buildApiApp() {
const app = express(); const app = express();
app.use((req, res, next) => { app.use((req, res, next) => {
@@ -133,6 +144,7 @@ async function buildApiApp() {
}); });
app.use(corsMiddleware); app.use(corsMiddleware);
app.use(express.json({ limit: env.bodyLimit })); app.use(express.json({ limit: env.bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: env.bodyLimit }));
app.use(cookieParser()); app.use(cookieParser());
app.use(loggerMiddleware); app.use(loggerMiddleware);
app.use(authMiddleware); app.use(authMiddleware);

View File

@@ -63,6 +63,12 @@ export const authMiddleware = async (req, res, next) => {
return 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 ') const bearerToken = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7).trim() ? req.headers.authorization.slice(7).trim()
: null; : null;
@@ -70,7 +76,7 @@ export const authMiddleware = async (req, res, next) => {
const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs'); const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs');
if (!token) { if (!token) {
if (isDocs) return next(); if (isDocs || isOAuthAuthorize) return next();
return respondWithAuthPayload(res, req, 401, { error: 'Unauthorized' }); return respondWithAuthPayload(res, req, 401, { error: 'Unauthorized' });
} }
@@ -118,7 +124,7 @@ export const authMiddleware = async (req, res, next) => {
next(); next();
} catch { } catch {
if (req.cookies.token) clearTokenCookie(res); if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next(); if (isDocs || isOAuthAuthorize) return next();
return respondWithAuthPayload(res, req, 401, { error: 'Invalid token' }); return respondWithAuthPayload(res, req, 401, { error: 'Invalid token' });
} }
}; };

761
src/api/routes/mcp.js Normal file
View File

@@ -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 = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Authorize MCP Client</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: #1e293b; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); width: 100%; max-width: 440px; text-align: center; border: 1px solid #334155; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #38bdf8; }
p { color: #94a3b8; font-size: 0.95rem; line-height: 1.5; }
.user-box { background: #0f172a; border-radius: 8px; padding: 0.75rem 1rem; margin: 1.25rem 0; text-align: left; border: 1px solid #334155; }
.user-email { font-weight: 600; color: #f1f5f9; }
.btn-row { display: flex; gap: 1rem; margin-top: 1.5rem; }
button { flex: 1; padding: 0.75rem 1rem; border-radius: 6px; font-weight: 600; cursor: pointer; border: none; font-size: 0.95rem; transition: background 0.2s; }
.btn-allow { background: #0284c7; color: #fff; }
.btn-allow:hover { background: #0369a1; }
.btn-deny { background: #334155; color: #94a3b8; }
.btn-deny:hover { background: #475569; color: #f8fafc; }
</style>
</head>
<body>
<div class="card">
<h1>Authorize Application</h1>
<p><strong>${escapeHtml(client.clientName)}</strong> would like access to your Dashback MCP tools.</p>
<div class="user-box">
<div style="font-size:0.75rem; color:#64748b; text-transform:uppercase; letter-spacing:0.05em;">Authorized Account</div>
<div class="user-email">${escapeHtml(req.user.email)}</div>
</div>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}">
<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}">
<input type="hidden" name="state" value="${escapeHtml(state)}">
<input type="hidden" name="scope" value="${escapeHtml(scope)}">
<div class="btn-row">
<button type="submit" name="action" value="deny" class="btn-deny">Cancel</button>
<button type="submit" name="action" value="allow" class="btn-allow">Authorize</button>
</div>
</form>
</div>
</body>
</html>`;
return res.setHeader('Content-Type', 'text/html').send(html);
}
// If user is not logged in: display login form with allowlisted email input
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Sign in to Authorize</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: #1e293b; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); width: 100%; max-width: 440px; text-align: center; border: 1px solid #334155; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #38bdf8; }
p { color: #94a3b8; font-size: 0.95rem; line-height: 1.5; margin-bottom: 1.5rem; }
input[type="email"] { width: 100%; box-sizing: border-box; padding: 0.75rem 1rem; border-radius: 6px; border: 1px solid #334155; background: #0f172a; color: #f8fafc; font-size: 1rem; margin-bottom: 1.25rem; }
input[type="email"]:focus { outline: none; border-color: #38bdf8; }
button { width: 100%; padding: 0.75rem 1rem; border-radius: 6px; font-weight: 600; cursor: pointer; border: none; font-size: 0.95rem; background: #0284c7; color: #fff; transition: background 0.2s; }
button:hover { background: #0369a1; }
</style>
</head>
<body>
<div class="card">
<h1>Dashback Sign In</h1>
<p>Sign in with your authorized Dashback email to grant <strong>${escapeHtml(client.clientName)}</strong> access.</p>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}">
<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}">
<input type="hidden" name="state" value="${escapeHtml(state)}">
<input type="hidden" name="scope" value="${escapeHtml(scope)}">
<input type="email" name="email" placeholder="user@example.com" required autofocus>
<button type="submit" name="action" value="allow">Sign In &amp; Authorize</button>
</form>
</div>
</body>
</html>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

51
src/db/mcp.js Normal file
View File

@@ -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);
}

View File

@@ -54,6 +54,7 @@ const STORE_NAMESPACES = [
'package', 'package',
'articlelist', 'articlelist',
'optionslist', 'optionslist',
'mcp',
]; ];
/** /**