import express, { type Request, type Response } from 'express'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js'; import { sessionsService } from '@/modules/providers/services/sessions.service.js'; import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js'; import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js'; const router = express.Router(); const readPathParam = (value: unknown, name: string): string => { if (typeof value === 'string') { return value; } if (Array.isArray(value) && typeof value[0] === 'string') { return value[0]; } throw new AppError(`${name} path parameter is invalid.`, { code: 'INVALID_PATH_PARAMETER', statusCode: 400, }); }; const normalizeProviderParam = (value: unknown): string => readPathParam(value, 'provider').trim().toLowerCase(); const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/; const parseSessionId = (value: unknown): string => { const sessionId = readPathParam(value, 'sessionId').trim(); if (!SESSION_ID_PATTERN.test(sessionId)) { throw new AppError('Invalid sessionId.', { code: 'INVALID_SESSION_ID', statusCode: 400, }); } return sessionId; }; const readOptionalQueryString = (value: unknown): string | undefined => { if (typeof value !== 'string') { return undefined; } const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; }; const parseOptionalBooleanQuery = (value: unknown, name: string): boolean | undefined => { if (value === undefined) { return undefined; } const normalized = readOptionalQueryString(value); if (!normalized) { return undefined; } if (normalized === 'true') { return true; } if (normalized === 'false') { return false; } throw new AppError(`${name} must be "true" or "false".`, { code: 'INVALID_QUERY_PARAMETER', statusCode: 400, }); }; const parseMcpScope = (value: unknown): McpScope | undefined => { if (value === undefined) { return undefined; } const normalized = readOptionalQueryString(value); if (!normalized) { return undefined; } if (normalized === 'user' || normalized === 'local' || normalized === 'project') { return normalized; } throw new AppError(`Unsupported MCP scope "${normalized}".`, { code: 'INVALID_MCP_SCOPE', statusCode: 400, }); }; const parseMcpTransport = (value: unknown): McpTransport => { const normalized = readOptionalQueryString(value); if (!normalized) { throw new AppError('transport is required.', { code: 'MCP_TRANSPORT_REQUIRED', statusCode: 400, }); } if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') { return normalized; } throw new AppError(`Unsupported MCP transport "${normalized}".`, { code: 'INVALID_MCP_TRANSPORT', statusCode: 400, }); }; const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => { if (!payload || typeof payload !== 'object') { throw new AppError('Request body must be an object.', { code: 'INVALID_REQUEST_BODY', statusCode: 400, }); } const body = payload as Record; const name = readOptionalQueryString(body.name); if (!name) { throw new AppError('name is required.', { code: 'MCP_NAME_REQUIRED', statusCode: 400, }); } const transport = parseMcpTransport(body.transport); const scope = parseMcpScope(body.scope); const workspacePath = readOptionalQueryString(body.workspacePath); return { name, transport, scope, workspacePath, command: readOptionalQueryString(body.command), args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined, env: typeof body.env === 'object' && body.env !== null ? Object.fromEntries( Object.entries(body.env as Record).filter( (entry): entry is [string, string] => typeof entry[1] === 'string', ), ) : undefined, cwd: readOptionalQueryString(body.cwd), url: readOptionalQueryString(body.url), headers: typeof body.headers === 'object' && body.headers !== null ? Object.fromEntries( Object.entries(body.headers as Record).filter( (entry): entry is [string, string] => typeof entry[1] === 'string', ), ) : undefined, envVars: Array.isArray(body.envVars) ? body.envVars.filter((entry): entry is string => typeof entry === 'string') : undefined, bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar), envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null ? Object.fromEntries( Object.entries(body.envHttpHeaders as Record).filter( (entry): entry is [string, string] => typeof entry[1] === 'string', ), ) : undefined, }; }; const parseProvider = (value: unknown): LLMProvider => { const normalized = normalizeProviderParam(value); if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') { return normalized; } throw new AppError(`Unsupported provider "${normalized}".`, { code: 'UNSUPPORTED_PROVIDER', statusCode: 400, }); }; const parseSessionRenameSummary = (payload: unknown): string => { if (!payload || typeof payload !== 'object') { throw new AppError('Request body must be an object.', { code: 'INVALID_REQUEST_BODY', statusCode: 400, }); } const body = payload as Record; const summary = typeof body.summary === 'string' ? body.summary.trim() : ''; if (!summary) { throw new AppError('Summary is required.', { code: 'INVALID_SESSION_SUMMARY', statusCode: 400, }); } if (summary.length > 500) { throw new AppError('Summary must not exceed 500 characters.', { code: 'INVALID_SESSION_SUMMARY', statusCode: 400, }); } return summary; }; const parseSessionSearchQuery = (value: unknown): string => { const query = readOptionalQueryString(value) ?? ''; if (query.length < 2) { throw new AppError('Query must be at least 2 characters', { code: 'INVALID_SEARCH_QUERY', statusCode: 400, }); } return query; }; const parseSessionSearchLimit = (value: unknown): number => { const raw = readOptionalQueryString(value); if (!raw) { return 50; } const parsed = Number.parseInt(raw, 10); if (Number.isNaN(parsed)) { throw new AppError('limit must be a valid integer.', { code: 'INVALID_QUERY_PARAMETER', statusCode: 400, }); } return Math.max(1, Math.min(parsed, 100)); }; router.get( '/:provider/auth/status', asyncHandler(async (req: Request, res: Response) => { const provider = parseProvider(req.params.provider); const status = await providerAuthService.getProviderAuthStatus(provider); res.json(createApiSuccessResponse(status)); }), ); // ----------------- MCP routes ----------------- router.get( '/:provider/mcp/servers', asyncHandler(async (req: Request, res: Response) => { const provider = parseProvider(req.params.provider); const workspacePath = readOptionalQueryString(req.query.workspacePath); const scope = parseMcpScope(req.query.scope); if (scope) { const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath }); res.json(createApiSuccessResponse({ provider, scope, servers })); return; } const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath }); res.json(createApiSuccessResponse({ provider, scopes: groupedServers })); }), ); router.post( '/:provider/mcp/servers', asyncHandler(async (req: Request, res: Response) => { const provider = parseProvider(req.params.provider); const payload = parseMcpUpsertPayload(req.body); const server = await providerMcpService.upsertProviderMcpServer(provider, payload); res.status(201).json(createApiSuccessResponse({ server })); }), ); router.delete( '/:provider/mcp/servers/:name', asyncHandler(async (req: Request, res: Response) => { const provider = parseProvider(req.params.provider); const scope = parseMcpScope(req.query.scope); const workspacePath = readOptionalQueryString(req.query.workspacePath); const result = await providerMcpService.removeProviderMcpServer(provider, { name: readPathParam(req.params.name, 'name'), scope, workspacePath, }); res.json(createApiSuccessResponse(result)); }), ); router.post( '/mcp/servers/global', asyncHandler(async (req: Request, res: Response) => { const payload = parseMcpUpsertPayload(req.body); if (payload.scope === 'local') { throw new AppError('Global MCP add supports only "user" or "project" scopes.', { code: 'INVALID_GLOBAL_MCP_SCOPE', statusCode: 400, }); } const results = await providerMcpService.addMcpServerToAllProviders({ ...payload, scope: payload.scope === 'user' ? 'user' : 'project', }); res.status(201).json(createApiSuccessResponse({ results })); }), ); // ----------------- Session routes ----------------- router.delete( '/sessions/:sessionId', asyncHandler(async (req: Request, res: Response) => { const sessionId = parseSessionId(req.params.sessionId); const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? false; const result = await sessionsService.deleteSessionById(sessionId, deletedFromDisk); res.json(createApiSuccessResponse(result)); }), ); router.put( '/sessions/:sessionId', asyncHandler(async (req: Request, res: Response) => { const sessionId = parseSessionId(req.params.sessionId); const summary = parseSessionRenameSummary(req.body); const result = sessionsService.renameSessionById(sessionId, summary); res.json(createApiSuccessResponse(result)); }), ); router.get( '/sessions/:sessionId/messages', asyncHandler(async (req: Request, res: Response) => { const sessionId = parseSessionId(req.params.sessionId); const limitRaw = readOptionalQueryString(req.query.limit); const offsetRaw = readOptionalQueryString(req.query.offset); const limit = limitRaw === undefined ? null : Number.parseInt(limitRaw, 10); const offset = offsetRaw === undefined ? 0 : Number.parseInt(offsetRaw, 10); if (limitRaw !== undefined && Number.isNaN(limit)) { throw new AppError('limit must be a valid integer.', { code: 'INVALID_QUERY_PARAMETER', statusCode: 400, }); } if (offsetRaw !== undefined && Number.isNaN(offset)) { throw new AppError('offset must be a valid integer.', { code: 'INVALID_QUERY_PARAMETER', statusCode: 400, }); } const result = await sessionsService.fetchHistory(sessionId, { limit, offset, }); res.json(result); }), ); router.get('/search/sessions', asyncHandler(async (req: Request, res: Response) => { const query = parseSessionSearchQuery(req.query.q); const limit = parseSessionSearchLimit(req.query.limit); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); let closed = false; const abortController = new AbortController(); req.on('close', () => { closed = true; abortController.abort(); }); try { await sessionConversationsSearchService.search({ query, limit, signal: abortController.signal, onProgress: ({ projectResult, totalMatches, scannedProjects, totalProjects }) => { if (closed) { return; } if (projectResult) { res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`); return; } res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`); }, }); if (!closed) { res.write('event: done\ndata: {}\n\n'); } } catch (error) { console.error('Error searching conversations:', error); if (!closed) { res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`); } } finally { if (!closed) { res.end(); } } })); export default router;