This commit is contained in:
seb
2026-10-13 00:36:38 +02:00
parent 2f92199d7f
commit 97540fb745
14 changed files with 1049 additions and 158 deletions

View File

@@ -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}` |

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

@@ -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 &amp; 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

View File

@@ -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',

View File

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