mcp-ssh-pty 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.
@@ -0,0 +1,12 @@
1
+ import { ServersConfig, ServerConfig } from "./types.js";
2
+ export declare class ConfigManager {
3
+ private config;
4
+ private configPath;
5
+ constructor(configPath?: string);
6
+ private resolveConfigPath;
7
+ private expandPath;
8
+ load(): ServersConfig;
9
+ getServer(name: string): ServerConfig | undefined;
10
+ listServers(): ServerConfig[];
11
+ getConfigPath(): string;
12
+ }
package/dist/config.js ADDED
@@ -0,0 +1,58 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ export class ConfigManager {
5
+ config = null;
6
+ configPath;
7
+ constructor(configPath) {
8
+ this.configPath = configPath || this.resolveConfigPath();
9
+ }
10
+ resolveConfigPath() {
11
+ // 1. 优先使用环境变量
12
+ if (process.env.SSH_MCP_CONFIG_PATH) {
13
+ return this.expandPath(process.env.SSH_MCP_CONFIG_PATH);
14
+ }
15
+ // 2. 查找项目目录下的 .claude/ssh-servers.json
16
+ const projectPath = join(process.cwd(), ".claude", "ssh-servers.json");
17
+ if (existsSync(projectPath)) {
18
+ return projectPath;
19
+ }
20
+ // 3. 查找用户目录下的 ~/.claude/ssh-servers.json
21
+ return join(homedir(), ".claude", "ssh-servers.json");
22
+ }
23
+ expandPath(path) {
24
+ if (path.startsWith("~")) {
25
+ return join(homedir(), path.slice(1));
26
+ }
27
+ return path;
28
+ }
29
+ load() {
30
+ if (!existsSync(this.configPath)) {
31
+ throw new Error(`配置文件不存在: ${this.configPath}\n请创建配置文件或设置 SSH_MCP_CONFIG_PATH 环境变量`);
32
+ }
33
+ try {
34
+ const content = readFileSync(this.configPath, "utf-8");
35
+ this.config = JSON.parse(content);
36
+ return this.config;
37
+ }
38
+ catch (error) {
39
+ if (error instanceof SyntaxError) {
40
+ throw new Error(`配置文件格式错误: ${this.configPath}\n${error.message}`);
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+ getServer(name) {
46
+ if (!this.config)
47
+ this.load();
48
+ return this.config.servers.find((s) => s.name === name);
49
+ }
50
+ listServers() {
51
+ if (!this.config)
52
+ this.load();
53
+ return this.config.servers;
54
+ }
55
+ getConfigPath() {
56
+ return this.configPath;
57
+ }
58
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { SSHManager } from "./ssh-manager.js";
5
+ import { ConfigManager } from "./config.js";
6
+ import { registerTools } from "./tools.js";
7
+ async function main() {
8
+ const server = new McpServer({
9
+ name: "ssh-mcp-server",
10
+ version: "1.0.0",
11
+ });
12
+ const sshManager = new SSHManager();
13
+ const configManager = new ConfigManager();
14
+ registerTools(server, sshManager, configManager);
15
+ const transport = new StdioServerTransport();
16
+ await server.connect(transport);
17
+ console.error("SSH MCP Server 已启动");
18
+ console.error(`配置文件路径: ${configManager.getConfigPath()}`);
19
+ process.on("SIGINT", async () => {
20
+ console.error("收到 SIGINT,正在关闭...");
21
+ await sshManager.disconnect();
22
+ process.exit(0);
23
+ });
24
+ process.on("SIGTERM", async () => {
25
+ console.error("收到 SIGTERM,正在关闭...");
26
+ await sshManager.disconnect();
27
+ process.exit(0);
28
+ });
29
+ }
30
+ main().catch((error) => {
31
+ console.error("启动失败:", error);
32
+ process.exit(1);
33
+ });
@@ -0,0 +1,50 @@
1
+ import { Client } from "ssh2";
2
+ import { ShellResult, ShellConfig } from "./types.js";
3
+ export declare class ShellManager {
4
+ private shell;
5
+ private outputBuffer;
6
+ private outputLines;
7
+ private lastOutputTime;
8
+ private config;
9
+ constructor(config?: Partial<ShellConfig>);
10
+ /**
11
+ * 打开 PTY Shell(在 SSH 连接成功后调用)
12
+ */
13
+ open(client: Client): Promise<void>;
14
+ /**
15
+ * 检查 shell 是否已打开
16
+ */
17
+ isOpen(): boolean;
18
+ /**
19
+ * 发送命令,智能等待完成
20
+ */
21
+ send(input: string, config?: Partial<ShellConfig>): Promise<ShellResult>;
22
+ /**
23
+ * 读取缓冲区内容
24
+ */
25
+ read(lines?: number, offset?: number, clear?: boolean): ShellResult;
26
+ /**
27
+ * 发送信号
28
+ */
29
+ sendSignal(signal: "SIGINT" | "SIGTSTP" | "SIGQUIT"): boolean;
30
+ /**
31
+ * 关闭 shell
32
+ */
33
+ close(): void;
34
+ /**
35
+ * 获取当前缓冲区行数
36
+ */
37
+ getBufferLineCount(): number;
38
+ /**
39
+ * 私有方法:等待命令完成
40
+ */
41
+ private waitForCompletion;
42
+ /**
43
+ * 私有方法:检测提示符
44
+ */
45
+ private detectPrompt;
46
+ /**
47
+ * 私有方法:构建结果对象
48
+ */
49
+ private buildResult;
50
+ }
@@ -0,0 +1,256 @@
1
+ const DEFAULT_CONFIG = {
2
+ quickTimeout: 2000,
3
+ maxTimeout: 5000,
4
+ maxLines: 200,
5
+ maxBufferLines: 10000,
6
+ };
7
+ export class ShellManager {
8
+ shell = null;
9
+ outputBuffer = "";
10
+ outputLines = [];
11
+ lastOutputTime = 0;
12
+ config;
13
+ constructor(config) {
14
+ this.config = { ...DEFAULT_CONFIG, ...config };
15
+ }
16
+ /**
17
+ * 打开 PTY Shell(在 SSH 连接成功后调用)
18
+ */
19
+ async open(client) {
20
+ return new Promise((resolve, reject) => {
21
+ client.shell({ term: "xterm-256color", rows: 40, cols: 120 }, (err, stream) => {
22
+ if (err) {
23
+ reject(new Error(`无法打开 shell: ${err.message}`));
24
+ return;
25
+ }
26
+ this.shell = stream;
27
+ this.outputBuffer = "";
28
+ this.outputLines = [];
29
+ this.lastOutputTime = Date.now();
30
+ // 监听数据事件
31
+ stream.on("data", (data) => {
32
+ const text = data.toString();
33
+ this.outputBuffer += text;
34
+ this.lastOutputTime = Date.now();
35
+ // 按行分割并存储
36
+ const lines = this.outputBuffer.split("\n");
37
+ // 最后一个可能是不完整的行,保留在 buffer
38
+ this.outputBuffer = lines.pop() || "";
39
+ this.outputLines.push(...lines);
40
+ // 限制缓冲区大小
41
+ if (this.outputLines.length > this.config.maxBufferLines) {
42
+ this.outputLines = this.outputLines.slice(-this.config.maxBufferLines);
43
+ }
44
+ });
45
+ // 监听关闭事件
46
+ stream.on("close", () => {
47
+ this.shell = null;
48
+ });
49
+ stream.on("error", (err) => {
50
+ console.error("Shell error:", err.message);
51
+ });
52
+ // 等待初始提示符
53
+ setTimeout(() => {
54
+ resolve();
55
+ }, 500);
56
+ });
57
+ });
58
+ }
59
+ /**
60
+ * 检查 shell 是否已打开
61
+ */
62
+ isOpen() {
63
+ return this.shell !== null;
64
+ }
65
+ /**
66
+ * 发送命令,智能等待完成
67
+ */
68
+ async send(input, config) {
69
+ if (!this.shell) {
70
+ return {
71
+ output: "",
72
+ totalLines: 0,
73
+ complete: false,
74
+ message: "Shell 未打开,请先连接服务器",
75
+ };
76
+ }
77
+ const mergedConfig = { ...this.config, ...config };
78
+ // 清空之前的输出,准备收集新输出
79
+ const startLineCount = this.outputLines.length;
80
+ this.outputBuffer = "";
81
+ // 发送命令
82
+ this.shell.write(input + "\n");
83
+ // 等待命令完成
84
+ return await this.waitForCompletion(startLineCount, mergedConfig);
85
+ }
86
+ /**
87
+ * 读取缓冲区内容
88
+ */
89
+ read(lines, offset, clear) {
90
+ const startIdx = offset || 0;
91
+ const endIdx = lines ? startIdx + lines : this.outputLines.length;
92
+ const selectedLines = this.outputLines.slice(startIdx, endIdx);
93
+ // 包含不完整的最后一行
94
+ let output = selectedLines.join("\n");
95
+ if (this.outputBuffer && (!lines || endIdx >= this.outputLines.length)) {
96
+ output += (output ? "\n" : "") + this.outputBuffer;
97
+ }
98
+ const result = {
99
+ output,
100
+ totalLines: this.outputLines.length + (this.outputBuffer ? 1 : 0),
101
+ complete: true,
102
+ message: `读取了 ${selectedLines.length} 行`,
103
+ };
104
+ if (clear) {
105
+ this.outputLines = [];
106
+ this.outputBuffer = "";
107
+ }
108
+ return result;
109
+ }
110
+ /**
111
+ * 发送信号
112
+ */
113
+ sendSignal(signal) {
114
+ if (!this.shell) {
115
+ return false;
116
+ }
117
+ // 通过写入控制字符来模拟信号
118
+ const signalChars = {
119
+ SIGINT: "\x03", // Ctrl+C
120
+ SIGTSTP: "\x1a", // Ctrl+Z
121
+ SIGQUIT: "\x1c", // Ctrl+\
122
+ };
123
+ const char = signalChars[signal];
124
+ if (char) {
125
+ this.shell.write(char);
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+ /**
131
+ * 关闭 shell
132
+ */
133
+ close() {
134
+ if (this.shell) {
135
+ this.shell.end();
136
+ this.shell = null;
137
+ }
138
+ this.outputLines = [];
139
+ this.outputBuffer = "";
140
+ }
141
+ /**
142
+ * 获取当前缓冲区行数
143
+ */
144
+ getBufferLineCount() {
145
+ return this.outputLines.length + (this.outputBuffer ? 1 : 0);
146
+ }
147
+ /**
148
+ * 私有方法:等待命令完成
149
+ */
150
+ async waitForCompletion(startLineCount, config) {
151
+ const startTime = Date.now();
152
+ let lastCheckTime = startTime;
153
+ let stableCount = 0;
154
+ return new Promise((resolve) => {
155
+ const check = setInterval(() => {
156
+ const elapsed = Date.now() - startTime;
157
+ const timeSinceLastOutput = Date.now() - this.lastOutputTime;
158
+ // 获取新输出
159
+ const newLines = this.outputLines.slice(startLineCount);
160
+ const currentOutput = newLines.join("\n") +
161
+ (this.outputBuffer ? "\n" + this.outputBuffer : "");
162
+ const lastLine = this.outputBuffer ||
163
+ (newLines.length > 0 ? newLines[newLines.length - 1] : "");
164
+ const hasPrompt = this.detectPrompt(lastLine);
165
+ // 检测输出是否稳定(连续 3 次检查没有新输出)
166
+ if (this.lastOutputTime <= lastCheckTime) {
167
+ stableCount++;
168
+ }
169
+ else {
170
+ stableCount = 0;
171
+ }
172
+ lastCheckTime = Date.now();
173
+ // 策略 A: 快速 + 提示符(< 2秒)
174
+ if (elapsed <= config.quickTimeout && hasPrompt && stableCount >= 2) {
175
+ clearInterval(check);
176
+ resolve(this.buildResult(currentOutput, newLines.length, true, false, false, false));
177
+ return;
178
+ }
179
+ // 策略 B: 慢速 + 提示符(2-5秒)
180
+ if (elapsed > config.quickTimeout && elapsed <= config.maxTimeout && hasPrompt && stableCount >= 2) {
181
+ clearInterval(check);
182
+ resolve(this.buildResult(currentOutput, newLines.length, true, false, true, false));
183
+ return;
184
+ }
185
+ // 策略 C: 超时(> 5秒)
186
+ if (elapsed > config.maxTimeout) {
187
+ clearInterval(check);
188
+ const truncated = newLines.length > config.maxLines;
189
+ const truncatedLines = truncated
190
+ ? newLines.slice(-config.maxLines)
191
+ : newLines;
192
+ const truncatedOutput = truncatedLines.join("\n") +
193
+ (this.outputBuffer ? "\n" + this.outputBuffer : "");
194
+ resolve(this.buildResult(truncatedOutput, newLines.length, hasPrompt, truncated, true, !hasPrompt));
195
+ return;
196
+ }
197
+ // 策略 D: 输出稳定但无提示符(500ms 无新输出)
198
+ if (timeSinceLastOutput > 500 && stableCount >= 5 && elapsed > 1000) {
199
+ clearInterval(check);
200
+ resolve(this.buildResult(currentOutput, newLines.length, false, false, false, true));
201
+ return;
202
+ }
203
+ }, 100);
204
+ });
205
+ }
206
+ /**
207
+ * 私有方法:检测提示符
208
+ */
209
+ detectPrompt(line) {
210
+ // 移除 ANSI 转义序列
211
+ const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, "").trim();
212
+ if (!cleanLine)
213
+ return false;
214
+ // 常见提示符模式
215
+ const patterns = [
216
+ /\$\s*$/, // $ 结尾(普通用户)
217
+ /#\s*$/, // # 结尾(root 用户)
218
+ />\s*$/, // > 结尾(Windows/PowerShell)
219
+ /\]\$\s*$/, // ]$ 结尾([user@host dir]$)
220
+ /\]#\s*$/, // ]# 结尾([user@host dir]#)
221
+ /\)\s*[$#>]\s*$/, // )$ 或 )# 结尾(一些自定义 PS1)
222
+ /~\s*[$#>]\s*$/, // ~$ 结尾
223
+ /@.*:\s*[$#>]\s*$/, // user@host: $ 格式
224
+ ];
225
+ return patterns.some((p) => p.test(cleanLine));
226
+ }
227
+ /**
228
+ * 私有方法:构建结果对象
229
+ */
230
+ buildResult(output, totalLines, complete, truncated, slow, waiting) {
231
+ let message = "";
232
+ if (complete) {
233
+ message = slow
234
+ ? `命令执行完成(耗时较长),共 ${totalLines} 行`
235
+ : `命令执行完成,共 ${totalLines} 行`;
236
+ }
237
+ else if (truncated) {
238
+ message = `输出超时,已截断至最近 ${this.config.maxLines} 行(共 ${totalLines} 行)。使用 read 获取完整输出。`;
239
+ }
240
+ else if (waiting) {
241
+ message = `输出已稳定,命令可能在等待输入或仍在运行。共 ${totalLines} 行。`;
242
+ }
243
+ else {
244
+ message = `共 ${totalLines} 行`;
245
+ }
246
+ return {
247
+ output,
248
+ totalLines,
249
+ complete,
250
+ truncated: truncated || undefined,
251
+ slow: slow || undefined,
252
+ waiting: waiting || undefined,
253
+ message,
254
+ };
255
+ }
256
+ }
@@ -0,0 +1,15 @@
1
+ import { ServerConfig, ConnectionStatus } from "./types.js";
2
+ import { ShellManager } from "./shell-manager.js";
3
+ export declare class SSHManager {
4
+ private client;
5
+ private currentServer;
6
+ private isConnected;
7
+ private shellManager;
8
+ constructor();
9
+ private expandPath;
10
+ connect(config: ServerConfig): Promise<void>;
11
+ disconnect(): Promise<void>;
12
+ private cleanup;
13
+ getShellManager(): ShellManager;
14
+ getStatus(): ConnectionStatus;
15
+ }
@@ -0,0 +1,98 @@
1
+ import { Client } from "ssh2";
2
+ import { readFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { ShellManager } from "./shell-manager.js";
6
+ export class SSHManager {
7
+ client = null;
8
+ currentServer = null;
9
+ isConnected = false;
10
+ shellManager;
11
+ constructor() {
12
+ this.shellManager = new ShellManager();
13
+ }
14
+ expandPath(path) {
15
+ if (path.startsWith("~")) {
16
+ return join(homedir(), path.slice(1));
17
+ }
18
+ return path;
19
+ }
20
+ async connect(config) {
21
+ if (this.isConnected) {
22
+ await this.disconnect();
23
+ }
24
+ return new Promise((resolve, reject) => {
25
+ this.client = new Client();
26
+ this.client.on("ready", async () => {
27
+ this.isConnected = true;
28
+ this.currentServer = config;
29
+ // 连接成功后自动打开 shell
30
+ try {
31
+ await this.shellManager.open(this.client);
32
+ resolve();
33
+ }
34
+ catch (err) {
35
+ this.cleanup();
36
+ reject(err);
37
+ }
38
+ });
39
+ this.client.on("error", (err) => {
40
+ this.cleanup();
41
+ reject(err);
42
+ });
43
+ this.client.on("close", () => {
44
+ this.cleanup();
45
+ });
46
+ const connectConfig = {
47
+ host: config.host,
48
+ port: config.port || 22,
49
+ username: config.username,
50
+ };
51
+ if (config.privateKeyPath) {
52
+ try {
53
+ const keyPath = this.expandPath(config.privateKeyPath);
54
+ connectConfig.privateKey = readFileSync(keyPath);
55
+ if (config.passphrase) {
56
+ connectConfig.passphrase = config.passphrase;
57
+ }
58
+ }
59
+ catch (error) {
60
+ reject(new Error(`无法读取私钥文件: ${config.privateKeyPath}`));
61
+ return;
62
+ }
63
+ }
64
+ else if (config.password) {
65
+ connectConfig.password = config.password;
66
+ }
67
+ else {
68
+ reject(new Error("必须提供 password 或 privateKeyPath"));
69
+ return;
70
+ }
71
+ this.client.connect(connectConfig);
72
+ });
73
+ }
74
+ async disconnect() {
75
+ // 先关闭 shell
76
+ this.shellManager.close();
77
+ if (this.client && this.isConnected) {
78
+ this.client.end();
79
+ }
80
+ this.cleanup();
81
+ }
82
+ cleanup() {
83
+ this.isConnected = false;
84
+ this.currentServer = null;
85
+ this.client = null;
86
+ }
87
+ getShellManager() {
88
+ return this.shellManager;
89
+ }
90
+ getStatus() {
91
+ return {
92
+ connected: this.isConnected,
93
+ serverName: this.currentServer?.name || null,
94
+ host: this.currentServer?.host || null,
95
+ username: this.currentServer?.username || null,
96
+ };
97
+ }
98
+ }
@@ -0,0 +1,4 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { SSHManager } from "./ssh-manager.js";
3
+ import { ConfigManager } from "./config.js";
4
+ export declare function registerTools(server: McpServer, sshManager: SSHManager, configManager: ConfigManager): void;
package/dist/tools.js ADDED
@@ -0,0 +1,328 @@
1
+ import { z } from "zod";
2
+ export function registerTools(server, sshManager, configManager) {
3
+ // 工具1: ssh - 连接管理 + 快捷命令
4
+ 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 继续交互`,
42
+ inputSchema: {
43
+ action: z
44
+ .enum(["list", "connect", "disconnect", "status"])
45
+ .optional()
46
+ .describe("操作类型:list/connect/disconnect/status"),
47
+ server: z.string().optional().describe("服务器名称(connect 时必填)"),
48
+ command: z.string().optional().describe("要执行的命令(直接执行,无需 action)"),
49
+ },
50
+ }, async ({ action, server: serverName, command }) => {
51
+ try {
52
+ // 快捷命令模式
53
+ if (command) {
54
+ const status = sshManager.getStatus();
55
+ if (!status.connected) {
56
+ return {
57
+ content: [{ type: "text", text: "未连接服务器,请先使用 ssh({ action: 'connect', server: '服务器名' }) 连接" }],
58
+ isError: true,
59
+ };
60
+ }
61
+ const shellManager = sshManager.getShellManager();
62
+ const result = await shellManager.send(command);
63
+ return {
64
+ content: [{
65
+ type: "text",
66
+ text: JSON.stringify({
67
+ server: status.serverName,
68
+ command,
69
+ ...result,
70
+ }, null, 2),
71
+ }],
72
+ isError: !result.complete && !result.waiting,
73
+ };
74
+ }
75
+ // 默认 action 为 status
76
+ const effectiveAction = action || "status";
77
+ switch (effectiveAction) {
78
+ case "list": {
79
+ const servers = configManager.listServers();
80
+ const status = sshManager.getStatus();
81
+ // 只返回服务器名称,不暴露 IP/端口/用户名
82
+ const list = servers.map((s) => ({
83
+ name: s.name,
84
+ connected: status.serverName === s.name,
85
+ }));
86
+ return {
87
+ content: [{ type: "text", text: JSON.stringify(list, null, 2) }],
88
+ };
89
+ }
90
+ case "connect": {
91
+ if (!serverName) {
92
+ return {
93
+ content: [{ type: "text", text: "缺少 server 参数" }],
94
+ isError: true,
95
+ };
96
+ }
97
+ const serverConfig = configManager.getServer(serverName);
98
+ if (!serverConfig) {
99
+ const available = configManager.listServers().map((s) => s.name);
100
+ return {
101
+ content: [{
102
+ type: "text",
103
+ text: `服务器 '${serverName}' 不存在。可用服务器: ${available.join(", ")}`,
104
+ }],
105
+ isError: true,
106
+ };
107
+ }
108
+ await sshManager.connect(serverConfig);
109
+ // 不暴露 IP/端口/用户名
110
+ return {
111
+ content: [{
112
+ type: "text",
113
+ text: `成功连接到 '${serverName}',PTY Shell 已就绪`,
114
+ }],
115
+ };
116
+ }
117
+ case "disconnect": {
118
+ const status = sshManager.getStatus();
119
+ if (!status.connected) {
120
+ return {
121
+ content: [{ type: "text", text: "当前没有活跃的连接" }],
122
+ };
123
+ }
124
+ const name = status.serverName;
125
+ await sshManager.disconnect();
126
+ return {
127
+ content: [{ type: "text", text: `已断开与 '${name}' 的连接` }],
128
+ };
129
+ }
130
+ case "status": {
131
+ const status = sshManager.getStatus();
132
+ const shellManager = sshManager.getShellManager();
133
+ if (status.connected) {
134
+ // 不暴露 host/username
135
+ return {
136
+ content: [{
137
+ type: "text",
138
+ text: JSON.stringify({
139
+ connected: true,
140
+ server: status.serverName,
141
+ shellOpen: shellManager.isOpen(),
142
+ bufferLines: shellManager.getBufferLineCount(),
143
+ }, null, 2),
144
+ }],
145
+ };
146
+ }
147
+ else {
148
+ return {
149
+ content: [{ type: "text", text: JSON.stringify({ connected: false }, null, 2) }],
150
+ };
151
+ }
152
+ }
153
+ }
154
+ }
155
+ catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ return {
158
+ content: [{ type: "text", text: `错误: ${message}` }],
159
+ isError: true,
160
+ };
161
+ }
162
+ });
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
+ }
@@ -0,0 +1,38 @@
1
+ export interface ServerConfig {
2
+ name: string;
3
+ host: string;
4
+ port: number;
5
+ username: string;
6
+ password?: string;
7
+ privateKeyPath?: string;
8
+ passphrase?: string;
9
+ }
10
+ export interface ServersConfig {
11
+ servers: ServerConfig[];
12
+ }
13
+ export interface CommandResult {
14
+ stdout: string;
15
+ stderr: string;
16
+ exitCode: number;
17
+ }
18
+ export interface ConnectionStatus {
19
+ connected: boolean;
20
+ serverName: string | null;
21
+ host: string | null;
22
+ username: string | null;
23
+ }
24
+ export interface ShellResult {
25
+ output: string;
26
+ totalLines: number;
27
+ complete: boolean;
28
+ truncated?: boolean;
29
+ slow?: boolean;
30
+ waiting?: boolean;
31
+ message: string;
32
+ }
33
+ export interface ShellConfig {
34
+ quickTimeout: number;
35
+ maxTimeout: number;
36
+ maxLines: number;
37
+ maxBufferLines: number;
38
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,46 @@
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
+ }
@@ -0,0 +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
+ }