Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
scheduler.tsβ€’32.6 kB
/** * Scheduler Internal MCP * * Provides tools for scheduling MCP tool executions with cron/launchd/Task Scheduler. */ import { InternalMCP, InternalTool, InternalToolResult } from './types.js'; import { Scheduler } from '../services/scheduler/scheduler.js'; import { logger } from '../utils/logger.js'; export class SchedulerMCP implements InternalMCP { name = 'schedule'; description = 'Schedule MCP tool executions with cron (built-in, Unix/Linux/macOS only)'; /** * Announce validation capability following MCP protocol * This makes the scheduler a reference implementation for capability-based validation */ capabilities = { experimental: { toolValidation: { supported: true, method: 'validate' } } }; private scheduler: Scheduler; private orchestrator?: any; // NCPOrchestrator constructor() { this.scheduler = new Scheduler(); // No orchestrator yet - will be injected later } /** * Set the orchestrator instance (called by MCP server after initialization) */ setOrchestrator(orchestrator: any): void { logger.info('[SchedulerMCP] Orchestrator injected - re-creating scheduler with orchestrator'); this.orchestrator = orchestrator; this.scheduler = new Scheduler(orchestrator); } tools: InternalTool[] = [ { name: 'validate', description: 'Validate tool parameters before scheduling (dry-run). REFERENCE IMPLEMENTATION for MCP validation protocol.', inputSchema: { type: 'object', properties: { tool: { type: 'string', description: 'Tool to validate in format "mcp:tool" (e.g., "filesystem:read_file")' }, parameters: { type: 'object', description: 'Tool parameters to validate' }, schedule: { type: 'string', description: 'Schedule to validate (optional). Supports: "in 5 minutes", "in 2 hours", "every day at 2pm", cron expressions, or RFC 3339 datetimes' } }, required: ['tool', 'parameters'] } }, { name: 'create', description: 'Create scheduled job with cron/natural language timing.', inputSchema: { type: 'object', properties: { // SAME AS RUN tool: { type: 'string', description: 'MCP tool (format: mcp:tool)' }, parameters: { type: 'object', description: 'Tool parameters' }, // SCHEDULER-SPECIFIC name: { type: 'string', description: 'Job name' }, schedule: { type: 'string', description: 'Schedule: "in 5min", "every day at 9am", cron (0 9 * * *), or RFC 3339' }, timezone: { type: 'string', description: `IANA timezone (default: ${Intl.DateTimeFormat().resolvedOptions().timeZone}). Ignored for RFC 3339.` }, active: { type: 'boolean', description: 'Start active (default: true) or paused', default: true }, // OPTIONAL description: { type: 'string', description: 'Job description' }, fireOnce: { type: 'boolean', description: 'Execute once then stop (default: false)', default: false }, maxExecutions: { type: 'number', description: 'Max executions before stopping' }, endDate: { type: 'string', description: 'Stop after this date (ISO 8601)' }, testRun: { type: 'boolean', description: 'Test execute before scheduling', default: false }, skipValidation: { type: 'boolean', description: 'Skip validation (not recommended)', default: false } }, required: ['tool', 'parameters', 'name', 'schedule'] } }, { name: 'retrieve', description: 'Get jobs and/or executions with search and filtering.', inputSchema: { type: 'object', properties: { include: { type: 'string', enum: ['jobs', 'executions', 'both'], description: 'What to return: jobs, executions, or both', default: 'jobs' }, query: { type: 'string', description: 'Search term (omit for all)' }, job_id: { type: 'string', description: 'Filter by job ID or name' }, execution_id: { type: 'string', description: 'Get specific execution' }, status: { type: 'string', enum: ['active', 'paused', 'completed', 'error', 'all', 'success', 'failure', 'timeout'], description: 'Filter by status', default: 'all' }, page: { type: 'number', description: 'Page number', default: 1 }, limit: { type: 'number', description: 'Max results per page', default: 50 } } } }, { name: 'update', description: 'Update job timing, parameters, or active state.', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'Job ID or name' }, // Can update any of these name: { type: 'string', description: 'New name' }, schedule: { type: 'string', description: 'New schedule' }, tool: { type: 'string', description: 'New tool' }, parameters: { type: 'object', description: 'New parameters' }, active: { type: 'boolean', description: 'Activate or pause' }, description: { type: 'string', description: 'New description' }, fireOnce: { type: 'boolean', description: 'New fireOnce' }, maxExecutions: { type: 'number', description: 'New max executions' }, endDate: { type: 'string', description: 'New end date' } }, required: ['job_id'] } }, { name: 'delete', description: 'Delete scheduled job.', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'Job ID or name' } }, required: ['job_id'] } }, { name: 'list', description: 'List all scheduled jobs.', inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['active', 'paused', 'completed', 'error', 'all'], description: 'Filter by status', default: 'all' } } } }, { name: 'get', description: 'Get job details.', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'Job ID or name' } }, required: ['job_id'] } }, { name: 'pause', description: 'Pause job (stops future executions).', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'Job ID or name' } }, required: ['job_id'] } }, { name: 'resume', description: 'Resume paused job.', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'Job ID or name' } }, required: ['job_id'] } }, { name: 'executions', description: 'View execution history.', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'Filter by job ID or name (omit for all)' }, status: { type: 'string', enum: ['success', 'failure', 'timeout', 'all'], description: 'Filter by status', default: 'all' }, limit: { type: 'number', description: 'Max results', default: 50 } } } }, { name: 'trigger', description: 'Trigger an immediate execution of a scheduled job (manual run).', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'Job ID or name' } }, required: ['job_id'] } } ]; async executeTool(toolName: string, args: any): Promise<InternalToolResult> { try { // Check if scheduler is available on this platform if (!this.scheduler.isAvailable()) { return { success: false, error: 'Schedule MCP not available on this platform', content: [{ type: 'text', text: '❌ Schedule MCP not available on this platform (Windows not supported). Scheduling requires Unix/Linux/macOS with cron.' }] }; } switch (toolName) { case 'validate': return await this.handleValidate(args); case 'create': return await this.handleCreate(args); case 'retrieve': return await this.handleRetrieve(args); case 'update': return await this.handleUpdate(args); case 'delete': return await this.handleDelete(args); // CLI-style action tools case 'list': return await this.handleList(args); case 'get': return await this.handleGet(args); case 'pause': return await this.handlePause(args); case 'resume': return await this.handleResume(args); case 'executions': return await this.handleExecutions(args); case 'trigger': return await this.handleTrigger(args); default: return { success: false, error: `Unknown schedule tool: ${toolName}. Available: validate, create, list, get, retrieve, pause, resume, cancel, executions, trigger`, content: [{ type: 'text', text: `❌ Unknown schedule tool: ${toolName}\n\nπŸ“‹ Available tools: validate, create, list, get, retrieve, pause, resume, cancel, executions, trigger` }] }; } } catch (error) { logger.error(`[SchedulerMCP] Tool execution error: ${error instanceof Error ? error.message : String(error)}`); const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: errorMessage, content: [{ type: 'text', text: `❌ Error: ${errorMessage}` }] }; } } /** * REFERENCE IMPLEMENTATION: tools/validate * * This demonstrates capability-based validation for the MCP protocol. * Validates the TOOL being scheduled (e.g., filesystem:read_file), not the schedule itself. */ private async handleValidate(args: any): Promise<InternalToolResult> { const { tool, parameters, schedule } = args; if (!tool || !parameters) { return { success: true, content: JSON.stringify({ valid: false, errors: ['Missing required parameters: tool and parameters'], warnings: [] }) }; } try { const errors: string[] = []; const warnings: string[] = []; // Validate schedule if provided if (schedule) { const { NaturalLanguageParser } = await import('../services/scheduler/natural-language-parser.js'); const scheduleResult = NaturalLanguageParser.parseSchedule(schedule); if (!scheduleResult.success) { errors.push(`Invalid schedule: ${scheduleResult.error}`); } } // Use ToolValidator to validate the tool being scheduled const { ToolValidator } = await import('../services/scheduler/tool-validator.js'); const validator = new ToolValidator(this.orchestrator); const result = await validator.validateTool(tool, parameters); return { success: true, content: JSON.stringify({ valid: result.valid && errors.length === 0, errors: [...errors, ...result.errors], warnings: [...warnings, ...result.warnings], validationMethod: result.validationMethod, schema: result.schema }) }; } catch (error) { return { success: true, content: JSON.stringify({ valid: false, errors: [`Validation error: ${error instanceof Error ? error.message : String(error)}`], warnings: [] }) }; } } /** * Create a new scheduled job * Uses same parameters as run + schedule + active flag */ private async handleCreate(args: any): Promise<InternalToolResult> { try { // Default active to true if not provided const active = args.active !== false; const job = await this.scheduler.createJob({ name: args.name, schedule: args.schedule, timezone: args.timezone, // IANA timezone or defaults to system tool: args.tool, parameters: args.parameters, description: args.description, fireOnce: args.fireOnce, maxExecutions: args.maxExecutions, endDate: args.endDate, testRun: args.testRun, skipValidation: args.skipValidation }); // If created as paused, pause it immediately if (!active) { this.scheduler.pauseJob(job.id); } let successMessage = `βœ… Scheduled job created successfully!\n\n` + `πŸ“‹ Job Details:\n` + ` β€’ Name: ${job.name}\n` + ` β€’ ID: ${job.id}\n` + ` β€’ Tool: ${job.tool}\n` + ` β€’ Schedule: ${job.cronExpression}\n` + `${job.timezone ? ` β€’ Timezone: ${job.timezone}\n` : ''}` + ` β€’ Status: ${active ? 'active' : 'paused'}\n` + ` β€’ Type: ${job.fireOnce ? 'One-time' : 'Recurring'}\n` + `${job.description ? ` β€’ Description: ${job.description}\n` : ''}` + `${job.maxExecutions ? ` β€’ Max Executions: ${job.maxExecutions}\n` : ''}` + `${job.endDate ? ` β€’ End Date: ${job.endDate}\n` : ''}`; // Add validation info if (args.testRun) { successMessage += `\nβœ… Test execution completed successfully - parameters validated\n`; } else if (!args.skipValidation) { successMessage += `\nβœ… Parameters validated against tool schema\n`; } if (active) { successMessage += `\nπŸ’‘ The job will execute automatically according to its schedule.\n`; } else { successMessage += `\n⏸️ Job created in paused state. Use update with active:true to start execution.\n`; } successMessage += `πŸ“Š Use retrieve with job_id="${job.id}" to monitor execution results.`; return { success: true, content: [{ type: 'text', text: successMessage }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: errorMessage, content: [{ type: 'text', text: `❌ Failed to create scheduled job\n\n` + `Error: ${errorMessage}\n\n` + `πŸ’‘ Tips:\n` + ` β€’ Verify the tool exists: use find to search\n` + ` β€’ Check parameter types match the tool's schema\n` + ` β€’ Use testRun: true to verify parameters before scheduling\n` + ` β€’ Use validate tool to dry-run test parameters` }] }; } } /** * Retrieve jobs and/or executions with search and filtering * Unified retrieval for all scheduler data */ private async handleRetrieve(args: any): Promise<InternalToolResult> { const include = args.include || 'jobs'; const query = args.query; const jobId = args.job_id; const executionId = args.execution_id; const status = args.status || 'all'; const limit = args.limit || 50; let result = ''; try { // Handle specific execution lookup if (executionId) { return this.getExecutionDetails(executionId); } // Handle specific job lookup if (jobId && include === 'jobs') { return this.getJobDetails(jobId); } // Handle jobs if (include === 'jobs' || include === 'both') { const jobsResult = await this.retrieveJobs({ query, jobId, status, limit }); result += jobsResult; } // Handle executions if (include === 'executions' || include === 'both') { if (result) result += '\n\n'; const execResult = await this.retrieveExecutions({ query, jobId, status, limit }); result += execResult; } return { success: true, content: [{ type: 'text', text: result || 'No results found matching the criteria.' }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: errorMessage, content: [{ type: 'text', text: `❌ Retrieve failed: ${errorMessage}` }] }; } } /** * Update existing job (including active state for pause/resume) */ private async handleUpdate(args: any): Promise<InternalToolResult> { let jobId = args.job_id; // Try to find by name if not found by ID let job = this.scheduler.getJob(jobId); if (!job) { job = this.scheduler.getJobByName(jobId); if (job) { jobId = job.id; } } if (!job) { return { success: false, error: `Job not found: ${args.job_id}`, content: [{ type: 'text', text: `❌ Job not found: ${args.job_id}` }] }; } try { // Handle active state change (pause/resume) if (args.active !== undefined) { if (args.active) { this.scheduler.resumeJob(jobId); } else { this.scheduler.pauseJob(jobId); } } // Update other fields if provided if (args.name || args.schedule || args.tool || args.parameters || args.description !== undefined || args.fireOnce !== undefined || args.maxExecutions !== undefined || args.endDate !== undefined) { const updatedJob = await this.scheduler.updateJob(jobId, { name: args.name, schedule: args.schedule, tool: args.tool, parameters: args.parameters, description: args.description, fireOnce: args.fireOnce, maxExecutions: args.maxExecutions, endDate: args.endDate }); return { success: true, content: [{ type: 'text', text: `βœ… Job updated successfully!\n\n` + `πŸ“‹ ${updatedJob.name}\n` + ` β€’ Schedule: ${updatedJob.cronExpression}\n` + ` β€’ Tool: ${updatedJob.tool}\n` + ` β€’ Status: ${updatedJob.status}` }] }; } else { // Only status change const updatedJob = this.scheduler.getJob(jobId)!; return { success: true, content: [{ type: 'text', text: `βœ… Job ${args.active ? 'resumed' : 'paused'}: ${updatedJob.name}\n\n` + `Status: ${updatedJob.status}${args.active ? ' - will execute according to schedule' : ' - execution stopped'}` }] }; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: errorMessage, content: [{ type: 'text', text: `❌ Update failed: ${errorMessage}` }] }; } } /** * Delete a scheduled job permanently */ private async handleDelete(args: any): Promise<InternalToolResult> { let jobId = args.job_id; let job = this.scheduler.getJob(jobId); if (!job) { job = this.scheduler.getJobByName(jobId); if (job) jobId = job.id; } if (!job) { return { success: false, error: `Job not found: ${args.job_id}`, content: [{ type: 'text', text: `❌ Job not found: ${args.job_id}` }] }; } try { this.scheduler.deleteJob(jobId); return { success: true, content: [{ type: 'text', text: `βœ… Job deleted: ${job.name}\n\nThe job has been permanently removed from the schedule.` }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: errorMessage, content: [{ type: 'text', text: `❌ Delete failed: ${errorMessage}` }] }; } } // Helper: Retrieve jobs with filtering private async retrieveJobs(filters: { query?: string; jobId?: string; status?: string; limit?: number }): Promise<string> { const statusFilter = filters.status === 'all' ? undefined : filters.status as 'active' | 'paused' | 'completed' | 'error' | undefined; let jobs = this.scheduler.listJobs(statusFilter); // Filter by query if (filters.query) { const q = filters.query.toLowerCase(); jobs = jobs.filter(job => job.name.toLowerCase().includes(q) || job.tool.toLowerCase().includes(q) || job.description?.toLowerCase().includes(q) ); } // Filter by job ID if (filters.jobId) { const job = this.scheduler.getJob(filters.jobId) || this.scheduler.getJobByName(filters.jobId); jobs = job ? [job] : []; } // Apply limit const limit = filters.limit || 50; const limited = jobs.slice(0, limit); if (limited.length === 0) { return 'πŸ“‹ No jobs found matching the criteria.'; } const jobsList = limited.map(job => { const execInfo = job.executionCount > 0 ? `Executed ${job.executionCount} time${job.executionCount === 1 ? '' : 's'}` : 'Not yet executed'; return `β€’ ${job.name} (${job.status})\n` + ` ID: ${job.id}\n` + ` Tool: ${job.tool}\n` + ` Schedule: ${job.cronExpression}\n` + ` ${execInfo}\n` + `${job.lastExecutionAt ? ` Last: ${job.lastExecutionAt}\n` : ''}`; }).join('\n'); const stats = this.scheduler.getJobStatistics(); return `πŸ“‹ Scheduled Jobs (${limited.length} of ${jobs.length})\n\n` + jobsList + `\nπŸ“Š Statistics:\n` + ` β€’ Active: ${stats.active}\n` + ` β€’ Paused: ${stats.paused}\n` + ` β€’ Completed: ${stats.completed}\n` + ` β€’ Error: ${stats.error}`; } // Helper: Retrieve executions with filtering private async retrieveExecutions(filters: { query?: string; jobId?: string; status?: string; limit?: number }): Promise<string> { let jobId = filters.jobId; if (jobId) { let job = this.scheduler.getJob(jobId); if (!job) { job = this.scheduler.getJobByName(jobId); if (job) jobId = job.id; } } const executions = this.scheduler.queryExecutions({ jobId, status: filters.status === 'all' ? undefined : filters.status }); // Filter by query let filtered = executions; if (filters.query) { const q = filters.query.toLowerCase(); filtered = executions.filter(exec => (exec.jobName || exec.taskName || '').toLowerCase().includes(q) || exec.tool.toLowerCase().includes(q) ); } const limit = filters.limit || 50; const limited = filtered.slice(0, limit); if (limited.length === 0) { return 'πŸ“Š No executions found matching the criteria.'; } const executionsList = limited.map(exec => { const status = exec.status === 'success' ? 'βœ…' : exec.status === 'failure' ? '❌' : '⏱️'; const duration = exec.duration ? `${exec.duration}ms` : 'N/A'; return `${status} ${exec.jobName}\n` + ` ID: ${exec.executionId}\n` + ` Time: ${exec.startedAt}\n` + ` Duration: ${duration}\n` + `${exec.errorMessage ? ` Error: ${exec.errorMessage}\n` : ''}`; }).join('\n'); return `πŸ“Š Executions (${limited.length} of ${filtered.length})\n\n${executionsList}`; } // Helper: Get detailed job info private getJobDetails(jobId: string): InternalToolResult { let job = this.scheduler.getJob(jobId); if (!job) { job = this.scheduler.getJobByName(jobId); } if (!job) { return { success: false, error: `Job not found: ${jobId}`, content: [{ type: 'text', text: `❌ Job not found: ${jobId}` }] }; } const execStats = this.scheduler.getExecutionStatistics(job.id); return { success: true, content: [{ type: 'text', text: `πŸ“‹ Job: ${job.name}\n\n` + `πŸ†” ID: ${job.id}\n` + `πŸ”§ Tool: ${job.tool}\n` + `⏰ Schedule: ${job.cronExpression}\n` + `πŸ“Š Status: ${job.status}\n` + `πŸ”„ Type: ${job.fireOnce ? 'One-time' : 'Recurring'}\n` + `${job.description ? `πŸ“ Description: ${job.description}\n` : ''}` + `${job.maxExecutions ? `πŸ”’ Max Executions: ${job.maxExecutions}\n` : ''}` + `${job.endDate ? `πŸ“… End Date: ${job.endDate}\n` : ''}` + `\nπŸ“ˆ Execution Statistics:\n` + ` β€’ Total: ${execStats.total}\n` + ` β€’ Success: ${execStats.success}\n` + ` β€’ Failure: ${execStats.failure}\n` + ` β€’ Timeout: ${execStats.timeout}\n` + `${execStats.avgDuration ? ` β€’ Avg Duration: ${Math.round(execStats.avgDuration)}ms\n` : ''}` + `\nβš™οΈ Parameters:\n${JSON.stringify(job.parameters, null, 2)}` }] }; } // Helper: Get execution details private getExecutionDetails(executionId: string): InternalToolResult { const execution = this.scheduler.executionRecorder.getExecution(executionId); if (!execution) { return { success: false, error: `Execution not found: ${executionId}`, content: [{ type: 'text', text: `❌ Execution not found: ${executionId}` }] }; } const status = execution.status === 'success' ? 'βœ… Success' : execution.status === 'failure' ? '❌ Failure' : execution.status === 'timeout' ? '⏱️ Timeout' : 'πŸ”„ Running'; return { success: true, content: [{ type: 'text', text: `πŸ“Š Execution: ${execution.executionId}\n\n` + `πŸ“‹ Job: ${execution.jobName} (${execution.jobId})\n` + `πŸ”§ Tool: ${execution.tool}\n` + `πŸ“Š Status: ${status}\n` + `⏰ Started: ${execution.startedAt}\n` + `${execution.completedAt ? `βœ… Completed: ${execution.completedAt}\n` : ''}` + `${execution.duration ? `⏱️ Duration: ${execution.duration}ms\n` : ''}` + `\nβš™οΈ Parameters:\n${JSON.stringify(execution.parameters, null, 2)}\n` + `${execution.result ? `\nπŸ“€ Result:\n${JSON.stringify(execution.result, null, 2)}` : ''}` + `${execution.error ? `\n❌ Error: ${execution.error.message}` : ''}` }] }; } /** * List all jobs (simpler alternative to retrieve) * Delegates to handleRetrieve with include='jobs' */ private async handleList(args: any): Promise<InternalToolResult> { return await this.handleRetrieve({ include: 'jobs', status: args?.status || 'all' }); } /** * Get specific job details * Delegates to handleRetrieve with job_id filter */ private async handleGet(args: any): Promise<InternalToolResult> { if (!args?.job_id) { return { success: false, error: 'job_id parameter is required', content: [{ type: 'text', text: '❌ job_id parameter is required' }] }; } return await this.handleRetrieve({ include: 'jobs', job_id: args.job_id }); } /** * Pause a job (set active=false) * Delegates to handleUpdate with active=false */ private async handlePause(args: any): Promise<InternalToolResult> { if (!args?.job_id) { return { success: false, error: 'job_id parameter is required', content: [{ type: 'text', text: '❌ job_id parameter is required' }] }; } return await this.handleUpdate({ job_id: args.job_id, active: false }); } /** * Resume a job (set active=true) * Delegates to handleUpdate with active=true */ private async handleResume(args: any): Promise<InternalToolResult> { if (!args?.job_id) { return { success: false, error: 'job_id parameter is required', content: [{ type: 'text', text: '❌ job_id parameter is required' }] }; } return await this.handleUpdate({ job_id: args.job_id, active: true }); } /** * View execution history * Delegates to handleRetrieve with include='executions' */ private async handleExecutions(args: any): Promise<InternalToolResult> { return await this.handleRetrieve({ include: 'executions', job_id: args?.job_id, status: args?.status || 'all', limit: args?.limit || 50 }); } /** * Trigger immediate execution of a job */ private async handleTrigger(args: any): Promise<InternalToolResult> { if (!args?.job_id) { return { success: false, error: 'job_id parameter is required', content: [{ type: 'text', text: '❌ job_id parameter is required' }] }; } let jobId = args.job_id; let job = this.scheduler.getJob(jobId); if (!job) { job = this.scheduler.getJobByName(jobId); if (job) jobId = job.id; } if (!job) { return { success: false, error: `Job not found: ${args.job_id}`, content: [{ type: 'text', text: `❌ Job not found: ${args.job_id}` }] }; } try { const result = await this.scheduler.runTaskNow(jobId); const statusIcon = result.status === 'success' ? 'βœ…' : '❌'; const duration = result.duration ? `${result.duration}ms` : 'N/A'; let message = `${statusIcon} Manual execution completed: ${job.name}\n\n` + ` ID: ${result.executionId}\n` + ` Status: ${result.status}\n` + ` Duration: ${duration}\n`; if (result.result) { message += `\nπŸ“€ Result:\n${result.result}`; } if (result.error) { message += `\n❌ Error:\n${result.error}`; } return { success: result.status === 'success', content: [{ type: 'text', text: message }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: errorMessage, content: [{ type: 'text', text: `❌ Trigger failed: ${errorMessage}` }] }; } } }

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/portel-dev/ncp'

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