Skip to main content
Glama
server.ts25.2 kB
#!/usr/bin/env node import { createRequire } from 'node:module'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as fs from 'node:fs'; import { spawn, type ChildProcess } from 'node:child_process'; import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { logger } from './util/logger.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version?: string }; import { createSearchToolsTool, warmupSearchIndex, shutdownSearchIndex, type SearchToolsRuntimeConfig } from './tools/prodisco/searchTools.js'; import { createRunSandboxTool } from './tools/prodisco/runSandbox.js'; import { getSandboxClient, closeSandboxClient } from '@prodisco/sandbox-server/client'; import { DEFAULT_LIBRARIES_CONFIG, loadLibrariesConfigFile, resolveNodeModulesBasePath, type LibrarySpec, } from './config/libraries.js'; import type { AnyToolDefinition } from './tools/types.js'; // Track the sandbox server subprocess let sandboxProcess: ChildProcess | null = null; // Track the HTTP server (when using HTTP transport) let httpServer: Server | null = null; import { PUBLIC_GENERATED_ROOT_PATH_WITH_SLASH, listGeneratedFiles, readGeneratedFile, } from './resources/filesystem.js'; import { PACKAGE_ROOT, SCRIPTS_CACHE_DIR } from './util/paths.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const GENERATED_DIR = path.resolve(__dirname, 'tools/prodisco'); function formatLibrariesForAgent(libraries: LibrarySpec[]): string { return libraries .map((l) => (l.description ? `${l.name} (${l.description})` : l.name)) .join(', '); } function buildServerInstructions(libraries: LibrarySpec[]): string { const libs = formatLibrariesForAgent(libraries); return ( '**ALWAYS SEARCH BEFORE WRITING CODE.** ' + 'Call prodisco.searchTools first to discover APIs, methods, and correct usage patterns. ' + 'Do NOT guess method names or parameters - search to find them. ' + '\n\n' + `CONFIGURED LIBRARIES: ${libs}` + '\n\n' + 'WORKFLOW: ' + '1. Call prodisco.searchTools with a relevant query ' + '2. Review results to find correct APIs ' + '3. Call prodisco.runSandbox to execute code using discovered APIs' ); } function registerGeneratedResources(mcpServer: McpServer): void { const resourceTemplate = new ResourceTemplate( `file://${PUBLIC_GENERATED_ROOT_PATH_WITH_SLASH}{path}`, { list: async () => { const files = await listGeneratedFiles(GENERATED_DIR); return { resources: files.map((f) => ({ uri: f.uri, name: f.name, description: f.description, mimeType: f.mimeType, })), }; }, }, ); mcpServer.registerResource( 'generated-typescript-files', resourceTemplate, { description: 'Generated TypeScript modules for Kubernetes operations', }, async (uri) => { // Extract relative path from canonical URI const requestedPath = decodeURIComponent(uri.pathname); const normalizedRoot = PUBLIC_GENERATED_ROOT_PATH_WITH_SLASH; if (!requestedPath.startsWith(normalizedRoot)) { throw new Error(`Resource ${requestedPath} is outside ${normalizedRoot}`); } const relativePosixPath = requestedPath.slice(normalizedRoot.length); if (!relativePosixPath) { throw new Error('Resource path missing'); } const relativePath = relativePosixPath.split('/').join(path.sep); const content = await readGeneratedFile(GENERATED_DIR, relativePath); return { contents: [ { uri: uri.toString(), mimeType: 'text/typescript', text: content, }, ], }; }, ); logger.info(`Exposed ${GENERATED_DIR} as MCP resources`); } function registerTools( mcpServer: McpServer, tools: { searchTools: AnyToolDefinition; runSandbox: AnyToolDefinition }, ): void { const searchTools = tools.searchTools; const runSandbox = tools.runSandbox; // Register prodisco.searchTools helper as an exposed tool. mcpServer.registerTool( searchTools.name, { title: 'ProDisco Search Tools', description: searchTools.description, inputSchema: searchTools.schema, }, async (args: Record<string, unknown>) => { const parsedArgs = await searchTools.schema.parseAsync(args); const result = await searchTools.execute(parsedArgs); return { content: [ { type: 'text' as const, text: result.summary, }, { type: 'text' as const, text: JSON.stringify(result.results, null, 2), }, ], structuredContent: result, }; }, ); // Register prodisco.runSandbox tool for executing scripts in a sandboxed environment mcpServer.registerTool( runSandbox.name, { title: 'ProDisco Run Sandbox', description: runSandbox.description, inputSchema: runSandbox.schema, }, async (args: Record<string, unknown>) => { const parsedArgs = await runSandbox.schema.parseAsync(args); const result = await runSandbox.execute(parsedArgs); // Build the output message based on mode let text: string; if ('error' in result && result.error && !('output' in result)) { // Error result without output text = `Error: ${result.error}`; } else if (result.mode === 'execute' || result.mode === 'stream') { // Execution results with output const execResult = result as { success: boolean; output: string; error?: string; executionTimeMs: number; cachedScript?: string }; const cachedInfo = execResult.cachedScript ? ` [cached: ${execResult.cachedScript}]` : ''; if (execResult.success) { text = `Execution successful${cachedInfo} (${execResult.executionTimeMs}ms)\n\nOutput:\n${execResult.output}`; } else { text = `Execution failed${cachedInfo} (${execResult.executionTimeMs}ms)\n\nError: ${execResult.error}\n\nOutput:\n${execResult.output}`; } } else if (result.mode === 'async') { // Async mode - return execution ID const asyncResult = result as { executionId: string; state: string; message: string }; text = `${asyncResult.message}\n\nExecution ID: ${asyncResult.executionId}\nState: ${asyncResult.state}`; } else if (result.mode === 'status') { // Status mode - return status and output const statusResult = result as { executionId: string; state: string; output: string; errorOutput: string; result?: { success: boolean } }; const isComplete = statusResult.result !== undefined; text = `Execution ${statusResult.executionId}\nState: ${statusResult.state}${isComplete ? ' (completed)' : ''}\n\nOutput:\n${statusResult.output}`; if (statusResult.errorOutput) { text += `\n\nErrors:\n${statusResult.errorOutput}`; } } else if (result.mode === 'cancel') { // Cancel mode const cancelResult = result as { success: boolean; executionId: string; state: string; message?: string }; text = cancelResult.success ? `Execution ${cancelResult.executionId} cancelled. State: ${cancelResult.state}` : `Failed to cancel execution ${cancelResult.executionId}: ${cancelResult.message || 'Unknown error'}`; } else if (result.mode === 'list') { // List mode const listResult = result as { executions: Array<{ executionId: string; state: string; codePreview: string }>; totalCount: number }; if (listResult.executions.length === 0) { text = 'No executions found.'; } else { text = `Found ${listResult.totalCount} execution(s):\n\n`; for (const exec of listResult.executions) { text += `• ${exec.executionId} [${exec.state}]: ${exec.codePreview}\n`; } } } else { // Fallback text = JSON.stringify(result, null, 2); } return { content: [ { type: 'text' as const, text, }, ], structuredContent: result, }; }, ); } /** * Check if TCP transport is configured via environment variables. */ function isUsingTcpTransport(): boolean { const envUseTcp = process.env.SANDBOX_USE_TCP; if (envUseTcp === 'true' || envUseTcp === '1') { return true; } // If TCP host or port is specified, assume TCP mode if (process.env.SANDBOX_TCP_HOST || process.env.SANDBOX_TCP_PORT) { return true; } return false; } /** * Start the gRPC sandbox server. * In TCP mode, connects to an existing remote sandbox server. * In local mode (default), spawns a subprocess. */ async function startSandboxServer(): Promise<void> { // If using TCP transport, connect to remote sandbox server instead of spawning subprocess if (isUsingTcpTransport()) { const host = process.env.SANDBOX_TCP_HOST || 'localhost'; const port = process.env.SANDBOX_TCP_PORT || '50051'; logger.info(`Connecting to remote sandbox server at ${host}:${port}...`); const client = getSandboxClient(); // Will use TCP env vars automatically const healthy = await client.waitForHealthy(10000); if (!healthy) { throw new Error(`Remote sandbox server at ${host}:${port} is not reachable`); } logger.info('Remote sandbox server is ready'); return; } // Local mode: spawn subprocess // Use require.resolve to find the sandbox-server package, works both in development // (where it's in packages/) and when installed from npm (where it's in node_modules/) const sandboxServerPath = require.resolve('@prodisco/sandbox-server/server'); const socketPath = process.env.SANDBOX_SOCKET_PATH || '/tmp/prodisco-sandbox.sock'; logger.info('Starting sandbox gRPC server...'); sandboxProcess = spawn('node', [sandboxServerPath], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, SANDBOX_SOCKET_PATH: socketPath, SCRIPTS_CACHE_DIR, }, }); // Log sandbox server output sandboxProcess.stdout?.on('data', (data: Buffer) => { logger.info(`[sandbox] ${data.toString().trim()}`); }); sandboxProcess.stderr?.on('data', (data: Buffer) => { logger.error(`[sandbox] ${data.toString().trim()}`); }); sandboxProcess.on('error', (error) => { logger.error('Sandbox server process error', error); }); sandboxProcess.on('exit', (code, signal) => { if (code !== 0 && code !== null) { logger.error(`Sandbox server exited with code ${code}`); } else if (signal) { logger.info(`Sandbox server terminated by signal ${signal}`); } sandboxProcess = null; }); // Wait for the sandbox server to become healthy const client = getSandboxClient({ socketPath }); const healthy = await client.waitForHealthy(10000); if (!healthy) { throw new Error('Sandbox server failed to start within timeout'); } logger.info('Sandbox gRPC server is ready'); } /** * Stop the sandbox server subprocess (only in local mode). */ function stopSandboxServer(): void { closeSandboxClient(); // Only stop subprocess if running in local mode if (sandboxProcess) { logger.info('Stopping sandbox server...'); sandboxProcess.kill('SIGTERM'); sandboxProcess = null; } } /** * Parse command line arguments for transport configuration. */ function parseArgs(args: string[]): { clearCache: boolean; transport: 'stdio' | 'http'; host: string; port: number; configPath?: string; installMissing: boolean; } { const clearCache = args.includes('--clear-cache'); // --config <path> (optional) const configIdx = args.indexOf('--config'); let configPath: string | undefined; if (configIdx !== -1) { const value = args[configIdx + 1]; if (!value || value.startsWith('--')) { throw new Error('Missing value for --config <path>'); } configPath = value; } // Environment fallback for config path if (!configPath && process.env.PRODISCO_CONFIG_PATH) { configPath = process.env.PRODISCO_CONFIG_PATH; } // --install-missing (optional, can also be set via env) const envInstall = process.env.PRODISCO_INSTALL_MISSING; const installMissing = args.includes('--install-missing') || envInstall === '1' || envInstall === 'true'; // Check for --transport flag const transportIdx = args.indexOf('--transport'); let transport: 'stdio' | 'http' = 'stdio'; if (transportIdx !== -1) { const value = args[transportIdx + 1]; if (value) { const lowerValue = value.toLowerCase(); if (lowerValue === 'http' || lowerValue === 'sse') { transport = 'http'; } else if (lowerValue !== 'stdio') { logger.warn(`Unknown transport "${value}", defaulting to stdio`); } } } // Check for --port flag (implies HTTP transport) const portIdx = args.indexOf('--port'); let port = 3000; if (portIdx !== -1) { const portValue = args[portIdx + 1]; if (portValue) { port = parseInt(portValue, 10); if (isNaN(port) || port < 1 || port > 65535) { throw new Error(`Invalid port number: ${portValue}`); } transport = 'http'; // --port implies HTTP transport } } // Check for --host flag const hostIdx = args.indexOf('--host'); let host = '127.0.0.1'; if (hostIdx !== -1) { const hostValue = args[hostIdx + 1]; if (hostValue) { host = hostValue; } } // Environment variables can also configure transport if (process.env.MCP_TRANSPORT === 'http' || process.env.MCP_TRANSPORT === 'sse') { transport = 'http'; } if (process.env.MCP_PORT) { port = parseInt(process.env.MCP_PORT, 10); transport = 'http'; } if (process.env.MCP_HOST) { host = process.env.MCP_HOST; } return { clearCache, transport, host, port, configPath, installMissing }; } /** * Start the MCP server with HTTP transport using StreamableHTTPServerTransport. */ async function startHttpTransport(mcpServer: McpServer, host: string, port: number): Promise<void> { // Track active transports by session ID const transports = new Map<string, StreamableHTTPServerTransport>(); httpServer = createServer(async (req, res) => { const url = new URL(req.url || '/', `http://${req.headers.host}`); // Health check endpoint if (url.pathname === '/health' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok' })); return; } // MCP endpoint if (url.pathname === '/mcp') { // Get session ID from header for existing sessions const sessionId = req.headers['mcp-session-id'] as string | undefined; if (sessionId && transports.has(sessionId)) { // Reuse existing transport for this session const transport = transports.get(sessionId)!; await transport.handleRequest(req, res); } else if (req.method === 'POST') { // New session - create a new transport const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId) => { transports.set(newSessionId, transport); logger.info(`New MCP session: ${newSessionId}`); }, onsessionclosed: (closedSessionId) => { transports.delete(closedSessionId); logger.info(`MCP session closed: ${closedSessionId}`); }, }); transport.onclose = () => { if (transport.sessionId) { transports.delete(transport.sessionId); } }; // Connect the transport to the MCP server await mcpServer.connect(transport); await transport.handleRequest(req, res); } else if (req.method === 'GET') { // SSE stream request without existing session - need to init first res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Bad Request', message: 'Session not initialized. Send POST request first.' })); } else { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); } return; } // 404 for other paths res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not Found' })); }); return new Promise((resolve, reject) => { httpServer!.on('error', reject); httpServer!.listen(port, host, () => { logger.info(`ProDisco MCP server ready on http://${host}:${port}/mcp`); resolve(); }); }); } function packageInstallPath(basePath: string, packageName: string): string { const nodeModulesPath = path.join(basePath, 'node_modules'); if (packageName.startsWith('@')) { const parts = packageName.split('/'); if (parts.length >= 2) { return path.join(nodeModulesPath, parts[0]!, parts[1]!); } } return path.join(nodeModulesPath, packageName); } function isPackageInstalled(basePath: string, packageName: string): boolean { return fs.existsSync(packageInstallPath(basePath, packageName)); } async function ensureDepsCacheDir(basePath: string): Promise<void> { await fs.promises.mkdir(basePath, { recursive: true }); const pkgJsonPath = path.join(basePath, 'package.json'); if (!fs.existsSync(pkgJsonPath)) { await fs.promises.writeFile( pkgJsonPath, JSON.stringify({ name: 'prodisco-deps-cache', private: true }, null, 2), 'utf-8', ); } } async function npmInstallPackages(cwd: string, packages: string[]): Promise<void> { if (packages.length === 0) { return; } await new Promise<void>((resolve, reject) => { const child = spawn( 'npm', ['install', '--no-audit', '--no-fund', '--no-progress', ...packages], { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: process.env, }, ); let stderr = ''; child.stdout?.on('data', (data: Buffer) => { const line = data.toString().trim(); if (line) { logger.info(`[npm] ${line}`); } }); child.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); child.on('error', (error) => reject(error)); child.on('exit', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`npm install failed (exit ${code}): ${stderr.trim()}`)); } }); }); } async function main() { // Parse command line arguments const args = process.argv.slice(2); // Show help if (args.includes('--help') || args.includes('-h')) { console.log(` ProDisco MCP Server - Progressive Disclosure Usage: prodisco-k8s [options] Transport Options: --transport <mode> Transport mode: stdio (default) or http --host <host> HTTP host to bind to (default: 127.0.0.1) --port <port> HTTP port to listen on (default: 3000, implies --transport http) Other Options: --config <path> Path to YAML/JSON config listing libraries to index/allow --install-missing Auto-install missing libraries into .cache/deps (opt-in) --clear-cache Clear the scripts cache on startup --help, -h Show this help message Environment Variables: PRODISCO_CONFIG_PATH Path to YAML/JSON config listing libraries to index/allow PRODISCO_INSTALL_MISSING If set to 1/true, auto-install missing libraries into .cache/deps MCP_TRANSPORT Transport mode (stdio or http) MCP_HOST HTTP host to bind to MCP_PORT HTTP port to listen on Examples: # Stdio mode (for Claude Desktop, claude mcp add) prodisco-k8s # Use a custom libraries config (YAML/JSON) prodisco-k8s --config prodisco.config.yaml # HTTP mode on default port prodisco-k8s --transport http # HTTP mode on custom port prodisco-k8s --port 8080 # HTTP mode on all interfaces prodisco-k8s --host 0.0.0.0 --port 3000 `); process.exit(0); } const { clearCache, transport, host, port, configPath, installMissing } = parseArgs(args); // Load libraries config (defaults to built-in list for backward compatibility) const librariesConfig = configPath ? await loadLibrariesConfigFile(configPath) : DEFAULT_LIBRARIES_CONFIG; const libraryNames = librariesConfig.libraries.map((l) => l.name); logger.info(`Configured libraries: ${libraryNames.join(', ')}`); // Resolve base path for node_modules let modulesBasePath: string; if (installMissing) { modulesBasePath = path.join(PACKAGE_ROOT, '.cache', 'deps'); await ensureDepsCacheDir(modulesBasePath); const missing = libraryNames.filter((name) => !isPackageInstalled(modulesBasePath, name)); if (missing.length > 0) { logger.info(`Installing ${missing.length} missing package(s) into ${modulesBasePath}...`); await npmInstallPackages(modulesBasePath, missing); } const stillMissing = libraryNames.filter((name) => !isPackageInstalled(modulesBasePath, name)); if (stillMissing.length > 0) { throw new Error( `Missing packages even after install: ${stillMissing.join(', ')}` ); } } else { modulesBasePath = resolveNodeModulesBasePath({ startDir: PACKAGE_ROOT, fallbackDir: process.cwd(), }); const missing = libraryNames.filter((name) => !isPackageInstalled(modulesBasePath, name)); if (missing.length > 0) { throw new Error( `Missing packages: ${missing.join(', ')}. ` + 'Install them into your environment or start with --install-missing.' ); } } logger.info(`Node modules base path: ${modulesBasePath}`); // Configure sandbox allowlist for local sandbox-server subprocess process.env.SANDBOX_ALLOWED_MODULES = JSON.stringify(libraryNames); process.env.SANDBOX_MODULES_BASE_PATH = modulesBasePath; const searchToolsRuntimeConfig: SearchToolsRuntimeConfig = { libraries: librariesConfig.libraries, basePath: modulesBasePath, }; const searchTools = createSearchToolsTool(searchToolsRuntimeConfig); const runSandbox = createRunSandboxTool({ libraries: librariesConfig.libraries }); // Create MCP server with dynamic instructions, then register resources/tools const mcpServer = new McpServer( { name: 'kubernetes-mcp', version: typeof pkg.version === 'string' ? pkg.version : '0.0.0', }, { instructions: buildServerInstructions(librariesConfig.libraries), }, ); registerGeneratedResources(mcpServer); registerTools(mcpServer, { searchTools, runSandbox }); // Handle --clear-cache flag if (clearCache) { logger.info('Clearing scripts cache...'); try { await fs.promises.rm(SCRIPTS_CACHE_DIR, { recursive: true, force: true }); logger.info('Scripts cache cleared'); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.warn(`Failed to clear cache: ${message}`); } } // Ensure scripts cache directory exists (server-controlled location) await fs.promises.mkdir(SCRIPTS_CACHE_DIR, { recursive: true }); logger.info(`Scripts cache directory: ${SCRIPTS_CACHE_DIR}`); // Start the sandbox gRPC server await startSandboxServer(); // No built-in cluster probes: ProDisco is library-agnostic; connectivity checks are up to user code. // Pre-warm the Orama search index to avoid delay on first search await warmupSearchIndex(searchToolsRuntimeConfig); // Start the appropriate transport if (transport === 'http') { await startHttpTransport(mcpServer, host, port); } else { const stdioTransport = new StdioServerTransport(); await mcpServer.connect(stdioTransport); logger.info('ProDisco MCP server ready on stdio'); } } /** * Graceful shutdown handler. * Stops the sandbox server, HTTP server, and cleans up resources. */ async function shutdown(signal: string): Promise<void> { logger.info(`Received ${signal}, shutting down gracefully...`); try { // Close HTTP server if running if (httpServer) { await new Promise<void>((resolve) => { httpServer!.close(() => resolve()); }); httpServer = null; logger.info('HTTP server stopped'); } stopSandboxServer(); await shutdownSearchIndex(); logger.info('Shutdown complete'); process.exit(0); } catch (error) { logger.error('Error during shutdown', error); process.exit(1); } } // Register shutdown handlers process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); main().catch((error) => { logger.error('Fatal error starting MCP server', error); process.exit(1); });

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/harche/ProDisco'

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