Files
bigwo/mcp-server-ssh/index.js

298 lines
9.3 KiB
JavaScript
Raw Permalink Normal View History

2026-03-12 12:47:56 +08:00
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { Client } from "ssh2";
// ========== SSH 配置(通过环境变量传入) ==========
const SSH_CONFIG = {
host: process.env.SSH_HOST || "",
port: parseInt(process.env.SSH_PORT || "22"),
username: process.env.SSH_USER || "root",
password: process.env.SSH_PASSWORD || "",
// 如果用密钥认证,设置 SSH_PRIVATE_KEY_PATH
privateKey: process.env.SSH_PRIVATE_KEY_PATH
? (await import("fs")).readFileSync(process.env.SSH_PRIVATE_KEY_PATH)
: undefined,
readyTimeout: 10000,
keepaliveInterval: 5000,
};
// ========== SSH 执行命令 ==========
function sshExec(command, timeout = 30000) {
return new Promise((resolve, reject) => {
const conn = new Client();
let output = "";
let errorOutput = "";
let timer = null;
conn
.on("ready", () => {
timer = setTimeout(() => {
conn.end();
resolve({
stdout: output,
stderr: errorOutput + "\n[TIMEOUT] Command timed out after " + timeout + "ms",
code: -1,
});
}, timeout);
conn.exec(command, (err, stream) => {
if (err) {
clearTimeout(timer);
conn.end();
return reject(err);
}
stream
.on("close", (code) => {
clearTimeout(timer);
conn.end();
resolve({ stdout: output, stderr: errorOutput, code });
})
.on("data", (data) => {
output += data.toString();
})
.stderr.on("data", (data) => {
errorOutput += data.toString();
});
});
})
.on("error", (err) => {
if (timer) clearTimeout(timer);
reject(new Error(`SSH connection failed: ${err.message}`));
})
.connect(SSH_CONFIG);
});
}
// ========== MCP Server ==========
const server = new Server(
{ name: "ssh-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// 工具列表
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "ssh_run",
description:
"在远程服务器上执行 Shell 命令。用于检查服务状态、查看日志、读取配置文件等。",
inputSchema: {
type: "object",
properties: {
command: {
type: "string",
description: "要执行的 Shell 命令",
},
timeout: {
type: "number",
description: "超时时间(毫秒),默认 30000",
},
},
required: ["command"],
},
},
{
name: "ssh_read_file",
description: "读取远程服务器上的文件内容",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "远程文件的绝对路径",
},
maxLines: {
type: "number",
description: "最大读取行数,默认 200",
},
},
required: ["path"],
},
},
{
name: "ssh_write_file",
description: "写入内容到远程服务器上的文件(覆盖写入)",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "远程文件的绝对路径",
},
content: {
type: "string",
description: "要写入的文件内容",
},
},
required: ["path", "content"],
},
},
{
name: "ssh_check_bigwo",
description:
"一键检查 BigWo 项目部署状态端口、PM2、Nginx、.env 配置、SSL、FC 回调可达性等",
inputSchema: {
type: "object",
properties: {},
},
},
],
}));
// 工具执行
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "ssh_run": {
const { command, timeout } = args;
const result = await sshExec(command, timeout || 30000);
const text = [
`$ ${command}`,
`Exit code: ${result.code}`,
result.stdout ? `--- stdout ---\n${result.stdout}` : "(no stdout)",
result.stderr ? `--- stderr ---\n${result.stderr}` : "",
]
.filter(Boolean)
.join("\n");
return { content: [{ type: "text", text }] };
}
case "ssh_read_file": {
const { path, maxLines } = args;
const limit = maxLines || 200;
const result = await sshExec(
`head -n ${limit} ${JSON.stringify(path)} 2>&1 && echo "---" && wc -l < ${JSON.stringify(path)} 2>/dev/null`
);
return {
content: [
{
type: "text",
text: `File: ${path}\n${result.stdout}${result.stderr ? "\n" + result.stderr : ""}`,
},
],
};
}
case "ssh_write_file": {
const { path, content } = args;
// 使用 heredoc 写入,避免转义问题
const escapedContent = content.replace(/'/g, "'\\''");
const result = await sshExec(
`cat > ${JSON.stringify(path)} << 'MCPEOF'\n${content}\nMCPEOF`
);
if (result.code === 0) {
return {
content: [{ type: "text", text: `✅ 文件已写入: ${path}` }],
};
}
return {
content: [
{
type: "text",
text: `❌ 写入失败: ${result.stderr || result.stdout}`,
},
],
};
}
case "ssh_check_bigwo": {
const checks = [];
const PROJECT = "/www/wwwroot/demo.tensorgrove.com.cn";
// 1. PM2 状态
const pm2 = await sshExec("pm2 jlist 2>/dev/null || echo 'PM2_NOT_FOUND'");
checks.push("=== 1. PM2 进程状态 ===\n" + pm2.stdout);
// 2. 后端端口监听
const ports = await sshExec("ss -tlnp | grep -E '3001|3012|80|443'");
checks.push("=== 2. 端口监听 ===\n" + ports.stdout);
// 3. .env 关键配置(脱敏)
const env = await sshExec(
`grep -E '^(PORT|FC_SERVER_URL|VOLC_ARK_KNOWLEDGE|VOLC_RTC_APP_ID|VOLC_ARK_ENDPOINT_ID|COZE_)' ${PROJECT}/server/.env 2>/dev/null | sed 's/=.\\{8\\}/=***REDACTED***/g'`
);
checks.push("=== 3. .env 关键配置 ===\n" + (env.stdout || "(文件不存在或为空)"));
// 4. .env 完整键列表
const envKeys = await sshExec(
`grep -v '^#' ${PROJECT}/server/.env 2>/dev/null | grep '=' | cut -d= -f1`
);
checks.push("=== 4. .env 所有配置键 ===\n" + (envKeys.stdout || "(无)"));
// 5. Nginx 配置
const nginx = await sshExec(
`grep -A5 'location /api' /www/server/panel/vhost/nginx/demo.tensorgrove.com.cn.conf 2>/dev/null || grep -rA5 'location /api' /www/server/panel/vhost/nginx/ 2>/dev/null | head -20`
);
checks.push("=== 5. Nginx API 代理配置 ===\n" + (nginx.stdout || "(未找到)"));
// 6. 本地测试后端 API
const health = await sshExec(
`curl -s http://127.0.0.1:3012/api/health 2>&1 || curl -s http://127.0.0.1:3001/api/health 2>&1 || echo 'BACKEND_UNREACHABLE'`
);
checks.push("=== 6. 后端 Health Check ===\n" + health.stdout);
// 7. 测试 FC 回调可达性(外部 HTTPS
const fcTest = await sshExec(
`curl -s -o /dev/null -w '%{http_code}' -X POST https://demo.tensorgrove.com.cn/api/voice/fc_callback -H 'Content-Type: application/json' -d '{"Type":"test"}' 2>&1`
);
checks.push(
"=== 7. FC 回调外部可达性 ===\n" +
`HTTPS POST → HTTP Status: ${fcTest.stdout}`
);
// 8. SSL 证书状态
const ssl = await sshExec(
`echo | openssl s_client -connect demo.tensorgrove.com.cn:443 -servername demo.tensorgrove.com.cn 2>/dev/null | openssl x509 -noout -dates 2>/dev/null || echo 'SSL_CHECK_FAILED'`
);
checks.push("=== 8. SSL 证书 ===\n" + ssl.stdout);
// 9. PM2 最近日志
const logs = await sshExec(
"pm2 logs bigwo-server --nostream --lines 30 2>/dev/null || echo 'NO_LOGS'"
);
checks.push("=== 9. PM2 最近30行日志 ===\n" + logs.stdout + (logs.stderr || ""));
// 10. 前端 dist 是否存在
const dist = await sshExec(
`ls -la ${PROJECT}/client/dist/index.html 2>&1 && echo "DIST_OK" || echo "DIST_MISSING"`
);
checks.push("=== 10. 前端构建产物 ===\n" + dist.stdout);
return {
content: [{ type: "text", text: checks.join("\n\n") }],
};
}
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
}
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
});
// 启动
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("SSH MCP Server running on stdio");
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});