u
This commit is contained in:
@@ -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}` |
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
15
docs/api/routes/.well-known/oauth-authorization-server.md
Normal file
15
docs/api/routes/.well-known/oauth-authorization-server.md
Normal 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`._
|
||||||
15
docs/api/routes/.well-known/oauth-protected-resource.md
Normal file
15
docs/api/routes/.well-known/oauth-protected-resource.md
Normal 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
21
docs/api/routes/mcp.md
Normal 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`._
|
||||||
32
docs/api/routes/oauth/authorize.md
Normal file
32
docs/api/routes/oauth/authorize.md
Normal 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`._
|
||||||
15
docs/api/routes/oauth/introspect.md
Normal file
15
docs/api/routes/oauth/introspect.md
Normal 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`._
|
||||||
31
docs/api/routes/oauth/register.md
Normal file
31
docs/api/routes/oauth/register.md
Normal 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`._
|
||||||
30
docs/api/routes/oauth/token.md
Normal file
30
docs/api/routes/oauth/token.md
Normal 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
212
package-lock.json
generated
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
761
src/api/routes/mcp.js
Normal 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 & 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
51
src/db/mcp.js
Normal file
51
src/db/mcp.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ const STORE_NAMESPACES = [
|
|||||||
'package',
|
'package',
|
||||||
'articlelist',
|
'articlelist',
|
||||||
'optionslist',
|
'optionslist',
|
||||||
|
'mcp',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user