Skip to main content
Glama
server.ts15 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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { logger } from './util/logger.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version?: string }; import { searchToolsTool, warmupSearchIndex, shutdownSearchIndex } from './tools/kubernetes/searchTools.js'; import { runSandboxTool } from './tools/kubernetes/runSandbox.js'; import { getSandboxClient, closeSandboxClient } from '@prodisco/sandbox-server/client'; // Track the sandbox server subprocess let sandboxProcess: ChildProcess | null = null; import { PUBLIC_GENERATED_ROOT_PATH_WITH_SLASH, listGeneratedFiles, readGeneratedFile, } from './resources/filesystem.js'; import { probeClusterConnectivity } from './kube/client.js'; import { 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/kubernetes'); const server = new McpServer( { name: 'kubernetes-mcp', version: typeof pkg.version === 'string' ? pkg.version : '0.0.0', }, { instructions: 'Kubernetes and Prometheus operations via Progressive Disclosure. ' + 'Use kubernetes.searchTools to discover available APIs. ' + 'Use kubernetes.runSandbox to execute TypeScript scripts directly. ' + 'The sandbox provides: k8s, kc (pre-configured KubeConfig), console, and require("prometheus-query").', }, ); // Expose generated TypeScript files as MCP resources using ResourceTemplate import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; 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, })), }; }, }, ); server.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`); // Register kubernetes.searchTools helper as an exposed tool. // This tool now supports both modes: 'methods' (API discovery) and 'types' (type definitions) server.registerTool( searchToolsTool.name, { title: 'Kubernetes Search Tools', description: searchToolsTool.description, inputSchema: searchToolsTool.schema, }, async (args: Record<string, unknown>) => { const parsedArgs = await searchToolsTool.schema.parseAsync(args); const result = await searchToolsTool.execute(parsedArgs); // Handle different result modes if (result.mode === 'types') { return { content: [ { type: 'text', text: result.summary, }, { type: 'text', text: JSON.stringify(result.types, null, 2), }, ], structuredContent: result, }; } else if (result.mode === 'scripts') { return { content: [ { type: 'text', text: result.summary, }, { type: 'text', text: JSON.stringify(result.scripts, null, 2), }, ], structuredContent: result, }; } else if (result.mode === 'prometheus') { // Handle metrics category (has 'metrics' array) vs methods (has 'methods' array) if ('category' in result && result.category === 'metrics') { return { content: [ { type: 'text', text: result.summary, }, { type: 'text', text: JSON.stringify(result.metrics, null, 2), }, ], structuredContent: result, }; } // Build summary - handle both success and error cases for PrometheusModeResult | PrometheusErrorResult const methodsResult = result as { summary?: string; error?: string; message?: string; example?: string; methods: unknown }; const summary = 'summary' in result ? result.summary : `${methodsResult.error}: ${methodsResult.message}\nExample: ${methodsResult.example}`; return { content: [ { type: 'text', text: summary, }, { type: 'text', text: JSON.stringify(methodsResult.methods, null, 2), }, ], structuredContent: result, }; } else { // mode === 'methods' return { content: [ { type: 'text', text: result.summary, }, { type: 'text', text: JSON.stringify(result.tools, null, 2), }, ], structuredContent: result, }; } }, ); // Register kubernetes.runSandbox tool for executing scripts in a sandboxed environment server.registerTool( runSandboxTool.name, { title: 'Kubernetes Run Sandbox', description: runSandboxTool.description, inputSchema: runSandboxTool.schema, }, async (args: Record<string, unknown>) => { const parsedArgs = await runSandboxTool.schema.parseAsync(args); const result = await runSandboxTool.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', 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`); } const healthStatus = await client.healthCheck(); logger.info(`Remote sandbox server is ready (context: ${healthStatus.kubernetesContext})`); 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; } } async function main() { // Parse command line arguments const args = process.argv.slice(2); const clearCache = args.includes('--clear-cache'); // 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(); // Probe cluster connectivity before starting the server // This ensures we fail fast if the cluster is not reachable logger.info('Probing Kubernetes cluster connectivity...'); try { await probeClusterConnectivity(); logger.info('Kubernetes cluster is reachable'); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error(`Failed to connect to Kubernetes cluster: ${message}`); throw new Error(`Kubernetes cluster is not accessible: ${message}`); } // Pre-warm the Orama search index to avoid delay on first search await warmupSearchIndex(); const transport = new StdioServerTransport(); await server.connect(transport); logger.info('Kubernetes MCP server ready on stdio'); } /** * Graceful shutdown handler. * Stops the sandbox server, script watcher, and cleans up resources. */ async function shutdown(signal: string): Promise<void> { logger.info(`Received ${signal}, shutting down gracefully...`); try { 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); });

Implementation Reference

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