&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);
+}