Files
dashback/src/api/routes/mcp.js
2026-10-14 00:08:11 +02:00

1189 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID, randomBytes, createHash } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
import env from '../../config/env.js';
import * as mcpDb from '../../db/mcp.js';
import * as googleAuthDb from '../../db/googleAuth.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from '../../tools/prompts.js';
import { createSearchArticlesTool } from '../../tools/search-articles.js';
import { createSubagentTool } from '../../tools/subagent.js';
import { resolveDefaultModel } from '../../tools/llm-key.js';
const MAX_RESPONSE_CHARS = 20_000;
const REQUEST_TIMEOUT_MS = 30_000;
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
function getBaseUrl(req) {
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const host = req.get('host') || `127.0.0.1:${env.port}`;
return `${proto}://${host}`;
}
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):
* "The authorization server MUST allow any port for loopback IP redirect URIs."
* @param {string} candidateUri
* @param {Array<string>|string} registeredUris
* @returns {boolean}
*/
function isRedirectUriAllowed(candidateUri, registeredUris) {
if (!candidateUri || !registeredUris) return false;
const uriList = Array.isArray(registeredUris) ? registeredUris : [registeredUris];
if (uriList.includes(candidateUri)) return true;
try {
const candidate = new URL(candidateUri);
const isCandidateLoopback = candidate.hostname === '127.0.0.1' || candidate.hostname === 'localhost';
for (const reg of uriList) {
try {
const registered = new URL(reg);
const normCandidatePath = candidate.pathname.replace(/\/$/, '');
const normRegisteredPath = registered.pathname.replace(/\/$/, '');
if (
candidate.origin === registered.origin &&
normCandidatePath === normRegisteredPath
) {
return true;
}
const isRegisteredLoopback = registered.hostname === '127.0.0.1' || registered.hostname === 'localhost';
if (
isCandidateLoopback &&
isRegisteredLoopback &&
candidate.protocol === registered.protocol &&
normCandidatePath === normRegisteredPath
) {
return true;
}
} catch {
// Ignore malformed registered URI
}
}
} catch {
return false;
}
return false;
}
const tokenVerifier = {
verifyAccessToken: async (token) => {
let decoded;
try {
decoded = jwt.verify(token, env.jwtSecret);
} catch (err) {
throw new Error(`Invalid access token: ${err.message}`);
}
const userId = Number(decoded.id || decoded.sub);
if (!Number.isInteger(userId) || userId <= 0) {
throw new Error('Invalid token subject');
}
const user = await googleAuthDb.getById(userId);
if (!user) {
throw new Error('User is no longer authorized');
}
const scopes = typeof decoded.scope === 'string'
? decoded.scope.split(' ')
: Array.isArray(decoded.scope)
? decoded.scope
: ['mcp:tools'];
return {
token,
clientId: decoded.client_id || 'unknown',
scopes,
expiresAt: decoded.exp,
resource: decoded.aud,
user: {
id: user.id,
email: user.email,
selectedCompanyId: user.selectedCompanyId,
},
};
},
};
function createDashbackMcpServer({ user }) {
const server = new McpServer({
name: 'dashback-mcp',
version: '1.0.0',
});
server.registerTool(
'dashback_request',
{
title: 'Dashback API Request',
description: TOOL_DESCRIPTIONS.dashback_request || 'Perform HTTP requests against the Dashback API',
inputSchema: {
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.method),
path: z.string().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.path),
query: z.record(z.string(), z.any()).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.query),
body: z.union([z.string(), z.record(z.string(), z.any())]).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.body),
note: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.dashback_request.note),
},
},
async ({ method = 'GET', path, query = null, body = null, note }) => {
if (!METHODS.includes(method)) throw new Error(`Unsupported method: ${method}`);
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) {
throw new Error('path must start with a single "/"');
}
const token = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '5m' });
const headers = { Authorization: `Bearer ${token}` };
const init = { method, headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) };
if (body != null && method !== 'GET' && method !== 'DELETE') {
headers['Content-Type'] = 'application/json';
init.body = typeof body === 'string' ? body : JSON.stringify(body);
}
const qs = query ? `?${new URLSearchParams(query).toString()}` : '';
const url = `${path.startsWith('/docs') || path.startsWith('/api/') ? path : `/api${path}`}${qs}`;
console.log(`[mcp] Tool invoke dashback_request: ${method} ${url}`);
const res = await fetch(`http://127.0.0.1:${env.port}${url}`, init);
let text = await res.text();
if (text.length > MAX_RESPONSE_CHARS) {
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
}
return {
content: [{ type: 'text', text: JSON.stringify({ status: res.status, body: text }) }],
};
}
);
server.registerTool(
'dash-subagent',
{
title: 'Dashback Subagent',
description: TOOL_DESCRIPTIONS.subagent || 'Delegate a task to an isolated subagent with access to Dashback tools and VFS.',
inputSchema: {
task: z.string().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.task || 'Specific instructions and goal for the subagent.'),
skills: z.array(z.string()).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.skills || 'Array of SKILL names or slugs to inject into the subagent prompt.'),
conversation_id: z.number().int().positive().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.conversation_id || 'Existing subagent conversation ID to follow up on, or null to create a new one.'),
max_iterations: z.number().int().min(1).max(30).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.max_iterations || 'Safety bound on tool loops (default 15, max 30).'),
model: z.string().nullable().optional().describe('Optional OpenRouter model override. Defaults to company default model.'),
note: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.subagent?.note || 'Status message shown during execution.'),
},
},
async ({ task, skills = null, conversation_id = null, max_iterations = null, model = null, note = null }, extra = {}) => {
const freshUser = await googleAuthDb.getById(user.id);
const companyId = freshUser?.selectedCompanyId || user.selectedCompanyId;
if (!companyId) throw new Error('No company selected');
const parentModel = model || (await resolveDefaultModel(user.id));
const tool = createSubagentTool({
googleAuthId: user.id,
companyId,
conversationId: null,
parentModel,
depth: 0,
});
console.log(`[mcp] Tool invoke dash-subagent: task="${task?.slice(0, 100)}"`);
const signals = [AbortSignal.timeout(300_000)];
if (extra?.signal) signals.push(extra.signal);
const signal = AbortSignal.any(signals);
const result = await tool.invoke(
{ task, skills, conversation_id, max_iterations, note },
{ signal }
);
let text = JSON.stringify(result, null, 2);
if (text.length > MAX_RESPONSE_CHARS) {
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
}
return {
content: [{ type: 'text', text }],
};
}
);
server.registerTool(
'search-articles',
{
title: 'Search Articles',
description: TOOL_DESCRIPTIONS.search_articles || 'Search articles by name, brand, EAN, SKU, or supplier SKU (AND logic, ordered by name).',
inputSchema: {
searchquery: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.searchquery || 'Keywords matching name, brand, EAN, SKU, or supplier SKU (AND logic).'),
excludewords: z.string().nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.excludewords || 'Keywords to exclude (AND logic).'),
offset: z.number().int().min(0).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.offset || 'Pagination offset (default 0).'),
limit: z.number().int().min(1).max(20).nullable().optional().describe(TOOL_PARAMETER_DESCRIPTIONS.search_articles?.limit || 'Window size 1–20 (default 3).'),
},
},
async ({ searchquery = '', excludewords = '', offset = 0, limit = 3 } = {}) => {
const freshUser = await googleAuthDb.getById(user.id);
const companyId = freshUser?.selectedCompanyId || user.selectedCompanyId;
if (!companyId) throw new Error('No company selected');
console.log(`[mcp] Tool invoke search-articles: query="${searchquery ?? ''}" exclude="${excludewords ?? ''}" offset=${offset} limit=${limit}`);
const tool = createSearchArticlesTool({ googleAuthId: user.id, companyId });
const result = await tool.invoke({
searchquery: searchquery ?? '',
excludewords: excludewords ?? '',
offset: offset ?? 0,
limit: limit ?? 3,
});
let text = JSON.stringify(result, null, 2);
if (text.length > MAX_RESPONSE_CHARS) {
text = `${text.slice(0, MAX_RESPONSE_CHARS)}\n…[truncated, ${text.length} chars total]`;
}
return {
content: [{ type: 'text', text }],
};
}
);
return server;
}
const transports = {};
function mcpAuthMiddleware(req, res, next) {
const baseUrl = getBaseUrl(req);
const middleware = requireBearerAuth({
verifier: tokenVerifier,
requiredScopes: [],
resourceMetadataUrl: `${baseUrl}/.well-known/oauth-protected-resource`,
});
return middleware(req, res, next);
}
/**
* Registers MCP server and OAuth 2.1 endpoints.
* @param {import('express').Application} app
* @param {string} [_basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /.well-known/oauth-protected-resource:
* get:
* tags: [MCP]
* summary: OAuth 2.0 Protected Resource Metadata (RFC 9728)
* description: Returns protected resource metadata discovering the authorization server for the MCP endpoint.
* security: []
* responses:
* 200:
* description: Protected Resource Metadata
*/
const handleProtectedResourceMetadata = (req, res) => {
const baseUrl = getBaseUrl(req);
res.json({
resource: `${baseUrl}/mcp`,
authorization_servers: [baseUrl],
scopes_supported: ['mcp:tools'],
resource_name: 'Dashback MCP Server',
});
};
app.get('/.well-known/oauth-protected-resource', handleProtectedResourceMetadata);
app.get('/.well-known/oauth-protected-resource/mcp', handleProtectedResourceMetadata);
/**
* @openapi
* /.well-known/oauth-authorization-server:
* get:
* tags: [MCP]
* summary: OAuth 2.0 Authorization Server Metadata (RFC 8414)
* description: Returns OAuth 2.0 authorization server endpoints and capabilities.
* security: []
* responses:
* 200:
* description: Authorization Server Metadata
*/
const handleAuthServerMetadata = (req, res) => {
const baseUrl = getBaseUrl(req);
const frontendUrl = getFrontendUrl(req);
res.json({
issuer: baseUrl,
authorization_endpoint: `${frontendUrl}/oauth/authorize`,
token_endpoint: `${baseUrl}/oauth/token`,
registration_endpoint: `${baseUrl}/oauth/register`,
introspection_endpoint: `${baseUrl}/oauth/introspect`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['mcp:tools'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
});
};
app.get('/.well-known/oauth-authorization-server', handleAuthServerMetadata);
app.get('/.well-known/openid-configuration', handleAuthServerMetadata);
/**
* @openapi
* /oauth/register:
* post:
* tags: [MCP]
* summary: Dynamic Client Registration (RFC 7591)
* description: Registers a new OAuth MCP client dynamically.
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [redirect_uris]
* properties:
* client_name:
* type: string
* redirect_uris:
* type: array
* items:
* type: string
* responses:
* 201:
* description: Registered client credentials
*/
app.post('/oauth/register', async (req, res) => {
try {
const body = req.body || {};
const clientName = body.client_name || 'MCP Client';
const redirectUris = body.redirect_uris;
if (!redirectUris || (!Array.isArray(redirectUris) && typeof redirectUris !== 'string')) {
return res.status(400).json({
error: 'invalid_client_metadata',
error_description: 'redirect_uris is required and must be an array or string',
});
}
const clientId = randomUUID();
const clientSecret = randomBytes(32).toString('hex');
const grantTypes = body.grant_types || ['authorization_code', 'refresh_token'];
const responseTypes = body.response_types || ['code'];
const scope = body.scope || 'mcp:tools';
const record = await mcpDb.registerClient({
clientId,
clientSecret,
clientName,
redirectUris,
grantTypes,
responseTypes,
scope,
});
res.status(201).json({
client_id: record.clientId,
client_secret: record.clientSecret,
client_name: record.clientName,
redirect_uris: Array.isArray(redirectUris) ? redirectUris : [redirectUris],
grant_types: Array.isArray(grantTypes) ? grantTypes : [grantTypes],
response_types: Array.isArray(responseTypes) ? responseTypes : [responseTypes],
scope: record.scope,
});
} catch (err) {
console.error('[oauth] Client registration error:', err);
res.status(500).json({ error: 'server_error', error_description: err.message });
}
});
/**
* @openapi
* /oauth/authorize:
* get:
* tags: [MCP]
* summary: OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636)
* description: Redirects browser to frontend authorization consent page.
* security: []
* parameters:
* - in: query
* name: client_id
* schema:
* type: string
* - in: query
* name: redirect_uri
* schema:
* type: string
* - in: query
* name: response_type
* schema:
* type: string
* - in: query
* name: code_challenge
* schema:
* type: string
* - in: query
* name: code_challenge_method
* schema:
* type: string
* - in: query
* name: state
* schema:
* type: string
* responses:
* 302:
* description: Redirects to frontend /oauth/authorize
*/
app.get('/oauth/authorize', async (req, res) => {
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) => {
const {
client_id,
redirect_uri,
code_challenge,
code_challenge_method = 'S256',
state = '',
scope = 'mcp:tools',
action,
email,
} = req.body;
const redirectUrl = new URL(redirect_uri);
if (action === 'deny') {
redirectUrl.searchParams.set('error', 'access_denied');
if (state) redirectUrl.searchParams.set('state', state);
return res.redirect(redirectUrl.toString());
}
let userId = req.user?.id;
if (!userId && email) {
const user = await googleAuthDb.getAllowedEmail(email.trim());
if (!user) {
return res.status(403).send('Email is not authorized in Dashback');
}
userId = user.id;
}
if (!userId) {
return res.status(401).send('Not authenticated');
}
const code = randomBytes(32).toString('hex');
await mcpDb.createAuthCode({
code,
clientId: client_id,
googleAuthId: userId,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method,
scope: scope || 'mcp:tools',
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
});
redirectUrl.searchParams.set('code', code);
if (state) redirectUrl.searchParams.set('state', state);
return res.redirect(redirectUrl.toString());
});
/**
* @openapi
* /oauth/token:
* post:
* tags: [MCP]
* summary: OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636)
* description: Exchanges authorization code with PKCE for an access token, or refreshes an expired token.
* security: []
* requestBody:
* required: true
* content:
* application/x-www-form-urlencoded:
* schema:
* type: object
* required: [grant_type, client_id]
* properties:
* grant_type:
* type: string
* enum: [authorization_code, refresh_token]
* code:
* type: string
* redirect_uri:
* type: string
* client_id:
* type: string
* code_verifier:
* type: string
* refresh_token:
* type: string
* responses:
* 200:
* description: Issued access token and refresh token
*/
app.post('/oauth/token', async (req, res) => {
try {
const body = req.body || {};
const grantType = body.grant_type;
const clientId = body.client_id;
const baseUrl = getBaseUrl(req);
if (grantType === 'authorization_code') {
const { code, redirect_uri, code_verifier } = body;
if (!code || !redirect_uri || !code_verifier || !clientId) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing code, redirect_uri, client_id, or code_verifier',
});
}
const authCode = await mcpDb.getAuthCode(code);
if (!authCode || authCode.used || new Date(authCode.expiresAt) < new Date()) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Authorization code is invalid or has expired',
});
}
if (authCode.clientId !== clientId) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Client ID does not match authorization code',
});
}
if (!isRedirectUriAllowed(redirect_uri, [authCode.redirectUri])) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Redirect URI does not match authorization code',
});
}
// PKCE S256 verification
const calculatedChallenge = createHash('sha256').update(code_verifier).digest('base64url');
if (calculatedChallenge !== authCode.codeChallenge) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'PKCE verification failed',
});
}
await mcpDb.consumeAuthCode(code);
const scope = authCode.scope || 'mcp:tools';
const accessToken = jwt.sign(
{
sub: String(authCode.googleAuthId),
id: authCode.googleAuthId,
client_id: clientId,
scope,
aud: `${baseUrl}/mcp`,
iss: baseUrl,
},
env.jwtSecret,
{ expiresIn: '1h' }
);
const refreshToken = randomBytes(32).toString('hex');
await mcpDb.createRefreshToken({
refreshToken,
clientId,
googleAuthId: authCode.googleAuthId,
scope,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
});
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
scope,
});
} else if (grantType === 'refresh_token') {
const { refresh_token } = body;
if (!refresh_token || !clientId) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing refresh_token or client_id',
});
}
const tokenRow = await mcpDb.getRefreshToken(refresh_token);
if (!tokenRow || tokenRow.revoked || new Date(tokenRow.expiresAt) < new Date()) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Refresh token is invalid or expired',
});
}
if (tokenRow.clientId !== clientId) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Client ID does not match refresh token',
});
}
const scope = tokenRow.scope || 'mcp:tools';
const accessToken = jwt.sign(
{
sub: String(tokenRow.googleAuthId),
id: tokenRow.googleAuthId,
client_id: clientId,
scope,
aud: `${baseUrl}/mcp`,
iss: baseUrl,
},
env.jwtSecret,
{ expiresIn: '1h' }
);
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token,
scope,
});
} else {
return res.status(400).json({
error: 'unsupported_grant_type',
error_description: `Grant type ${grantType} is not supported`,
});
}
} catch (err) {
console.error('[oauth] Token endpoint error:', err);
res.status(500).json({ error: 'server_error', error_description: err.message });
}
});
/**
* @openapi
* /oauth/introspect:
* post:
* tags: [MCP]
* summary: OAuth 2.0 Token Introspection (RFC 7662)
* description: Verifies the validity of an issued access token.
* security: []
* responses:
* 200:
* description: Token status and metadata
*/
app.post('/oauth/introspect', async (req, res) => {
const { token } = req.body || {};
if (!token) {
return res.json({ active: false });
}
try {
const decoded = jwt.verify(token, env.jwtSecret);
res.json({
active: true,
client_id: decoded.client_id,
sub: decoded.sub,
scope: decoded.scope,
exp: decoded.exp,
aud: decoded.aud,
iss: decoded.iss,
});
} catch {
res.json({ active: false });
}
});
/**
* @openapi
* /mcp:
* post:
* tags: [MCP]
* summary: Model Context Protocol (MCP) Streamable HTTP endpoint
* description: Handles MCP JSON-RPC protocol requests and streamable sessions. Protected by Bearer token authorization.
* security:
* - bearerAuth: []
* responses:
* 200:
* description: MCP JSON-RPC response
* 401:
* description: Missing or invalid authorization token
*/
app.post('/mcp', mcpAuthMiddleware, async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
let transport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId].transport;
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableJsonResponse: true,
onsessioninitialized: (newSessionId) => {
transports[newSessionId] = {
transport,
user: req.auth?.user,
};
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId];
}
};
const server = createDashbackMcpServer({ user: req.auth.user });
await server.connect(transport);
} else {
return res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
}
await transport.handleRequest(req, res, req.body);
});
const handleSessionRequest = async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !transports[sessionId]) {
return res.status(400).send('Invalid or missing session ID');
}
const { transport } = transports[sessionId];
await transport.handleRequest(req, res);
};
app.get('/mcp', mcpAuthMiddleware, handleSessionRequest);
app.delete('/mcp', mcpAuthMiddleware, handleSessionRequest);
/**
* @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
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}