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;
        }
      }
    }
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries full burden but provides minimal behavioral details. It mentions 'maintains state and context' which hints at persistence, but lacks critical information like authentication requirements, session lifecycle (timeouts, cleanup), resource implications, or error handling. For a stateful tool, this is inadequate.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single, efficient sentence that directly states the tool's purpose with zero wasted words. It's appropriately sized and front-loaded, making it easy to parse quickly.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a stateful session tool with no annotations and no output schema, the description is incomplete. It lacks details on what 'starting a session' entails (e.g., authentication, connection establishment), what state/context means practically, return values, or error conditions. This leaves significant gaps for an AI agent to use it correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so parameters are fully documented in the schema. The description adds no additional meaning beyond what the schema provides (e.g., it doesn't explain what 'server name from configuration' entails or how 'session name' is used). Baseline 3 is appropriate as the schema handles parameter documentation.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the action ('Start') and resource ('persistent SSH session'), specifying it maintains state and context. It distinguishes from siblings like ssh_execute (one-off commands) and ssh_session_list (listing sessions), but doesn't explicitly differentiate from all siblings like ssh_session_close or ssh_tunnel_create.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

No guidance on when to use this tool versus alternatives is provided. The description doesn't mention prerequisites (e.g., configured servers), when not to use it (e.g., for one-off commands), or explicit alternatives like ssh_execute for non-persistent tasks.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

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