From 97540fb74513d48f6c6b1926fb722d25da97d9a5 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 13 Oct 2026 00:36:38 +0200 Subject: [PATCH] u --- docs/api/README.md | 6 + docs/api/routes-all.md | 24 +- docs/api/routes.md | 24 +- docs/api/routes/mcp/authorizations.md | 21 + .../routes/mcp/authorizations/_clientId.md | 28 + docs/api/routes/mcp/authorize.get.md | 35 ++ docs/api/routes/mcp/authorize.post.md | 47 ++ docs/api/routes/mcp/clients.md | 21 + docs/api/routes/mcp/clients/_clientId.md | 28 + docs/api/routes/oauth/authorize.md | 18 +- mcp.md | 371 ++++++++++++ src/api/routes/mcp.js | 573 +++++++++++++----- src/config/env.js | 1 + src/db/mcp.js | 10 + 14 files changed, 1049 insertions(+), 158 deletions(-) create mode 100644 docs/api/routes/mcp/authorizations.md create mode 100644 docs/api/routes/mcp/authorizations/_clientId.md create mode 100644 docs/api/routes/mcp/authorize.get.md create mode 100644 docs/api/routes/mcp/authorize.post.md create mode 100644 docs/api/routes/mcp/clients.md create mode 100644 docs/api/routes/mcp/clients/_clientId.md create mode 100644 mcp.md diff --git a/docs/api/README.md b/docs/api/README.md index 5bde1dd..4937332 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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/sku/_sku.md`](routes/ledger/sku/_sku.md) | `GET /ledger/sku/{sku}` | | [`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.post.md`](routes/modelconversation.post.md) | `POST /modelconversation` | | [`routes/modelconversation/_id.delete.md`](routes/modelconversation/_id.delete.md) | `DELETE /modelconversation/{id}` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 2793599..f5bee7f 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 223 API endpoints across all modules. +Overview of all 229 API endpoints across all modules. ### Notation @@ -519,6 +519,24 @@ _none_ #### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp` _none_ - 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 @@ -556,8 +574,8 @@ _none_ ### 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` +- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str` +_no response body_ #### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect` _none_ - Returns: `200` diff --git a/docs/api/routes.md b/docs/api/routes.md index eaae012..c705358 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 176 API endpoints across all modules. +Overview of all 182 API endpoints across all modules. ### Notation @@ -444,6 +444,24 @@ _none_ #### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp` _none_ - 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 @@ -457,8 +475,8 @@ _none_ ### 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` +- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str` +_no response body_ #### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect` _none_ - Returns: `200` diff --git a/docs/api/routes/mcp/authorizations.md b/docs/api/routes/mcp/authorizations.md new file mode 100644 index 0000000..873bd6e --- /dev/null +++ b/docs/api/routes/mcp/authorizations.md @@ -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`._ diff --git a/docs/api/routes/mcp/authorizations/_clientId.md b/docs/api/routes/mcp/authorizations/_clientId.md new file mode 100644 index 0000000..0e9c82b --- /dev/null +++ b/docs/api/routes/mcp/authorizations/_clientId.md @@ -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`._ diff --git a/docs/api/routes/mcp/authorize.get.md b/docs/api/routes/mcp/authorize.get.md new file mode 100644 index 0000000..f10d209 --- /dev/null +++ b/docs/api/routes/mcp/authorize.get.md @@ -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`._ diff --git a/docs/api/routes/mcp/authorize.post.md b/docs/api/routes/mcp/authorize.post.md new file mode 100644 index 0000000..d2b99aa --- /dev/null +++ b/docs/api/routes/mcp/authorize.post.md @@ -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`._ diff --git a/docs/api/routes/mcp/clients.md b/docs/api/routes/mcp/clients.md new file mode 100644 index 0000000..187aa27 --- /dev/null +++ b/docs/api/routes/mcp/clients.md @@ -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`._ diff --git a/docs/api/routes/mcp/clients/_clientId.md b/docs/api/routes/mcp/clients/_clientId.md new file mode 100644 index 0000000..a4b6d21 --- /dev/null +++ b/docs/api/routes/mcp/clients/_clientId.md @@ -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`._ diff --git a/docs/api/routes/oauth/authorize.md b/docs/api/routes/oauth/authorize.md index 6487644..7edd15b 100644 --- a/docs/api/routes/oauth/authorize.md +++ b/docs/api/routes/oauth/authorize.md @@ -4,28 +4,24 @@ 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. +Redirects browser to frontend authorization consent page. ## 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 | | +| `client_id` | no | string | | +| `redirect_uri` | no | string | | +| `response_type` | no | string | | +| `code_challenge` | no | string | | +| `code_challenge_method` | no | string | | | `state` | no | string | | -## Response `200` - -Authorization / Consent HTML form - ## Errors | Status | Description | |--------|-------------| -| 302 | Redirect back to client callback | +| 302 | Redirects to frontend /oauth/authorize | --- diff --git a/mcp.md b/mcp.md new file mode 100644 index 0000000..64d87f2 --- /dev/null +++ b/mcp.md @@ -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`
`/.well-known/oauth-protected-resource/mcp` | +| **RFC 8414** / OIDC | Authorization Server Metadata | `/.well-known/oauth-authorization-server`
`/.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`
`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://:4208/.well-known/oauth-protected-resource" + ``` +- The client queries `GET /.well-known/oauth-protected-resource`, receiving: + ```json + { + "resource": "http://:4208/mcp", + "authorization_servers": ["http://: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://:4208", + "authorization_endpoint": "http://:4208/oauth/authorize", + "token_endpoint": "http://:4208/oauth/token", + "registration_endpoint": "http://:4208/oauth/register", + "introspection_endpoint": "http://: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://: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?`. +- The user is authenticated in the Dashfront frontend SPA. +- The frontend calls `GET /api/mcp/authorize?` 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:/?code=&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=&client_id=&redirect_uri=...&code_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: "/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 `. +- **`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:/api + // 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://: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://: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? +``` +**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": "", + "redirect_uri": "", + "code_challenge": "", + "code_challenge_method": "", + "state": "", + "scope": "", + "action": "allow" +} +``` +**Response (200 OK):** +```json +{ + "ok": true, + "redirectUrl": "http://127.0.0.1:54754/?code=&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": "", + "redirect_uri": "", + "state": "", + "action": "deny" +} +``` +**Response (200 OK):** +```json +{ + "ok": false, + "redirectUrl": "http://127.0.0.1:54754/?error=access_denied&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`. + diff --git a/src/api/routes/mcp.js b/src/api/routes/mcp.js index 6500b26..9edf58e 100644 --- a/src/api/routes/mcp.js +++ b/src/api/routes/mcp.js @@ -20,6 +20,26 @@ function getBaseUrl(req) { 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. * 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 {string} [_basePath=''] */ -export function register(app, _basePath = '') { +export function register(app, basePath = '') { /** * @openapi * /.well-known/oauth-protected-resource: @@ -216,9 +236,10 @@ export function register(app, _basePath = '') { */ const handleAuthServerMetadata = (req, res) => { const baseUrl = getBaseUrl(req); + const frontendUrl = getFrontendUrl(req); res.json({ issuer: baseUrl, - authorization_endpoint: `${baseUrl}/oauth/authorize`, + authorization_endpoint: `${frontendUrl}/oauth/authorize`, token_endpoint: `${baseUrl}/oauth/token`, registration_endpoint: `${baseUrl}/oauth/register`, introspection_endpoint: `${baseUrl}/oauth/introspect`, @@ -308,175 +329,41 @@ export function register(app, _basePath = '') { * 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. + * description: Redirects browser to frontend authorization consent page. * 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 + * description: Redirects to frontend /oauth/authorize */ 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 || !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 = ` - - - - Authorize MCP Client - - - -
-

Authorize Application

-

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

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

Dashback Sign In

-

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

-
- - - - - - - - -
-
- -`; - return res.setHeader('Content-Type', 'text/html').send(html); + const frontendUrl = getFrontendUrl(req); + const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''; + return res.redirect(`${frontendUrl}/oauth/authorize${qs}`); }); app.post('/oauth/authorize', async (req, res) => { @@ -798,8 +685,412 @@ export function register(app, _basePath = '') { app.get('/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) { if (typeof str !== 'string') return ''; return str diff --git a/src/config/env.js b/src/config/env.js index 53ef396..2932469 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -23,6 +23,7 @@ export default { .split(',') .map((s) => s.trim()) .filter(Boolean), + frontendUrl: process.env.FRONTEND_URL || 'http://localhost:4209', googleClientId: process.env.GOOGLE_CLIENT_ID, implicitCreate: process.env.IMPLICIT_CREATE === 'true', jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production', diff --git a/src/db/mcp.js b/src/db/mcp.js index 994189b..fd9d465 100644 --- a/src/db/mcp.js +++ b/src/db/mcp.js @@ -49,3 +49,13 @@ export async function revokeRefreshToken(refreshToken) { const store = await getStore(); 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); +}