u
This commit is contained in:
@@ -158,6 +158,12 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
|||||||
| [`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/mcp.md`](routes/mcp.md) | `POST /mcp` |
|
||||||
|
| [`routes/mcp/authorizations.md`](routes/mcp/authorizations.md) | `GET /mcp/authorizations` |
|
||||||
|
| [`routes/mcp/authorizations/_clientId.md`](routes/mcp/authorizations/_clientId.md) | `DELETE /mcp/authorizations/{clientId}` |
|
||||||
|
| [`routes/mcp/authorize.get.md`](routes/mcp/authorize.get.md) | `GET /mcp/authorize` |
|
||||||
|
| [`routes/mcp/authorize.post.md`](routes/mcp/authorize.post.md) | `POST /mcp/authorize` |
|
||||||
|
| [`routes/mcp/clients.md`](routes/mcp/clients.md) | `GET /mcp/clients` |
|
||||||
|
| [`routes/mcp/clients/_clientId.md`](routes/mcp/clients/_clientId.md) | `DELETE /mcp/clients/{clientId}` |
|
||||||
| [`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}` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 223 API endpoints across all modules.
|
Overview of all 229 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -519,6 +519,24 @@ _none_
|
|||||||
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
|
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200`
|
- Returns: `200`
|
||||||
|
#### List active MCP client authorizations — `GET /mcp/authorizations`
|
||||||
|
_none_
|
||||||
|
- Returns: `200`
|
||||||
|
#### Revoke MCP client authorizations — `DELETE /mcp/authorizations/{clientId}`
|
||||||
|
- Params: `clientId: str`
|
||||||
|
- Returns: `200`
|
||||||
|
#### Get MCP authorization prompt details — `GET /mcp/authorize`
|
||||||
|
- Params: `query: client_id: str, redirect_uri: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str`
|
||||||
|
- Returns: `200`
|
||||||
|
#### Grant or deny MCP client authorization — `POST /mcp/authorize`
|
||||||
|
- Params: `body: { client_id: str, redirect_uri: str, action: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str }`
|
||||||
|
- Returns: `200`
|
||||||
|
#### List registered MCP clients — `GET /mcp/clients`
|
||||||
|
_none_
|
||||||
|
- Returns: `200`
|
||||||
|
#### Delete registered MCP client — `DELETE /mcp/clients/{clientId}`
|
||||||
|
- Params: `clientId: str`
|
||||||
|
- Returns: `200`
|
||||||
|
|
||||||
### Section: /modelconversation
|
### Section: /modelconversation
|
||||||
|
|
||||||
@@ -556,8 +574,8 @@ _none_
|
|||||||
### Section: /oauth
|
### Section: /oauth
|
||||||
|
|
||||||
#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize`
|
#### 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`
|
- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str`
|
||||||
- Returns: `200`
|
_no response body_
|
||||||
#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect`
|
#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200`
|
- Returns: `200`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dash API Routes Overview
|
# Dash API Routes Overview
|
||||||
|
|
||||||
Overview of all 176 API endpoints across all modules.
|
Overview of all 182 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -444,6 +444,24 @@ _none_
|
|||||||
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
|
#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200`
|
- Returns: `200`
|
||||||
|
#### List active MCP client authorizations — `GET /mcp/authorizations`
|
||||||
|
_none_
|
||||||
|
- Returns: `200`
|
||||||
|
#### Revoke MCP client authorizations — `DELETE /mcp/authorizations/{clientId}`
|
||||||
|
- Params: `clientId: str`
|
||||||
|
- Returns: `200`
|
||||||
|
#### Get MCP authorization prompt details — `GET /mcp/authorize`
|
||||||
|
- Params: `query: client_id: str, redirect_uri: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str`
|
||||||
|
- Returns: `200`
|
||||||
|
#### Grant or deny MCP client authorization — `POST /mcp/authorize`
|
||||||
|
- Params: `body: { client_id: str, redirect_uri: str, action: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str }`
|
||||||
|
- Returns: `200`
|
||||||
|
#### List registered MCP clients — `GET /mcp/clients`
|
||||||
|
_none_
|
||||||
|
- Returns: `200`
|
||||||
|
#### Delete registered MCP client — `DELETE /mcp/clients/{clientId}`
|
||||||
|
- Params: `clientId: str`
|
||||||
|
- Returns: `200`
|
||||||
|
|
||||||
### Section: /number
|
### Section: /number
|
||||||
|
|
||||||
@@ -457,8 +475,8 @@ _none_
|
|||||||
### Section: /oauth
|
### Section: /oauth
|
||||||
|
|
||||||
#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize`
|
#### 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`
|
- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str`
|
||||||
- Returns: `200`
|
_no response body_
|
||||||
#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect`
|
#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200`
|
- Returns: `200`
|
||||||
|
|||||||
21
docs/api/routes/mcp/authorizations.md
Normal file
21
docs/api/routes/mcp/authorizations.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# `GET /mcp/authorizations`
|
||||||
|
|
||||||
|
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||||
|
|
||||||
|
List active MCP client authorizations
|
||||||
|
|
||||||
|
Lists all MCP clients authorized by the authenticated user with token counts and authorization timestamps.
|
||||||
|
|
||||||
|
## Response `200`
|
||||||
|
|
||||||
|
List of active user authorizations
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| 401 | User not authenticated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||||
28
docs/api/routes/mcp/authorizations/_clientId.md
Normal file
28
docs/api/routes/mcp/authorizations/_clientId.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# `DELETE /mcp/authorizations/{clientId}`
|
||||||
|
|
||||||
|
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||||
|
|
||||||
|
Revoke MCP client authorizations
|
||||||
|
|
||||||
|
Revokes all tokens and authorizations granted to an MCP client by the authenticated user.
|
||||||
|
|
||||||
|
## Path parameters
|
||||||
|
|
||||||
|
| Param | Required | Type | Description |
|
||||||
|
|-------|----------|------|-------------|
|
||||||
|
| `clientId` | yes | string | |
|
||||||
|
|
||||||
|
## Response `200`
|
||||||
|
|
||||||
|
Authorizations revoked
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| 400 | Missing clientId |
|
||||||
|
| 401 | User not authenticated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||||
35
docs/api/routes/mcp/authorize.get.md
Normal file
35
docs/api/routes/mcp/authorize.get.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# `GET /mcp/authorize`
|
||||||
|
|
||||||
|
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||||
|
|
||||||
|
Get MCP authorization prompt details
|
||||||
|
|
||||||
|
Fetches client information and current user context for the frontend MCP authorization consent screen.
|
||||||
|
|
||||||
|
## Query parameters
|
||||||
|
|
||||||
|
| Param | Required | Type | Description |
|
||||||
|
|-------|----------|------|-------------|
|
||||||
|
| `client_id` | yes | string | |
|
||||||
|
| `redirect_uri` | yes | string | |
|
||||||
|
| `response_type` | no | string | |
|
||||||
|
| `code_challenge` | no | string | |
|
||||||
|
| `code_challenge_method` | no | string | |
|
||||||
|
| `state` | no | string | |
|
||||||
|
| `scope` | no | string | |
|
||||||
|
|
||||||
|
## Response `200`
|
||||||
|
|
||||||
|
Client and user consent metadata
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| 400 | Invalid request or unregistered redirect_uri |
|
||||||
|
| 401 | User not authenticated |
|
||||||
|
| 404 | Client not found |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||||
47
docs/api/routes/mcp/authorize.post.md
Normal file
47
docs/api/routes/mcp/authorize.post.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# `POST /mcp/authorize`
|
||||||
|
|
||||||
|
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||||
|
|
||||||
|
Grant or deny MCP client authorization
|
||||||
|
|
||||||
|
Issues an OAuth authorization code or refusal for the requesting MCP client on behalf of the authenticated user.
|
||||||
|
|
||||||
|
## Body (JSON)
|
||||||
|
|
||||||
|
| Field | Required | Type | Description |
|
||||||
|
|-------|----------|------|-------------|
|
||||||
|
| `client_id` | yes | string | |
|
||||||
|
| `redirect_uri` | yes | string | |
|
||||||
|
| `code_challenge` | no | string | |
|
||||||
|
| `code_challenge_method` | no | string | |
|
||||||
|
| `state` | no | string | |
|
||||||
|
| `scope` | no | string | |
|
||||||
|
| `action` | yes | string | |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "…",
|
||||||
|
"redirect_uri": "…",
|
||||||
|
"code_challenge": "…",
|
||||||
|
"code_challenge_method": "…",
|
||||||
|
"state": "…",
|
||||||
|
"scope": "…",
|
||||||
|
"action": "allow"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response `200`
|
||||||
|
|
||||||
|
Success with redirect URL containing code or error
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| 400 | Invalid request parameters |
|
||||||
|
| 401 | User not authenticated |
|
||||||
|
| 404 | Client not found |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||||
21
docs/api/routes/mcp/clients.md
Normal file
21
docs/api/routes/mcp/clients.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# `GET /mcp/clients`
|
||||||
|
|
||||||
|
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||||
|
|
||||||
|
List registered MCP clients
|
||||||
|
|
||||||
|
Lists all registered OAuth MCP clients.
|
||||||
|
|
||||||
|
## Response `200`
|
||||||
|
|
||||||
|
List of registered clients
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| 401 | User not authenticated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||||
28
docs/api/routes/mcp/clients/_clientId.md
Normal file
28
docs/api/routes/mcp/clients/_clientId.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# `DELETE /mcp/clients/{clientId}`
|
||||||
|
|
||||||
|
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||||
|
|
||||||
|
Delete registered MCP client
|
||||||
|
|
||||||
|
Deletes an MCP client registration.
|
||||||
|
|
||||||
|
## Path parameters
|
||||||
|
|
||||||
|
| Param | Required | Type | Description |
|
||||||
|
|-------|----------|------|-------------|
|
||||||
|
| `clientId` | yes | string | |
|
||||||
|
|
||||||
|
## Response `200`
|
||||||
|
|
||||||
|
Client deleted
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| 400 | Missing clientId |
|
||||||
|
| 401 | User not authenticated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||||
@@ -4,28 +4,24 @@ Public. No cookie required.
|
|||||||
|
|
||||||
OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636)
|
OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636)
|
||||||
|
|
||||||
Authorization prompt for end-users to grant access to an MCP client.
|
Redirects browser to frontend authorization consent page.
|
||||||
|
|
||||||
## Query parameters
|
## Query parameters
|
||||||
|
|
||||||
| Param | Required | Type | Description |
|
| Param | Required | Type | Description |
|
||||||
|-------|----------|------|-------------|
|
|-------|----------|------|-------------|
|
||||||
| `client_id` | yes | string | |
|
| `client_id` | no | string | |
|
||||||
| `redirect_uri` | yes | string | |
|
| `redirect_uri` | no | string | |
|
||||||
| `response_type` | yes | string | |
|
| `response_type` | no | string | |
|
||||||
| `code_challenge` | yes | string | |
|
| `code_challenge` | no | string | |
|
||||||
| `code_challenge_method` | yes | string | |
|
| `code_challenge_method` | no | string | |
|
||||||
| `state` | no | string | |
|
| `state` | no | string | |
|
||||||
|
|
||||||
## Response `200`
|
|
||||||
|
|
||||||
Authorization / Consent HTML form
|
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
| Status | Description |
|
| Status | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| 302 | Redirect back to client callback |
|
| 302 | Redirects to frontend /oauth/authorize |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
371
mcp.md
Normal file
371
mcp.md
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
# Model Context Protocol (MCP) in Dashback
|
||||||
|
|
||||||
|
This document explains the architecture, database schema, OAuth 2.1 authentication flow, and endpoint implementation of the Model Context Protocol (MCP) server in Dashback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview & Purpose
|
||||||
|
|
||||||
|
The Dashback backend provides an MCP server running over **Streamable HTTP** at `/mcp`. It exposes the internal **`dashback_request`** tool (the same tool provided to the local agent via `src/tools/dashback-request.js`) to external MCP clients such as:
|
||||||
|
- Visual Studio Code / GitHub Copilot
|
||||||
|
- Claude Desktop
|
||||||
|
- Cursor / Windsurf
|
||||||
|
|
||||||
|
All operations executed via `dashback_request` are cryptographically authenticated and scoped to the authorizing Dashback user (`google_auth_id`) and their selected company.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authentication: OAuth 2.1 Specification Compliance
|
||||||
|
|
||||||
|
MCP remote HTTP transports require standardized authorization following [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13). Dashback implements a full, built-in OAuth 2.1 authorization server supporting:
|
||||||
|
|
||||||
|
| Standard | Purpose | Endpoint(s) |
|
||||||
|
|---|---|---|
|
||||||
|
| **RFC 9728** | Protected Resource Metadata (PRM) | `/.well-known/oauth-protected-resource`<br>`/.well-known/oauth-protected-resource/mcp` |
|
||||||
|
| **RFC 8414** / OIDC | Authorization Server Metadata | `/.well-known/oauth-authorization-server`<br>`/.well-known/openid-configuration` |
|
||||||
|
| **RFC 7591** | Dynamic Client Registration (DCR) | `POST /oauth/register` |
|
||||||
|
| **RFC 6749** & **RFC 7636** | Authorization Code Flow with PKCE (`S256`) | `GET /oauth/authorize`<br>`POST /oauth/authorize` |
|
||||||
|
| **RFC 6749** | Token Exchange & Refresh | `POST /oauth/token` |
|
||||||
|
| **RFC 7662** | Token Introspection | `POST /oauth/introspect` |
|
||||||
|
| **RFC 8252** §7.3 | Native App Loopback Interface Redirection | Port-agnostic redirect URI matching for `127.0.0.1` and `localhost` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Database Schema (`dashdbschema`)
|
||||||
|
|
||||||
|
Three new tables were added to both **MSSQL** (`src/providers/mssql/schema.js`) and **SQLite** (`src/providers/sqlite3/schema.js`):
|
||||||
|
|
||||||
|
### 3.1. `mcp_client`
|
||||||
|
Stores dynamically registered MCP clients (e.g., VS Code instances).
|
||||||
|
- `id` (INTEGER / INT IDENTITY, PK)
|
||||||
|
- `client_id` (NVARCHAR(100) / TEXT, UNIQUE NOT NULL)
|
||||||
|
- `client_secret` (NVARCHAR(255) / TEXT, NULL)
|
||||||
|
- `client_name` (NVARCHAR(255) / TEXT, NOT NULL)
|
||||||
|
- `redirect_uris` (NVARCHAR(MAX) / TEXT, NOT NULL, JSON array or URI list)
|
||||||
|
- `grant_types` (NVARCHAR(255) / TEXT, NULL, e.g. `'authorization_code,refresh_token'`)
|
||||||
|
- `response_types` (NVARCHAR(255) / TEXT, NULL, e.g. `'code'`)
|
||||||
|
- `scope` (NVARCHAR(255) / TEXT, NULL, default `'mcp:tools'`)
|
||||||
|
- `created_at` (DATETIME2 / TEXT)
|
||||||
|
|
||||||
|
### 3.2. `mcp_auth_code`
|
||||||
|
Stores short-lived authorization codes (5-minute TTL) during the PKCE handshake.
|
||||||
|
- `code` (NVARCHAR(128) / TEXT, PK)
|
||||||
|
- `client_id` (NVARCHAR(100) / TEXT, NOT NULL)
|
||||||
|
- `google_auth_id` (INT / INTEGER, FK `google_auth(id) ON DELETE CASCADE`)
|
||||||
|
- `redirect_uri` (NVARCHAR(2048) / TEXT, NOT NULL)
|
||||||
|
- `code_challenge` (NVARCHAR(255) / TEXT, NOT NULL)
|
||||||
|
- `code_challenge_method` (NVARCHAR(32) / TEXT, NOT NULL, e.g. `'S256'`)
|
||||||
|
- `scope` (NVARCHAR(255) / TEXT, NULL)
|
||||||
|
- `expires_at` (DATETIME2 / TEXT, NOT NULL)
|
||||||
|
- `used` (TINYINT / INTEGER, NOT NULL DEFAULT 0)
|
||||||
|
- `created_at` (DATETIME2 / TEXT)
|
||||||
|
|
||||||
|
### 3.3. `mcp_token`
|
||||||
|
Stores refresh tokens (30-day TTL) for long-lived MCP connections.
|
||||||
|
- `id` (INTEGER / INT IDENTITY, PK)
|
||||||
|
- `refresh_token` (NVARCHAR(128) / TEXT, UNIQUE NOT NULL)
|
||||||
|
- `client_id` (NVARCHAR(100) / TEXT, NOT NULL)
|
||||||
|
- `google_auth_id` (INT / INTEGER, FK `google_auth(id) ON DELETE CASCADE`)
|
||||||
|
- `scope` (NVARCHAR(255) / TEXT, NULL)
|
||||||
|
- `expires_at` (DATETIME2 / TEXT, NOT NULL)
|
||||||
|
- `revoked` (TINYINT / INTEGER, NOT NULL DEFAULT 0)
|
||||||
|
- `created_at` (DATETIME2 / TEXT)
|
||||||
|
|
||||||
|
### Store Methods (`libdashstore`)
|
||||||
|
Under `src/store/mcp/` in `dashdbschema`:
|
||||||
|
- `store.mcp.registerClient(input)`
|
||||||
|
- `store.mcp.getClient(clientId)`
|
||||||
|
- `store.mcp.listClients()`
|
||||||
|
- `store.mcp.deleteClient(clientId)`
|
||||||
|
- `store.mcp.createAuthCode(input)`
|
||||||
|
- `store.mcp.getAuthCode(code)`
|
||||||
|
- `store.mcp.consumeAuthCode(code)`
|
||||||
|
- `store.mcp.createRefreshToken(input)`
|
||||||
|
- `store.mcp.getRefreshToken(refreshToken)`
|
||||||
|
- `store.mcp.revokeRefreshToken(refreshToken)`
|
||||||
|
|
||||||
|
Wrapped with execution timing logs in `dashback` via `src/db/mcp.js` and `STORE_NAMESPACES`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Endpoints & Flow Breakdown
|
||||||
|
|
||||||
|
All routes are defined in [`src/api/routes/mcp.js`](src/api/routes/mcp.js).
|
||||||
|
|
||||||
|
### Step 1: Protected Resource Discovery
|
||||||
|
When an MCP client connects to `POST /mcp` without credentials:
|
||||||
|
- Server responds with **`401 Unauthorized`** and header:
|
||||||
|
```http
|
||||||
|
WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="http://<host>:4208/.well-known/oauth-protected-resource"
|
||||||
|
```
|
||||||
|
- The client queries `GET /.well-known/oauth-protected-resource`, receiving:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resource": "http://<host>:4208/mcp",
|
||||||
|
"authorization_servers": ["http://<host>:4208"],
|
||||||
|
"scopes_supported": ["mcp:tools"],
|
||||||
|
"resource_name": "Dashback MCP Server"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Authorization Server Discovery
|
||||||
|
The client queries `GET /.well-known/oauth-authorization-server`, receiving:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"issuer": "http://<host>:4208",
|
||||||
|
"authorization_endpoint": "http://<host>:4208/oauth/authorize",
|
||||||
|
"token_endpoint": "http://<host>:4208/oauth/token",
|
||||||
|
"registration_endpoint": "http://<host>:4208/oauth/register",
|
||||||
|
"introspection_endpoint": "http://<host>:4208/oauth/introspect",
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
|
"code_challenge_methods_supported": ["S256"],
|
||||||
|
"scopes_supported": ["mcp:tools"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Dynamic Client Registration
|
||||||
|
The client registers with `POST /oauth/register`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_name": "Visual Studio Code",
|
||||||
|
"redirect_uris": [
|
||||||
|
"https://vscode.dev/redirect",
|
||||||
|
"http://127.0.0.1/",
|
||||||
|
"http://127.0.0.1:33418/"
|
||||||
|
],
|
||||||
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
|
"response_types": ["code"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The server stores the client in `mcp_client` and returns status `201 Created` with a unique `client_id` (UUID).
|
||||||
|
|
||||||
|
### Step 4: User Authorization & Consent via Frontend
|
||||||
|
When the MCP client directs the user's browser to authenticate:
|
||||||
|
- The MCP client reads `authorization_endpoint` from `/.well-known/oauth-authorization-server`, which resolves to `http://<host>:4209/oauth/authorize`.
|
||||||
|
- If an agent or client hits `GET /oauth/authorize` directly on the backend, the backend redirects (HTTP 302) to `${frontendUrl}/oauth/authorize?<queryParams>`.
|
||||||
|
- The user is authenticated in the Dashfront frontend SPA.
|
||||||
|
- The frontend calls `GET /api/mcp/authorize?<queryParams>` to fetch client details and user context for the consent dialog.
|
||||||
|
- When the user clicks **Authorize**, the frontend calls `POST /api/mcp/authorize` (`action: "allow"`).
|
||||||
|
- The server generates a single-use authorization code (in `mcp_auth_code`) and returns `{ ok: true, redirectUrl: "http://127.0.0.1:<port>/?code=<code>&state=<state>" }`.
|
||||||
|
- The frontend sets `window.location.href = data.redirectUrl`, delivering the code to the client loopback listener.
|
||||||
|
- **Loopback Port Matching (RFC 8252 §7.3)**: Native applications bind to ephemeral ports (e.g. `54754`). The server uses `isRedirectUriAllowed()` to permit any port on `127.0.0.1` and `localhost` when the registered URI is on loopback.
|
||||||
|
|
||||||
|
### Step 5: Token Exchange
|
||||||
|
The MCP client sends a POST request with PKCE verifier to `POST /oauth/token`:
|
||||||
|
```http
|
||||||
|
POST /oauth/token
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
grant_type=authorization_code&code=<code>&client_id=<id>&redirect_uri=...&code_verifier=<verifier>
|
||||||
|
```
|
||||||
|
The server:
|
||||||
|
1. Validates `code`, client ID, expiration, and unused status.
|
||||||
|
2. Computes `BASE64URL(SHA256(code_verifier))` and compares it against `code_challenge`.
|
||||||
|
3. Marks the code as used in `mcp_auth_code`.
|
||||||
|
4. Issues a signed JWT access token (1-hour TTL) with payload `{ sub: userId, id: userId, client_id, scope: "mcp:tools", aud: "<host>/mcp" }`.
|
||||||
|
5. Issues a 30-day random hex refresh token stored in `mcp_token`.
|
||||||
|
|
||||||
|
### Step 6: MCP Session Handling (`/mcp`)
|
||||||
|
The client connects to `/mcp` with `Authorization: Bearer <access_token>`.
|
||||||
|
- **`requireBearerAuth`**: Validates the JWT and verifies that the `userId` exists and is active in `google_auth`.
|
||||||
|
- **`StreamableHTTPServerTransport`** (`@modelcontextprotocol/sdk`):
|
||||||
|
- `POST /mcp`: Handles session `initialize` (generating a session UUID and returning `Mcp-Session-Id` header) and subsequent JSON-RPC tool calls.
|
||||||
|
- `GET /mcp`: Handles server-sent events (SSE) streaming connections.
|
||||||
|
- `DELETE /mcp`: Closes the transport session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The Exposed Tool: `dashback_request`
|
||||||
|
|
||||||
|
The MCP server registers the exact same tool exposed to the internal local agent (`src/tools/dashback-request.js`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
server.registerTool('dashback_request', {
|
||||||
|
title: 'Dashback API Request',
|
||||||
|
description: 'Perform HTTP requests against the Dashback API',
|
||||||
|
inputSchema: {
|
||||||
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
||||||
|
path: z.string().describe('e.g. /articles, /orders, /category'),
|
||||||
|
query: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
body: z.union([z.string(), z.record(z.string(), z.any())]).nullable().optional(),
|
||||||
|
note: z.string().nullable().optional(),
|
||||||
|
},
|
||||||
|
}, async ({ method = 'GET', path, query = null, body = null, note }) => {
|
||||||
|
// Generates a 5-minute JWT signed with env.jwtSecret scoped to the authenticated user's ID
|
||||||
|
// Performs fetch against http://127.0.0.1:<port>/api<path>
|
||||||
|
// Returns status and truncated body (up to 20,000 characters)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the internal fetch uses a JWT containing the user's `google_auth_id`, all Dashback route-level permissions, company isolation (`req.user.selectedCompanyId`), audit logging, and data scoping apply automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. How to Connect from VS Code / GitHub Copilot
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
Add the server to your VS Code MCP configuration (either in `.vscode/mcp.json` or your global VS Code settings):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dashback": {
|
||||||
|
"url": "http://127.0.0.1:4208/mcp",
|
||||||
|
"type": "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*(If connecting from another device over LAN, use `http://<server-ip>:4208/mcp`)*.
|
||||||
|
|
||||||
|
### First Connection Flow:
|
||||||
|
1. Open VS Code with Copilot Chat.
|
||||||
|
2. VS Code contacts `/mcp`, triggers OAuth 2.1 discovery, and asks you to open a browser for authorization.
|
||||||
|
3. Click **Open** and press **Authorize** on the Dashback consent screen.
|
||||||
|
4. VS Code receives the tokens and displays `#dashback_request` in Copilot Chat tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Frontend Developer Implementation Guide
|
||||||
|
|
||||||
|
The frontend (`dashfront`, port 4209) needs two views / features:
|
||||||
|
1. **Consent Screen Route (`/oauth/authorize`)**: Where external MCP clients send users to approve access.
|
||||||
|
2. **Authorizations Management (Settings/Security)**: Where users view authorized MCP clients and revoke access.
|
||||||
|
|
||||||
|
### 7.1. Consent Screen Route: `/oauth/authorize`
|
||||||
|
|
||||||
|
When an MCP client initiates authentication, the user is navigated to:
|
||||||
|
```
|
||||||
|
http://<host>:4209/oauth/authorize?client_id=...&redirect_uri=...&response_type=code&code_challenge=...&code_challenge_method=S256&state=...&scope=mcp:tools
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 1: Ensure User Authentication
|
||||||
|
- Check if the user is authenticated in Dashfront.
|
||||||
|
- If not logged in, redirect to `/login` preserving the full URL as `returnTo` so they return here after Google SSO.
|
||||||
|
|
||||||
|
#### Step 2: Fetch Authorization Prompt Metadata
|
||||||
|
The frontend makes an authenticated GET request:
|
||||||
|
```http
|
||||||
|
GET /api/mcp/authorize?<all_query_params_from_url>
|
||||||
|
```
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client": {
|
||||||
|
"clientId": "642a188e-036f-4f5a-8448-95207096c4ef",
|
||||||
|
"clientName": "Visual Studio Code",
|
||||||
|
"scope": "mcp:tools"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"email": "user@example.com"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"clientId": "642a188e-036f-4f5a-8448-95207096c4ef",
|
||||||
|
"redirectUri": "http://127.0.0.1:54754/",
|
||||||
|
"codeChallenge": "...",
|
||||||
|
"codeChallengeMethod": "S256",
|
||||||
|
"state": "...",
|
||||||
|
"scope": "mcp:tools"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Render Consent UI
|
||||||
|
Display a clean dialog or card:
|
||||||
|
- Application requesting access: `client.clientName`
|
||||||
|
- Authorizing account: `user.email`
|
||||||
|
- Permissions requested: `dashback_request` API tools (`mcp:tools`)
|
||||||
|
- Action buttons:
|
||||||
|
- **Authorize** (Allow)
|
||||||
|
- **Cancel** (Deny)
|
||||||
|
|
||||||
|
#### Step 4: Submit Consent Decision
|
||||||
|
When the user clicks **Authorize**:
|
||||||
|
```http
|
||||||
|
POST /api/mcp/authorize
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"client_id": "<params.clientId>",
|
||||||
|
"redirect_uri": "<params.redirectUri>",
|
||||||
|
"code_challenge": "<params.codeChallenge>",
|
||||||
|
"code_challenge_method": "<params.codeChallengeMethod>",
|
||||||
|
"state": "<params.state>",
|
||||||
|
"scope": "<params.scope>",
|
||||||
|
"action": "allow"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"redirectUrl": "http://127.0.0.1:54754/?code=<auth_code>&state=<state>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The frontend immediately navigates the browser:
|
||||||
|
```javascript
|
||||||
|
window.location.href = res.data.redirectUrl;
|
||||||
|
```
|
||||||
|
|
||||||
|
When the user clicks **Cancel**:
|
||||||
|
```http
|
||||||
|
POST /api/mcp/authorize
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"client_id": "<params.clientId>",
|
||||||
|
"redirect_uri": "<params.redirectUri>",
|
||||||
|
"state": "<params.state>",
|
||||||
|
"action": "deny"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"redirectUrl": "http://127.0.0.1:54754/?error=access_denied&state=<state>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Navigate: `window.location.href = res.data.redirectUrl;`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2. Authorizations Management (Settings / Security)
|
||||||
|
|
||||||
|
In the user profile or security settings, users can view active MCP client authorizations and revoke them.
|
||||||
|
|
||||||
|
#### List Active Authorizations
|
||||||
|
```http
|
||||||
|
GET /api/mcp/authorizations
|
||||||
|
```
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authorizations": [
|
||||||
|
{
|
||||||
|
"clientDbId": 3,
|
||||||
|
"clientId": "0a2e6bbb-05a5-4b03-bed1-9b853b5aec34",
|
||||||
|
"clientName": "Visual Studio Code",
|
||||||
|
"scope": "mcp:tools",
|
||||||
|
"clientCreatedAt": "2026-10-12T20:36:46.036Z",
|
||||||
|
"authorizedAt": "2026-10-12T20:39:28.148Z",
|
||||||
|
"tokenCount": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Revoke an Authorization
|
||||||
|
When the user clicks **Revoke** / **Delete**:
|
||||||
|
```http
|
||||||
|
DELETE /api/mcp/authorizations/:clientId
|
||||||
|
```
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"message": "Authorizations revoked"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This immediately revokes all active refresh tokens for that client and the user. Any future token refresh requests by that client will be rejected with `invalid_grant`.
|
||||||
|
|
||||||
@@ -20,6 +20,26 @@ function getBaseUrl(req) {
|
|||||||
return `${proto}://${host}`;
|
return `${proto}://${host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFrontendUrl(req) {
|
||||||
|
if (process.env.FRONTEND_URL) {
|
||||||
|
return process.env.FRONTEND_URL.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
||||||
|
const host = req.get('host') || `127.0.0.1:${env.port}`;
|
||||||
|
const hostname = host.split(':')[0];
|
||||||
|
return `${proto}://${hostname}:4209`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpErrorStatus(error) {
|
||||||
|
if (!error) return 500;
|
||||||
|
if (error.message === 'No company selected') return 400;
|
||||||
|
if (error.message === 'Not authorized' || error.message === 'User is no longer authorized') return 403;
|
||||||
|
if (error.message === 'Unauthorized') return 401;
|
||||||
|
if (error.message?.endsWith(' not found') || error.message?.includes('not found')) return 404;
|
||||||
|
if (error.message?.includes('required') || error.message?.includes('Invalid')) return 400;
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a candidate redirect_uri matches any registered redirect URI.
|
* Checks if a candidate redirect_uri matches any registered redirect URI.
|
||||||
* Per RFC 8252 Section 7.3 (OAuth 2.0 for Native Apps):
|
* Per RFC 8252 Section 7.3 (OAuth 2.0 for Native Apps):
|
||||||
@@ -177,7 +197,7 @@ function mcpAuthMiddleware(req, res, next) {
|
|||||||
* @param {import('express').Application} app
|
* @param {import('express').Application} app
|
||||||
* @param {string} [_basePath='']
|
* @param {string} [_basePath='']
|
||||||
*/
|
*/
|
||||||
export function register(app, _basePath = '') {
|
export function register(app, basePath = '') {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /.well-known/oauth-protected-resource:
|
* /.well-known/oauth-protected-resource:
|
||||||
@@ -216,9 +236,10 @@ export function register(app, _basePath = '') {
|
|||||||
*/
|
*/
|
||||||
const handleAuthServerMetadata = (req, res) => {
|
const handleAuthServerMetadata = (req, res) => {
|
||||||
const baseUrl = getBaseUrl(req);
|
const baseUrl = getBaseUrl(req);
|
||||||
|
const frontendUrl = getFrontendUrl(req);
|
||||||
res.json({
|
res.json({
|
||||||
issuer: baseUrl,
|
issuer: baseUrl,
|
||||||
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
authorization_endpoint: `${frontendUrl}/oauth/authorize`,
|
||||||
token_endpoint: `${baseUrl}/oauth/token`,
|
token_endpoint: `${baseUrl}/oauth/token`,
|
||||||
registration_endpoint: `${baseUrl}/oauth/register`,
|
registration_endpoint: `${baseUrl}/oauth/register`,
|
||||||
introspection_endpoint: `${baseUrl}/oauth/introspect`,
|
introspection_endpoint: `${baseUrl}/oauth/introspect`,
|
||||||
@@ -308,175 +329,41 @@ export function register(app, _basePath = '') {
|
|||||||
* get:
|
* get:
|
||||||
* tags: [MCP]
|
* tags: [MCP]
|
||||||
* summary: OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636)
|
* summary: OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636)
|
||||||
* description: Authorization prompt for end-users to grant access to an MCP client.
|
* description: Redirects browser to frontend authorization consent page.
|
||||||
* security: []
|
* security: []
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: client_id
|
* name: client_id
|
||||||
* required: true
|
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: redirect_uri
|
* name: redirect_uri
|
||||||
* required: true
|
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: response_type
|
* name: response_type
|
||||||
* required: true
|
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [code]
|
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: code_challenge
|
* name: code_challenge
|
||||||
* required: true
|
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: code_challenge_method
|
* name: code_challenge_method
|
||||||
* required: true
|
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [S256]
|
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: state
|
* name: state
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
|
||||||
* description: Authorization / Consent HTML form
|
|
||||||
* 302:
|
* 302:
|
||||||
* description: Redirect back to client callback
|
* description: Redirects to frontend /oauth/authorize
|
||||||
*/
|
*/
|
||||||
app.get('/oauth/authorize', async (req, res) => {
|
app.get('/oauth/authorize', async (req, res) => {
|
||||||
const {
|
const frontendUrl = getFrontendUrl(req);
|
||||||
client_id,
|
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
||||||
redirect_uri,
|
return res.redirect(`${frontendUrl}/oauth/authorize${qs}`);
|
||||||
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 || !isRedirectUriAllowed(redirect_uri, registeredUris)) {
|
|
||||||
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) => {
|
app.post('/oauth/authorize', async (req, res) => {
|
||||||
@@ -798,8 +685,412 @@ export function register(app, _basePath = '') {
|
|||||||
|
|
||||||
app.get('/mcp', mcpAuthMiddleware, handleSessionRequest);
|
app.get('/mcp', mcpAuthMiddleware, handleSessionRequest);
|
||||||
app.delete('/mcp', mcpAuthMiddleware, handleSessionRequest);
|
app.delete('/mcp', mcpAuthMiddleware, handleSessionRequest);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /mcp/authorize:
|
||||||
|
* get:
|
||||||
|
* tags: [MCP]
|
||||||
|
* summary: Get MCP authorization prompt details
|
||||||
|
* description: Fetches client information and current user context for the frontend MCP authorization consent screen.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* 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
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [code]
|
||||||
|
* - in: query
|
||||||
|
* name: code_challenge
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: code_challenge_method
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [S256]
|
||||||
|
* - in: query
|
||||||
|
* name: state
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: scope
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Client and user consent metadata
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request or unregistered redirect_uri
|
||||||
|
* 401:
|
||||||
|
* description: User not authenticated
|
||||||
|
* 404:
|
||||||
|
* description: Client not found
|
||||||
|
*/
|
||||||
|
app.get(`${basePath}/mcp/authorize`, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
client_id,
|
||||||
|
redirect_uri,
|
||||||
|
response_type = 'code',
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method = 'S256',
|
||||||
|
state = '',
|
||||||
|
scope = 'mcp:tools',
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
if (!client_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'Missing client_id',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await mcpDb.getClient(client_id);
|
||||||
|
if (!client) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'invalid_client',
|
||||||
|
error_description: `Unknown client_id: ${client_id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let registeredUris = [];
|
||||||
|
try {
|
||||||
|
registeredUris = JSON.parse(client.redirectUris);
|
||||||
|
} catch {
|
||||||
|
registeredUris = [client.redirectUris];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!redirect_uri || !isRedirectUriAllowed(redirect_uri, registeredUris)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'Invalid or unregistered redirect_uri',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response_type !== 'code') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'unsupported_response_type',
|
||||||
|
error_description: 'Only response_type=code is supported',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code_challenge || code_challenge_method !== 'S256') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'PKCE code_challenge with method S256 is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
client: {
|
||||||
|
clientId: client.clientId,
|
||||||
|
clientName: client.clientName,
|
||||||
|
scope: client.scope,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: req.user.id,
|
||||||
|
email: req.user.email,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
clientId: client_id,
|
||||||
|
redirectUri: redirect_uri,
|
||||||
|
codeChallenge: code_challenge,
|
||||||
|
codeChallengeMethod: code_challenge_method,
|
||||||
|
state,
|
||||||
|
scope: scope || client.scope || 'mcp:tools',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[mcp] Authorize prompt error:', err);
|
||||||
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /mcp/authorize:
|
||||||
|
* post:
|
||||||
|
* tags: [MCP]
|
||||||
|
* summary: Grant or deny MCP client authorization
|
||||||
|
* description: Issues an OAuth authorization code or refusal for the requesting MCP client on behalf of the authenticated user.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required: [client_id, redirect_uri, action]
|
||||||
|
* properties:
|
||||||
|
* client_id:
|
||||||
|
* type: string
|
||||||
|
* redirect_uri:
|
||||||
|
* type: string
|
||||||
|
* code_challenge:
|
||||||
|
* type: string
|
||||||
|
* code_challenge_method:
|
||||||
|
* type: string
|
||||||
|
* state:
|
||||||
|
* type: string
|
||||||
|
* scope:
|
||||||
|
* type: string
|
||||||
|
* action:
|
||||||
|
* type: string
|
||||||
|
* enum: [allow, deny]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Success with redirect URL containing code or error
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request parameters
|
||||||
|
* 401:
|
||||||
|
* description: User not authenticated
|
||||||
|
* 404:
|
||||||
|
* description: Client not found
|
||||||
|
*/
|
||||||
|
app.post(`${basePath}/mcp/authorize`, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
client_id,
|
||||||
|
redirect_uri,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method = 'S256',
|
||||||
|
state = '',
|
||||||
|
scope = 'mcp:tools',
|
||||||
|
action = 'allow',
|
||||||
|
} = req.body || {};
|
||||||
|
|
||||||
|
if (!client_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'Missing client_id',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await mcpDb.getClient(client_id);
|
||||||
|
if (!client) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'invalid_client',
|
||||||
|
error_description: `Unknown client_id: ${client_id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let registeredUris = [];
|
||||||
|
try {
|
||||||
|
registeredUris = JSON.parse(client.redirectUris);
|
||||||
|
} catch {
|
||||||
|
registeredUris = [client.redirectUris];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!redirect_uri || !isRedirectUriAllowed(redirect_uri, registeredUris)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'Invalid or unregistered redirect_uri',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUrl = new URL(redirect_uri);
|
||||||
|
|
||||||
|
if (action === 'deny') {
|
||||||
|
redirectUrl.searchParams.set('error', 'access_denied');
|
||||||
|
if (state) redirectUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
|
if (req.is('application/json') || req.headers.accept?.includes('application/json')) {
|
||||||
|
return res.json({ ok: false, redirectUrl: redirectUrl.toString() });
|
||||||
|
}
|
||||||
|
return res.redirect(redirectUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code_challenge || code_challenge_method !== 'S256') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'PKCE code_challenge with method S256 is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = randomBytes(32).toString('hex');
|
||||||
|
await mcpDb.createAuthCode({
|
||||||
|
code,
|
||||||
|
clientId: client_id,
|
||||||
|
googleAuthId: req.user.id,
|
||||||
|
redirectUri: redirect_uri,
|
||||||
|
codeChallenge: code_challenge,
|
||||||
|
codeChallengeMethod: code_challenge_method,
|
||||||
|
scope: scope || client.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);
|
||||||
|
|
||||||
|
if (req.is('application/json') || req.headers.accept?.includes('application/json')) {
|
||||||
|
return res.json({ ok: true, redirectUrl: redirectUrl.toString() });
|
||||||
|
}
|
||||||
|
return res.redirect(redirectUrl.toString());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[mcp] Authorize action error:', err);
|
||||||
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /mcp/authorizations:
|
||||||
|
* get:
|
||||||
|
* tags: [MCP]
|
||||||
|
* summary: List active MCP client authorizations
|
||||||
|
* description: Lists all MCP clients authorized by the authenticated user with token counts and authorization timestamps.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of active user authorizations
|
||||||
|
* 401:
|
||||||
|
* description: User not authenticated
|
||||||
|
*/
|
||||||
|
app.get(`${basePath}/mcp/authorizations`, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const authorizations = await mcpDb.listAuthorizations(req.user.id);
|
||||||
|
res.json({ authorizations });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[mcp] List authorizations error:', err);
|
||||||
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /mcp/authorizations/{clientId}:
|
||||||
|
* delete:
|
||||||
|
* tags: [MCP]
|
||||||
|
* summary: Revoke MCP client authorizations
|
||||||
|
* description: Revokes all tokens and authorizations granted to an MCP client by the authenticated user.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: clientId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Authorizations revoked
|
||||||
|
* 400:
|
||||||
|
* description: Missing clientId
|
||||||
|
* 401:
|
||||||
|
* description: User not authenticated
|
||||||
|
*/
|
||||||
|
app.delete(`${basePath}/mcp/authorizations/:clientId`, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const { clientId } = req.params;
|
||||||
|
if (!clientId) {
|
||||||
|
return res.status(400).json({ error: 'invalid_request', error_description: 'clientId is required' });
|
||||||
|
}
|
||||||
|
await mcpDb.revokeAuthorizations(req.user.id, clientId);
|
||||||
|
res.json({ ok: true, message: 'Authorizations revoked' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[mcp] Revoke authorizations error:', err);
|
||||||
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /mcp/clients:
|
||||||
|
* get:
|
||||||
|
* tags: [MCP]
|
||||||
|
* summary: List registered MCP clients
|
||||||
|
* description: Lists all registered OAuth MCP clients.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of registered clients
|
||||||
|
* 401:
|
||||||
|
* description: User not authenticated
|
||||||
|
*/
|
||||||
|
app.get(`${basePath}/mcp/clients`, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const clients = await mcpDb.listClients();
|
||||||
|
res.json({ clients });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[mcp] List clients error:', err);
|
||||||
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /mcp/clients/{clientId}:
|
||||||
|
* delete:
|
||||||
|
* tags: [MCP]
|
||||||
|
* summary: Delete registered MCP client
|
||||||
|
* description: Deletes an MCP client registration.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: clientId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Client deleted
|
||||||
|
* 400:
|
||||||
|
* description: Missing clientId
|
||||||
|
* 401:
|
||||||
|
* description: User not authenticated
|
||||||
|
*/
|
||||||
|
app.delete(`${basePath}/mcp/clients/:clientId`, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const { clientId } = req.params;
|
||||||
|
if (!clientId) {
|
||||||
|
return res.status(400).json({ error: 'invalid_request', error_description: 'clientId is required' });
|
||||||
|
}
|
||||||
|
await mcpDb.deleteClient(clientId);
|
||||||
|
res.json({ ok: true, message: 'Client deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[mcp] Delete client error:', err);
|
||||||
|
res.status(mcpErrorStatus(err)).json({ error: 'server_error', error_description: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (typeof str !== 'string') return '';
|
if (typeof str !== 'string') return '';
|
||||||
return str
|
return str
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:4209',
|
||||||
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
implicitCreate: process.env.IMPLICIT_CREATE === 'true',
|
implicitCreate: process.env.IMPLICIT_CREATE === 'true',
|
||||||
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',
|
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',
|
||||||
|
|||||||
@@ -49,3 +49,13 @@ export async function revokeRefreshToken(refreshToken) {
|
|||||||
const store = await getStore();
|
const store = await getStore();
|
||||||
return store.mcp.revokeRefreshToken(refreshToken);
|
return store.mcp.revokeRefreshToken(refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAuthorizations(googleAuthId) {
|
||||||
|
const store = await getStore();
|
||||||
|
return store.mcp.listAuthorizations(googleAuthId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAuthorizations(googleAuthId, clientId) {
|
||||||
|
const store = await getStore();
|
||||||
|
return store.mcp.revokeAuthorizations(googleAuthId, clientId);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user