mcp-ssh-pty 1.0.0 → 1.2.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.
@@ -3,11 +3,20 @@ import { readFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { join } from "path";
5
5
  import { ShellManager } from "./shell-manager.js";
6
+ import { getSanitizer } from "./sanitizer.js";
7
+ // 内置的本地服务器配置
8
+ export const LOCAL_SERVER = {
9
+ name: "local",
10
+ host: "localhost",
11
+ port: 0,
12
+ username: process.env.USER || process.env.USERNAME || "local",
13
+ };
6
14
  export class SSHManager {
7
15
  client = null;
8
16
  currentServer = null;
9
17
  isConnected = false;
10
18
  shellManager;
19
+ isLocalConnection = false;
11
20
  constructor() {
12
21
  this.shellManager = new ShellManager();
13
22
  }
@@ -17,15 +26,49 @@ export class SSHManager {
17
26
  }
18
27
  return path;
19
28
  }
29
+ /**
30
+ * 检查是否是本地连接
31
+ */
32
+ static isLocalServer(config) {
33
+ return config.name === "local" || config.host === "local";
34
+ }
20
35
  async connect(config) {
21
36
  if (this.isConnected) {
22
37
  await this.disconnect();
23
38
  }
39
+ // 检查是否是本地连接
40
+ if (SSHManager.isLocalServer(config)) {
41
+ return this.connectLocal();
42
+ }
43
+ return this.connectSSH(config);
44
+ }
45
+ /**
46
+ * 本地连接
47
+ */
48
+ async connectLocal() {
49
+ try {
50
+ await this.shellManager.openLocal();
51
+ this.isConnected = true;
52
+ this.isLocalConnection = true;
53
+ this.currentServer = LOCAL_SERVER;
54
+ }
55
+ catch (error) {
56
+ this.cleanup();
57
+ throw error;
58
+ }
59
+ }
60
+ /**
61
+ * SSH 远程连接
62
+ */
63
+ async connectSSH(config) {
24
64
  return new Promise((resolve, reject) => {
25
65
  this.client = new Client();
26
66
  this.client.on("ready", async () => {
27
67
  this.isConnected = true;
68
+ this.isLocalConnection = false;
28
69
  this.currentServer = config;
70
+ // 注册敏感信息到过滤器
71
+ this.registerSensitiveInfo(config);
29
72
  // 连接成功后自动打开 shell
30
73
  try {
31
74
  await this.shellManager.open(this.client);
@@ -74,24 +117,43 @@ export class SSHManager {
74
117
  async disconnect() {
75
118
  // 先关闭 shell
76
119
  this.shellManager.close();
77
- if (this.client && this.isConnected) {
120
+ if (this.client && this.isConnected && !this.isLocalConnection) {
78
121
  this.client.end();
79
122
  }
80
123
  this.cleanup();
81
124
  }
82
125
  cleanup() {
83
126
  this.isConnected = false;
127
+ this.isLocalConnection = false;
84
128
  this.currentServer = null;
85
129
  this.client = null;
130
+ // 清除敏感信息
131
+ getSanitizer().clearSensitiveValues();
132
+ }
133
+ /**
134
+ * 注册服务器配置中的敏感信息
135
+ */
136
+ registerSensitiveInfo(config) {
137
+ const sanitizer = getSanitizer();
138
+ sanitizer.addSensitiveValues([
139
+ config.host,
140
+ config.password,
141
+ config.passphrase,
142
+ config.username,
143
+ config.privateKeyPath,
144
+ ]);
86
145
  }
87
146
  getShellManager() {
88
147
  return this.shellManager;
89
148
  }
149
+ isLocal() {
150
+ return this.isLocalConnection;
151
+ }
90
152
  getStatus() {
91
153
  return {
92
154
  connected: this.isConnected,
93
155
  serverName: this.currentServer?.name || null,
94
- host: this.currentServer?.host || null,
156
+ host: this.isLocalConnection ? "local" : (this.currentServer?.host || null),
95
157
  username: this.currentServer?.username || null,
96
158
  };
97
159
  }
package/dist/tools.js CHANGED
@@ -1,55 +1,129 @@
1
1
  import { z } from "zod";
2
+ import { LOCAL_SERVER } from "./ssh-manager.js";
3
+ import { sanitize } from "./sanitizer.js";
4
+ /**
5
+ * 过滤 ShellResult 中的敏感信息
6
+ */
7
+ function sanitizeResult(result) {
8
+ return {
9
+ ...result,
10
+ output: sanitize(result.output),
11
+ message: sanitize(result.message),
12
+ };
13
+ }
2
14
  export function registerTools(server, sshManager, configManager) {
3
- // 工具1: ssh - 连接管理 + 快捷命令
4
15
  server.registerTool("ssh", {
5
- description: `SSH 远程服务器连接管理和命令执行。使用持久 PTY Shell,支持交互式操作。
6
-
7
- ## 操作类型 (action)
8
- - list: 列出所有可用服务器
9
- - connect: 连接服务器(需提供 server 参数)
10
- - disconnect: 断开当前连接
11
- - status: 查看连接状态和 shell 缓冲区行数
12
-
13
- ## 快捷命令 (command)
14
- 直接提供 command 参数可执行命令,无需指定 action。
15
-
16
- ## 智能输出检测
17
- 命令执行后自动检测完成状态:
18
- - 快速命令(<2秒):检测到提示符后返回完整输出
19
- - 慢速命令(2-5秒):返回完整输出,标记 slow=true
20
- - 超时命令(>5秒):自动截断到最近 200 行,标记 truncated=true
21
- - 持续输出(如 tail -f):输出稳定后返回,标记 waiting=true
22
-
23
- ## 返回字段说明
24
- - output: 命令输出内容
25
- - totalLines: 总行数
26
- - complete: 是否检测到命令完成(出现提示符)
27
- - truncated: 是否被截断(超时时)
28
- - slow: 是否耗时较长
29
- - waiting: 命令可能仍在运行或等待输入
30
- - message: 状态描述
31
-
32
- ## 使用示例
33
- 1. 列出服务器: ssh({ action: "list" })
34
- 2. 连接: ssh({ action: "connect", server: "my-server" })
35
- 3. 执行命令: ssh({ command: "ls -la" })
36
- 4. 查看状态: ssh({ action: "status" })
37
- 5. 断开: ssh({ action: "disconnect" })
38
-
39
- ## 注意事项
40
- - 如果 truncated=true,使用 ssh_shell read 获取完整输出
41
- - 如果 waiting=true,命令可能需要输入或是持续运行的程序,使用 ssh_shell 继续交互`,
16
+ description: `SSH 远程服务器连接管理和 PTY Shell 交互。
17
+
18
+ ## 连接管理 (action)
19
+ - list: 列出所有可用服务器
20
+ - connect: 连接服务器(需提供 server 参数)
21
+ - disconnect: 断开当前连接
22
+ - status: 查看连接状态和 shell 缓冲区行数
23
+
24
+ ## 命令执行 (command)
25
+ 直接提供 command 参数执行命令,支持交互式程序。
26
+
27
+ ## Shell 控制
28
+ - read: 设为 true 读取缓冲区(配合 lines/offset/clear)
29
+ - signal: 发送信号 SIGINT(Ctrl+C)/SIGTSTP(Ctrl+Z)/SIGQUIT
30
+
31
+ ## 智能输出检测
32
+ - 快速命令(<2秒):检测到提示符后返回
33
+ - 慢速命令(2-5秒):标记 slow=true
34
+ - 超时命令(>5秒):截断到最近 200 行,标记 truncated=true
35
+ - 持续输出:输出稳定后返回,标记 waiting=true
36
+
37
+ ## 返回字段
38
+ - output: 命令输出
39
+ - totalLines: 总行数
40
+ - complete: 是否完成(出现提示符)
41
+ - truncated: 是否被截断
42
+ - slow: 是否耗时较长
43
+ - waiting: 可能在等待输入或仍在运行
44
+
45
+ ## 使用示例
46
+
47
+ ### 基本操作
48
+ ssh({ action: "list" })
49
+ ssh({ action: "connect", server: "my-server" })
50
+ ssh({ command: "ls -la" })
51
+ ssh({ action: "status" })
52
+ ssh({ action: "disconnect" })
53
+
54
+ ### 读取缓冲区
55
+ ssh({ read: true }) # 读取最近 20 行
56
+ ssh({ read: true, lines: -1 }) # 读取全部
57
+ ssh({ read: true, lines: 100 }) # 读取 100 行
58
+
59
+ ### 交互式程序
60
+ ssh({ command: "mysql -u root -p" }) # 启动 mysql
61
+ ssh({ command: "password123" }) # 输入密码
62
+ ssh({ command: "SHOW DATABASES;" }) # 执行 SQL
63
+
64
+ ### 信号控制
65
+ ssh({ command: "tail -f /var/log/syslog" })
66
+ ssh({ read: true }) # 查看输出
67
+ ssh({ signal: "SIGINT" }) # Ctrl+C 停止`,
42
68
  inputSchema: {
43
69
  action: z
44
70
  .enum(["list", "connect", "disconnect", "status"])
45
71
  .optional()
46
- .describe("操作类型:list/connect/disconnect/status"),
72
+ .describe("连接管理操作"),
47
73
  server: z.string().optional().describe("服务器名称(connect 时必填)"),
48
- command: z.string().optional().describe("要执行的命令(直接执行,无需 action)"),
74
+ command: z.string().optional().describe("要执行的命令"),
75
+ read: z.boolean().optional().describe("读取缓冲区"),
76
+ lines: z.number().optional().describe("读取行数,默认 20,-1 返回全部"),
77
+ offset: z.number().optional().describe("读取起始偏移,默认 0"),
78
+ clear: z.boolean().optional().describe("读取后清空缓冲区"),
79
+ signal: z
80
+ .enum(["SIGINT", "SIGTSTP", "SIGQUIT"])
81
+ .optional()
82
+ .describe("发送信号:SIGINT(Ctrl+C)/SIGTSTP(Ctrl+Z)/SIGQUIT"),
49
83
  },
50
- }, async ({ action, server: serverName, command }) => {
84
+ }, async ({ action, server: serverName, command, read, lines, offset, clear, signal }) => {
51
85
  try {
52
- // 快捷命令模式
86
+ // 1. 发送信号
87
+ if (signal) {
88
+ const status = sshManager.getStatus();
89
+ if (!status.connected) {
90
+ return {
91
+ content: [{ type: "text", text: "未连接服务器" }],
92
+ isError: true,
93
+ };
94
+ }
95
+ const shellManager = sshManager.getShellManager();
96
+ const success = shellManager.sendSignal(signal);
97
+ return {
98
+ content: [{
99
+ type: "text",
100
+ text: success ? `已发送 ${signal}` : `发送 ${signal} 失败`,
101
+ }],
102
+ isError: !success,
103
+ };
104
+ }
105
+ // 2. 读取缓冲区
106
+ if (read) {
107
+ const status = sshManager.getStatus();
108
+ if (!status.connected) {
109
+ return {
110
+ content: [{ type: "text", text: "未连接服务器" }],
111
+ isError: true,
112
+ };
113
+ }
114
+ const shellManager = sshManager.getShellManager();
115
+ const result = sanitizeResult(shellManager.read(lines, offset, clear));
116
+ return {
117
+ content: [{
118
+ type: "text",
119
+ text: JSON.stringify({
120
+ server: status.serverName,
121
+ ...result,
122
+ }, null, 2),
123
+ }],
124
+ };
125
+ }
126
+ // 3. 执行命令
53
127
  if (command) {
54
128
  const status = sshManager.getStatus();
55
129
  if (!status.connected) {
@@ -59,7 +133,7 @@ export function registerTools(server, sshManager, configManager) {
59
133
  };
60
134
  }
61
135
  const shellManager = sshManager.getShellManager();
62
- const result = await shellManager.send(command);
136
+ const result = sanitizeResult(await shellManager.send(command));
63
137
  return {
64
138
  content: [{
65
139
  type: "text",
@@ -72,17 +146,25 @@ export function registerTools(server, sshManager, configManager) {
72
146
  isError: !result.complete && !result.waiting,
73
147
  };
74
148
  }
75
- // 默认 action 为 status
149
+ // 4. 连接管理操作
76
150
  const effectiveAction = action || "status";
77
151
  switch (effectiveAction) {
78
152
  case "list": {
79
153
  const servers = configManager.listServers();
80
154
  const status = sshManager.getStatus();
81
- // 只返回服务器名称,不暴露 IP/端口/用户名
82
- const list = servers.map((s) => ({
83
- name: s.name,
84
- connected: status.serverName === s.name,
85
- }));
155
+ // 添加内置的 local 服务器
156
+ const list = [
157
+ {
158
+ name: LOCAL_SERVER.name,
159
+ connected: status.serverName === LOCAL_SERVER.name,
160
+ type: "built-in",
161
+ },
162
+ ...servers.map((s) => ({
163
+ name: s.name,
164
+ connected: status.serverName === s.name,
165
+ type: "configured",
166
+ })),
167
+ ];
86
168
  return {
87
169
  content: [{ type: "text", text: JSON.stringify(list, null, 2) }],
88
170
  };
@@ -94,9 +176,19 @@ export function registerTools(server, sshManager, configManager) {
94
176
  isError: true,
95
177
  };
96
178
  }
179
+ // 检查是否是本地连接
180
+ if (serverName === "local") {
181
+ await sshManager.connect(LOCAL_SERVER);
182
+ return {
183
+ content: [{
184
+ type: "text",
185
+ text: "成功连接到本地 Shell",
186
+ }],
187
+ };
188
+ }
97
189
  const serverConfig = configManager.getServer(serverName);
98
190
  if (!serverConfig) {
99
- const available = configManager.listServers().map((s) => s.name);
191
+ const available = ["local", ...configManager.listServers().map((s) => s.name)];
100
192
  return {
101
193
  content: [{
102
194
  type: "text",
@@ -106,7 +198,6 @@ export function registerTools(server, sshManager, configManager) {
106
198
  };
107
199
  }
108
200
  await sshManager.connect(serverConfig);
109
- // 不暴露 IP/端口/用户名
110
201
  return {
111
202
  content: [{
112
203
  type: "text",
@@ -131,7 +222,6 @@ export function registerTools(server, sshManager, configManager) {
131
222
  const status = sshManager.getStatus();
132
223
  const shellManager = sshManager.getShellManager();
133
224
  if (status.connected) {
134
- // 不暴露 host/username
135
225
  return {
136
226
  content: [{
137
227
  type: "text",
@@ -160,169 +250,4 @@ export function registerTools(server, sshManager, configManager) {
160
250
  };
161
251
  }
162
252
  });
163
- // 工具2: ssh_shell - PTY Shell 会话控制
164
- server.registerTool("ssh_shell", {
165
- description: `PTY Shell 高级会话控制。用于交互式程序、长输出处理、信号发送等场景。
166
-
167
- ## 操作类型 (action)
168
-
169
- ### send - 发送命令或输入
170
- 发送命令到 shell,智能等待完成。适用于:
171
- - 需要多次交互的程序(如 mysql, python 交互模式)
172
- - 需要发送特定输入(如回答 y/n 提示)
173
- 参数:input(必填)- 要发送的内容
174
-
175
- ### read - 读取缓冲区
176
- 获取 shell 输出缓冲区内容。适用于:
177
- - 命令被截断后获取完整输出
178
- - 查看持续运行命令的最新输出
179
- - 分页读取大量输出
180
- 参数:
181
- - lines: 返回行数(默认全部)
182
- - offset: 起始偏移(默认 0)
183
- - clear: 读取后清空缓冲区(默认 false)
184
-
185
- ### signal - 发送信号
186
- 向当前进程发送控制信号:
187
- - SIGINT: Ctrl+C,中断当前命令
188
- - SIGTSTP: Ctrl+Z,暂停进程
189
- - SIGQUIT: Ctrl+\,退出进程
190
- 参数:signal(必填)
191
-
192
- ### close - 关闭 shell
193
- 关闭当前 shell 会话,但保持 SSH 连接。
194
-
195
- ## 使用场景示例
196
-
197
- ### 场景1:查看大文件
198
- \`\`\`
199
- ssh({ command: "cat /var/log/syslog" })
200
- # 返回 truncated=true,只有最近 200 行
201
- ssh_shell({ action: "read", lines: 500, offset: 0 })
202
- # 获取前 500 行
203
- \`\`\`
204
-
205
- ### 场景2:交互式程序
206
- \`\`\`
207
- ssh({ command: "mysql -u root -p" })
208
- # 返回 waiting=true,等待密码
209
- ssh_shell({ action: "send", input: "password123" })
210
- # 发送密码
211
- ssh_shell({ action: "send", input: "SHOW DATABASES;" })
212
- # 执行 SQL
213
- \`\`\`
214
-
215
- ### 场景3:监控日志
216
- \`\`\`
217
- ssh({ command: "tail -f /var/log/nginx/access.log" })
218
- # 返回 waiting=true
219
- # ... 等待一段时间 ...
220
- ssh_shell({ action: "read" })
221
- # 查看新日志
222
- ssh_shell({ action: "signal", signal: "SIGINT" })
223
- # 停止 tail
224
- \`\`\`
225
-
226
- ### 场景4:vim 等全屏程序
227
- \`\`\`
228
- ssh_shell({ action: "send", input: "vim config.txt" })
229
- ssh_shell({ action: "send", input: "i" }) # 进入插入模式
230
- ssh_shell({ action: "send", input: "new content" })
231
- ssh_shell({ action: "send", input: "\\x1b" }) # ESC 键
232
- ssh_shell({ action: "send", input: ":wq" }) # 保存退出
233
- \`\`\``,
234
- inputSchema: {
235
- action: z
236
- .enum(["send", "read", "signal", "close"])
237
- .describe("操作类型:send/read/signal/close"),
238
- input: z.string().optional().describe("send 时的命令或输入内容"),
239
- signal: z
240
- .enum(["SIGINT", "SIGTSTP", "SIGQUIT"])
241
- .optional()
242
- .describe("signal 时的信号类型:SIGINT(Ctrl+C)/SIGTSTP(Ctrl+Z)/SIGQUIT"),
243
- lines: z.number().optional().describe("read 时返回的行数,默认全部"),
244
- offset: z.number().optional().describe("read 时的起始行偏移,默认 0"),
245
- clear: z.boolean().optional().describe("read 后是否清空缓冲区,默认 false"),
246
- },
247
- }, async ({ action, input, signal, lines, offset, clear }) => {
248
- try {
249
- const status = sshManager.getStatus();
250
- if (!status.connected) {
251
- return {
252
- content: [{ type: "text", text: "未连接服务器,请先使用 ssh 工具连接" }],
253
- isError: true,
254
- };
255
- }
256
- const shellManager = sshManager.getShellManager();
257
- if (!shellManager.isOpen()) {
258
- return {
259
- content: [{ type: "text", text: "Shell 未打开" }],
260
- isError: true,
261
- };
262
- }
263
- switch (action) {
264
- case "send": {
265
- if (!input) {
266
- return {
267
- content: [{ type: "text", text: "缺少 input 参数" }],
268
- isError: true,
269
- };
270
- }
271
- const result = await shellManager.send(input);
272
- return {
273
- content: [{
274
- type: "text",
275
- text: JSON.stringify({
276
- server: status.serverName,
277
- input,
278
- ...result,
279
- }, null, 2),
280
- }],
281
- isError: !result.complete && !result.waiting,
282
- };
283
- }
284
- case "read": {
285
- const result = shellManager.read(lines, offset, clear);
286
- return {
287
- content: [{
288
- type: "text",
289
- text: JSON.stringify({
290
- server: status.serverName,
291
- ...result,
292
- }, null, 2),
293
- }],
294
- };
295
- }
296
- case "signal": {
297
- if (!signal) {
298
- return {
299
- content: [{ type: "text", text: "缺少 signal 参数" }],
300
- isError: true,
301
- };
302
- }
303
- const success = shellManager.sendSignal(signal);
304
- return {
305
- content: [{
306
- type: "text",
307
- text: success ? `已发送 ${signal}` : `发送 ${signal} 失败`,
308
- }],
309
- isError: !success,
310
- };
311
- }
312
- case "close": {
313
- shellManager.close();
314
- return {
315
- content: [{ type: "text", text: "Shell 已关闭" }],
316
- };
317
- }
318
- }
319
- }
320
- catch (error) {
321
- const message = error instanceof Error ? error.message : String(error);
322
- return {
323
- content: [{ type: "text", text: `错误: ${message}` }],
324
- isError: true,
325
- };
326
- }
327
- });
328
253
  }
package/package.json CHANGED
@@ -1,46 +1,47 @@
1
- {
2
- "name": "mcp-ssh-pty",
3
- "version": "1.0.0",
4
- "description": "MCP Server for SSH remote command execution with PTY shell support",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "bin": {
8
- "mcp-ssh-pty": "./dist/index.js"
9
- },
10
- "scripts": {
11
- "build": "tsc",
12
- "start": "node dist/index.js",
13
- "dev": "tsc --watch",
14
- "prepublishOnly": "npm run build"
15
- },
16
- "keywords": [
17
- "mcp",
18
- "ssh",
19
- "claude",
20
- "anthropic",
21
- "ai",
22
- "remote",
23
- "shell",
24
- "pty"
25
- ],
26
- "author": "me",
27
- "license": "MIT",
28
-
29
- "files": [
30
- "dist",
31
- "ssh-servers.example.json"
32
- ],
33
- "dependencies": {
34
- "@modelcontextprotocol/sdk": "^1.0.0",
35
- "ssh2": "^1.16.0",
36
- "zod": "^3.24.0"
37
- },
38
- "devDependencies": {
39
- "@types/node": "^22.0.0",
40
- "@types/ssh2": "^1.15.0",
41
- "typescript": "^5.7.0"
42
- },
43
- "engines": {
44
- "node": ">=18.0.0"
45
- }
46
- }
1
+ {
2
+ "name": "mcp-ssh-pty",
3
+ "version": "1.2.0",
4
+ "description": "MCP Server for SSH remote command execution with PTY shell support",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mcp-ssh-pty": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "dev": "tsc --watch",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "ssh",
19
+ "claude",
20
+ "anthropic",
21
+ "ai",
22
+ "remote",
23
+ "shell",
24
+ "pty"
25
+ ],
26
+ "author": "me",
27
+ "license": "MIT",
28
+ "files": [
29
+ "dist",
30
+ "ssh-servers.example.json"
31
+ ],
32
+ "dependencies": {
33
+ "@inquirer/prompts": "^8.1.0",
34
+ "@modelcontextprotocol/sdk": "^1.0.0",
35
+ "commander": "^14.0.2",
36
+ "ssh2": "^1.16.0",
37
+ "zod": "^3.24.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.0.0",
41
+ "@types/ssh2": "^1.15.0",
42
+ "typescript": "^5.7.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ }
47
+ }
@@ -1,18 +1,18 @@
1
- {
2
- "servers": [
3
- {
4
- "name": "my-server",
5
- "host": "192.168.1.100",
6
- "port": 22,
7
- "username": "root",
8
- "privateKeyPath": "~/.ssh/id_rsa"
9
- },
10
- {
11
- "name": "dev-server",
12
- "host": "10.0.0.50",
13
- "port": 22,
14
- "username": "ubuntu",
15
- "password": "your-password"
16
- }
17
- ]
18
- }
1
+ {
2
+ "servers": [
3
+ {
4
+ "name": "my-server",
5
+ "host": "192.168.1.100",
6
+ "port": 22,
7
+ "username": "root",
8
+ "privateKeyPath": "~/.ssh/id_rsa"
9
+ },
10
+ {
11
+ "name": "dev-server",
12
+ "host": "10.0.0.50",
13
+ "port": 22,
14
+ "username": "ubuntu",
15
+ "password": "your-password"
16
+ }
17
+ ]
18
+ }