Files
bigwo/mcp-server-ssh/index.js
2026-03-12 12:47:56 +08:00

298 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});