UNPKG

@tb.p/terminai

Version:

MCP (Model Context Protocol) server for secure SSH remote command execution. Enables AI assistants like Claude, Cursor, and VS Code to execute commands on remote servers via SSH with command validation, history tracking, and web-based configuration UI.

480 lines (407 loc) 13.6 kB
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { loadConfig } from './config/loader.js'; import { TOOL_DEFINITIONS } from './tools/index.js'; // Track if UI server is already running let uiServerRunning = false; export function createServer(options = {}) { const { configPath } = options; const config = loadConfig(configPath); const server = new Server( { name: 'terminai', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: TOOL_DEFINITIONS }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case 'terminai_list_connections': return await listConnections(config); case 'terminai_get_config': return await getConfig(config); case 'terminai_add_connection': case 'terminai_update_connection': case 'terminai_remove_connection': return await handleConnectionManagement(name, args, config, configPath); case 'terminai_exec': return await execCommand(config, args); case 'terminai_get_history': return await handleHistory(name, args); case 'terminai_open_config_ui': return await openConfigUI(args, configPath); default: throw new Error(`Unknown tool: ${name}`); } }); return server; } async function listConnections(config) { const connections = Object.values(config.connections).map(conn => ({ name: conn.name?.trim() || conn.name, description: conn.description, host: conn.host?.trim() || conn.host, username: conn.username?.trim() || conn.username, allowedCommands: conn.allowedCommands, disallowedCommands: conn.disallowedCommands, })); return { content: [{ type: 'text', text: JSON.stringify({ connections }, null, 2) }] }; } async function getConfig(config) { return { content: [{ type: 'text', text: JSON.stringify(config, null, 2) }] }; } async function handleConnectionManagement(toolName, args, config, configPath) { const { saveConfig } = await import('./config/loader.js'); try { if (toolName === 'terminai_add_connection') { const { name, description = '', host, port = 22, username, identityFile, allowedCommands = [], disallowedCommands = [] } = args; if (!name || !host || !username || !identityFile) { throw new Error('Missing required fields: name, host, username, identityFile'); } const trimmedName = name.trim(); const trimmedHost = host.trim(); const trimmedUsername = username.trim(); if (config.connections[trimmedName]) { throw new Error(`Connection '${trimmedName}' already exists`); } config.connections[trimmedName] = { name: trimmedName, description: description?.trim() || description, host: trimmedHost, port, username: trimmedUsername, identityFile: identityFile?.trim() || identityFile, allowedCommands, disallowedCommands, }; saveConfig(config, configPath); return { content: [{ type: 'text', text: `Successfully added connection '${trimmedName}'` }] }; } else if (toolName === 'terminai_update_connection') { const { name, ...updates } = args; if (!name) { throw new Error('Missing required field: name'); } const trimmedName = name.trim(); if (!config.connections[trimmedName]) { throw new Error(`Connection '${trimmedName}' not found`); } // Trim fields that are being updated const trimmedUpdates = { ...updates }; if (trimmedUpdates.name !== undefined) { trimmedUpdates.name = trimmedUpdates.name.trim(); // If name is being updated, we need to rename the connection key if (trimmedUpdates.name !== trimmedName) { if (config.connections[trimmedUpdates.name]) { throw new Error(`Connection '${trimmedUpdates.name}' already exists`); } // Apply other trimmed updates first if (trimmedUpdates.host !== undefined) { trimmedUpdates.host = trimmedUpdates.host.trim(); } if (trimmedUpdates.username !== undefined) { trimmedUpdates.username = trimmedUpdates.username.trim(); } if (trimmedUpdates.description !== undefined && trimmedUpdates.description) { trimmedUpdates.description = trimmedUpdates.description.trim(); } if (trimmedUpdates.identityFile !== undefined && trimmedUpdates.identityFile) { trimmedUpdates.identityFile = trimmedUpdates.identityFile.trim(); } config.connections[trimmedUpdates.name] = { ...config.connections[trimmedName], ...trimmedUpdates, }; delete config.connections[trimmedName]; saveConfig(config, configPath); return { content: [{ type: 'text', text: `Successfully updated connection '${trimmedUpdates.name}' (renamed from '${trimmedName}')` }] }; } } if (trimmedUpdates.host !== undefined) { trimmedUpdates.host = trimmedUpdates.host.trim(); } if (trimmedUpdates.username !== undefined) { trimmedUpdates.username = trimmedUpdates.username.trim(); } if (trimmedUpdates.description !== undefined && trimmedUpdates.description) { trimmedUpdates.description = trimmedUpdates.description.trim(); } if (trimmedUpdates.identityFile !== undefined && trimmedUpdates.identityFile) { trimmedUpdates.identityFile = trimmedUpdates.identityFile.trim(); } config.connections[trimmedName] = { ...config.connections[trimmedName], ...trimmedUpdates, }; saveConfig(config, configPath); return { content: [{ type: 'text', text: `Successfully updated connection '${trimmedName}'` }] }; } else if (toolName === 'terminai_remove_connection') { const { name } = args; if (!name) { throw new Error('Missing required field: name'); } const trimmedName = name.trim(); if (!config.connections[trimmedName]) { throw new Error(`Connection '${trimmedName}' not found`); } delete config.connections[trimmedName]; saveConfig(config, configPath); return { content: [{ type: 'text', text: `Successfully removed connection '${trimmedName}'` }] }; } } catch (error) { return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }; } } async function execCommand(config, args) { const { validateCommand } = await import('./commands/validator.js'); const { executeCommand } = await import('./ssh.js'); const { appendHistory } = await import('./history/store.js'); function applyRedaction(text, patterns) { let redacted = text; for (const pattern of patterns) { try { const regex = new RegExp(pattern, 'g'); redacted = redacted.replace(regex, '[REDACTED]'); } catch (error) { console.error(`Invalid redaction pattern: ${pattern}`); } } return redacted; } function truncateOutput(text, maxLength) { if (text.length <= maxLength) { return text; } return text.substring(0, maxLength) + '\n... (output truncated)'; } try { const { connection: connectionName, command } = args; if (!connectionName || !command) { throw new Error('Missing required fields: connection, command'); } const validation = validateCommand(command, connectionName, config); if (!validation.allowed) { appendHistory(connectionName, { command, exitCode: null, duration: 0, status: 'denied', }); return { content: [{ type: 'text', text: `Command denied: ${validation.reason}` }], isError: true }; } const connection = config.connections[connectionName]; const timeout = config.global.defaultTimeout; let result; try { result = await executeCommand(connection, command, timeout); } catch (error) { const status = error.message.includes('timed out') ? 'timeout' : 'error'; appendHistory(connectionName, { command, exitCode: null, duration: timeout, status, stderr: error.message, }); return { content: [{ type: 'text', text: `Error executing command: ${error.message}` }], isError: true }; } let { stdout, stderr, exitCode, duration } = result; if (config.global.logOutput) { stdout = applyRedaction(stdout, config.global.redactPatterns); stderr = applyRedaction(stderr, config.global.redactPatterns); stdout = truncateOutput(stdout, config.global.outputMaxLength); stderr = truncateOutput(stderr, config.global.outputMaxLength); } const status = exitCode === 0 ? 'success' : 'error'; appendHistory(connectionName, { command, exitCode, duration, status, stdout: config.global.logOutput ? stdout : undefined, stderr: config.global.logOutput ? stderr : undefined, }); return { content: [{ type: 'text', text: JSON.stringify({ stdout, stderr, exitCode, duration }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }; } } async function handleHistory(toolName, args) { const { getHistory } = await import('./history/store.js'); try { const { connection, limit = 50 } = args; if (!connection) { throw new Error('Missing required field: connection'); } const entries = getHistory(connection, limit); return { content: [{ type: 'text', text: JSON.stringify({ entries }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true }; } } async function openConfigUI(args, configPath) { const port = args.port || 8374; const url = `http://127.0.0.1:${port}`; try { if (uiServerRunning) { // UI server already running, just open the browser try { const { default: open } = await import('open'); await open(url); } catch (error) { // If open fails, just return the URL } return { content: [{ type: 'text', text: `Configuration UI is already running at ${url}\n\nOpening in browser...` }] }; } // Import and start UI server components directly const express = (await import('express')).default; const { readFileSync } = await import('fs'); const { resolve, dirname } = await import('path'); const { fileURLToPath } = await import('url'); const { createConfigRouter } = await import('./ui/routes/config.js'); const { createFilesRouter } = await import('./ui/routes/files.js'); const { createKeysRouter } = await import('./ui/routes/keys.js'); const { createHistoryRouter } = await import('./ui/routes/history.js'); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const uiDir = resolve(__dirname, 'ui'); const app = express(); app.use(express.json()); app.use('/api', createConfigRouter(configPath)); app.use('/api', createFilesRouter()); app.use('/api', createKeysRouter()); app.use('/api', createHistoryRouter()); app.get('/', (req, res) => { try { const htmlPath = resolve(uiDir, 'index.html'); const html = readFileSync(htmlPath, 'utf-8'); res.send(html); } catch (error) { res.status(500).send('Error loading UI'); } }); // Start server (non-blocking) const server = app.listen(port, '127.0.0.1', () => { // Server started successfully }); uiServerRunning = true; // Wait a moment for server to start await new Promise(resolve => setTimeout(resolve, 500)); // Try to open the browser try { const { default: open } = await import('open'); await open(url); } catch (error) { // If open fails, just return the URL } return { content: [{ type: 'text', text: `Configuration UI started at ${url}\n\nThe UI server is running in the background. You can access it at the URL above.` }] }; } catch (error) { uiServerRunning = false; return { content: [{ type: 'text', text: `Error starting UI server: ${error.message}` }], isError: true }; } } export async function runServer(configPath) { const server = createServer({ configPath }); const transport = new StdioServerTransport(); await server.connect(transport); process.on('SIGINT', async () => { await server.close(); process.exit(0); }); }