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} 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }