@jadchene/mcp-ssh-service 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/config.js +123 -0
- package/dist/core/confirmation.js +54 -0
- package/dist/index.js +27 -0
- package/dist/logger.js +42 -0
- package/dist/mcp.js +69 -0
- package/dist/ssh.js +142 -0
- package/dist/tools/definitions.js +260 -0
- package/dist/tools/handlers.js +207 -0
- package/dist/version.js +8 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jadch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
English | [įŽäŊ䏿](./README_zh.md)
|
|
2
|
+
|
|
3
|
+
# đ mcp-ssh
|
|
4
|
+
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://nodejs.org/)
|
|
7
|
+
[](https://modelcontextprotocol.io/)
|
|
8
|
+
|
|
9
|
+
A **production-grade** Model Context Protocol (MCP) server designed for secure, stateless SSH automation. This service empowers AI agents to manage remote infrastructure with **human-in-the-loop** safety and **semantic environment awareness**.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## đ Key Pillars
|
|
14
|
+
|
|
15
|
+
### đ Uncompromising Security
|
|
16
|
+
* **Two-Step Confirmation**: High-risk operations (writes, deletes, restarts) return a `confirmationId`. Nothing happens until a human approves the specific transaction.
|
|
17
|
+
* **Command Blacklist**: Real-time regex interception for catastrophic commands like `rm -rf /` or `mkfs`.
|
|
18
|
+
* **Server-Level Read-Only**: Lock specific servers to a non-destructive mode at the configuration level.
|
|
19
|
+
* **Restricted File Deletion**: Hardcoded prevention of accidental deletion of system-critical paths like `/etc` or `/usr`.
|
|
20
|
+
|
|
21
|
+
### đ§ AI-Native Design
|
|
22
|
+
* **Semantic Infrastructure Discovery**: AI can list servers and understand their purposes via natural language descriptions.
|
|
23
|
+
* **Working Directory Aliases**: Map complex paths to simple aliases like `app-root` with descriptive metadata.
|
|
24
|
+
* **Contextual Pre-checks**: Built-in tools to verify dependencies (Docker, Git) before execution.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## đ Quick Start
|
|
29
|
+
|
|
30
|
+
### Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Install globally via npm
|
|
34
|
+
npm install -g @jadchene/mcp-ssh-service
|
|
35
|
+
|
|
36
|
+
# Start the server with a config file
|
|
37
|
+
mcp-ssh-service --config ./config.json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Source Setup
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/jadchene/mcp-ssh.git
|
|
44
|
+
cd mcp-ssh
|
|
45
|
+
npm install
|
|
46
|
+
npm run build
|
|
47
|
+
node dist/index.js --config ./config.json
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## âī¸ Configuration Schema
|
|
53
|
+
|
|
54
|
+
### Global Settings
|
|
55
|
+
| Parameter | Type | Description |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `logDir` | string | Directory for logs. Supports env vars like `${HOME}`. |
|
|
58
|
+
| `commandBlacklist` | string[] | Prohibited command regex patterns (e.g., `["^rm -rf"]`). |
|
|
59
|
+
| `defaultTimeout` | number | Command timeout in milliseconds (default: 60000). |
|
|
60
|
+
| `servers` | object | Dictionary of server configs where key is the `serverAlias`. |
|
|
61
|
+
|
|
62
|
+
### Server Object
|
|
63
|
+
| Parameter | Type | Description |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `host` | string | Remote IP or hostname. Supports env vars. |
|
|
66
|
+
| `port` | number | SSH port (default: 22). |
|
|
67
|
+
| `username` | string | SSH login user. |
|
|
68
|
+
| `password` | string | SSH password. Use `${VAR}` for security. |
|
|
69
|
+
| `privateKeyPath` | string | Path to private key file. |
|
|
70
|
+
| `passphrase` | string | Passphrase for the private key. |
|
|
71
|
+
| `readOnly` | boolean | Disables all write/modify tools for this server. |
|
|
72
|
+
| `desc` | string | Server description shown in `list_servers`. |
|
|
73
|
+
| `strictHostKeyChecking` | boolean | Set to `false` to bypass host key verification. |
|
|
74
|
+
| `workingDirectories` | object | Semantic path mappings (Key: { path, desc }). |
|
|
75
|
+
| `proxyJump` | object | Optional jump host (recursive server config). |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## âī¸ Configuration Example
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"logDir": "./logs",
|
|
84
|
+
"defaultTimeout": 60000,
|
|
85
|
+
"commandBlacklist": ["^apt-get upgrade", "curl.*\\|.*sh"],
|
|
86
|
+
"servers": {
|
|
87
|
+
"prod-web": {
|
|
88
|
+
"desc": "Primary API Cluster",
|
|
89
|
+
"host": "10.0.0.5",
|
|
90
|
+
"username": "deploy",
|
|
91
|
+
"privateKeyPath": "~/.ssh/id_rsa",
|
|
92
|
+
"passphrase": "${SSH_KEY_PWD}",
|
|
93
|
+
"workingDirectories": {
|
|
94
|
+
"logs": { "path": "/var/log/nginx", "desc": "Nginx access logs" }
|
|
95
|
+
},
|
|
96
|
+
"proxyJump": {
|
|
97
|
+
"host": "bastion.example.com",
|
|
98
|
+
"username": "jumpuser"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## đ ī¸ Integrated Toolset (45 Tools)
|
|
108
|
+
|
|
109
|
+
### đ Discovery & Context
|
|
110
|
+
* `list_servers`: Discovery available hosts.
|
|
111
|
+
* `ping_server`: Test SSH connection & credentials.
|
|
112
|
+
* `list_working_directories`: Get semantic path mappings.
|
|
113
|
+
* `get_system_info`: CPU, Memory, and System Uptime.
|
|
114
|
+
* `check_dependencies`: Verify remote binaries.
|
|
115
|
+
|
|
116
|
+
### đģ Shell & Files
|
|
117
|
+
* `execute_command`*, `execute_batch`*: Run single or sequenced shell commands.
|
|
118
|
+
* `ll`, `cat`, `tail`, `grep`, `pwd`, `cd`: Browse and search remote files.
|
|
119
|
+
* `upload_file`*, `download_file`: Transfer data.
|
|
120
|
+
* `mkdir`*, `mv`*, `cp`*, `chmod`*, `rm_safe`*, `touch`*: File system management.
|
|
121
|
+
|
|
122
|
+
### đŗ DevOps & Services
|
|
123
|
+
* `docker_ps`, `docker_logs`, `docker_compose_up`*, `docker_compose_restart`*: Container orchestration.
|
|
124
|
+
* `systemctl_status`, `systemctl_restart`*: System service control.
|
|
125
|
+
* `git_status`, `git_pull`*: Version control.
|
|
126
|
+
* `ip_addr`, `ping`, `netstat`, `df_h`, `nvidia_smi`: Diagnostics.
|
|
127
|
+
|
|
128
|
+
*\* High-risk: Requires `confirmationId` and `confirmExecution: true`.*
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## đ The Confirmation Workflow
|
|
133
|
+
|
|
134
|
+
1. **Request**: AI calls `rm_safe({ path: '/tmp/old' })`.
|
|
135
|
+
2. **Intercept**: Server returns `status: "pending"` with a `confirmationId`.
|
|
136
|
+
3. **Human Input**: You review the action in your chat client and approve.
|
|
137
|
+
4. **Execution**: AI calls `rm_safe` again with the `confirmationId` and `confirmExecution: true`.
|
|
138
|
+
5. **Verify**: Server ensures parameters match exactly and executes the SSH command.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## đ License
|
|
143
|
+
Released under the [MIT License](./LICENSE).
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { logger, updateLogTransports } from './logger.js';
|
|
4
|
+
export class ConfigManager {
|
|
5
|
+
configPath;
|
|
6
|
+
config;
|
|
7
|
+
watchTimeout = null;
|
|
8
|
+
constructor(configPath) {
|
|
9
|
+
this.configPath = path.resolve(configPath);
|
|
10
|
+
this.config = { servers: {} };
|
|
11
|
+
this.config = this.loadConfig();
|
|
12
|
+
this.watchConfig();
|
|
13
|
+
}
|
|
14
|
+
substituteEnvVars(val) {
|
|
15
|
+
return val.replace(/\${(\w+)}/g, (_, name) => process.env[name] || '');
|
|
16
|
+
}
|
|
17
|
+
processProxy(proxy) {
|
|
18
|
+
if (!proxy)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (proxy.host)
|
|
21
|
+
proxy.host = this.substituteEnvVars(proxy.host);
|
|
22
|
+
if (proxy.username)
|
|
23
|
+
proxy.username = this.substituteEnvVars(proxy.username);
|
|
24
|
+
if (proxy.password)
|
|
25
|
+
proxy.password = this.substituteEnvVars(proxy.password);
|
|
26
|
+
if (proxy.passphrase)
|
|
27
|
+
proxy.passphrase = this.substituteEnvVars(proxy.passphrase);
|
|
28
|
+
if (proxy.privateKeyPath) {
|
|
29
|
+
proxy.privateKeyPath = this.substituteEnvVars(proxy.privateKeyPath);
|
|
30
|
+
try {
|
|
31
|
+
const keyPath = path.resolve(path.dirname(this.configPath), proxy.privateKeyPath);
|
|
32
|
+
proxy.privateKey = fs.readFileSync(keyPath, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
logger.error(`Failed to read proxy private key:`, err);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return proxy;
|
|
39
|
+
}
|
|
40
|
+
loadConfig() {
|
|
41
|
+
try {
|
|
42
|
+
if (!fs.existsSync(this.configPath)) {
|
|
43
|
+
logger.warn(`Config file not found at ${this.configPath}.`);
|
|
44
|
+
return { servers: {} };
|
|
45
|
+
}
|
|
46
|
+
const rawData = fs.readFileSync(this.configPath, 'utf8');
|
|
47
|
+
if (!rawData.trim())
|
|
48
|
+
return this.config;
|
|
49
|
+
const parsed = JSON.parse(rawData);
|
|
50
|
+
if (parsed.logDir)
|
|
51
|
+
updateLogTransports(this.substituteEnvVars(parsed.logDir));
|
|
52
|
+
const maskedConfig = JSON.parse(JSON.stringify(parsed)); // For logging
|
|
53
|
+
for (const key of Object.keys(parsed.servers || {})) {
|
|
54
|
+
const srv = parsed.servers[key];
|
|
55
|
+
const logSrv = maskedConfig.servers[key];
|
|
56
|
+
if (srv.host)
|
|
57
|
+
srv.host = this.substituteEnvVars(srv.host);
|
|
58
|
+
if (srv.username)
|
|
59
|
+
srv.username = this.substituteEnvVars(srv.username);
|
|
60
|
+
// Mask passwords/passphrases in log object
|
|
61
|
+
if (srv.password) {
|
|
62
|
+
srv.password = this.substituteEnvVars(srv.password);
|
|
63
|
+
logSrv.password = "********";
|
|
64
|
+
}
|
|
65
|
+
if (srv.passphrase) {
|
|
66
|
+
srv.passphrase = this.substituteEnvVars(srv.passphrase);
|
|
67
|
+
logSrv.passphrase = "********";
|
|
68
|
+
}
|
|
69
|
+
if (srv.proxyJump) {
|
|
70
|
+
srv.proxyJump = this.processProxy(srv.proxyJump);
|
|
71
|
+
if (logSrv.proxyJump.password)
|
|
72
|
+
logSrv.proxyJump.password = "********";
|
|
73
|
+
if (logSrv.proxyJump.passphrase)
|
|
74
|
+
logSrv.proxyJump.passphrase = "********";
|
|
75
|
+
}
|
|
76
|
+
if (srv.privateKeyPath) {
|
|
77
|
+
srv.privateKeyPath = this.substituteEnvVars(srv.privateKeyPath);
|
|
78
|
+
try {
|
|
79
|
+
const keyPath = path.resolve(path.dirname(this.configPath), srv.privateKeyPath);
|
|
80
|
+
srv.privateKey = fs.readFileSync(keyPath, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
logger.error(`Failed to read private key for server ${key}:`, err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
logger.info('Configuration loaded successfully (sensitive data masked).');
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
logger.error('Failed to load config:', error);
|
|
92
|
+
return this.config;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
watchConfig() {
|
|
96
|
+
if (fs.existsSync(this.configPath)) {
|
|
97
|
+
fs.watch(this.configPath, () => {
|
|
98
|
+
if (this.watchTimeout)
|
|
99
|
+
clearTimeout(this.watchTimeout);
|
|
100
|
+
this.watchTimeout = setTimeout(() => {
|
|
101
|
+
this.config = this.loadConfig();
|
|
102
|
+
logger.info('Config hot-reloaded.');
|
|
103
|
+
}, 100);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
getServerConfig(alias) {
|
|
108
|
+
return this.config.servers[alias];
|
|
109
|
+
}
|
|
110
|
+
getAllServers() {
|
|
111
|
+
const result = {};
|
|
112
|
+
for (const [alias, srv] of Object.entries(this.config.servers)) {
|
|
113
|
+
result[alias] = { desc: srv.desc, host: srv.host };
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
getGlobalBlacklist() {
|
|
118
|
+
return this.config.commandBlacklist || [];
|
|
119
|
+
}
|
|
120
|
+
getDefaultTimeout() {
|
|
121
|
+
return this.config.defaultTimeout || 60000;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { logger } from "../logger.js";
|
|
3
|
+
const TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
4
|
+
const MAX_PENDING = 500;
|
|
5
|
+
export class ConfirmationManager {
|
|
6
|
+
pending = new Map();
|
|
7
|
+
createPending(toolName, serverAlias, args) {
|
|
8
|
+
// Cleanup expired first
|
|
9
|
+
this.cleanup();
|
|
10
|
+
if (this.pending.size >= MAX_PENDING) {
|
|
11
|
+
throw new Error("Too many pending confirmations. Please wait or confirm existing ones.");
|
|
12
|
+
}
|
|
13
|
+
const id = randomUUID();
|
|
14
|
+
this.pending.set(id, {
|
|
15
|
+
toolName,
|
|
16
|
+
serverAlias,
|
|
17
|
+
args,
|
|
18
|
+
expiresAt: Date.now() + TTL_MS
|
|
19
|
+
});
|
|
20
|
+
logger.info(`Action pending confirmation [${id}]: ${toolName} on ${serverAlias}`);
|
|
21
|
+
return id;
|
|
22
|
+
}
|
|
23
|
+
validateAndPop(id, toolName, serverAlias, args) {
|
|
24
|
+
const action = this.pending.get(id);
|
|
25
|
+
if (!action)
|
|
26
|
+
return false;
|
|
27
|
+
// Check expiration
|
|
28
|
+
if (Date.now() > action.expiresAt) {
|
|
29
|
+
this.pending.delete(id);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
// Verify consistency: tool, server and essential args must match
|
|
33
|
+
const isToolMatch = action.toolName === toolName;
|
|
34
|
+
const isServerMatch = action.serverAlias === serverAlias;
|
|
35
|
+
// Deep compare essential args (excluding the confirmation fields themselves)
|
|
36
|
+
const { confirmationId: _1, confirmExecution: _2, ...currentArgs } = args;
|
|
37
|
+
const { confirmationId: _3, confirmExecution: _4, ...pendingArgs } = action.args;
|
|
38
|
+
const isArgsMatch = JSON.stringify(currentArgs) === JSON.stringify(pendingArgs);
|
|
39
|
+
if (isToolMatch && isServerMatch && isArgsMatch) {
|
|
40
|
+
this.pending.delete(id); // Use once
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
cleanup() {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
for (const [id, action] of this.pending.entries()) {
|
|
48
|
+
if (now > action.expiresAt) {
|
|
49
|
+
this.pending.delete(id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const confirmationManager = new ConfirmationManager();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { MCPServer } from "./mcp.js";
|
|
3
|
+
import { ConfigManager } from "./config.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
async function main() {
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
if (args.includes("-v") || args.includes("--version")) {
|
|
9
|
+
console.log(`v${VERSION}`);
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
let configPath = process.env.MCP_SSH_CONFIG || "config.json";
|
|
14
|
+
const configIdx = args.indexOf("--config");
|
|
15
|
+
if (configIdx !== -1 && args[configIdx + 1]) {
|
|
16
|
+
configPath = args[configIdx + 1];
|
|
17
|
+
}
|
|
18
|
+
const configManager = new ConfigManager(configPath);
|
|
19
|
+
const server = new MCPServer(configManager);
|
|
20
|
+
await server.start();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
logger.error("Failed to start MCP SSH server:", error);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
main();
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import winston from 'winston';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
let currentLogDir = process.env.MCP_SSH_LOG_DIR || 'logs';
|
|
5
|
+
export const logger = winston.createLogger({
|
|
6
|
+
level: 'info',
|
|
7
|
+
format: winston.format.combine(winston.format.timestamp({
|
|
8
|
+
format: 'YYYY-MM-DD HH:mm:ss',
|
|
9
|
+
}), winston.format.errors({ stack: true }), winston.format.splat(), winston.format.json()),
|
|
10
|
+
defaultMeta: { service: 'mcp-ssh' },
|
|
11
|
+
transports: [
|
|
12
|
+
new winston.transports.Console({
|
|
13
|
+
// MCP over stdio requires stdout to carry only protocol frames.
|
|
14
|
+
// Route all logs to stderr to avoid corrupting the MCP stream.
|
|
15
|
+
stderrLevels: ['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly'],
|
|
16
|
+
format: winston.format.combine(winston.format.colorize(), winston.format.simple())
|
|
17
|
+
})
|
|
18
|
+
]
|
|
19
|
+
});
|
|
20
|
+
export function updateLogTransports(logDir) {
|
|
21
|
+
if (path.resolve(logDir) === path.resolve(currentLogDir)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
currentLogDir = logDir;
|
|
25
|
+
if (!fs.existsSync(logDir)) {
|
|
26
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
// Remove existing file transports
|
|
29
|
+
const fileTransports = logger.transports.filter(t => t instanceof winston.transports.File);
|
|
30
|
+
fileTransports.forEach(t => logger.remove(t));
|
|
31
|
+
// Add new file transports
|
|
32
|
+
logger.add(new winston.transports.File({
|
|
33
|
+
filename: path.join(logDir, 'error.log'),
|
|
34
|
+
level: 'error'
|
|
35
|
+
}));
|
|
36
|
+
logger.add(new winston.transports.File({
|
|
37
|
+
filename: path.join(logDir, 'mcp-ssh.log')
|
|
38
|
+
}));
|
|
39
|
+
logger.info(`Log directory updated to: ${logDir}`);
|
|
40
|
+
}
|
|
41
|
+
// Initialize with default or env var
|
|
42
|
+
updateLogTransports(currentLogDir);
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { toolDefinitions } from "./tools/definitions.js";
|
|
5
|
+
import { ToolHandlers } from "./tools/handlers.js";
|
|
6
|
+
import { logger } from "./logger.js";
|
|
7
|
+
import { NAME, VERSION } from "./version.js";
|
|
8
|
+
export class MCPServer {
|
|
9
|
+
server;
|
|
10
|
+
handlers;
|
|
11
|
+
constructor(configManager) {
|
|
12
|
+
this.server = new Server({
|
|
13
|
+
name: NAME,
|
|
14
|
+
version: VERSION,
|
|
15
|
+
}, {
|
|
16
|
+
capabilities: {
|
|
17
|
+
tools: {},
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
this.handlers = new ToolHandlers(configManager);
|
|
21
|
+
this.setupHandlers();
|
|
22
|
+
this.server.onerror = (error) => logger.error("[MCP Error]", error);
|
|
23
|
+
process.on("SIGINT", async () => {
|
|
24
|
+
await this.server.close();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
setupHandlers() {
|
|
29
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
30
|
+
tools: toolDefinitions,
|
|
31
|
+
}));
|
|
32
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
33
|
+
try {
|
|
34
|
+
if (!request.params.arguments) {
|
|
35
|
+
throw new Error("No arguments provided");
|
|
36
|
+
}
|
|
37
|
+
logger.info(`Handling tool call: ${request.params.name}`, request.params.arguments);
|
|
38
|
+
const result = await this.handlers.handleTool(request.params.name, request.params.arguments);
|
|
39
|
+
// Ensure the result is a string for the MCP "text" content type
|
|
40
|
+
const textOutput = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: textOutput,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
logger.error(`Tool execution error: ${error.message}`);
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: "text",
|
|
56
|
+
text: `Error: ${error.message}`,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
isError: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async start() {
|
|
65
|
+
const transport = new StdioServerTransport();
|
|
66
|
+
await this.server.connect(transport);
|
|
67
|
+
logger.info("MCP SSH Server running on stdio");
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/ssh.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Client } from 'ssh2';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
const MAX_OUTPUT_LENGTH = 30000;
|
|
6
|
+
export class SSHClient {
|
|
7
|
+
static getBaseConnectConfig(srv) {
|
|
8
|
+
const config = {
|
|
9
|
+
host: srv.host,
|
|
10
|
+
port: srv.port || 22,
|
|
11
|
+
username: srv.username,
|
|
12
|
+
readyTimeout: 15000,
|
|
13
|
+
};
|
|
14
|
+
if (srv.privateKey) {
|
|
15
|
+
config.privateKey = srv.privateKey;
|
|
16
|
+
if (srv.passphrase)
|
|
17
|
+
config.passphrase = srv.passphrase;
|
|
18
|
+
}
|
|
19
|
+
else if (srv.password) {
|
|
20
|
+
config.password = srv.password;
|
|
21
|
+
}
|
|
22
|
+
return config;
|
|
23
|
+
}
|
|
24
|
+
static truncate(text) {
|
|
25
|
+
if (text.length <= MAX_OUTPUT_LENGTH)
|
|
26
|
+
return text;
|
|
27
|
+
return text.substring(0, MAX_OUTPUT_LENGTH) + `\n\n[... Output truncated (${text.length} chars) ...]`;
|
|
28
|
+
}
|
|
29
|
+
static async runSession(serverConfig, action) {
|
|
30
|
+
const mainConfig = this.getBaseConnectConfig(serverConfig);
|
|
31
|
+
// Support strictHostKeyChecking: false (standard automation practice)
|
|
32
|
+
if (serverConfig.strictHostKeyChecking === false) {
|
|
33
|
+
mainConfig.algorithms = { serverHostKey: ['ssh-rsa', 'ssh-dss', 'ecdsa-sha2-nistp256', 'ssh-ed25519'] };
|
|
34
|
+
}
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const conn = new Client();
|
|
37
|
+
const connect = () => {
|
|
38
|
+
if (serverConfig.proxyJump) {
|
|
39
|
+
const proxyConn = new Client();
|
|
40
|
+
const pConfig = this.getBaseConnectConfig(serverConfig.proxyJump);
|
|
41
|
+
proxyConn.on('ready', () => {
|
|
42
|
+
logger.info(`Proxy connection ready to ${pConfig.host}`);
|
|
43
|
+
proxyConn.forwardOut('127.0.0.1', 0, mainConfig.host, mainConfig.port, (err, stream) => {
|
|
44
|
+
if (err) {
|
|
45
|
+
proxyConn.end();
|
|
46
|
+
return reject(new Error(`Proxy forwarding failed: ${err.message}`));
|
|
47
|
+
}
|
|
48
|
+
conn.connect({ ...mainConfig, sock: stream });
|
|
49
|
+
});
|
|
50
|
+
}).on('error', (err) => {
|
|
51
|
+
proxyConn.end();
|
|
52
|
+
reject(new Error(`Proxy connection error: ${err.message}`));
|
|
53
|
+
}).connect(pConfig);
|
|
54
|
+
// Ensure proxy closes when main connection closes
|
|
55
|
+
conn.on('close', () => proxyConn.end());
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
conn.connect(mainConfig);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
conn.on('ready', async () => {
|
|
62
|
+
logger.info(`SSH Session ready for ${serverConfig.host}`);
|
|
63
|
+
try {
|
|
64
|
+
const result = await action(conn);
|
|
65
|
+
conn.end();
|
|
66
|
+
resolve(result);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
conn.end();
|
|
70
|
+
reject(err);
|
|
71
|
+
}
|
|
72
|
+
}).on('error', (err) => {
|
|
73
|
+
logger.error(`SSH Connection Error:`, err);
|
|
74
|
+
reject(err);
|
|
75
|
+
});
|
|
76
|
+
connect();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
static async executeOnConn(conn, command, cwd, timeoutMs = 60000) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
let stdout = '';
|
|
82
|
+
let stderr = '';
|
|
83
|
+
const finalCommand = cwd ? `cd ${cwd} && ${command}` : command;
|
|
84
|
+
const timeout = setTimeout(() => {
|
|
85
|
+
conn.end();
|
|
86
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
87
|
+
}, timeoutMs);
|
|
88
|
+
conn.exec(finalCommand, (err, stream) => {
|
|
89
|
+
if (err) {
|
|
90
|
+
clearTimeout(timeout);
|
|
91
|
+
return reject(err);
|
|
92
|
+
}
|
|
93
|
+
stream.on('close', (code, signal) => {
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
resolve({
|
|
96
|
+
stdout: this.truncate(stdout),
|
|
97
|
+
stderr: this.truncate(stderr),
|
|
98
|
+
code,
|
|
99
|
+
signal
|
|
100
|
+
});
|
|
101
|
+
}).on('data', (data) => {
|
|
102
|
+
stdout += data.toString();
|
|
103
|
+
}).stderr.on('data', (data) => {
|
|
104
|
+
stderr += data.toString();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
static async executeCommand(serverConfig, command, cwd, timeoutMs = 60000) {
|
|
110
|
+
return this.runSession(serverConfig, (conn) => this.executeOnConn(conn, command, cwd, timeoutMs));
|
|
111
|
+
}
|
|
112
|
+
static async uploadFile(serverConfig, localPath, remotePath) {
|
|
113
|
+
return this.runSession(serverConfig, (conn) => {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
conn.sftp((err, sftp) => {
|
|
116
|
+
if (err)
|
|
117
|
+
return reject(err);
|
|
118
|
+
const readStream = fs.createReadStream(path.resolve(localPath));
|
|
119
|
+
const writeStream = sftp.createWriteStream(remotePath);
|
|
120
|
+
writeStream.on('close', resolve).on('error', reject);
|
|
121
|
+
readStream.pipe(writeStream);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
static async downloadFile(serverConfig, remotePath, localPath) {
|
|
127
|
+
return this.runSession(serverConfig, (conn) => {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
conn.sftp((err, sftp) => {
|
|
130
|
+
if (err)
|
|
131
|
+
return reject(err);
|
|
132
|
+
sftp.fastGet(remotePath, path.resolve(localPath), (err) => {
|
|
133
|
+
if (err)
|
|
134
|
+
reject(err);
|
|
135
|
+
else
|
|
136
|
+
resolve();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
function baseParams(properties = {}, required = []) {
|
|
2
|
+
return {
|
|
3
|
+
type: 'object',
|
|
4
|
+
properties: {
|
|
5
|
+
serverAlias: { type: 'string', description: 'Unique server key from config.json. Use list_servers to find available keys.' },
|
|
6
|
+
...properties
|
|
7
|
+
},
|
|
8
|
+
required: ['serverAlias', ...required]
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const confirmationParams = {
|
|
12
|
+
confirmationId: { type: 'string', description: 'The ID returned from the first attempt of a high-risk tool.' },
|
|
13
|
+
confirmExecution: { type: 'boolean', description: 'Set to true to finalize execution after receiving a confirmationId.' }
|
|
14
|
+
};
|
|
15
|
+
const grepParam = { grep: { type: 'string', description: 'Filter output using regex pattern.' } };
|
|
16
|
+
const cwdParam = { cwd: { type: 'string', description: 'Execution directory (supports aliases from list_working_directories).' } };
|
|
17
|
+
export const toolDefinitions = [
|
|
18
|
+
// --- Discovery ---
|
|
19
|
+
{
|
|
20
|
+
name: 'list_servers',
|
|
21
|
+
description: 'Discovery tool: List all configured SSH servers, their hosts, and descriptions.',
|
|
22
|
+
inputSchema: { type: 'object', properties: {} }
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'ping_server',
|
|
26
|
+
description: 'Connection test: Verifies if the SSH configuration for a specific server is valid and reachable.',
|
|
27
|
+
inputSchema: baseParams()
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'list_working_directories',
|
|
31
|
+
description: 'Context tool: Retrieves path mappings for a specific server.',
|
|
32
|
+
inputSchema: baseParams()
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'check_dependencies',
|
|
36
|
+
description: 'Environmental pre-check: Verifies if specific binaries exist on the remote server.',
|
|
37
|
+
inputSchema: baseParams({ commands: { type: 'array', items: { type: 'string' } } }, ['commands'])
|
|
38
|
+
},
|
|
39
|
+
// --- System ---
|
|
40
|
+
{
|
|
41
|
+
name: 'get_system_info',
|
|
42
|
+
description: 'System health check: Returns current user, system uptime, kernel, and memory.',
|
|
43
|
+
inputSchema: baseParams()
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'pwd',
|
|
47
|
+
description: 'Current path: Returns the absolute path of the current directory on remote.',
|
|
48
|
+
inputSchema: baseParams(cwdParam)
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'cd',
|
|
52
|
+
description: 'Directory navigation: Changes the working directory (effective within batch).',
|
|
53
|
+
inputSchema: baseParams({ path: { type: 'string' } }, ['path'])
|
|
54
|
+
},
|
|
55
|
+
// --- Batch ---
|
|
56
|
+
{
|
|
57
|
+
name: 'execute_batch',
|
|
58
|
+
description: 'Workflow automation: Executes a sequence of multiple tools in a single persistent SSH session. REQUIRES CONFIRMATION if any sub-tool is high-risk.',
|
|
59
|
+
inputSchema: baseParams({
|
|
60
|
+
commands: {
|
|
61
|
+
type: 'array',
|
|
62
|
+
items: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
name: { type: 'string' },
|
|
66
|
+
arguments: { type: 'object' }
|
|
67
|
+
},
|
|
68
|
+
required: ['name', 'arguments']
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
...cwdParam,
|
|
72
|
+
...confirmationParams
|
|
73
|
+
}, ['commands'])
|
|
74
|
+
},
|
|
75
|
+
// --- Shell ---
|
|
76
|
+
{
|
|
77
|
+
name: 'execute_command',
|
|
78
|
+
description: 'Arbitrary execution: Runs any shell command via SSH. REQUIRES CONFIRMATION.',
|
|
79
|
+
inputSchema: baseParams({
|
|
80
|
+
command: { type: 'string' },
|
|
81
|
+
...cwdParam,
|
|
82
|
+
...confirmationParams
|
|
83
|
+
}, ['command'])
|
|
84
|
+
},
|
|
85
|
+
// --- Files ---
|
|
86
|
+
{
|
|
87
|
+
name: 'upload_file',
|
|
88
|
+
description: 'File transfer (Local -> Remote). REQUIRES CONFIRMATION.',
|
|
89
|
+
inputSchema: baseParams({
|
|
90
|
+
localPath: { type: 'string' },
|
|
91
|
+
remotePath: { type: 'string' },
|
|
92
|
+
...confirmationParams
|
|
93
|
+
}, ['localPath', 'remotePath'])
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'download_file',
|
|
97
|
+
description: 'File transfer (Remote -> Local).',
|
|
98
|
+
inputSchema: baseParams({
|
|
99
|
+
remotePath: { type: 'string' },
|
|
100
|
+
localPath: { type: 'string' }
|
|
101
|
+
}, ['remotePath', 'localPath'])
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'll',
|
|
105
|
+
description: 'Directory listing: Lists files in a directory with detailed information.',
|
|
106
|
+
inputSchema: baseParams({ ...cwdParam, ...grepParam })
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'cat',
|
|
110
|
+
description: 'File reading: Reads text file content.',
|
|
111
|
+
inputSchema: baseParams({ filePath: { type: 'string' }, ...grepParam }, ['filePath'])
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'tail',
|
|
115
|
+
description: 'Log inspection: Reads last N lines of a file.',
|
|
116
|
+
inputSchema: baseParams({ filePath: { type: 'string' }, lines: { type: 'number' }, ...grepParam }, ['filePath'])
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'edit_text_file',
|
|
120
|
+
description: 'File creation/overwrite: Completely replaces file content. REQUIRES CONFIRMATION.',
|
|
121
|
+
inputSchema: baseParams({
|
|
122
|
+
filePath: { type: 'string' },
|
|
123
|
+
content: { type: 'string' },
|
|
124
|
+
...confirmationParams
|
|
125
|
+
}, ['filePath', 'content'])
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'append_text_file',
|
|
129
|
+
description: 'File appending: Adds text to end of file. REQUIRES CONFIRMATION.',
|
|
130
|
+
inputSchema: baseParams({
|
|
131
|
+
filePath: { type: 'string' },
|
|
132
|
+
content: { type: 'string' },
|
|
133
|
+
...confirmationParams
|
|
134
|
+
}, ['filePath', 'content'])
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'mkdir',
|
|
138
|
+
description: 'Directory creation: Creates a directory (mkdir -p). REQUIRES CONFIRMATION.',
|
|
139
|
+
inputSchema: baseParams({ path: { type: 'string' }, ...confirmationParams }, ['path'])
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'chmod',
|
|
143
|
+
description: 'Permission management: Changes file or directory permissions. REQUIRES CONFIRMATION.',
|
|
144
|
+
inputSchema: baseParams({ mode: { type: 'string' }, path: { type: 'string' }, ...confirmationParams }, ['mode', 'path'])
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'mv',
|
|
148
|
+
description: 'File movement/rename: Moves or renames files or directories. REQUIRES CONFIRMATION.',
|
|
149
|
+
inputSchema: baseParams({ source: { type: 'string' }, destination: { type: 'string' }, ...confirmationParams }, ['source', 'destination'])
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'cp',
|
|
153
|
+
description: 'File copy: Copies files or directories. REQUIRES CONFIRMATION.',
|
|
154
|
+
inputSchema: baseParams({
|
|
155
|
+
source: { type: 'string' },
|
|
156
|
+
destination: { type: 'string' },
|
|
157
|
+
recursive: { type: 'boolean' },
|
|
158
|
+
...confirmationParams
|
|
159
|
+
}, ['source', 'destination'])
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'rm_safe',
|
|
163
|
+
description: 'File deletion: Removes file or directory. REQUIRES CONFIRMATION.',
|
|
164
|
+
inputSchema: baseParams({ path: { type: 'string' }, recursive: { type: 'boolean' }, ...confirmationParams }, ['path'])
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'touch',
|
|
168
|
+
description: 'Timestamp/File creation: Updates access time or creates empty file. REQUIRES CONFIRMATION.',
|
|
169
|
+
inputSchema: baseParams({ filePath: { type: 'string' }, ...confirmationParams }, ['filePath'])
|
|
170
|
+
},
|
|
171
|
+
// --- Git ---
|
|
172
|
+
{
|
|
173
|
+
name: 'git_status',
|
|
174
|
+
description: 'Git status: Displays repository status.',
|
|
175
|
+
inputSchema: baseParams(cwdParam)
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'git_pull',
|
|
179
|
+
description: 'Git update: Pulls latest changes. REQUIRES CONFIRMATION.',
|
|
180
|
+
inputSchema: baseParams({ ...cwdParam, ...confirmationParams })
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'git_log',
|
|
184
|
+
description: 'Git history: Shows commit logs.',
|
|
185
|
+
inputSchema: baseParams({ ...cwdParam, count: { type: 'number' } })
|
|
186
|
+
},
|
|
187
|
+
// --- Docker ---
|
|
188
|
+
{
|
|
189
|
+
name: 'docker_compose_up',
|
|
190
|
+
description: 'Deploy docker stack. REQUIRES CONFIRMATION.',
|
|
191
|
+
inputSchema: baseParams({ ...cwdParam, ...confirmationParams }, ['cwd'])
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'docker_compose_down',
|
|
195
|
+
description: 'Remove docker stack. REQUIRES CONFIRMATION.',
|
|
196
|
+
inputSchema: baseParams({ ...cwdParam, ...confirmationParams }, ['cwd'])
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'docker_compose_logs',
|
|
200
|
+
description: 'View compose logs.',
|
|
201
|
+
inputSchema: baseParams({ ...cwdParam, lines: { type: 'number' }, ...grepParam }, ['cwd'])
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: 'docker_compose_restart',
|
|
205
|
+
description: 'Restart compose stack. REQUIRES CONFIRMATION.',
|
|
206
|
+
inputSchema: baseParams({ ...cwdParam, ...confirmationParams }, ['cwd'])
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: 'docker_ps',
|
|
210
|
+
description: 'List docker containers.',
|
|
211
|
+
inputSchema: baseParams(grepParam)
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'docker_logs',
|
|
215
|
+
description: 'Get container logs.',
|
|
216
|
+
inputSchema: baseParams({ container: { type: 'string' }, lines: { type: 'number' }, ...grepParam }, ['container'])
|
|
217
|
+
},
|
|
218
|
+
// --- Service & Network ---
|
|
219
|
+
{
|
|
220
|
+
name: 'systemctl_status',
|
|
221
|
+
description: 'Check systemd service status.',
|
|
222
|
+
inputSchema: baseParams({ service: { type: 'string' }, ...grepParam }, ['service'])
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: 'systemctl_restart',
|
|
226
|
+
description: 'Restart system service. REQUIRES CONFIRMATION.',
|
|
227
|
+
inputSchema: baseParams({ service: { type: 'string' }, ...confirmationParams }, ['service'])
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'ip_addr',
|
|
231
|
+
description: 'Show network interface info.',
|
|
232
|
+
inputSchema: baseParams(grepParam)
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: 'ping',
|
|
236
|
+
description: 'Verify host accessibility.',
|
|
237
|
+
inputSchema: baseParams({ host: { type: 'string' }, count: { type: 'number' } }, ['host'])
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'netstat',
|
|
241
|
+
description: 'Monitor ports/connections.',
|
|
242
|
+
inputSchema: baseParams({ args: { type: 'string' }, ...grepParam })
|
|
243
|
+
},
|
|
244
|
+
// --- Stats ---
|
|
245
|
+
{
|
|
246
|
+
name: 'df_h',
|
|
247
|
+
description: 'System disk usage.',
|
|
248
|
+
inputSchema: baseParams(grepParam)
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'du_sh',
|
|
252
|
+
description: 'Directory size estimation.',
|
|
253
|
+
inputSchema: baseParams({ path: { type: 'string' }, ...grepParam }, ['path'])
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: 'nvidia_smi',
|
|
257
|
+
description: 'GPU utilization status.',
|
|
258
|
+
inputSchema: baseParams()
|
|
259
|
+
}
|
|
260
|
+
];
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { SSHClient } from '../ssh.js';
|
|
2
|
+
import { confirmationManager } from '../core/confirmation.js';
|
|
3
|
+
const WRITE_TOOLS = [
|
|
4
|
+
'execute_command',
|
|
5
|
+
'upload_file',
|
|
6
|
+
'edit_text_file',
|
|
7
|
+
'append_text_file',
|
|
8
|
+
'mkdir',
|
|
9
|
+
'chmod',
|
|
10
|
+
'mv',
|
|
11
|
+
'cp',
|
|
12
|
+
'rm_safe',
|
|
13
|
+
'touch',
|
|
14
|
+
'git_pull',
|
|
15
|
+
'docker_compose_up',
|
|
16
|
+
'docker_compose_down',
|
|
17
|
+
'docker_compose_restart',
|
|
18
|
+
'systemctl_restart'
|
|
19
|
+
];
|
|
20
|
+
const DEFAULT_BLACKLIST = [
|
|
21
|
+
/rm\s+-(rf|fr|r|f)\s+\//i,
|
|
22
|
+
/rm\s+-(rf|fr|r|f)\s+\*/i,
|
|
23
|
+
/mkfs/i,
|
|
24
|
+
/dd\s+if=/i,
|
|
25
|
+
/>\s*\/dev\/sd[a-z]/i,
|
|
26
|
+
/shutdown/i,
|
|
27
|
+
/reboot/i
|
|
28
|
+
];
|
|
29
|
+
export class ToolHandlers {
|
|
30
|
+
configManager;
|
|
31
|
+
constructor(configManager) {
|
|
32
|
+
this.configManager = configManager;
|
|
33
|
+
}
|
|
34
|
+
getServerConfig(alias) {
|
|
35
|
+
const config = this.configManager.getServerConfig(alias);
|
|
36
|
+
if (!config) {
|
|
37
|
+
throw new Error(`Server alias '${alias}' not found in configuration.`);
|
|
38
|
+
}
|
|
39
|
+
return config;
|
|
40
|
+
}
|
|
41
|
+
resolveCwd(srv, cwd) {
|
|
42
|
+
if (!cwd)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (srv.workingDirectories && srv.workingDirectories[cwd]) {
|
|
45
|
+
return srv.workingDirectories[cwd].path;
|
|
46
|
+
}
|
|
47
|
+
return cwd;
|
|
48
|
+
}
|
|
49
|
+
checkBlacklist(command) {
|
|
50
|
+
const userBlacklist = this.configManager.getGlobalBlacklist();
|
|
51
|
+
const combined = [...DEFAULT_BLACKLIST, ...userBlacklist.map(p => new RegExp(p, 'i'))];
|
|
52
|
+
for (const pattern of combined) {
|
|
53
|
+
if (pattern.test(command)) {
|
|
54
|
+
throw new Error(`Security Violation: Prohibited pattern: ${pattern.toString()}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
getCommandForTool(name, params) {
|
|
59
|
+
switch (name) {
|
|
60
|
+
case 'get_system_info': return 'echo "USER: $(whoami)"; echo "UPTIME: $(uptime)"; echo "KERNEL: $(uname -a)"; echo "MEMORY:"; free -m';
|
|
61
|
+
case 'check_dependencies': return `for cmd in ${params.commands.join(' ')}; do which $cmd || echo "$cmd not found"; done`;
|
|
62
|
+
case 'pwd': return 'pwd';
|
|
63
|
+
case 'cd': return `cd ${params.path}`;
|
|
64
|
+
case 'll': return 'ls -l';
|
|
65
|
+
case 'cat': return `cat ${params.filePath}`;
|
|
66
|
+
case 'tail': return `tail -n ${params.lines || 50} ${params.filePath}`;
|
|
67
|
+
case 'mkdir': return `mkdir -p ${params.path}`;
|
|
68
|
+
case 'chmod': return `chmod ${params.mode} ${params.path}`;
|
|
69
|
+
case 'mv': return `mv -f ${params.source} ${params.destination}`;
|
|
70
|
+
case 'cp': return `cp -f ${params.recursive ? '-r' : ''} ${params.source} ${params.destination}`;
|
|
71
|
+
case 'rm_safe':
|
|
72
|
+
const restricted = ['/', '/etc', '/usr', '/bin', '/var', '/root', '/home'];
|
|
73
|
+
if (restricted.includes(params.path.trim()))
|
|
74
|
+
throw new Error(`RM_SAFE: Denied for restricted directory.`);
|
|
75
|
+
return `rm ${params.recursive ? '-rf' : '-f'} ${params.path}`;
|
|
76
|
+
case 'touch': return `touch ${params.filePath}`;
|
|
77
|
+
case 'append_text_file':
|
|
78
|
+
const appB64 = Buffer.from(params.content).toString('base64');
|
|
79
|
+
return `echo "${appB64}" | base64 -d >> ${params.filePath}`;
|
|
80
|
+
case 'edit_text_file':
|
|
81
|
+
const edB64 = Buffer.from(params.content).toString('base64');
|
|
82
|
+
return `echo "${edB64}" | base64 -d > ${params.filePath}`;
|
|
83
|
+
case 'git_status': return 'git status';
|
|
84
|
+
case 'git_pull': return 'git pull --no-edit';
|
|
85
|
+
case 'git_log': return `git log -n ${params.count || 10} --oneline`;
|
|
86
|
+
case 'execute_command': return params.command;
|
|
87
|
+
case 'docker_compose_up': return 'docker-compose up -d';
|
|
88
|
+
case 'docker_compose_down': return 'docker-compose down --remove-orphans';
|
|
89
|
+
case 'docker_compose_logs': return `docker-compose logs -n ${params.lines || 100}`;
|
|
90
|
+
case 'docker_compose_restart': return 'docker-compose restart';
|
|
91
|
+
case 'docker_ps': return 'docker ps';
|
|
92
|
+
case 'docker_logs': return `docker logs -n ${params.lines || 100} ${params.container}`;
|
|
93
|
+
case 'systemctl_status': return `systemctl status ${params.service}`;
|
|
94
|
+
case 'systemctl_restart': return `systemctl restart ${params.service}`;
|
|
95
|
+
case 'ip_addr': return 'ip addr';
|
|
96
|
+
case 'ping': return `ping -c ${params.count || 4} ${params.host}`;
|
|
97
|
+
case 'netstat': return `netstat ${params.args || '-tuln'}`;
|
|
98
|
+
case 'df_h': return 'df -h';
|
|
99
|
+
case 'du_sh': return `du -sh ${params.path}`;
|
|
100
|
+
case 'nvidia_smi': return 'nvidia-smi';
|
|
101
|
+
default: return '';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async handleTool(name, args) {
|
|
105
|
+
if (name === 'list_servers') {
|
|
106
|
+
const servers = this.configManager.getAllServers();
|
|
107
|
+
if (Object.keys(servers).length === 0)
|
|
108
|
+
return "No servers configured.";
|
|
109
|
+
return "Available SSH Servers:\n" + Object.entries(servers)
|
|
110
|
+
.map(([alias, info]) => `- [${alias}] ${info.host}${info.desc ? ' (' + info.desc + ')' : ''}`)
|
|
111
|
+
.join('\n');
|
|
112
|
+
}
|
|
113
|
+
const { serverAlias, confirmationId, confirmExecution, ...params } = args;
|
|
114
|
+
const srv = this.getServerConfig(serverAlias);
|
|
115
|
+
const timeout = this.configManager.getDefaultTimeout();
|
|
116
|
+
if (name === 'ping_server') {
|
|
117
|
+
try {
|
|
118
|
+
await SSHClient.runSession(srv, async () => true);
|
|
119
|
+
return `Successfully connected to server '${serverAlias}' (${srv.host}). Configuration is valid.`;
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
return `Connection failed for server '${serverAlias}': ${err.message}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// --- Confirmation Logic ---
|
|
126
|
+
const isWriteAction = WRITE_TOOLS.includes(name) || (name === 'execute_batch' && params.commands?.some((c) => WRITE_TOOLS.includes(c.name)));
|
|
127
|
+
if (isWriteAction) {
|
|
128
|
+
if (srv.readOnly)
|
|
129
|
+
throw new Error(`Server '${serverAlias}' is read-only.`);
|
|
130
|
+
if (confirmationId && confirmExecution === true) {
|
|
131
|
+
const isValid = confirmationManager.validateAndPop(confirmationId, name, serverAlias, args);
|
|
132
|
+
if (!isValid)
|
|
133
|
+
throw new Error("Invalid or expired confirmationId. Please try again.");
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const newId = confirmationManager.createPending(name, serverAlias, args);
|
|
137
|
+
return {
|
|
138
|
+
status: "pending",
|
|
139
|
+
confirmationId: newId,
|
|
140
|
+
message: `Manual confirmation required for high-risk tool: ${name}. Call this tool again with confirmExecution=true and the provided confirmationId.`,
|
|
141
|
+
actionPreview: { tool: name, server: serverAlias, args: params }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// --- Execution Logic ---
|
|
146
|
+
if (name === 'execute_batch') {
|
|
147
|
+
const commands = params.commands;
|
|
148
|
+
let results = [];
|
|
149
|
+
let currentBatchCwd = this.resolveCwd(srv, params.cwd);
|
|
150
|
+
for (const cmd of commands) {
|
|
151
|
+
if (cmd.name === 'execute_command')
|
|
152
|
+
this.checkBlacklist(cmd.arguments.command);
|
|
153
|
+
}
|
|
154
|
+
return await SSHClient.runSession(srv, async (conn) => {
|
|
155
|
+
for (const cmd of commands) {
|
|
156
|
+
if (cmd.name === 'cd') {
|
|
157
|
+
currentBatchCwd = cmd.arguments.path;
|
|
158
|
+
results.push(`Directory changed to: ${currentBatchCwd}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
let cmdStr = this.getCommandForTool(cmd.name, cmd.arguments);
|
|
162
|
+
if (!cmdStr) {
|
|
163
|
+
results.push(`[${cmd.name}] Error: Not supported in batch.`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (cmd.arguments.grep)
|
|
167
|
+
cmdStr += ` | grep -E "${cmd.arguments.grep.replace(/"/g, '\\"')}"`;
|
|
168
|
+
const res = await SSHClient.executeOnConn(conn, cmdStr, currentBatchCwd, timeout);
|
|
169
|
+
results.push(`[${cmd.name}]\n${res.stdout}${res.stderr ? '\n[STDERR]\n' + res.stderr : ''}`);
|
|
170
|
+
}
|
|
171
|
+
return results.join('\n\n---\n\n');
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (name === 'execute_command')
|
|
175
|
+
this.checkBlacklist(params.command);
|
|
176
|
+
const cwd = this.resolveCwd(srv, params.cwd);
|
|
177
|
+
if (name === 'list_working_directories') {
|
|
178
|
+
if (!srv.workingDirectories || Object.keys(srv.workingDirectories).length === 0) {
|
|
179
|
+
return `No directory mappings for '${serverAlias}'.`;
|
|
180
|
+
}
|
|
181
|
+
return `Working Directories for [${serverAlias}]:\n` + Object.entries(srv.workingDirectories)
|
|
182
|
+
.map(([alias, info]) => ` - ${alias}: ${info.path} (${info.desc})`)
|
|
183
|
+
.join('\n');
|
|
184
|
+
}
|
|
185
|
+
if (name === 'upload_file') {
|
|
186
|
+
await SSHClient.uploadFile(srv, params.localPath, params.remotePath);
|
|
187
|
+
return `Successfully uploaded ${params.localPath} to ${params.remotePath}`;
|
|
188
|
+
}
|
|
189
|
+
if (name === 'download_file') {
|
|
190
|
+
await SSHClient.downloadFile(srv, params.remotePath, params.localPath);
|
|
191
|
+
return `Successfully downloaded ${params.remotePath} to ${params.localPath}`;
|
|
192
|
+
}
|
|
193
|
+
let commandToRun = this.getCommandForTool(name, params);
|
|
194
|
+
if (commandToRun) {
|
|
195
|
+
if (params.grep)
|
|
196
|
+
commandToRun += ` | grep -E "${params.grep.replace(/"/g, '\\"')}"`;
|
|
197
|
+
const res = await SSHClient.executeCommand(srv, commandToRun, cwd, timeout);
|
|
198
|
+
let out = res.stdout;
|
|
199
|
+
if (res.stderr)
|
|
200
|
+
out += `\n[STDERR]\n${res.stderr}`;
|
|
201
|
+
if (res.code !== 0 && res.code !== null && !params.grep)
|
|
202
|
+
out += `\n[Exited with code ${res.code}]`;
|
|
203
|
+
return out || 'Success (no output)';
|
|
204
|
+
}
|
|
205
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const pkgPath = resolve(__dirname, "../package.json");
|
|
6
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
7
|
+
export const VERSION = pkg.version || "0.0.0";
|
|
8
|
+
export const NAME = pkg.name || "mcp-ssh";
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jadchene/mcp-ssh-service",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A production-ready, highly secure SSH MCP server featuring stateless connections, two-step operation confirmation, and comprehensive DevOps tool integration.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-ssh-service": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepare": "npm run build",
|
|
16
|
+
"watch": "tsc -w",
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
|
+
"start": "node dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mcp",
|
|
22
|
+
"ssh",
|
|
23
|
+
"model context protocol",
|
|
24
|
+
"automation",
|
|
25
|
+
"stateless"
|
|
26
|
+
],
|
|
27
|
+
"author": "jadchene",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/jadchene/mcp-ssh.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/jadchene/mcp-ssh/issues"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.0.1",
|
|
41
|
+
"ssh2": "^1.15.0",
|
|
42
|
+
"winston": "^3.11.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^20.10.0",
|
|
46
|
+
"@types/ssh2": "^1.11.19",
|
|
47
|
+
"typescript": "^5.3.3"
|
|
48
|
+
}
|
|
49
|
+
}
|