298 lines
9.3 KiB
JavaScript
298 lines
9.3 KiB
JavaScript
|
|
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);
|
|||
|
|
});
|