# Directory Structure ``` ├── .github │ └── workflows │ └── publish.yml ├── .gitignore ├── .npmignore ├── bin │ └── mcp-ssh.js ├── CHANGELOG.md ├── claude_desktop_config.json ├── CLAUDE.md ├── doc │ ├── Claude.png │ └── example.png ├── gist_comment.json ├── github_issue_response.md ├── IMPLEMENTATION_NOTES.md ├── LICENSE ├── manifest.json ├── package-lock.json ├── package.json ├── PUBLISHING.md ├── README.md ├── scripts │ └── build-dxt.sh ├── server-simple.mjs ├── src │ ├── index.ts │ ├── ssh-client.ts │ ├── ssh-config-parser.ts │ └── types.ts ├── start-silent.sh ├── start.sh ├── temp-extract │ └── manifest.json └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` 1 | # Development files 2 | src/ 3 | tsconfig.json 4 | .vscode/ 5 | IMPLEMENTATION_NOTES.md 6 | 7 | # Documentation images (keep README.md) 8 | doc/ 9 | 10 | # Development scripts 11 | start.sh 12 | start-silent.sh 13 | 14 | # Git and CI/CD 15 | .git/ 16 | .github/ 17 | .gitignore 18 | *.log 19 | node_modules/ 20 | 21 | # Generated files 22 | *.tgz 23 | 24 | # Keep these files for npm package 25 | !package.json 26 | !README.md 27 | !server-simple.mjs 28 | !LICENSE 29 | !CHANGELOG.md 30 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | *.pid.lock 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage/ 15 | *.lcov 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # Bower dependency directory (https://bower.io/) 24 | bower_components 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (https://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directories 33 | jspm_packages/ 34 | 35 | # TypeScript cache 36 | *.tsbuildinfo 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | # Optional eslint cache 42 | .eslintcache 43 | 44 | # Microbundle cache 45 | .rpt2_cache/ 46 | .rts2_cache_cjs/ 47 | .rts2_cache_es/ 48 | .rts2_cache_umd/ 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | .env.test 62 | .env.production 63 | .env.local 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | .parcel-cache 68 | 69 | # Next.js build output 70 | .next 71 | 72 | # Nuxt.js build / generate output 73 | .nuxt 74 | dist 75 | 76 | # Gatsby files 77 | .cache/ 78 | public 79 | 80 | # Storybook build outputs 81 | .out 82 | .storybook-out 83 | 84 | # Temporary folders 85 | tmp/ 86 | temp/ 87 | 88 | # Logs 89 | logs 90 | *.log 91 | 92 | # Runtime data 93 | pids 94 | *.pid 95 | *.seed 96 | *.pid.lock 97 | 98 | # OS generated files 99 | .DS_Store 100 | .DS_Store? 101 | ._* 102 | .Spotlight-V100 103 | .Trashes 104 | ehthumbs.db 105 | Thumbs.db 106 | 107 | # IDE files 108 | .vscode/ 109 | .idea/ 110 | *.swp 111 | *.swo 112 | *~ 113 | 114 | # Build outputs 115 | dist/ 116 | build/ 117 | 118 | # DXT build artifacts 119 | *.dxt 120 | releases/ 121 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP SSH Agent 2 | 3 | A Model Context Protocol (MCP) server for managing and controlling SSH connections. This server integrates seamlessly with Claude Desktop and other MCP-compatible clients to provide AI-powered SSH operations. 4 | 5 | ## Overview 6 | 7 | This MCP server provides SSH operations through a clean, standardized interface that can be used by MCP-compatible language models like Claude Desktop. The server automatically discovers SSH hosts from your `~/.ssh/config` and `~/.ssh/known_hosts` files and executes commands using native SSH tools for maximum reliability. 8 | 9 | ## Quick Start 10 | 11 | ### Desktop Extension Installation (Recommended) 12 | 13 | The easiest way to install MCP SSH Agent is through the Desktop Extension (.dxt) format: 14 | 15 | 1. Download the latest `mcp-ssh-*.dxt` file from the [GitHub releases page](https://github.com/aiondadotcom/mcp-ssh/releases) 16 | 2. Double-click the `.dxt` file to install it in Claude Desktop 17 | 3. The SSH tools will be automatically available in your conversations with Claude 18 | 19 | ### Alternative Installation Methods 20 | 21 | #### Installation via npx 22 | 23 | ```bash 24 | npx @aiondadotcom/mcp-ssh 25 | ``` 26 | 27 | #### Manual Claude Desktop Configuration 28 | 29 | To use this MCP server with Claude Desktop using manual configuration, add the following to your MCP settings file: 30 | 31 | **On macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` 32 | **On Windows**: `%APPDATA%/Claude/claude_desktop_config.json` 33 | 34 | ```json 35 | { 36 | "mcpServers": { 37 | "mcp-ssh": { 38 | "command": "npx", 39 | "args": ["@aiondadotcom/mcp-ssh"] 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | After adding this configuration, restart Claude Desktop. The SSH tools will be available for use in your conversations with Claude. 46 | 47 | #### Global Installation 48 | ```bash 49 | npm install -g @aiondadotcom/mcp-ssh 50 | ``` 51 | 52 | #### Local Development 53 | ```bash 54 | git clone https://github.com/aiondadotcom/mcp-ssh.git 55 | cd mcp-ssh 56 | npm install 57 | npm start 58 | ``` 59 | 60 | ## Example Usage 61 | 62 |  63 | 64 | The screenshot above shows the MCP SSH Agent in action, demonstrating how it integrates with MCP-compatible clients to provide seamless SSH operations. 65 | 66 | ### Integration with Claude 67 | 68 |  69 | 70 | This screenshot demonstrates the MCP SSH Agent integrated with Claude, showing how the AI assistant can directly manage SSH connections and execute remote commands through the MCP protocol. 71 | 72 | ## Key Features 73 | 74 | - **Reliable SSH**: Uses native `ssh`/`scp` commands instead of JavaScript SSH libraries 75 | - **Automatic Discovery**: Finds hosts from SSH config and known_hosts files 76 | - **Full SSH Support**: Works with SSH agents, keys, and all authentication methods 77 | - **File Operations**: Upload and download files using `scp` 78 | - **Batch Commands**: Execute multiple commands in sequence 79 | - **Error Handling**: Comprehensive error reporting with timeouts 80 | 81 | ## Functions 82 | 83 | The agent provides the following MCP tools: 84 | 85 | 1. **listKnownHosts()** - Lists all known SSH hosts, prioritizing entries from ~/.ssh/config first, then additional hosts from ~/.ssh/known_hosts 86 | 2. **runRemoteCommand(hostAlias, command)** - Executes a command on a remote host using `ssh` 87 | 3. **getHostInfo(hostAlias)** - Returns detailed configuration for a specific host 88 | 4. **checkConnectivity(hostAlias)** - Tests SSH connectivity to a host 89 | 5. **uploadFile(hostAlias, localPath, remotePath)** - Uploads a file to the remote host using `scp` 90 | 6. **downloadFile(hostAlias, remotePath, localPath)** - Downloads a file from the remote host using `scp` 91 | 7. **runCommandBatch(hostAlias, commands)** - Executes multiple commands sequentially 92 | 93 | ## Configuration Examples 94 | 95 | ### Claude Desktop Integration 96 | 97 | Here's how your Claude Desktop configuration should look: 98 | 99 | ```json 100 | { 101 | "mcpServers": { 102 | "mcp-ssh": { 103 | "command": "npx", 104 | "args": ["@aiondadotcom/mcp-ssh"] 105 | } 106 | } 107 | } 108 | ``` 109 | 110 | ### Manual Server Configuration 111 | 112 | If you prefer to run the server manually or integrate it with other MCP clients: 113 | 114 | ```json 115 | { 116 | "servers": { 117 | "mcp-ssh": { 118 | "command": "npx", 119 | "args": ["@aiondadotcom/mcp-ssh"] 120 | } 121 | } 122 | } 123 | ``` 124 | 125 | ## Requirements 126 | 127 | - Node.js 18 or higher 128 | - SSH client installed (`ssh` and `scp` commands available) 129 | - SSH configuration files (`~/.ssh/config` and `~/.ssh/known_hosts`) 130 | 131 | ## Usage with Claude Desktop 132 | 133 | Once configured, you can ask Claude to help you with SSH operations like: 134 | 135 | - "List all my SSH hosts" 136 | - "Check connectivity to my production server" 137 | - "Run a command on my web server" 138 | - "Upload this file to my remote server" 139 | - "Download logs from my application server" 140 | 141 | Claude will use the MCP SSH tools to perform these operations safely and efficiently. 142 | 143 | ## Usage 144 | 145 | The agent runs as a Model Context Protocol server over STDIO. When installed via npm, you can use it directly: 146 | 147 | ```bash 148 | # Run via npx (recommended) 149 | npx @aiondadotcom/mcp-ssh 150 | 151 | # Or if installed globally 152 | mcp-ssh 153 | 154 | # For development - run with debug output 155 | npm start 156 | ``` 157 | 158 | The server communicates via clean JSON over STDIO, making it perfect for MCP clients like Claude Desktop. 159 | 160 | ## Advanced Configuration 161 | 162 | ### Environment Variables 163 | 164 | - `MCP_SILENT=true` - Disable debug output (automatically set when used as MCP server) 165 | 166 | ### SSH Configuration 167 | 168 | The agent reads from standard SSH configuration files: 169 | - `~/.ssh/config` - SSH client configuration (supports Include directives) 170 | - `~/.ssh/known_hosts` - Known host keys 171 | 172 | Make sure your SSH keys are properly configured and accessible via SSH agent or key files. 173 | 174 | #### Include Directive Support 175 | 176 | The MCP SSH Agent fully supports SSH `Include` directives to organize your configuration across multiple files. However, there's an important SSH bug to be aware of: 177 | 178 | **⚠️ SSH Include Directive Bug Warning** 179 | 180 | SSH has a configuration parsing bug where `Include` statements **must be placed at the beginning** of your `~/.ssh/config` file to work correctly. If placed at the end, SSH will read them but won't properly apply the included configurations. 181 | 182 | **✅ Correct placement (at the beginning):** 183 | ```ssh-config 184 | # ~/.ssh/config 185 | Include ~/.ssh/config.d/* 186 | Include ~/.ssh/work-hosts 187 | 188 | # Global settings 189 | ServerAliveInterval 55 190 | 191 | # Host definitions 192 | Host myserver 193 | HostName example.com 194 | ``` 195 | 196 | **❌ Incorrect placement (at the end) - won't work:** 197 | ```ssh-config 198 | # ~/.ssh/config 199 | # Global settings 200 | ServerAliveInterval 55 201 | 202 | # Host definitions 203 | Host myserver 204 | HostName example.com 205 | 206 | # These Include statements won't work properly due to SSH bug: 207 | Include ~/.ssh/config.d/* 208 | Include ~/.ssh/work-hosts 209 | ``` 210 | 211 | The MCP SSH Agent correctly processes `Include` directives regardless of their placement in the file, so you'll get full host discovery even if SSH itself has issues with your configuration. 212 | 213 | #### Example ~/.ssh/config 214 | 215 | Here's an example SSH configuration file that demonstrates various connection scenarios including Include directives: 216 | 217 | ```ssh-config 218 | # Include directives must be at the beginning due to SSH bug 219 | Include ~/.ssh/config.d/* 220 | Include ~/.ssh/work-servers 221 | 222 | # Global settings - keep connections alive 223 | ServerAliveInterval 55 224 | 225 | # Production server with jump host 226 | Host prod 227 | Hostname 203.0.113.10 228 | Port 22022 229 | User deploy 230 | IdentityFile ~/.ssh/id_prod_rsa 231 | 232 | # Root access to production (separate entry) 233 | Host root@prod 234 | Hostname 203.0.113.10 235 | Port 22022 236 | User root 237 | IdentityFile ~/.ssh/id_prod_rsa 238 | 239 | # Archive server accessed through production jump host 240 | Host archive 241 | Hostname 2001:db8:1f0:cafe::1 242 | Port 22077 243 | User archive-user 244 | ProxyJump prod 245 | 246 | # Web servers with specific configurations 247 | Host web1.example.com 248 | Hostname 198.51.100.15 249 | Port 22022 250 | User root 251 | IdentityFile ~/.ssh/id_ed25519 252 | 253 | Host web2.example.com 254 | Hostname 198.51.100.25 255 | Port 22022 256 | User root 257 | IdentityFile ~/.ssh/id_ed25519 258 | 259 | # Database server with custom key 260 | Host database 261 | Hostname 203.0.113.50 262 | Port 22077 263 | User dbadmin 264 | IdentityFile ~/.ssh/id_database_rsa 265 | IdentitiesOnly yes 266 | 267 | # Mail servers 268 | Host mail1 269 | Hostname 198.51.100.88 270 | Port 22078 271 | User mailuser 272 | 273 | Host root@mail1 274 | Hostname 198.51.100.88 275 | Port 22078 276 | User root 277 | 278 | # Monitoring server 279 | Host monitor 280 | Hostname 203.0.113.100 281 | Port 22077 282 | User monitoring 283 | IdentityFile ~/.ssh/id_monitor_ed25519 284 | IdentitiesOnly yes 285 | 286 | # Load balancers 287 | Host lb-a 288 | Hostname 198.51.100.200 289 | Port 22077 290 | User root 291 | 292 | Host lb-b 293 | Hostname 198.51.100.201 294 | Port 22077 295 | User root 296 | ``` 297 | 298 | This configuration demonstrates: 299 | - **Global settings**: `ServerAliveInterval` to keep connections alive 300 | - **Custom ports**: Non-standard SSH ports for security 301 | - **Multiple users**: Different user accounts for the same host (e.g., `prod` and `root@prod`) 302 | - **Jump hosts**: Using `ProxyJump` to access servers through bastion hosts 303 | - **IPv6 addresses**: Modern networking support 304 | - **Identity files**: Specific SSH keys for different servers 305 | - **Security options**: `IdentitiesOnly yes` to use only specified keys 306 | 307 | #### How MCP SSH Agent Uses Your Configuration 308 | 309 | The MCP SSH agent automatically discovers and uses your SSH configuration: 310 | 311 | 1. **Host Discovery**: All hosts from `~/.ssh/config` are automatically available 312 | 2. **Native SSH**: Uses your system's `ssh` command, so all config options work 313 | 3. **Authentication**: Respects your SSH agent, key files, and authentication settings 314 | 4. **Jump Hosts**: Supports complex proxy chains and bastion host setups 315 | 5. **Port Forwarding**: Can work with custom ports and connection options 316 | 317 | **Example Usage with Claude Desktop:** 318 | - "List my SSH hosts" → Shows all configured hosts including `prod`, `archive`, `web1.example.com`, etc. 319 | - "Connect to archive server" → Uses the ProxyJump configuration automatically 320 | - "Run 'df -h' on web1.example.com" → Connects with the correct user, port, and key 321 | - "Upload file to database server" → Uses the specific identity file and port configuration 322 | 323 | ## Troubleshooting 324 | 325 | ### Common Issues 326 | 327 | 1. **Command not found**: Ensure `ssh` and `scp` are installed and in your PATH 328 | 2. **Permission denied**: Check SSH key permissions and SSH agent 329 | 3. **Host not found**: Verify host exists in `~/.ssh/config` or `~/.ssh/known_hosts` 330 | 4. **Connection timeout**: Check network connectivity and firewall settings 331 | 332 | ### Debug Mode 333 | 334 | Run with debug output to see detailed operation logs: 335 | 336 | ```bash 337 | # Enable debug mode 338 | MCP_SILENT=false npx @aiondadotcom/mcp-ssh 339 | ``` 340 | 341 | ## SSH Key Setup Guide 342 | 343 | For the MCP SSH Agent to work properly, you need to set up SSH key authentication. Here's a complete guide: 344 | 345 | ### 1. Creating SSH Keys 346 | 347 | Generate a new SSH key pair (use Ed25519 for better security): 348 | 349 | ```bash 350 | # Generate Ed25519 key (recommended) 351 | ssh-keygen -t ed25519 -C "[email protected]" 352 | 353 | # Or generate RSA key (if Ed25519 is not supported) 354 | ssh-keygen -t rsa -b 4096 -C "[email protected]" 355 | ``` 356 | 357 | **Important**: When prompted for a passphrase, **leave it empty** (press Enter). The MCP SSH Agent cannot handle password-protected keys as it runs non-interactively. 358 | 359 | ``` 360 | Enter passphrase (empty for no passphrase): [Press Enter] 361 | Enter same passphrase again: [Press Enter] 362 | ``` 363 | 364 | This creates two files: 365 | - `~/.ssh/id_ed25519` (private key) - Keep this secret! 366 | - `~/.ssh/id_ed25519.pub` (public key) - This gets copied to servers 367 | 368 | ### 2. Installing Public Key on Remote Servers 369 | 370 | Copy your public key to the remote server's authorized_keys file: 371 | 372 | ```bash 373 | # Method 1: Using ssh-copy-id (easiest) 374 | ssh-copy-id user@hostname 375 | 376 | # Method 2: Manual copy 377 | cat ~/.ssh/id_ed25519.pub | ssh user@hostname "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys" 378 | 379 | # Method 3: Copy and paste manually 380 | cat ~/.ssh/id_ed25519.pub 381 | # Then SSH to the server and paste into ~/.ssh/authorized_keys 382 | ``` 383 | 384 | ### 3. Server-Side SSH Configuration 385 | 386 | To enable secure key-only authentication on your SSH servers, edit `/etc/ssh/sshd_config`: 387 | 388 | ```bash 389 | # Edit SSH daemon configuration 390 | sudo nano /etc/ssh/sshd_config 391 | ``` 392 | 393 | Add or modify these settings: 394 | 395 | ```ssh-config 396 | # Enable public key authentication 397 | PubkeyAuthentication yes 398 | AuthorizedKeysFile .ssh/authorized_keys 399 | 400 | # Disable password authentication (security best practice) 401 | PasswordAuthentication no 402 | ChallengeResponseAuthentication no 403 | UsePAM no 404 | 405 | # Root login options (choose one): 406 | # Option 1: Allow root login with SSH keys only (recommended for admin access) 407 | PermitRootLogin prohibit-password 408 | 409 | # Option 2: Completely disable root login (most secure, but less flexible) 410 | # PermitRootLogin no 411 | 412 | # Optional: Restrict SSH to specific users 413 | AllowUsers deploy root admin 414 | 415 | # Optional: Change default port for security 416 | Port 22022 417 | ``` 418 | 419 | After editing, restart the SSH service: 420 | 421 | ```bash 422 | # On Ubuntu/Debian 423 | sudo systemctl restart ssh 424 | 425 | # On CentOS/RHEL/Fedora 426 | sudo systemctl restart sshd 427 | 428 | # On macOS 429 | sudo launchctl unload /System/Library/LaunchDaemons/ssh.plist 430 | sudo launchctl load /System/Library/LaunchDaemons/ssh.plist 431 | ``` 432 | 433 | ### 4. Setting Correct Permissions 434 | 435 | SSH is very strict about file permissions. Set them correctly: 436 | 437 | **On your local machine:** 438 | ```bash 439 | chmod 700 ~/.ssh 440 | chmod 600 ~/.ssh/id_ed25519 441 | chmod 644 ~/.ssh/id_ed25519.pub 442 | chmod 644 ~/.ssh/config 443 | chmod 644 ~/.ssh/known_hosts 444 | ``` 445 | 446 | **On the remote server:** 447 | ```bash 448 | chmod 700 ~/.ssh 449 | chmod 600 ~/.ssh/authorized_keys 450 | ``` 451 | 452 | ### 5. Testing SSH Key Authentication 453 | 454 | Test your connection before using with MCP SSH Agent: 455 | 456 | ```bash 457 | # Test connection 458 | ssh -i ~/.ssh/id_ed25519 user@hostname 459 | 460 | # Test with verbose output for debugging 461 | ssh -v -i ~/.ssh/id_ed25519 user@hostname 462 | 463 | # Test specific configuration 464 | ssh -F ~/.ssh/config hostname 465 | ``` 466 | 467 | ### 6. Multiple Keys for Different Servers 468 | 469 | You can create different keys for different servers: 470 | 471 | ```bash 472 | # Create specific keys 473 | ssh-keygen -t ed25519 -f ~/.ssh/id_production -C "production-server" 474 | ssh-keygen -t ed25519 -f ~/.ssh/id_staging -C "staging-server" 475 | ``` 476 | 477 | Then configure them in `~/.ssh/config`: 478 | 479 | ```ssh-config 480 | Host production 481 | Hostname prod.example.com 482 | User deploy 483 | IdentityFile ~/.ssh/id_production 484 | IdentitiesOnly yes 485 | 486 | Host staging 487 | Hostname staging.example.com 488 | User deploy 489 | IdentityFile ~/.ssh/id_staging 490 | IdentitiesOnly yes 491 | ``` 492 | 493 | ## Security Best Practices 494 | 495 | ### SSH Key Security 496 | - **Never use password-protected keys** with MCP SSH Agent 497 | - **Never share private keys** - they should stay on your machine only 498 | - **Use Ed25519 keys** when possible (more secure than RSA) 499 | - **Create separate keys** for different environments/purposes 500 | - **Regularly rotate keys** (every 6-12 months) 501 | 502 | ### Server Security 503 | - **Disable password authentication** completely 504 | - **Use non-standard SSH ports** to reduce automated attacks 505 | - **Limit SSH access** to specific users with `AllowUsers` 506 | - **Choose appropriate root login policy**: 507 | - `PermitRootLogin prohibit-password` - Allows root access with SSH keys only (recommended for admin tasks) 508 | - `PermitRootLogin no` - Completely disables root login (most secure, but requires sudo access) 509 | - **Enable SSH key-only authentication** for all accounts 510 | - **Consider using jump hosts** for additional security layers 511 | 512 | ### Network Security 513 | - **Use VPN or bastion hosts** for production servers 514 | - **Implement fail2ban** to block brute force attempts 515 | - **Monitor SSH logs** regularly 516 | - **Use SSH key forwarding carefully** (disable when not needed) 517 | 518 | ## Building Desktop Extensions 519 | 520 | For developers who want to build DXT packages locally: 521 | 522 | ### Prerequisites 523 | 524 | - Node.js 18 or higher 525 | - npm 526 | 527 | ### Building DXT Package 528 | 529 | ```bash 530 | # Install dependencies 531 | npm install 532 | 533 | # Build the DXT package 534 | npm run build:dxt 535 | ``` 536 | 537 | This creates a `.dxt` file in the `build/` directory that can be installed in Claude Desktop. 538 | 539 | ### Publishing DXT Releases 540 | 541 | To publish a new DXT release: 542 | 543 | ```bash 544 | # Build the DXT package 545 | npm run build:dxt 546 | 547 | # Create a GitHub release with the DXT file 548 | gh release create v1.0.3 build/mcp-ssh-1.0.3.dxt --title "Release v1.0.3" --notes "MCP SSH Agent v1.0.3" 549 | ``` 550 | 551 | The DXT file will be available as a release asset for users to download and install. 552 | 553 | ## Contributing 554 | 555 | Contributions are welcome! Please feel free to submit a Pull Request. 556 | 557 | ## License 558 | 559 | MIT License - see LICENSE file for details. 560 | 561 | ## Project Structure 562 | 563 | ``` 564 | mcp-ssh/ 565 | ├── server-simple.mjs # Main MCP server implementation 566 | ├── manifest.json # DXT package manifest 567 | ├── package.json # Dependencies and scripts 568 | ├── README.md # Documentation 569 | ├── LICENSE # MIT License 570 | ├── CHANGELOG.md # Release history 571 | ├── PUBLISHING.md # Publishing instructions 572 | ├── start.sh # Development startup script 573 | ├── start-silent.sh # Silent startup script 574 | ├── scripts/ 575 | │ └── build-dxt.sh # DXT package build script 576 | ├── doc/ 577 | │ ├── example.png # Usage example screenshot 578 | │ └── Claude.png # Claude Desktop integration example 579 | ├── src/ # TypeScript source files (development) 580 | │ ├── ssh-client.ts # SSH operations implementation 581 | │ ├── ssh-config-parser.ts # SSH configuration parsing 582 | │ └── types.ts # Type definitions 583 | └── tsconfig.json # TypeScript configuration 584 | ``` 585 | 586 | ## About 587 | 588 | This project is maintained by [aionda.com](https://aionda.com) and provides a reliable bridge between AI assistants and SSH infrastructure through the Model Context Protocol. 589 | ``` -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- ```markdown 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is MCP SSH Agent (@aiondadotcom/mcp-ssh) - a Model Context Protocol (MCP) server that provides SSH operations for AI assistants like Claude Desktop. The project uses native SSH commands (`ssh`, `scp`) rather than JavaScript SSH libraries for maximum reliability and compatibility. 8 | 9 | ## Development Commands 10 | 11 | ### Basic Operations 12 | - `npm start` - Start the MCP server (same as `npm run dev`) 13 | - `npm run dev` - Start the MCP server with debug output 14 | - `npm run build` - Currently a no-op (echo "Build skipped") 15 | - `npm test` - Currently a no-op (echo "No tests specified") 16 | 17 | ### Development Scripts 18 | - `./start.sh` - Start the server with debug output 19 | - `./start-silent.sh` - Start the server in silent mode (no debug output) 20 | - `node server-simple.mjs` - Direct server execution 21 | 22 | ### Publishing 23 | - `npm version patch|minor|major` - Bump version and create git tag 24 | - `npm publish` - Publish to npm (see PUBLISHING.md for details) 25 | - `npm pack` - Create tarball for testing 26 | 27 | ### DXT Package Building 28 | - `npm run build:dxt` - Build Desktop Extension (.dxt) package 29 | - `./scripts/build-dxt.sh` - Direct build script execution 30 | 31 | ## Architecture 32 | 33 | ### Main Entry Point 34 | - `server-simple.mjs` - Self-contained MCP server implementation that includes all functionality inline to avoid module resolution issues 35 | 36 | ### Source Structure (Development) 37 | - `src/` - TypeScript source files (currently not compiled/used in production) 38 | - `ssh-client.ts` - SSH operations using node-ssh library (development version) 39 | - `ssh-config-parser.ts` - SSH config parsing utilities 40 | - `types.ts` - TypeScript type definitions 41 | - `bin/mcp-ssh.js` - Binary wrapper for npx compatibility 42 | 43 | ### Key Design Decisions 44 | 1. **Native SSH Tools**: Uses system `ssh` and `scp` commands rather than JavaScript SSH libraries for reliability 45 | 2. **Self-contained**: `server-simple.mjs` includes all code inline to avoid ESM import issues 46 | 3. **Dual Implementation**: TypeScript source in `src/` for development, JavaScript implementation in `server-simple.mjs` for production 47 | 4. **Silent Mode**: Controlled by `MCP_SILENT` environment variable to disable debug output when used as MCP server 48 | 49 | ## SSH Configuration Integration 50 | 51 | The agent automatically discovers SSH hosts from: 52 | - `~/.ssh/config` - Primary source for host configurations 53 | - `~/.ssh/known_hosts` - Additional hosts not in config 54 | 55 | Host discovery prioritizes SSH config entries first, then adds additional hosts from known_hosts. 56 | 57 | ## MCP Tools Provided 58 | 59 | 1. **listKnownHosts()** - Lists all discovered SSH hosts 60 | 2. **runRemoteCommand(hostAlias, command)** - Execute commands via SSH 61 | 3. **getHostInfo(hostAlias)** - Get host configuration details 62 | 4. **checkConnectivity(hostAlias)** - Test SSH connectivity 63 | 5. **uploadFile(hostAlias, localPath, remotePath)** - Upload files via SCP 64 | 6. **downloadFile(hostAlias, remotePath, localPath)** - Download files via SCP 65 | 7. **runCommandBatch(hostAlias, commands)** - Execute multiple commands sequentially 66 | 67 | ## Testing and Debugging 68 | 69 | ### Manual Testing 70 | ```bash 71 | # Test as MCP server 72 | npx @aiondadotcom/mcp-ssh 73 | 74 | # Test with debug output 75 | MCP_SILENT=false npx @aiondadotcom/mcp-ssh 76 | 77 | # Test installation 78 | npm pack 79 | npm install -g ./aiondadotcom-mcp-ssh-*.tgz 80 | mcp-ssh 81 | ``` 82 | 83 | ### Integration Testing 84 | Configure in Claude Desktop's `claude_desktop_config.json`: 85 | ```json 86 | { 87 | "mcpServers": { 88 | "mcp-ssh": { 89 | "command": "npx", 90 | "args": ["@aiondadotcom/mcp-ssh"] 91 | } 92 | } 93 | } 94 | ``` 95 | 96 | ## Dependencies 97 | 98 | - `@modelcontextprotocol/sdk` - MCP protocol implementation 99 | - `ssh-config` - SSH configuration file parsing 100 | - Node.js built-ins: `child_process`, `fs/promises`, `os`, `path` 101 | 102 | ## Desktop Extension Support 103 | 104 | The project supports Desktop Extensions (.dxt) for easy installation in Claude Desktop: 105 | 106 | - `manifest.json` - DXT package manifest with server configuration 107 | - `scripts/build-dxt.sh` - Build script that creates .dxt packages in `build/` directory 108 | - `.dxt` files are ZIP archives containing the manifest and server files 109 | - Built packages are excluded from git via `.gitignore` but can be uploaded to GitHub releases 110 | 111 | ## Important Notes 112 | 113 | - The project is ESM-only (`"type": "module"` in package.json) 114 | - Production code is in `server-simple.mjs`, not compiled from TypeScript 115 | - SSH operations require properly configured SSH keys and host access 116 | - The agent runs over STDIO as an MCP server, not as a standalone application 117 | - DXT packages provide one-click installation alternative to manual JSON configuration ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | ``` -------------------------------------------------------------------------------- /claude_desktop_config.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "mcpServers": { 3 | "mcp-ssh": { 4 | "command": "/absolute/path/to/mcp-ssh/start.sh" 5 | } 6 | } 7 | } ``` -------------------------------------------------------------------------------- /start-silent.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # Change to the directory where this script is located 4 | cd "$(dirname "$0")" 5 | 6 | # MCP SSH Agent Silent Startup Script 7 | # This script starts the MCP SSH server in silent mode for MCP clients 8 | # No debug output will be shown, only clean JSON communication 9 | 10 | export MCP_SILENT=true 11 | exec node server-simple.mjs 12 | ``` -------------------------------------------------------------------------------- /bin/mcp-ssh.js: -------------------------------------------------------------------------------- ```javascript 1 | #!/usr/bin/env node 2 | 3 | // Simple wrapper to run the main server-simple.mjs file 4 | import path from 'path'; 5 | import { fileURLToPath } from 'url'; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | // Import and run the main module 11 | const mainModule = path.join(__dirname, '..', 'server-simple.mjs'); 12 | import(mainModule); 13 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "allowSyntheticDefaultImports": true, 14 | "typeRoots": ["./node_modules/@types"] 15 | }, 16 | "include": ["src/ssh-client.ts", "src/ssh-config-parser.ts", "src/types.ts"], 17 | "exclude": ["node_modules"] 18 | } 19 | ``` -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # Change to the directory where this script is located 4 | cd "$(dirname "$0")" 5 | 6 | # MCP SSH Agent Startup Script 7 | # This script starts the MCP SSH server using npm 8 | # Set MCP_SILENT=true to disable debug output for MCP clients 9 | 10 | # Check if we should run in silent mode (for MCP clients) 11 | if [ "$1" = "--silent" ] || [ "$MCP_SILENT" = "true" ]; then 12 | # Silent mode - no startup messages 13 | MCP_SILENT=true npm start 14 | else 15 | # Normal mode with startup messages 16 | echo "Starting MCP SSH Agent..." 17 | npm start 18 | fi 19 | ``` -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Types for SSH Host information 2 | export interface SSHHostInfo { 3 | hostname: string; 4 | alias?: string; 5 | user?: string; 6 | port?: number; 7 | identityFile?: string; 8 | [key: string]: any; // For other configuration options 9 | } 10 | 11 | // Result of a remote command 12 | export interface CommandResult { 13 | stdout: string; 14 | stderr: string; 15 | code: number; 16 | } 17 | 18 | // SSH connection status 19 | export interface ConnectionStatus { 20 | connected: boolean; 21 | message: string; 22 | } 23 | 24 | // Batch result of remote commands 25 | export interface BatchCommandResult { 26 | results: CommandResult[]; 27 | success: boolean; 28 | } 29 | ``` -------------------------------------------------------------------------------- /temp-extract/manifest.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "dxt_version": "0.1", 3 | "name": "mcp-ssh", 4 | "version": "1.0.3", 5 | "description": "Connect to SSH hosts, run commands, and transfer files securely through Claude Desktop", 6 | "author": { 7 | "name": "aionda.com", 8 | "url": "https://aionda.com" 9 | }, 10 | "license": "MIT", 11 | "homepage": "https://github.com/aiondadotcom/mcp-ssh", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/aiondadotcom/mcp-ssh.git" 15 | }, 16 | "icon": "doc/Claude.png", 17 | "keywords": [ 18 | "ssh", 19 | "remote", 20 | "server", 21 | "scp", 22 | "file-transfer", 23 | "automation", 24 | "devops" 25 | ], 26 | "server": { 27 | "type": "node", 28 | "entry_point": "server-simple.mjs", 29 | "mcp_config": { 30 | "command": "node", 31 | "args": ["server-simple.mjs"], 32 | "env": { 33 | "MCP_SILENT": "true" 34 | } 35 | } 36 | } 37 | } ``` -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "dxt_version": "0.1", 3 | "name": "mcp-ssh", 4 | "version": "1.0.3", 5 | "description": "Connect to SSH hosts, run commands, and transfer files securely through Claude Desktop", 6 | "author": { 7 | "name": "aionda.com", 8 | "url": "https://aionda.com" 9 | }, 10 | "license": "MIT", 11 | "homepage": "https://github.com/aiondadotcom/mcp-ssh", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/aiondadotcom/mcp-ssh.git" 15 | }, 16 | "icon": "doc/Claude.png", 17 | "keywords": [ 18 | "ssh", 19 | "remote", 20 | "server", 21 | "scp", 22 | "file-transfer", 23 | "automation", 24 | "devops" 25 | ], 26 | "server": { 27 | "type": "node", 28 | "entry_point": "server-simple.mjs", 29 | "mcp_config": { 30 | "command": "node", 31 | "args": ["${__dirname}/server-simple.mjs"], 32 | "env": { 33 | "MCP_SILENT": "true" 34 | } 35 | } 36 | } 37 | } ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Publish to NPM 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | inputs: 8 | version: 9 | description: 'Version to publish (e.g., patch, minor, major)' 10 | required: true 11 | default: 'patch' 12 | type: choice 13 | options: 14 | - patch 15 | - minor 16 | - major 17 | 18 | jobs: 19 | publish: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '18' 30 | registry-url: 'https://registry.npmjs.org' 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Run tests (if any) 36 | run: npm test --if-present 37 | 38 | - name: Bump version (if manual trigger) 39 | if: github.event_name == 'workflow_dispatch' 40 | run: npm version ${{ github.event.inputs.version }} --no-git-tag-version 41 | 42 | - name: Publish to NPM 43 | run: npm publish 44 | env: 45 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | ``` -------------------------------------------------------------------------------- /gist_comment.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "body": "**Security Fix Applied**\n\nThank you for reporting this command injection vulnerability. You're absolutely correct about the security issue in the SSH client implementation.\n\n**Issue Confirmed:**\nThe vulnerability existed in `server-simple.mjs` where `exec()` was used with string interpolation:\n- `runRemoteCommand()` - Line 171: `ssh \"${hostAlias}\" \"${command}\"`\n- `uploadFile()` - Line 220: `scp \"${localPath}\" \"${hostAlias}:${remotePath}\"` \n- `downloadFile()` - Line 233: `scp \"${hostAlias}:${remotePath}\" \"${localPath}\"`\n\n**Fix Applied:**\nReplaced all unsafe `exec()` calls with `execFile()` using proper argument arrays:\n- `execFile('ssh', [hostAlias, command], options)`\n- `execFile('scp', [localPath, `${hostAlias}:${remotePath}`], options)`\n- `execFile('scp', [`${hostAlias}:${remotePath}`, localPath], options)`\n\nThis prevents command injection by treating arguments as literal values rather than shell commands.\n\n**Commit:** [5b9b9c5](https://github.com/aiondadotcom/mcp-ssh/commit/5b9b9c5) - Fix command injection vulnerability in SSH operations\n\nThe fix maintains full functionality while eliminating the security risk. Thank you for the responsible disclosure!" 3 | } ``` -------------------------------------------------------------------------------- /github_issue_response.md: -------------------------------------------------------------------------------- ```markdown 1 | Thank you for reporting this issue! You're absolutely right that the MCP SSH Agent doesn't support `Include` directives, and we'll implement this feature. 2 | 3 | **Implementation Plan:** 4 | We'll add proper `Include` directive support to ensure complete host discovery from all SSH configuration files. 5 | 6 | **Important SSH Configuration Note:** 7 | During our investigation, we discovered that SSH itself has a bug with `Include` directive processing. The `Include` statements **must be placed at the beginning** of your `~/.ssh/config` file to work correctly. 8 | 9 | **Example of correct placement:** 10 | ``` 11 | # ~/.ssh/config 12 | Include ~/.ssh/config.d/* 13 | Include ~/.ssh/work-hosts 14 | 15 | # Global settings 16 | ServerAliveInterval 55 17 | 18 | # Host definitions 19 | Host myserver 20 | HostName example.com 21 | ``` 22 | 23 | **Why this matters:** 24 | If `Include` statements are placed at the end of the config file, SSH reads them but doesn't properly apply the included host configurations. This is a bug in OpenSSH's configuration parser. 25 | 26 | **Our solution:** 27 | 1. We'll implement `Include` support in the MCP SSH Agent to read all configuration files 28 | 2. We'll document this SSH quirk in our README to help users avoid configuration issues 29 | 3. The agent will work correctly regardless of where users place their `Include` statements 30 | 31 | We'll have this feature implemented soon. Thanks for bringing this to our attention! ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "@aiondadotcom/mcp-ssh", 3 | "version": "1.1.0", 4 | "description": "MCP Agent for managing SSH hosts - A Model Context Protocol server for SSH operations", 5 | "main": "server-simple.mjs", 6 | "bin": { 7 | "mcp-ssh": "bin/mcp-ssh.js" 8 | }, 9 | "type": "module", 10 | "scripts": { 11 | "start": "node server-simple.mjs", 12 | "dev": "node server-simple.mjs", 13 | "build": "echo \"Build skipped\"", 14 | "build:dxt": "./scripts/build-dxt.sh", 15 | "test": "echo \"No tests specified\" && exit 0", 16 | "prepublishOnly": "npm run test", 17 | "version": "git add -A", 18 | "postversion": "git push && git push --tags" 19 | }, 20 | "keywords": [ 21 | "mcp", 22 | "ssh", 23 | "agent", 24 | "model-context-protocol", 25 | "claude", 26 | "ai", 27 | "remote", 28 | "server" 29 | ], 30 | "author": "aionda.com", 31 | "license": "MIT", 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/aiondadotcom/mcp-ssh.git" 35 | }, 36 | "homepage": "https://github.com/aiondadotcom/mcp-ssh", 37 | "bugs": { 38 | "url": "https://github.com/aiondadotcom/mcp-ssh/issues" 39 | }, 40 | "publishConfig": { 41 | "access": "public" 42 | }, 43 | "dependencies": { 44 | "@modelcontextprotocol/sdk": "^1.12.0", 45 | "glob": "^11.0.3", 46 | "ssh-config": "^5.0.0" 47 | }, 48 | "devDependencies": { 49 | "@anthropic-ai/dxt": "^0.2.5", 50 | "@types/node": "^20.11.26", 51 | "@types/ssh2": "^1.15.0", 52 | "tmp": ">=0.2.4", 53 | "ts-node": "^10.9.2", 54 | "typescript": "^5.4.3" 55 | }, 56 | "overrides": { 57 | "tmp": ">=0.2.4" 58 | } 59 | } 60 | ``` -------------------------------------------------------------------------------- /PUBLISHING.md: -------------------------------------------------------------------------------- ```markdown 1 | # Publishing Instructions 2 | 3 | This document contains instructions for publishing the @aiondadotcom/mcp-ssh package to npm. 4 | 5 | ## Prerequisites 6 | 7 | 1. You need to be a member of the @aiondadotcom organization on npm 8 | 2. You need to be logged in to npm: `npm login` 9 | 3. Verify your access: `npm access list packages @aiondadotcom` 10 | 11 | ## Publishing Process 12 | 13 | ### Automated Publishing (Recommended) 14 | 15 | The package is automatically published when you create a GitHub release: 16 | 17 | 1. Commit all changes 18 | 2. Create a new release on GitHub 19 | 3. The GitHub Action will automatically publish to npm 20 | 21 | ### Manual Publishing 22 | 23 | ```bash 24 | # 1. Make sure you're on the main branch and everything is committed 25 | git checkout main 26 | git pull origin main 27 | 28 | # 2. Bump the version (patch, minor, or major) 29 | npm version patch # or minor/major 30 | 31 | # 3. Publish to npm 32 | npm publish 33 | 34 | # 4. Push the version commit and tag 35 | git push origin main --tags 36 | ``` 37 | 38 | ### Testing Before Publishing 39 | 40 | ```bash 41 | # Test the package locally 42 | npm pack 43 | npm install -g ./aiondadotcom-mcp-ssh-1.0.0.tgz 44 | 45 | # Test the binary 46 | mcp-ssh --help 47 | 48 | # Clean up 49 | npm uninstall -g @aiondadotcom/mcp-ssh 50 | rm *.tgz 51 | ``` 52 | 53 | ## First-Time Setup 54 | 55 | If this is the first time publishing this package: 56 | 57 | ```bash 58 | # Login to npm 59 | npm login 60 | 61 | # Verify you have access to the @aiondadotcom scope 62 | npm access list packages @aiondadotcom 63 | 64 | # Publish the package 65 | npm publish --access public 66 | ``` 67 | 68 | ## Package Configuration 69 | 70 | The package is configured with: 71 | - Scoped name: `@aiondadotcom/mcp-ssh` 72 | - Public access 73 | - Binary: `mcp-ssh` command 74 | - Entry point: `server-simple.mjs` 75 | ``` -------------------------------------------------------------------------------- /scripts/build-dxt.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # Build script for creating DXT packages for mcp-ssh 4 | # This script creates .dxt files for distribution without committing them to the repository 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | NC='\033[0m' # No Color 13 | 14 | echo -e "${GREEN}Building MCP SSH DXT Package${NC}" 15 | 16 | # Check if dxt CLI is available 17 | if ! command -v npx &> /dev/null; then 18 | echo -e "${RED}Error: npm/npx not found. Please install Node.js${NC}" 19 | exit 1 20 | fi 21 | 22 | # Check if we have the dxt package 23 | if ! npm list @anthropic-ai/dxt &> /dev/null; then 24 | echo -e "${RED}Error: @anthropic-ai/dxt not found. Please run 'npm install'${NC}" 25 | exit 1 26 | fi 27 | 28 | # Create build directory (not tracked in git) 29 | BUILD_DIR="build" 30 | rm -rf "$BUILD_DIR" 31 | mkdir -p "$BUILD_DIR" 32 | 33 | echo -e "${YELLOW}Creating DXT package...${NC}" 34 | 35 | # Get version from package.json 36 | VERSION=$(node -p "require('./package.json').version") 37 | DXT_FILE="mcp-ssh-${VERSION}.dxt" 38 | 39 | # Create the DXT package 40 | npx dxt pack . "$BUILD_DIR/$DXT_FILE" 41 | 42 | if [ $? -eq 0 ]; then 43 | echo -e "${GREEN}✓ DXT package created successfully: $BUILD_DIR/$DXT_FILE${NC}" 44 | echo -e "${GREEN}✓ Package size: $(ls -lh "$BUILD_DIR/$DXT_FILE" | awk '{print $5}')${NC}" 45 | 46 | # Display next steps 47 | echo -e "\n${YELLOW}Next steps:${NC}" 48 | echo "1. Test the DXT package locally" 49 | echo "2. Upload to GitHub releases:" 50 | echo " gh release create v${VERSION} $BUILD_DIR/$DXT_FILE --title 'Release v${VERSION}' --notes 'MCP SSH Agent v${VERSION}'" 51 | echo "3. Or upload manually to GitHub releases page" 52 | else 53 | echo -e "${RED}✗ Failed to create DXT package${NC}" 54 | exit 1 55 | fi ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.1.0] - 2025-08-17 9 | 10 | ### Added 11 | - **NEW FEATURE**: SSH config Include directive support 12 | - Added recursive processing of Include directives in SSH configuration files 13 | - Support for glob patterns in Include paths (e.g., `Include ~/.ssh/configs/*`) 14 | - Enhanced SSH host discovery from included configuration files 15 | - Added `glob` dependency for Include path pattern matching 16 | 17 | ### Enhanced 18 | - Improved SSH configuration parsing to handle complex Include hierarchies 19 | - Enhanced host discovery to recursively process all included config files 20 | - Better error handling for malformed or inaccessible Include files 21 | 22 | ## [1.0.4] - 2025-08-17 23 | 24 | ### Security 25 | - **SECURITY FIX**: Fixed command injection vulnerability in SSH operations (commit 5b9b9c5) 26 | - **SECURITY FIX**: Upgraded `tmp` dependency to version 0.2.5 to address CVE vulnerability 27 | - Fixed arbitrary temporary file/directory write via symbolic link in `tmp` package (GHSA-52f5-9888-hmc6) 28 | - Added dependency overrides to ensure all transitive dependencies use secure `tmp` version 29 | - Enhanced input validation and sanitization for SSH commands and file paths 30 | 31 | ### Technical 32 | - Added `tmp: ">=0.2.4"` to devDependencies to force secure version 33 | - Added npm overrides configuration to enforce secure tmp version across entire dependency tree 34 | - Updated package-lock.json to reflect security fixes 35 | 36 | ## [1.0.3] - 2025-06-06 37 | 38 | ### Added 39 | - Binary wrapper script (`bin/mcp-ssh.js`) for proper npx compatibility 40 | - Fixed npx execution issues by implementing wrapper pattern 41 | 42 | ### Fixed 43 | - NPX executable resolution using wrapper script approach 44 | - Package binary configuration now points to proper wrapper 45 | 46 | ### Technical 47 | - Added `bin/mcp-ssh.js` wrapper to handle npx execution 48 | - Updated package.json bin configuration to use wrapper script 49 | 50 | ## [1.0.2] - 2025-06-06 51 | 52 | ### Fixed 53 | - Build script temporary fix 54 | - File permissions for executable 55 | 56 | ## [1.0.1] - 2025-06-06 57 | 58 | ### Fixed 59 | - Initial package configuration 60 | - File permissions 61 | 62 | ## [1.0.0] - 2025-06-06 63 | 64 | ### Added 65 | - Initial release of MCP SSH Agent 66 | - Support for all SSH operations via native ssh/scp commands 67 | - Automatic SSH host discovery from ~/.ssh/config and ~/.ssh/known_hosts 68 | - Functions: listKnownHosts, runRemoteCommand, getHostInfo, checkConnectivity, uploadFile, downloadFile, runCommandBatch 69 | - Claude Desktop integration support 70 | - NPM package distribution via @aiondadotcom/mcp-ssh 71 | - npx compatibility for easy installation and usage 72 | 73 | ### Features 74 | - Native SSH command execution for maximum compatibility 75 | - Silent mode for MCP clients (MCP_SILENT=true) 76 | - Comprehensive error handling with timeouts 77 | - Batch command execution support 78 | - File upload/download via scp 79 | - SSH connectivity testing 80 | 81 | ### Documentation 82 | - Complete README with Claude Desktop setup instructions 83 | - Usage examples and troubleshooting guide 84 | - Professional npm package configuration 85 | ``` -------------------------------------------------------------------------------- /src/ssh-config-parser.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { readFile } from 'fs/promises'; 2 | import { homedir } from 'os'; 3 | import { join } from 'path'; 4 | import * as sshConfig from 'ssh-config'; 5 | import { SSHHostInfo } from './types.js'; 6 | 7 | export class SSHConfigParser { 8 | private configPath: string; 9 | private knownHostsPath: string; 10 | 11 | constructor() { 12 | const homeDir = homedir(); 13 | this.configPath = join(homeDir, '.ssh', 'config'); 14 | this.knownHostsPath = join(homeDir, '.ssh', 'known_hosts'); 15 | } 16 | 17 | /** 18 | * Reads and parses the SSH config file 19 | */ 20 | async parseConfig(): Promise<SSHHostInfo[]> { 21 | try { 22 | const content = await readFile(this.configPath, 'utf-8'); 23 | const config = sshConfig.parse(content); 24 | return this.extractHostsFromConfig(config); 25 | } catch (error) { 26 | console.error('Error reading SSH config:', error); 27 | return []; 28 | } 29 | } 30 | 31 | /** 32 | * Extracts host information from SSH Config 33 | */ 34 | private extractHostsFromConfig(config: any[]): SSHHostInfo[] { 35 | const hosts: SSHHostInfo[] = []; 36 | 37 | for (const section of config) { 38 | if (section.param === 'Host' && section.value !== '*') { 39 | const hostInfo: SSHHostInfo = { 40 | hostname: '', 41 | alias: section.value, 42 | }; 43 | 44 | // Search all entries for this host 45 | for (const param of section.config) { 46 | switch (param.param.toLowerCase()) { 47 | case 'hostname': 48 | hostInfo.hostname = param.value; 49 | break; 50 | case 'user': 51 | hostInfo.user = param.value; 52 | break; 53 | case 'port': 54 | hostInfo.port = parseInt(param.value, 10); 55 | break; 56 | case 'identityfile': 57 | hostInfo.identityFile = param.value; 58 | break; 59 | default: 60 | // Store other parameters 61 | hostInfo[param.param.toLowerCase()] = param.value; 62 | } 63 | } 64 | 65 | // Only add hosts with complete information 66 | if (hostInfo.hostname) { 67 | hosts.push(hostInfo); 68 | } 69 | } 70 | } 71 | 72 | return hosts; 73 | } 74 | 75 | /** 76 | * Reads the known_hosts file and extracts hostnames 77 | */ 78 | async parseKnownHosts(): Promise<string[]> { 79 | try { 80 | const content = await readFile(this.knownHostsPath, 'utf-8'); 81 | const knownHosts = content 82 | .split('\n') 83 | .filter(line => line.trim() !== '') 84 | .map(line => { 85 | // Format: hostname[,hostname2...] key-type public-key 86 | const parts = line.split(' ')[0]; 87 | return parts.split(',')[0]; 88 | }); 89 | 90 | return knownHosts; 91 | } catch (error) { 92 | console.error('Error reading known_hosts file:', error); 93 | return []; 94 | } 95 | } 96 | 97 | /** 98 | * Consolidates information from config and known_hosts 99 | */ 100 | async getAllKnownHosts(): Promise<SSHHostInfo[]> { 101 | const configHosts = await this.parseConfig(); 102 | const knownHostnames = await this.parseKnownHosts(); 103 | 104 | // Add hosts from known_hosts that aren't in the config 105 | for (const hostname of knownHostnames) { 106 | if (!configHosts.some(host => 107 | host.hostname === hostname || 108 | host.alias === hostname)) { 109 | configHosts.push({ 110 | hostname: hostname 111 | }); 112 | } 113 | } 114 | 115 | return configHosts; 116 | } 117 | } 118 | ``` -------------------------------------------------------------------------------- /src/ssh-client.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { NodeSSH } from 'node-ssh'; 2 | import { SSHHostInfo, CommandResult, ConnectionStatus, BatchCommandResult } from './types.js'; 3 | import { SSHConfigParser } from './ssh-config-parser.js'; 4 | 5 | export class SSHClient { 6 | private ssh: NodeSSH; 7 | private configParser: SSHConfigParser; 8 | 9 | constructor() { 10 | this.ssh = new NodeSSH(); 11 | this.configParser = new SSHConfigParser(); 12 | } 13 | 14 | /** 15 | * Lists all known SSH hosts 16 | */ 17 | async listKnownHosts(): Promise<SSHHostInfo[]> { 18 | return await this.configParser.getAllKnownHosts(); 19 | } 20 | 21 | /** 22 | * Connects to an SSH host and executes a command 23 | */ 24 | async runRemoteCommand(hostAlias: string, command: string): Promise<CommandResult> { 25 | try { 26 | // First connect to the host 27 | await this.connectToHost(hostAlias); 28 | 29 | // Execute the command 30 | const result = await this.ssh.execCommand(command); 31 | 32 | return { 33 | stdout: result.stdout, 34 | stderr: result.stderr, 35 | code: result.code || 0 36 | }; 37 | } catch (error) { 38 | console.error(`Error executing command on ${hostAlias}:`, error); 39 | return { 40 | stdout: '', 41 | stderr: error instanceof Error ? error.message : String(error), 42 | code: 1 43 | }; 44 | } finally { 45 | this.ssh.dispose(); 46 | } 47 | } 48 | 49 | /** 50 | * Returns all details about a host 51 | */ 52 | async getHostInfo(hostAlias: string): Promise<SSHHostInfo | null> { 53 | const hosts = await this.configParser.parseConfig(); 54 | return hosts.find(host => host.alias === hostAlias || host.hostname === hostAlias) || null; 55 | } 56 | 57 | /** 58 | * Checks if a connection to the host is possible 59 | */ 60 | async checkConnectivity(hostAlias: string): Promise<ConnectionStatus> { 61 | try { 62 | // Establish connection 63 | await this.connectToHost(hostAlias); 64 | 65 | // Execute ping command 66 | const result = await this.ssh.execCommand('echo connected'); 67 | 68 | const connected = result.stdout.trim() === 'connected'; 69 | 70 | this.ssh.dispose(); 71 | 72 | return { 73 | connected, 74 | message: connected ? 'Connection successful' : 'Echo test failed' 75 | }; 76 | } catch (error) { 77 | console.error(`Connectivity error with ${hostAlias}:`, error); 78 | return { 79 | connected: false, 80 | message: error instanceof Error ? error.message : String(error) 81 | }; 82 | } 83 | } 84 | 85 | /** 86 | * Uploads a file to the remote host 87 | */ 88 | async uploadFile(hostAlias: string, localPath: string, remotePath: string): Promise<boolean> { 89 | try { 90 | await this.connectToHost(hostAlias); 91 | 92 | await this.ssh.putFile(localPath, remotePath); 93 | 94 | this.ssh.dispose(); 95 | return true; 96 | } catch (error) { 97 | console.error(`Error uploading file to ${hostAlias}:`, error); 98 | return false; 99 | } 100 | } 101 | 102 | /** 103 | * Downloads a file from the remote host 104 | */ 105 | async downloadFile(hostAlias: string, remotePath: string, localPath: string): Promise<boolean> { 106 | try { 107 | await this.connectToHost(hostAlias); 108 | 109 | await this.ssh.getFile(localPath, remotePath); 110 | 111 | this.ssh.dispose(); 112 | return true; 113 | } catch (error) { 114 | console.error(`Error downloading file from ${hostAlias}:`, error); 115 | return false; 116 | } 117 | } 118 | 119 | /** 120 | * Executes multiple commands in sequence 121 | */ 122 | async runCommandBatch(hostAlias: string, commands: string[]): Promise<BatchCommandResult> { 123 | try { 124 | await this.connectToHost(hostAlias); 125 | 126 | const results: CommandResult[] = []; 127 | let success = true; 128 | 129 | for (const command of commands) { 130 | const result = await this.ssh.execCommand(command); 131 | const cmdResult: CommandResult = { 132 | stdout: result.stdout, 133 | stderr: result.stderr, 134 | code: result.code || 0 135 | }; 136 | 137 | results.push(cmdResult); 138 | 139 | if (cmdResult.code !== 0) { 140 | success = false; 141 | // We don't abort, execute all commands 142 | } 143 | } 144 | 145 | this.ssh.dispose(); 146 | return { 147 | results, 148 | success 149 | }; 150 | } catch (error) { 151 | console.error(`Error during batch execution on ${hostAlias}:`, error); 152 | return { 153 | results: [{ 154 | stdout: '', 155 | stderr: error instanceof Error ? error.message : String(error), 156 | code: 1 157 | }], 158 | success: false 159 | }; 160 | } 161 | } 162 | 163 | /** 164 | * Establishes a connection to a host 165 | */ 166 | private async connectToHost(hostAlias: string): Promise<void> { 167 | // Get host information 168 | const hostInfo = await this.getHostInfo(hostAlias); 169 | 170 | if (!hostInfo) { 171 | throw new Error(`Host ${hostAlias} not found`); 172 | } 173 | 174 | // Create connection configuration 175 | const connectionConfig = { 176 | host: hostInfo.hostname, 177 | username: hostInfo.user, 178 | port: hostInfo.port || 22, 179 | privateKeyPath: hostInfo.identityFile 180 | }; 181 | 182 | try { 183 | await this.ssh.connect(connectionConfig); 184 | } catch (error) { 185 | throw new Error(`Connection to ${hostAlias} failed: ${error instanceof Error ? error.message : String(error)}`); 186 | } 187 | } 188 | } 189 | ``` -------------------------------------------------------------------------------- /IMPLEMENTATION_NOTES.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP SSH Agent Implementation Notes 2 | 3 | ## Final Implementation (v2.0 - Simplified SSH) 4 | 5 | The MCP SSH Agent has been successfully implemented with a simplified, more reliable SSH approach that replaced the problematic `node-ssh` library. 6 | 7 | ### Architecture 8 | 9 | 1. **Main Server File**: `server-simple.mjs` 10 | - Pure JavaScript implementation using ES modules with createRequire 11 | - Uses `@modelcontextprotocol/sdk` for MCP protocol compliance 12 | - Uses native `ssh` and `scp` commands via `child_process.exec()` 13 | 14 | 2. **Core Components**: 15 | - `SSHConfigParser`: Parses `~/.ssh/config` and `~/.ssh/known_hosts` 16 | - `SSHClient`: Handles all SSH operations using local SSH commands 17 | - MCP Server: Provides standardized tool interface 18 | 19 | ### Key Changes in v2.0 20 | 21 | **Problem Solved**: The original implementation using `node-ssh` library failed with authentication errors ("All configured authentication methods failed") even though manual SSH connections worked perfectly. 22 | 23 | **Solution**: Replaced `node-ssh` with direct execution of local `ssh` and `scp` commands using Node.js `child_process.exec()`. This approach: 24 | - Leverages existing SSH infrastructure (agent, keys, config) 25 | - Avoids JavaScript library authentication complexities 26 | - Is more reliable and simpler to maintain 27 | - Provides better error messages and debugging 28 | 29 | ### MCP Protocol Implementation 30 | 31 | - **Server Creation**: Uses `Server` class from MCP SDK 32 | - **Transport**: `StdioServerTransport` for STDIO communication 33 | - **Request Handlers**: 34 | - `ListToolsRequestSchema`: Returns available SSH tools 35 | - `CallToolRequestSchema`: Executes SSH operations 36 | 37 | ### SSH Tools Provided 38 | 39 | 1. **listKnownHosts**: Returns all configured SSH hosts 40 | 2. **runRemoteCommand**: Executes single commands remotely using `ssh "host" "command"` 41 | 3. **getHostInfo**: Returns host configuration details 42 | 4. **checkConnectivity**: Tests SSH connectivity with simple echo test 43 | 5. **uploadFile**: Transfers files to remote hosts using `scp` 44 | 6. **downloadFile**: Downloads files from remote hosts using `scp` 45 | 7. **runCommandBatch**: Executes multiple commands sequentially 46 | 47 | ### Technical Implementation Details 48 | 49 | 1. **SSH Command Execution**: 50 | ```javascript 51 | const sshCommand = `ssh "${hostAlias}" "${command.replace(/"/g, '\\"')}"`; 52 | const { stdout, stderr } = await execAsync(sshCommand, { timeout: 30000 }); 53 | ``` 54 | 55 | 2. **File Transfers**: 56 | ```javascript 57 | // Upload: scp "localPath" "hostAlias:remotePath" 58 | // Download: scp "hostAlias:remotePath" "localPath" 59 | ``` 60 | 61 | 3. **Error Handling**: Proper timeout handling (30s for commands, 60s for transfers) 62 | 63 | ### Key Technical Decisions 64 | 65 | 1. **Module Resolution**: Used `createRequire()` to handle MCP SDK CommonJS exports 66 | 2. **Schema Compliance**: Used proper MCP request schemas instead of string identifiers 67 | 3. **SSH Approach**: Native SSH commands instead of JavaScript SSH libraries 68 | 4. **Error Handling**: Comprehensive error handling with meaningful error messages 69 | 5. **File Structure**: Simplified to single main file to avoid build complexity 70 | 71 | ### Dependencies (Simplified) 72 | 73 | - `@modelcontextprotocol/sdk`: MCP protocol implementation 74 | - `ssh-config`: SSH configuration parsing 75 | - Native Node.js modules: `child_process`, `util`, `os`, `fs` 76 | 77 | **Removed**: `node-ssh`, `ssh2` (unreliable authentication) 78 | 79 | ### Testing 80 | 81 | The implementation has been thoroughly tested with: 82 | 83 | 1. **Basic Functionality Tests**: 84 | ```bash 85 | # Test tools listing 86 | echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node server-simple.mjs 87 | 88 | # Test SSH command execution 89 | echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"runRemoteCommand","arguments":{"hostAlias":"prod","command":"echo \"test\""}}}' | node server-simple.mjs 90 | ``` 91 | 92 | 2. **SSH Connectivity**: Successfully tested against prod host (157.90.89.149:42077) 93 | 3. **Authentication**: Works with SSH agent and default key resolution 94 | 4. **Error Handling**: Proper error reporting for failed connections 95 | 96 | ### Performance and Reliability 97 | 98 | - **Startup Time**: Fast server initialization (~1-2 seconds) 99 | - **Command Execution**: Efficient direct SSH command execution 100 | - **Memory Usage**: Minimal - no persistent SSH connections 101 | - **Reliability**: Leverages proven SSH infrastructure 102 | 103 | ### Usage 104 | 105 | ```bash 106 | npm start # Starts the MCP server on STDIO 107 | ``` 108 | 109 | The server is ready for integration with MCP-compatible clients and language models. 110 | 111 | ## Troubleshooting History 112 | 113 | ### Issue: SSH Authentication Failures with node-ssh 114 | 115 | **Problem**: The original implementation using `node-ssh` consistently failed with: 116 | ``` 117 | Connection to prod failed: All configured authentication methods failed 118 | ``` 119 | 120 | **Investigation**: 121 | - SSH config parsing worked correctly (prod host found) 122 | - Manual SSH connection (`ssh prod echo test`) worked perfectly 123 | - Multiple attempts to configure SSH agent, keys, and connection options failed 124 | - Issue appeared to be with node-ssh library's authentication handling 125 | 126 | **Solution**: Complete replacement with native SSH command execution 127 | - Removed dependencies: `node-ssh`, `ssh2` 128 | - Replaced with: `child_process.exec()` + local `ssh`/`scp` commands 129 | - Result: Immediate success, much simpler code, better reliability 130 | 131 | This demonstrates the value of using proven system tools over complex JavaScript libraries for system operations. 132 | 133 | ## Security Considerations 134 | 135 | - Uses existing SSH key infrastructure 136 | - No password storage or handling 137 | - Relies on properly configured SSH authentication 138 | - All operations respect SSH configuration restrictions 139 | 140 | ## Maintenance Notes 141 | 142 | - Keep MCP SDK dependency updated for protocol compliance 143 | - Monitor SSH library updates for security patches 144 | - Test with various SSH configurations periodically 145 | ``` -------------------------------------------------------------------------------- /server-simple.mjs: -------------------------------------------------------------------------------- ``` 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * MCP SSH Agent - A Model Context Protocol server for managing SSH connections 5 | * 6 | * This is a simplified implementation that directly imports from specific files 7 | * to avoid module resolution issues. 8 | */ 9 | 10 | // Import required Node.js modules 11 | import { homedir } from 'os'; 12 | import { readFile } from 'fs/promises'; 13 | import { join } from 'path'; 14 | import { fileURLToPath } from 'url'; 15 | import { createRequire } from 'module'; 16 | 17 | // Use createRequire to work around ESM import issues 18 | const require = createRequire(import.meta.url); 19 | 20 | // Required libraries 21 | const { spawn, exec, execFile } = require('child_process'); 22 | const { promisify } = require('util'); 23 | const sshConfig = require('ssh-config'); 24 | 25 | const execAsync = promisify(exec); 26 | const execFileAsync = promisify(execFile); 27 | 28 | // Silent mode for MCP clients - disable debug output when used as MCP server 29 | const SILENT_MODE = process.env.MCP_SILENT === 'true' || process.argv.includes('--silent'); 30 | 31 | // Debug logging function - only outputs in non-silent mode 32 | function debugLog(message) { 33 | if (!SILENT_MODE) { 34 | process.stderr.write(message); 35 | } 36 | } 37 | 38 | // Import MCP components using proper export paths 39 | const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); 40 | const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); 41 | const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js'); 42 | 43 | // SSH Configuration Parser 44 | class SSHConfigParser { 45 | constructor() { 46 | const homeDir = homedir(); 47 | this.configPath = join(homeDir, '.ssh', 'config'); 48 | this.knownHostsPath = join(homeDir, '.ssh', 'known_hosts'); 49 | } 50 | 51 | async parseConfig() { 52 | try { 53 | const content = await readFile(this.configPath, 'utf-8'); 54 | const config = sshConfig.parse(content); 55 | return this.extractHostsFromConfig(config, this.configPath); 56 | } catch (error) { 57 | debugLog(`Error reading SSH config: ${error.message}\n`); 58 | return []; 59 | } 60 | } 61 | 62 | async processIncludeDirectives(configPath) { 63 | try { 64 | const content = await readFile(configPath, 'utf-8'); 65 | const config = sshConfig.parse(content); 66 | const hosts = []; 67 | 68 | for (const section of config) { 69 | if (section.param === 'Include' && section.value) { 70 | const includePaths = this.expandIncludePath(section.value, configPath); 71 | 72 | for (const includePath of includePaths) { 73 | try { 74 | const includeHosts = await this.processIncludeDirectives(includePath); 75 | hosts.push(...includeHosts); 76 | } catch (error) { 77 | debugLog(`Error processing include file ${includePath}: ${error.message}\n`); 78 | } 79 | } 80 | } 81 | } 82 | 83 | // Add hosts from the current config file 84 | const currentHosts = this.extractHostsFromConfig(config, configPath); 85 | hosts.push(...currentHosts); 86 | 87 | return hosts; 88 | } catch (error) { 89 | debugLog(`Error processing config file ${configPath}: ${error.message}\n`); 90 | return []; 91 | } 92 | } 93 | 94 | expandIncludePath(includePath, baseConfigPath) { 95 | const { dirname, resolve } = require('path'); 96 | const { glob } = require('glob'); 97 | const { existsSync } = require('fs'); 98 | 99 | // Handle tilde expansion 100 | if (includePath.startsWith('~/')) { 101 | includePath = includePath.replace('~', homedir()); 102 | } 103 | 104 | // Handle relative paths 105 | if (!includePath.startsWith('/')) { 106 | const baseDir = dirname(baseConfigPath); 107 | includePath = resolve(baseDir, includePath); 108 | } 109 | 110 | try { 111 | // Handle glob patterns 112 | if (includePath.includes('*') || includePath.includes('?')) { 113 | return glob.sync(includePath).filter(path => existsSync(path)); 114 | } else { 115 | return existsSync(includePath) ? [includePath] : []; 116 | } 117 | } catch (error) { 118 | debugLog(`Error expanding include path ${includePath}: ${error.message}\n`); 119 | return []; 120 | } 121 | } 122 | 123 | extractHostsFromConfig(config, configPath) { 124 | const hosts = []; 125 | 126 | for (const section of config) { 127 | // Skip Include directives as they are processed separately 128 | if (section.param === 'Include') { 129 | continue; 130 | } 131 | 132 | if (section.param === 'Host' && section.value !== '*') { 133 | const hostInfo = { 134 | hostname: '', 135 | alias: section.value, 136 | configFile: configPath 137 | }; 138 | 139 | // Search all entries for this host 140 | for (const param of section.config) { 141 | // Safety check for undefined param 142 | if (!param || !param.param) { 143 | continue; 144 | } 145 | 146 | switch (param.param.toLowerCase()) { 147 | case 'hostname': 148 | hostInfo.hostname = param.value; 149 | break; 150 | case 'user': 151 | hostInfo.user = param.value; 152 | break; 153 | case 'port': 154 | hostInfo.port = parseInt(param.value, 10); 155 | break; 156 | case 'identityfile': 157 | hostInfo.identityFile = param.value; 158 | break; 159 | default: 160 | // Store other parameters 161 | hostInfo[param.param.toLowerCase()] = param.value; 162 | } 163 | } 164 | 165 | // Only add hosts with complete information 166 | if (hostInfo.hostname) { 167 | hosts.push(hostInfo); 168 | } 169 | } 170 | } 171 | 172 | return hosts; 173 | } 174 | 175 | async parseKnownHosts() { 176 | try { 177 | const content = await readFile(this.knownHostsPath, 'utf-8'); 178 | const knownHosts = content 179 | .split('\n') 180 | .filter(line => line.trim() !== '') 181 | .map(line => { 182 | // Format: hostname[,hostname2...] key-type public-key 183 | const parts = line.split(' ')[0]; 184 | return parts.split(',')[0]; 185 | }); 186 | 187 | return knownHosts; 188 | } catch (error) { 189 | debugLog(`Error reading known_hosts file: ${error.message}\n`); 190 | return []; 191 | } 192 | } 193 | 194 | async getAllKnownHosts() { 195 | // First: Get all hosts from ~/.ssh/config including Include directives (these are prioritized) 196 | const configHosts = await this.processIncludeDirectives(this.configPath); 197 | 198 | // Second: Get hostnames from ~/.ssh/known_hosts 199 | const knownHostnames = await this.parseKnownHosts(); 200 | 201 | // Create a comprehensive list starting with config hosts 202 | const allHosts = [...configHosts]; 203 | 204 | // Add hosts from known_hosts that aren't already in the config 205 | // These will appear after the config hosts 206 | for (const hostname of knownHostnames) { 207 | if (!configHosts.some(host => 208 | host.hostname === hostname || 209 | host.alias === hostname)) { 210 | allHosts.push({ 211 | hostname: hostname, 212 | source: 'known_hosts' 213 | }); 214 | } 215 | } 216 | 217 | // Mark config hosts for clarity 218 | configHosts.forEach(host => { 219 | host.source = 'ssh_config'; 220 | }); 221 | 222 | return allHosts; 223 | } 224 | } 225 | 226 | // SSH Client Implementation 227 | class SSHClient { 228 | constructor() { 229 | this.configParser = new SSHConfigParser(); 230 | } 231 | 232 | async listKnownHosts() { 233 | return await this.configParser.getAllKnownHosts(); 234 | } 235 | 236 | async runRemoteCommand(hostAlias, command) { 237 | try { 238 | // Use execFile for security - prevents command injection 239 | debugLog(`Executing: ssh ${hostAlias} ${command}\n`); 240 | 241 | const { stdout, stderr } = await execFileAsync('ssh', [hostAlias, command], { 242 | timeout: 30000, // 30 second timeout 243 | maxBuffer: 1024 * 1024 * 10 // 10MB buffer 244 | }); 245 | 246 | return { 247 | stdout: stdout || '', 248 | stderr: stderr || '', 249 | code: 0 250 | }; 251 | } catch (error) { 252 | debugLog(`Error executing command on ${hostAlias}: ${error.message}\n`); 253 | return { 254 | stdout: error.stdout || '', 255 | stderr: error.stderr || error.message, 256 | code: error.code || 1 257 | }; 258 | } 259 | } 260 | 261 | async getHostInfo(hostAlias) { 262 | const hosts = await this.configParser.processIncludeDirectives(this.configParser.configPath); 263 | return hosts.find(host => host.alias === hostAlias || host.hostname === hostAlias) || null; 264 | } 265 | 266 | async checkConnectivity(hostAlias) { 267 | try { 268 | // Simple connectivity test using ssh 269 | const result = await this.runRemoteCommand(hostAlias, 'echo connected'); 270 | const connected = result.code === 0 && result.stdout.trim() === 'connected'; 271 | 272 | return { 273 | connected, 274 | message: connected ? 'Connection successful' : 'Connection failed' 275 | }; 276 | } catch (error) { 277 | debugLog(`Connectivity error with ${hostAlias}: ${error.message}\n`); 278 | return { 279 | connected: false, 280 | message: error instanceof Error ? error.message : String(error) 281 | }; 282 | } 283 | } 284 | 285 | async uploadFile(hostAlias, localPath, remotePath) { 286 | try { 287 | debugLog(`Executing: scp ${localPath} ${hostAlias}:${remotePath}\n`); 288 | 289 | await execFileAsync('scp', [localPath, `${hostAlias}:${remotePath}`], { 290 | timeout: 60000 // 60 second timeout for file transfer 291 | }); 292 | return true; 293 | } catch (error) { 294 | debugLog(`Error uploading file to ${hostAlias}: ${error.message}\n`); 295 | return false; 296 | } 297 | } 298 | 299 | async downloadFile(hostAlias, remotePath, localPath) { 300 | try { 301 | debugLog(`Executing: scp ${hostAlias}:${remotePath} ${localPath}\n`); 302 | 303 | await execFileAsync('scp', [`${hostAlias}:${remotePath}`, localPath], { 304 | timeout: 60000 // 60 second timeout for file transfer 305 | }); 306 | return true; 307 | } catch (error) { 308 | debugLog(`Error downloading file from ${hostAlias}: ${error.message}\n`); 309 | return false; 310 | } 311 | } 312 | 313 | async runCommandBatch(hostAlias, commands) { 314 | try { 315 | const results = []; 316 | let success = true; 317 | 318 | for (const command of commands) { 319 | const result = await this.runRemoteCommand(hostAlias, command); 320 | results.push(result); 321 | 322 | if (result.code !== 0) { 323 | success = false; 324 | // Continue executing remaining commands 325 | } 326 | } 327 | 328 | return { 329 | results, 330 | success 331 | }; 332 | } catch (error) { 333 | debugLog(`Error during batch execution on ${hostAlias}: ${error.message}\n`); 334 | return { 335 | results: [{ 336 | stdout: '', 337 | stderr: error instanceof Error ? error.message : String(error), 338 | code: 1 339 | }], 340 | success: false 341 | }; 342 | } 343 | } 344 | } 345 | 346 | // Main function to start the MCP server 347 | async function main() { 348 | try { 349 | // Create an instance of the SSH client 350 | debugLog("Initializing SSH client...\n"); 351 | const sshClient = new SSHClient(); 352 | 353 | debugLog("Creating MCP server...\n"); 354 | // Create an MCP server 355 | const server = new Server( 356 | { name: "mcp-ssh", version: "1.0.0" }, 357 | { capabilities: { tools: {} } } 358 | ); 359 | 360 | debugLog("Setting up request handlers...\n"); 361 | // Handler for listing available tools 362 | server.setRequestHandler(ListToolsRequestSchema, async () => { 363 | debugLog("Received listTools request\n"); 364 | return { 365 | tools: [ 366 | { 367 | name: "listKnownHosts", 368 | description: "Returns a consolidated list of all known SSH hosts, prioritizing ~/.ssh/config entries first, then additional hosts from ~/.ssh/known_hosts", 369 | inputSchema: { 370 | type: "object", 371 | properties: {}, 372 | required: [], 373 | }, 374 | }, 375 | { 376 | name: "runRemoteCommand", 377 | description: "Executes a shell command on an SSH host", 378 | inputSchema: { 379 | type: "object", 380 | properties: { 381 | hostAlias: { 382 | type: "string", 383 | description: "Alias or hostname of the SSH host", 384 | }, 385 | command: { 386 | type: "string", 387 | description: "The shell command to execute", 388 | }, 389 | }, 390 | required: ["hostAlias", "command"], 391 | }, 392 | }, 393 | { 394 | name: "getHostInfo", 395 | description: "Returns all configuration details for an SSH host", 396 | inputSchema: { 397 | type: "object", 398 | properties: { 399 | hostAlias: { 400 | type: "string", 401 | description: "Alias or hostname of the SSH host", 402 | }, 403 | }, 404 | required: ["hostAlias"], 405 | }, 406 | }, 407 | { 408 | name: "checkConnectivity", 409 | description: "Checks if an SSH connection to the host is possible", 410 | inputSchema: { 411 | type: "object", 412 | properties: { 413 | hostAlias: { 414 | type: "string", 415 | description: "Alias or hostname of the SSH host", 416 | }, 417 | }, 418 | required: ["hostAlias"], 419 | }, 420 | }, 421 | { 422 | name: "uploadFile", 423 | description: "Uploads a local file to an SSH host", 424 | inputSchema: { 425 | type: "object", 426 | properties: { 427 | hostAlias: { 428 | type: "string", 429 | description: "Alias or hostname of the SSH host", 430 | }, 431 | localPath: { 432 | type: "string", 433 | description: "Path to the local file", 434 | }, 435 | remotePath: { 436 | type: "string", 437 | description: "Path on the remote host", 438 | }, 439 | }, 440 | required: ["hostAlias", "localPath", "remotePath"], 441 | }, 442 | }, 443 | { 444 | name: "downloadFile", 445 | description: "Downloads a file from an SSH host", 446 | inputSchema: { 447 | type: "object", 448 | properties: { 449 | hostAlias: { 450 | type: "string", 451 | description: "Alias or hostname of the SSH host", 452 | }, 453 | remotePath: { 454 | type: "string", 455 | description: "Path on the remote host", 456 | }, 457 | localPath: { 458 | type: "string", 459 | description: "Path to the local destination", 460 | }, 461 | }, 462 | required: ["hostAlias", "remotePath", "localPath"], 463 | }, 464 | }, 465 | { 466 | name: "runCommandBatch", 467 | description: "Executes multiple shell commands sequentially on an SSH host", 468 | inputSchema: { 469 | type: "object", 470 | properties: { 471 | hostAlias: { 472 | type: "string", 473 | description: "Alias or hostname of the SSH host", 474 | }, 475 | commands: { 476 | type: "array", 477 | items: { type: "string" }, 478 | description: "List of shell commands to execute", 479 | }, 480 | }, 481 | required: ["hostAlias", "commands"], 482 | }, 483 | }, 484 | ], 485 | }; 486 | }); 487 | 488 | // Handler for tool calls 489 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 490 | const { name, arguments: args } = request.params; 491 | debugLog(`Received callTool request for tool: ${name}\n`); 492 | 493 | if (!args && name !== "listKnownHosts") { 494 | throw new Error(`No arguments provided for tool: ${name}`); 495 | } 496 | 497 | try { 498 | switch (name) { 499 | case "listKnownHosts": { 500 | const hosts = await sshClient.listKnownHosts(); 501 | return { 502 | content: [{ type: "text", text: JSON.stringify(hosts, null, 2) }], 503 | }; 504 | } 505 | 506 | case "runRemoteCommand": { 507 | const result = await sshClient.runRemoteCommand( 508 | args.hostAlias, 509 | args.command 510 | ); 511 | return { 512 | content: [{ type: "text", text: JSON.stringify(result, null, 2) }], 513 | }; 514 | } 515 | 516 | case "getHostInfo": { 517 | const hostInfo = await sshClient.getHostInfo(args.hostAlias); 518 | return { 519 | content: [{ type: "text", text: JSON.stringify(hostInfo, null, 2) }], 520 | }; 521 | } 522 | 523 | case "checkConnectivity": { 524 | const status = await sshClient.checkConnectivity(args.hostAlias); 525 | return { 526 | content: [{ type: "text", text: JSON.stringify(status, null, 2) }], 527 | }; 528 | } 529 | 530 | case "uploadFile": { 531 | const success = await sshClient.uploadFile( 532 | args.hostAlias, 533 | args.localPath, 534 | args.remotePath 535 | ); 536 | return { 537 | content: [{ type: "text", text: JSON.stringify({ success }, null, 2) }], 538 | }; 539 | } 540 | 541 | case "downloadFile": { 542 | const success = await sshClient.downloadFile( 543 | args.hostAlias, 544 | args.remotePath, 545 | args.localPath 546 | ); 547 | return { 548 | content: [{ type: "text", text: JSON.stringify({ success }, null, 2) }], 549 | }; 550 | } 551 | 552 | case "runCommandBatch": { 553 | const result = await sshClient.runCommandBatch( 554 | args.hostAlias, 555 | args.commands 556 | ); 557 | return { 558 | content: [{ type: "text", text: JSON.stringify(result, null, 2) }], 559 | }; 560 | } 561 | 562 | default: 563 | throw new Error(`Unknown tool: ${name}`); 564 | } 565 | } catch (error) { 566 | debugLog(`Error executing tool ${name}: ${error.message}\n`); 567 | return { 568 | content: [ 569 | { 570 | type: "text", 571 | text: JSON.stringify({ 572 | error: error instanceof Error ? error.message : String(error), 573 | }), 574 | }, 575 | ], 576 | }; 577 | } 578 | }); 579 | 580 | debugLog("Starting MCP SSH Agent on STDIO...\n"); 581 | const transport = new StdioServerTransport(); 582 | await server.connect(transport); 583 | debugLog("MCP SSH Agent connected and ready!\n"); 584 | 585 | } catch (error) { 586 | debugLog(`Error starting MCP SSH Agent: ${error.message}\n`); 587 | process.exit(1); 588 | } 589 | } 590 | 591 | // Start the server 592 | main().catch(error => { 593 | debugLog(`Unhandled error: ${error.message}\n`); 594 | process.exit(1); 595 | }); 596 | ```