Skip to main content
Glama
joelmnz

Article Manager MCP Server

by joelmnz
api.ts22.2 kB
import { authenticate, requireAuth } from '../middleware/auth'; import { DatabaseServiceError, DatabaseErrorType } from '../services/databaseErrors.js'; import { listArticles, getFolders, renameFolder, deleteFolder, searchArticles, readArticle, createArticle, updateArticle, deleteArticle, isArticlePublic, setArticlePublic, getArticleBySlug, listArticleVersions, getArticleVersion, restoreArticleVersion, deleteArticleVersions } from '../services/articles'; import { semanticSearch, hybridSearch, getDetailedIndexStats, indexUnindexedArticles } from '../services/vectorIndex'; import { databaseHealthService } from '../services/databaseHealth.js'; import { databaseInit } from '../services/databaseInit.js'; import { backgroundWorkerService } from '../services/backgroundWorker.js'; import { embeddingQueueService } from '../services/embeddingQueue.js'; import { embeddingQueueConfigService } from '../services/embeddingQueueConfig.js'; import { importStatusService } from '../services/importStatus.js'; const SEMANTIC_SEARCH_ENABLED = process.env.SEMANTIC_SEARCH_ENABLED?.toLowerCase() === 'true'; /** * Helper to handle service errors and return appropriate HTTP responses */ function handleServiceError(error: unknown, defaultMessage: string): Response { if (error instanceof DatabaseServiceError) { let status = 500; if (error.type === DatabaseErrorType.NOT_FOUND) { status = 404; } else if (error.type === DatabaseErrorType.VALIDATION_ERROR || error.type === DatabaseErrorType.CONSTRAINT_VIOLATION) { status = 400; } return new Response(JSON.stringify({ error: error.userMessage || error.message }), { status, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ error: error instanceof Error ? error.message : defaultMessage }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } export async function handleApiRequest(request: Request): Promise<Response> { const url = new URL(request.url); const path = url.pathname; // Health check endpoint (no auth required) if (path === '/health') { try { const isAuthenticated = authenticate(request); // Simple health check that uses minimal database connections const basicHealthCheck = await databaseInit.healthCheck(); // Public/minimal response to reduce information leakage if (!isAuthenticated) { const systemHealthy = basicHealthCheck.healthy; return new Response(JSON.stringify({ status: systemHealthy ? 'ok' : 'degraded', timestamp: new Date().toISOString(), database: { healthy: basicHealthCheck.healthy, message: basicHealthCheck.message, }, }), { status: systemHealthy ? 200 : 503, headers: { 'Content-Type': 'application/json' } }); } // Get embedding queue configuration (no database calls) const queueConfig = embeddingQueueConfigService.getConfig(); const configStatus = embeddingQueueConfigService.getConfigStatus(); // Only get worker stats if basic health is good let workerStats = null; if (basicHealthCheck.healthy && queueConfig.enabled && configStatus.isValid) { try { workerStats = await backgroundWorkerService.getWorkerStats(); } catch (error) { console.error('Error getting worker stats:', error); } } // Determine overall system health const systemHealthy = basicHealthCheck.healthy; return new Response(JSON.stringify({ status: systemHealthy ? 'ok' : 'degraded', timestamp: new Date().toISOString(), database: { healthy: basicHealthCheck.healthy, message: basicHealthCheck.message, details: basicHealthCheck.details || null }, services: { semanticSearch: SEMANTIC_SEARCH_ENABLED, mcpServer: process.env.MCP_SERVER_ENABLED?.toLowerCase() === 'true', embeddingQueue: { enabled: queueConfig.enabled, configValid: configStatus.isValid, configErrors: configStatus.errors, configWarnings: configStatus.warnings } }, worker: workerStats ? { isRunning: workerStats.isRunning, tasksProcessed: workerStats.tasksProcessed, tasksSucceeded: workerStats.tasksSucceeded, tasksFailed: workerStats.tasksFailed, averageProcessingTime: workerStats.averageProcessingTime, lastProcessedAt: workerStats.lastProcessedAt } : null }), { status: systemHealthy ? 200 : 503, headers: { 'Content-Type': 'application/json' } }); } catch (error) { console.error('Health check error:', error); return new Response(JSON.stringify({ status: 'error', timestamp: new Date().toISOString(), error: error instanceof Error ? error.message : 'Health check failed', database: { healthy: false, message: 'Health check failed' } }), { status: 503, headers: { 'Content-Type': 'application/json' } }); } } // Public article endpoint (no auth required) if (path.startsWith('/api/public-articles/') && request.method === 'GET') { try { const slug = path.replace('/api/public-articles/', ''); const article = await getArticleBySlug(slug); if (!article) { return new Response(JSON.stringify({ error: 'Article not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify(article), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { console.error('Public article error:', error); return new Response(JSON.stringify({ error: error instanceof Error ? error.message : 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // All other API endpoints require authentication const authError = requireAuth(request); if (authError) return authError; try { // GET /api/search - Semantic or Hybrid search if (path === '/api/search' && request.method === 'GET') { if (!SEMANTIC_SEARCH_ENABLED) { return new Response(JSON.stringify({ error: 'Semantic search is not enabled' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } const query = url.searchParams.get('query'); const k = parseInt(url.searchParams.get('k') || '5', 10); const mode = url.searchParams.get('mode') || 'hybrid'; // 'semantic' or 'hybrid' const folder = url.searchParams.get('folder') || undefined; if (!query) { return new Response(JSON.stringify({ error: 'Query parameter is required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } const results = mode === 'semantic' ? await semanticSearch(query, k, folder) : await hybridSearch(query, k, folder); return new Response(JSON.stringify(results), { headers: { 'Content-Type': 'application/json' } }); } // GET /api/rag/status - Get RAG index status if (path === '/api/rag/status' && request.method === 'GET') { if (!SEMANTIC_SEARCH_ENABLED) { return new Response(JSON.stringify({ enabled: false, message: 'Semantic search is not enabled' }), { headers: { 'Content-Type': 'application/json' } }); } const stats = await getDetailedIndexStats(); return new Response(JSON.stringify({ enabled: true, ...stats }), { headers: { 'Content-Type': 'application/json' } }); } // GET /api/queue/status - Get Embedding Queue status if (path === '/api/queue/status' && request.method === 'GET') { if (!SEMANTIC_SEARCH_ENABLED) { return new Response(JSON.stringify({ enabled: false, message: 'Semantic search is not enabled' }), { headers: { 'Content-Type': 'application/json' } }); } const config = embeddingQueueConfigService.getConfig(); if (!config.enabled) { return new Response(JSON.stringify({ enabled: false, message: 'Embedding queue is disabled' }), { headers: { 'Content-Type': 'application/json' } }); } try { const [detailedStats, health] = await Promise.all([ embeddingQueueService.getDetailedQueueStats(), embeddingQueueService.getQueueHealth() ]); return new Response(JSON.stringify({ enabled: true, stats: detailedStats.stats, tasksByPriority: detailedStats.tasksByPriority, tasksByOperation: detailedStats.tasksByOperation, recentActivity: detailedStats.recentActivity, health: health }), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return new Response(JSON.stringify({ enabled: true, error: error instanceof Error ? error.message : 'Failed to retrieve queue status' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // POST /api/rag/reindex - Rebuild entire index if (path === '/api/rag/reindex' && request.method === 'POST') { if (!SEMANTIC_SEARCH_ENABLED) { return new Response(JSON.stringify({ error: 'Semantic search is not enabled' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } try { const result = await embeddingQueueService.resetAndReindexAll(); const stats = await getDetailedIndexStats(); return new Response(JSON.stringify({ success: true, message: `Reindexing started. Queued ${result.queuedTasks} articles.`, queuedTasks: result.queuedTasks, ...stats }), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return new Response(JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Failed to rebuild index' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // POST /api/rag/index-unindexed - Index only unindexed articles if (path === '/api/rag/index-unindexed' && request.method === 'POST') { if (!SEMANTIC_SEARCH_ENABLED) { return new Response(JSON.stringify({ error: 'Semantic search is not enabled' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } try { const result = await indexUnindexedArticles(); const stats = await getDetailedIndexStats(); return new Response(JSON.stringify({ success: true, indexed: result.indexed, failed: result.failed, ...stats }), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return new Response(JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Failed to index articles' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // GET /api/folders - List all folders if (path === '/api/folders' && request.method === 'GET') { try { const folders = await getFolders(); return new Response(JSON.stringify(folders), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return handleServiceError(error, 'Failed to retrieve folders'); } } // PUT /api/folders/manage/:oldName - Rename a folder if (path.startsWith('/api/folders/manage/') && request.method === 'PUT') { try { const oldFolderName = decodeURIComponent(path.replace('/api/folders/manage/', '')); const body = await request.json(); const { newName } = body; if (!newName || !newName.trim()) { return new Response(JSON.stringify({ error: 'New folder name is required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } const result = await renameFolder(oldFolderName, newName); return new Response(JSON.stringify({ success: true, message: `Folder renamed successfully. ${result.updatedCount} articles updated.`, updatedCount: result.updatedCount }), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return handleServiceError(error, 'Failed to rename folder'); } } // DELETE /api/folders/manage/:folderName - Delete a folder if (path.startsWith('/api/folders/manage/') && request.method === 'DELETE') { try { const folderName = decodeURIComponent(path.replace('/api/folders/manage/', '')); const result = await deleteFolder(folderName); return new Response(JSON.stringify({ success: true, message: `Folder deleted successfully. ${result.updatedCount} articles updated.`, updatedCount: result.updatedCount }), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return handleServiceError(error, 'Failed to delete folder'); } } // GET /api/articles - List all articles if (path === '/api/articles' && request.method === 'GET') { const query = url.searchParams.get('q'); const folder = url.searchParams.get('folder') || undefined; if (query) { // Search articles const results = await searchArticles(query, folder); return new Response(JSON.stringify(results), { headers: { 'Content-Type': 'application/json' } }); } else { // List all articles const articles = await listArticles(folder); return new Response(JSON.stringify(articles), { headers: { 'Content-Type': 'application/json' } }); } } // GET /api/articles/:filename - Read single article or list versions if (path.startsWith('/api/articles/') && request.method === 'GET') { const fullPath = path.replace('/api/articles/', ''); // Check if this is a versions endpoint using regex to avoid path parsing vulnerabilities // Matches: <filename>/versions or <filename>/versions/<versionId> const versionMatch = fullPath.match(/^(.+?)\/versions(?:\/([^\/]+))?$/); if (versionMatch) { const filename = versionMatch[1]; const versionId = versionMatch[2]; if (!versionId) { // List all versions const versions = await listArticleVersions(filename); return new Response(JSON.stringify(versions), { headers: { 'Content-Type': 'application/json' } }); } else { // Get specific version const version = await getArticleVersion(filename, versionId); if (!version) { return new Response(JSON.stringify({ error: 'Version not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify(version), { headers: { 'Content-Type': 'application/json' } }); } } // Regular article read const filename = fullPath; const article = await readArticle(filename); if (!article) { return new Response(JSON.stringify({ error: 'Article not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify(article), { headers: { 'Content-Type': 'application/json' } }); } // POST /api/articles - Create new article if (path === '/api/articles' && request.method === 'POST') { const body = await request.json(); const { title, content, folder, message } = body; if (!title || !content) { return new Response(JSON.stringify({ error: 'Title and content are required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } const article = await createArticle(title, content, folder, message); return new Response(JSON.stringify(article), { status: 201, headers: { 'Content-Type': 'application/json' } }); } // PUT /api/articles/:filename - Update article if (path.startsWith('/api/articles/') && request.method === 'PUT') { const filename = path.replace('/api/articles/', ''); // Check if this is a version restore endpoint using regex to avoid path parsing vulnerabilities // Matches: <filename>/versions/<versionId>/restore const restoreMatch = filename.match(/^(.+?)\/versions\/([^\/]+)\/restore$/); if (restoreMatch) { const articleFilename = restoreMatch[1]; const versionId = restoreMatch[2]; const body = await request.json(); const { message } = body; const article = await restoreArticleVersion(articleFilename, versionId, message); return new Response(JSON.stringify(article), { headers: { 'Content-Type': 'application/json' } }); } // Regular article update const body = await request.json(); const { title, content, folder, message } = body; if (!title || !content) { return new Response(JSON.stringify({ error: 'Title and content are required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } const article = await updateArticle(filename, title, content, folder, message); return new Response(JSON.stringify(article), { headers: { 'Content-Type': 'application/json' } }); } // DELETE /api/articles/:filename - Delete article or versions if (path.startsWith('/api/articles/') && request.method === 'DELETE') { const fullPath = path.replace('/api/articles/', ''); // Check if this is a versions delete endpoint using regex to avoid path parsing vulnerabilities // Matches: <filename>/versions or <filename>/versions/<versionId> const versionMatch = fullPath.match(/^(.+?)\/versions(?:\/([^\/]+))?$/); if (versionMatch) { const filename = versionMatch[1]; const versionId = versionMatch[2]; if (!versionId) { // Delete all versions await deleteArticleVersions(filename); } else { // Delete specific version await deleteArticleVersions(filename, [versionId]); } return new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } }); } // Regular article deletion const filename = fullPath; await deleteArticle(filename); return new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } }); } // GET /api/articles/:filename/public-status - Get public status if (path.match(/^\/api\/articles\/[^\/]+\/public-status$/) && request.method === 'GET') { const filename = path.replace('/api/articles/', '').replace('/public-status', ''); const isPublic = await isArticlePublic(filename); return new Response(JSON.stringify({ isPublic }), { headers: { 'Content-Type': 'application/json' } }); } // POST /api/articles/:filename/public - Set public status if (path.match(/^\/api\/articles\/[^\/]+\/public$/) && request.method === 'POST') { const filename = path.replace('/api/articles/', '').replace('/public', ''); const body = await request.json(); const { isPublic } = body; if (typeof isPublic !== 'boolean') { return new Response(JSON.stringify({ error: 'isPublic must be a boolean' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } await setArticlePublic(filename, isPublic); return new Response(JSON.stringify({ success: true, isPublic }), { headers: { 'Content-Type': 'application/json' } }); } // GET /api/import/status - Get import status if (path === '/api/import/status' && request.method === 'GET') { const status = importStatusService.getStatus(); return new Response(JSON.stringify(status), { headers: { 'Content-Type': 'application/json' } }); } // POST /api/import/validate - Start validation if (path === '/api/import/validate' && request.method === 'POST') { try { const result = await importStatusService.validate(); return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return handleServiceError(error, 'Validation failed'); } } // POST /api/import/start - Start import if (path === '/api/import/start' && request.method === 'POST') { try { await importStatusService.startImport(); return new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return handleServiceError(error, 'Import failed to start'); } } // Route not found return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }); } catch (error) { console.error('API Error:', error); return handleServiceError(error, 'Internal server error'); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/joelmnz/mcp-markdown-manager'

If you have feedback or need assistance with the MCP directory API, please join our Discord server