Skip to main content
Glama
bvisible

MCP SSH Manager

ssh_session_start

Start a persistent SSH session to maintain state and context for remote server management, enabling command execution and file transfers across configured servers.

Instructions

Start a persistent SSH session that maintains state and context

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
serverYesServer name from configuration
nameNoOptional session name for identification

Implementation Reference

  • 'ssh_session_start' is listed in the TOOL_GROUPS.sessions array, used for conditional tool registration based on user configuration.
    // Sessions group (4 tools) - Persistent SSH session management
    sessions: [
      'ssh_session_start',
      'ssh_session_send',
      'ssh_session_list',
      'ssh_session_close'
    ],
  • The createSession function creates, initializes, and returns a new persistent SSH session instance. This is the primary implementation logic for the 'ssh_session_start' tool.
      const sessionId = `ssh_${Date.now()}_${uuidv4().substring(0, 8)}`;
    
      const session = new SSHSession(sessionId, serverName, ssh);
      sessions.set(sessionId, session);
    
      try {
        await session.initialize();
    
        logger.info('SSH session created', {
          id: sessionId,
          server: serverName
        });
    
        return session;
      } catch (error) {
        sessions.delete(sessionId);
        throw error;
      }
    }
  • SSHSession class provides the stateful session management, shell handling, command execution, context tracking (CWD, env, history), and lifecycle methods used by ssh_session_start.
    class SSHSession {
      constructor(id, serverName, ssh) {
        this.id = id;
        this.serverName = serverName;
        this.ssh = ssh;
        this.state = SESSION_STATES.INITIALIZING;
        this.context = {
          cwd: null,
          env: {},
          history: [],
          variables: {}
        };
        this.createdAt = new Date();
        this.lastActivity = new Date();
        this.shell = null;
        this.outputBuffer = '';
        this.errorBuffer = '';
      }
    
      /**
       * Initialize the session with a shell
       */
      async initialize() {
        try {
          logger.info(`Initializing SSH session ${this.id}`, {
            server: this.serverName
          });
    
          // Start an interactive shell
          this.shell = await this.ssh.requestShell({
            term: 'xterm-256color',
            cols: 80,
            rows: 24
          });
    
          // Setup event handlers
          this.shell.on('data', (data) => {
            this.outputBuffer += data.toString();
            this.lastActivity = new Date();
    
            // Log output in verbose mode
            if (logger.verbose) {
              logger.debug(`Session ${this.id} output`, {
                data: data.toString().substring(0, 200)
              });
            }
          });
    
          this.shell.on('close', () => {
            logger.info(`Session ${this.id} shell closed`);
            this.state = SESSION_STATES.CLOSED;
            this.cleanup();
          });
    
          this.shell.stderr.on('data', (data) => {
            this.errorBuffer += data.toString();
            logger.warn(`Session ${this.id} stderr`, {
              error: data.toString()
            });
          });
    
          // Wait for shell prompt
          await this.waitForPrompt();
    
          // Allow context queries through standard execute flow
          this.state = SESSION_STATES.READY;
    
          // Get initial working directory
          await this.updateContext();
    
          logger.info(`Session ${this.id} initialized`, {
            server: this.serverName,
            cwd: this.context.cwd
          });
    
        } catch (error) {
          this.state = SESSION_STATES.ERROR;
          logger.error(`Failed to initialize session ${this.id}`, {
            error: error.message
          });
          throw error;
        }
      }
    
      /**
       * Wait for shell prompt
       */
      async waitForPrompt(timeout = 5000) {
        const startTime = Date.now();
    
        while (Date.now() - startTime < timeout) {
          // Check if we have a prompt (ends with $ or # typically)
          if (this.outputBuffer.match(/[$#>]\s*$/)) {
            return true;
          }
    
          // Wait a bit
          await new Promise(resolve => setTimeout(resolve, 100));
        }
    
        throw new Error('Timeout waiting for shell prompt');
      }
    
      /**
       * Update session context (pwd, env)
       */
      async updateContext() {
        try {
          // Get current directory
          const pwdResult = await this.execute('pwd', { silent: true });
          if (pwdResult.success) {
            this.context.cwd = pwdResult.output.trim();
          }
    
          // Get environment variables (selective)
          const envResult = await this.execute('echo $PATH:$USER:$HOME', { silent: true });
          if (envResult.success) {
            const [path, user, home] = envResult.output.trim().split(':');
            this.context.env = { PATH: path, USER: user, HOME: home };
          }
        } catch (error) {
          logger.warn(`Failed to update context for session ${this.id}`, {
            error: error.message
          });
        }
      }
    
      /**
       * Execute a command in the session
       */
      async execute(command, options = {}) {
        if (this.state !== SESSION_STATES.READY) {
          throw new Error(`Session ${this.id} is not ready (state: ${this.state})`);
        }
    
        this.state = SESSION_STATES.BUSY;
        this.lastActivity = new Date();
    
        try {
          // Clear buffers
          this.outputBuffer = '';
          this.errorBuffer = '';
    
          // Add to history unless silent
          if (!options.silent) {
            this.context.history.push({
              command,
              timestamp: new Date(),
              cwd: this.context.cwd
            });
    
            logger.info(`Session ${this.id} executing`, {
              command: command.substring(0, 100),
              server: this.serverName
            });
          }
    
          // Send command
          this.shell.write(command + '\n');
    
          // Wait for command to complete
          await this.waitForPrompt(options.timeout || 30000);
    
          // Parse output (remove command echo and prompt)
          let output = this.outputBuffer;
    
          // Remove the command echo (first line)
          const lines = output.split('\n');
          if (lines[0].includes(command)) {
            lines.shift();
          }
    
          // Remove the prompt (last line)
          const lastLine = lines[lines.length - 1];
          if (lastLine.match(/[$#>]\s*$/)) {
            lines.pop();
          }
    
          output = lines.join('\n').trim();
    
          // Check for command success (basic heuristic)
          const success = !this.errorBuffer && !output.includes('command not found');
    
          // Update context if command might have changed it
          if (command.startsWith('cd ') || command.startsWith('export ')) {
            await this.updateContext();
          }
    
          this.state = SESSION_STATES.READY;
    
          return {
            success,
            output,
            error: this.errorBuffer,
            session: this.id
          };
    
        } catch (error) {
          this.state = SESSION_STATES.ERROR;
          logger.error(`Session ${this.id} execution failed`, {
            command,
            error: error.message
          });
          throw error;
        }
      }
    
      /**
       * Set session variable
       */
      setVariable(name, value) {
        this.context.variables[name] = value;
        this.lastActivity = new Date();
      }
    
      /**
       * Get session variable
       */
      getVariable(name) {
        return this.context.variables[name];
      }
    
      /**
       * Get session info
       */
      getInfo() {
        return {
          id: this.id,
          server: this.serverName,
          state: this.state,
          cwd: this.context.cwd,
          env: this.context.env,
          created: this.createdAt,
          lastActivity: this.lastActivity,
          historyCount: this.context.history.length,
          variables: Object.keys(this.context.variables)
        };
      }
    
      /**
       * Close the session
       */
      close() {
        logger.info(`Closing session ${this.id}`);
    
        if (this.shell) {
          this.shell.write('exit\n');
          this.shell.end();
          this.shell = null;
        }
    
        this.state = SESSION_STATES.CLOSED;
        this.cleanup();
      }
    
      /**
       * Cleanup resources
       */
      cleanup() {
        sessions.delete(this.id);
        this.outputBuffer = '';
        this.errorBuffer = '';
        this.context.history = [];
      }
    }
  • SSHManager class handles SSH connections, authentication, command execution, SFTP, which is required to obtain the connected 'ssh' client passed to createSession.
    class SSHManager {
      constructor(config) {
        this.config = config;
        this.client = new Client();
        this.connected = false;
        this.sftp = null;
        this.cachedHomeDir = null;
        this.autoAcceptHostKey = config.autoAcceptHostKey || false;
        this.hostKeyVerification = config.hostKeyVerification !== false; // Default true
      }
    
      async connect() {
        return new Promise((resolve, reject) => {
          this.client.on('ready', () => {
            this.connected = true;
            resolve();
          });
    
          this.client.on('error', (err) => {
            this.connected = false;
            reject(err);
          });
    
          this.client.on('end', () => {
            this.connected = false;
          });
    
          // Build connection config
          const connConfig = {
            host: this.config.host,
            port: this.config.port || 22,
            username: this.config.user,
            readyTimeout: 60000, // Increased from 20000 to 60000 for slow connections
            keepaliveInterval: 10000,
            // Add compatibility options for problematic servers
            algorithms: {
              kex: ['ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521', 'diffie-hellman-group-exchange-sha256', 'diffie-hellman-group14-sha256', 'diffie-hellman-group14-sha1'],
              cipher: ['aes128-ctr', 'aes192-ctr', 'aes256-ctr', 'aes128-gcm', 'aes256-gcm', 'aes128-cbc', 'aes192-cbc', 'aes256-cbc'],
              serverHostKey: ['ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519'],
              hmac: ['hmac-sha2-256', 'hmac-sha2-512', 'hmac-sha1']
            },
            debug: (info) => {
              if (info.includes('Handshake') || info.includes('error')) {
                logger.debug('SSH2 Debug', { info });
              }
            }
          };
    
          // Add host key verification callback if enabled
          if (this.hostKeyVerification) {
            connConfig.hostVerifier = (hashedKey) => {
              const port = this.config.port || 22;
              const host = this.config.host;
    
              // Check if host is already known
              if (isHostKnown(host, port)) {
                // For now, accept all known hosts
                // TODO: Implement proper fingerprint comparison once we understand SSH2's hash format
                logger.info('Host key verified', { host, port });
                return true;
              }
    
              // Host is not known
              logger.info('New host detected', { host, port });
    
              // If autoAcceptHostKey is enabled, accept and add the key
              if (this.autoAcceptHostKey) {
                logger.info('Auto-accept host key', { host, port });
                // Schedule key addition after connection
                setImmediate(async () => {
                  try {
                    await addHostKey(host, port);
                    logger.info('Host key added', { host, port });
                  } catch (err) {
                    logger.warn('Failed to add host key', {
                      host,
                      port,
                      error: err.message
                    });
                  }
                });
                return true;
              }
    
              // For backward compatibility, accept new hosts by default
              // In production, you might want to prompt the user or check a whitelist
              logger.warn('Auto-accepting new host', { host, port });
              return true;
            };
          }
    
          // Add authentication (support both keyPath and keypath for compatibility)
          const keyPath = this.config.keyPath || this.config.keypath;
          if (keyPath) {
            const resolvedKeyPath = keyPath.replace('~', process.env.HOME);
            connConfig.privateKey = fs.readFileSync(resolvedKeyPath);
          } else if (this.config.password) {
            connConfig.password = this.config.password;
          }
    
          this.client.connect(connConfig);
        });
      }
    
      async execCommand(command, options = {}) {
        if (!this.connected) {
          throw new Error('Not connected to SSH server');
        }
    
        const { timeout = 30000, cwd, rawCommand = false } = options;
        const fullCommand = (cwd && !rawCommand) ? `cd ${cwd} && ${command}` : command;
    
        return new Promise((resolve, reject) => {
          let stdout = '';
          let stderr = '';
          let completed = false;
          let stream = null;
          let timeoutId = null;
    
          // Setup timeout first
          if (timeout > 0) {
            timeoutId = setTimeout(() => {
              if (!completed) {
                completed = true;
    
                // Try multiple ways to kill the stream
                if (stream) {
                  try {
                    stream.write('\x03'); // Send Ctrl+C
                    stream.end();
                    stream.destroy();
                  } catch (e) {
                    // Ignore errors
                  }
                }
    
                // Kill the entire client connection as last resort
                try {
                  this.client.end();
                  this.connected = false;
                } catch (e) {
                  // Ignore errors
                }
    
                reject(new Error(`Command timeout after ${timeout}ms: ${command.substring(0, 100)}...`));
              }
            }, timeout);
          }
    
          this.client.exec(fullCommand, (err, streamObj) => {
            if (err) {
              completed = true;
              if (timeoutId) clearTimeout(timeoutId);
              reject(err);
              return;
            }
    
            stream = streamObj;
    
            stream.on('close', (code, signal) => {
              if (!completed) {
                completed = true;
                if (timeoutId) clearTimeout(timeoutId);
                resolve({
                  stdout,
                  stderr,
                  code: code || 0,
                  signal
                });
              }
            });
    
            stream.on('data', (data) => {
              stdout += data.toString();
            });
    
            stream.stderr.on('data', (data) => {
              stderr += data.toString();
            });
    
            stream.on('error', (err) => {
              if (!completed) {
                completed = true;
                if (timeoutId) clearTimeout(timeoutId);
                reject(err);
              }
            });
          });
        });
      }
    
      async execCommandStream(command, options = {}) {
        if (!this.connected) {
          throw new Error('Not connected to SSH server');
        }
    
        const { cwd, onStdout, onStderr } = options;
        const fullCommand = cwd ? `cd ${cwd} && ${command}` : command;
    
        return new Promise((resolve, reject) => {
          this.client.exec(fullCommand, (err, stream) => {
            if (err) {
              reject(err);
              return;
            }
    
            let stdout = '';
            let stderr = '';
    
            stream.on('close', (code, signal) => {
              resolve({
                stdout,
                stderr,
                code: code || 0,
                signal,
                stream
              });
            });
    
            stream.on('data', (data) => {
              const chunk = data.toString();
              stdout += chunk;
              if (onStdout) onStdout(chunk);
            });
    
            stream.stderr.on('data', (data) => {
              const chunk = data.toString();
              stderr += chunk;
              if (onStderr) onStderr(chunk);
            });
    
            stream.on('error', reject);
          });
        });
      }
    
      async requestShell(options = {}) {
        if (!this.connected) {
          throw new Error('Not connected to SSH server');
        }
    
        return new Promise((resolve, reject) => {
          this.client.shell(options, (err, stream) => {
            if (err) {
              reject(err);
              return;
            }
            resolve(stream);
          });
        });
      }
    
      async getSFTP() {
        if (this.sftp) return this.sftp;
    
        return new Promise((resolve, reject) => {
          this.client.sftp((err, sftp) => {
            if (err) {
              reject(err);
              return;
            }
            this.sftp = sftp;
            resolve(sftp);
          });
        });
      }
    
      async resolveHomePath() {
        if (this.cachedHomeDir) {
          return this.cachedHomeDir;
        }
    
        let homeDir = null;
    
        // Method 1: Try getent (most reliable)
        try {
          const result = await this.execCommand('getent passwd $USER | cut -d: -f6', {
            timeout: 5000,
            rawCommand: true
          });
          homeDir = result.stdout.trim();
          if (homeDir && homeDir.startsWith('/')) {
            this.cachedHomeDir = homeDir;
            return homeDir;
          }
        } catch (err) {
          // getent might not be available, try next method
        }
    
        // Method 2: Try env -i to get clean HOME
        try {
          const result = await this.execCommand('env -i HOME=$HOME bash -c "echo $HOME"', {
            timeout: 5000,
            rawCommand: true
          });
          homeDir = result.stdout.trim();
          if (homeDir && homeDir.startsWith('/')) {
            this.cachedHomeDir = homeDir;
            return homeDir;
          }
        } catch (err) {
          // env method failed, try next
        }
    
        // Method 3: Parse /etc/passwd directly
        try {
          const result = await this.execCommand('grep "^$USER:" /etc/passwd | cut -d: -f6', {
            timeout: 5000,
            rawCommand: true
          });
          homeDir = result.stdout.trim();
          if (homeDir && homeDir.startsWith('/')) {
            this.cachedHomeDir = homeDir;
            return homeDir;
          }
        } catch (err) {
          // /etc/passwd parsing failed, try last resort
        }
    
        // Method 4: Last resort - try cd ~ && pwd
        try {
          const result = await this.execCommand('cd ~ && pwd', {
            timeout: 5000,
            rawCommand: true
          });
          homeDir = result.stdout.trim();
          if (homeDir && homeDir.startsWith('/')) {
            this.cachedHomeDir = homeDir;
            return homeDir;
          }
        } catch (err) {
          // All methods failed
        }
    
        throw new Error('Unable to determine home directory on remote server');
      }
    
      async putFile(localPath, remotePath) {
        // SFTP doesn't resolve ~ automatically, we need to get the real path
        let resolvedRemotePath = remotePath;
        if (remotePath.includes('~')) {
          try {
            const homeDir = await this.resolveHomePath();
            // Replace ~ with the actual home directory
            // Handle both ~/path and ~ alone
            if (remotePath === '~') {
              resolvedRemotePath = homeDir;
            } else if (remotePath.startsWith('~/')) {
              resolvedRemotePath = homeDir + remotePath.substring(1);
            } else {
              // If ~ is not at the beginning, don't replace it
              resolvedRemotePath = remotePath;
            }
          } catch (err) {
            // If we can't resolve home, throw a more descriptive error
            throw new Error(`Failed to resolve home directory for path: ${remotePath}. ${err.message}`);
          }
        }
    
        const sftp = await this.getSFTP();
        return new Promise((resolve, reject) => {
          // Check if local file exists and is readable
          if (!fs.existsSync(localPath)) {
            reject(new Error(`Local file does not exist: ${localPath}`));
            return;
          }
    
          sftp.fastPut(localPath, resolvedRemotePath, (err) => {
            if (err) reject(err);
            else resolve();
          });
        });
      }
    
      async getFile(localPath, remotePath) {
        // SFTP doesn't resolve ~ automatically, we need to get the real path
        let resolvedRemotePath = remotePath;
        if (remotePath.includes('~')) {
          try {
            const homeDir = await this.resolveHomePath();
            // Replace ~ with the actual home directory
            // Handle both ~/path and ~ alone
            if (remotePath === '~') {
              resolvedRemotePath = homeDir;
            } else if (remotePath.startsWith('~/')) {
              resolvedRemotePath = homeDir + remotePath.substring(1);
            } else {
              // If ~ is not at the beginning, don't replace it
              resolvedRemotePath = remotePath;
            }
          } catch (err) {
            // If we can't resolve home, throw a more descriptive error
            throw new Error(`Failed to resolve home directory for path: ${remotePath}. ${err.message}`);
          }
        }
    
        const sftp = await this.getSFTP();
        return new Promise((resolve, reject) => {
          sftp.fastGet(resolvedRemotePath, localPath, (err) => {
            if (err) reject(err);
            else resolve();
          });
        });
      }
    
      async putFiles(files, options = {}) {
        const sftp = await this.getSFTP();
        const results = [];
    
        for (const file of files) {
          try {
            await this.putFile(file.local, file.remote);
            results.push({ ...file, success: true });
          } catch (error) {
            results.push({ ...file, success: false, error: error.message });
            if (options.stopOnError) break;
          }
        }
    
        return results;
      }
    
      isConnected() {
        return this.connected && this.client && !this.client.destroyed;
      }
    
      dispose() {
        if (this.sftp) {
          this.sftp.end();
          this.sftp = null;
        }
        if (this.client) {
          this.client.end();
          this.connected = false;
        }
      }
    
      async ping() {
        try {
          const result = await this.execCommand('echo "ping"', { timeout: 5000 });
          return result.stdout.trim() === 'ping';
        } catch (error) {
          return false;
        }
      }
    }

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/bvisible/mcp-ssh-manager'

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