@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
JavaScript
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);
});
}