240 lines
9.5 KiB
JavaScript
Raw Normal View History

/**
* 铸渊自建MCP Server v1.0
* 光湖第二只手 · Notion 面孔服务器 桥接
*
* 部署: ZY-SVR-002 (43.134.16.246) · 端口 3900
* 协议: MCP Streamable HTTP (stateless)
* 认证: Bearer Token (ZY_MCP_SECRET)
*
* 版权: 国作登字-2026-A-00037559
* 开发: 霜砚(AG-SY-01) · 守护: 铸渊(ICE-GL-ZY001)
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { readFile } from 'fs/promises';
import { timingSafeEqual } from 'crypto';
import { z } from 'zod';
const execAsync = promisify(exec);
const app = express();
app.use(express.json());
const PORT = process.env.ZY_MCP_PORT || 3900;
const MCP_SECRET = process.env.ZY_MCP_SECRET;
// ─── 常量时间比较(防时序攻击) ───
function safeCompare(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
if (a.length !== b.length) return false;
try {
return timingSafeEqual(Buffer.from(a, 'utf-8'), Buffer.from(b, 'utf-8'));
} catch {
return false;
}
}
// ─── 认证中间件 ───
function auth(req, res, next) {
// 未配置密钥时,仅允许 loopback 地址访问
if (!MCP_SECRET) {
const ip = req.ip || req.connection?.remoteAddress || '';
const isLocal = (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1');
if (isLocal) return next();
return res.status(403).json({
jsonrpc: '2.0',
error: { code: -32001, message: '未配置 ZY_MCP_SECRET仅允许本地访问(127.0.0.1)' }
});
}
const token = req.headers.authorization;
if (!token || !safeCompare(token, `Bearer ${MCP_SECRET}`)) {
return res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: '未授权' } });
}
next();
}
// ─── 安全: 命令执行封装(带超时) ───
async function safeExec(cmd, timeoutMs = 15000) {
try {
const { stdout, stderr } = await execAsync(cmd, { timeout: timeoutMs });
return stdout || stderr || '(无输出)';
} catch (e) {
return `错误: ${e.message}`;
}
}
// ─── 安全: 路径检查 ───
function isPathSafe(p) {
const resolved = p.replace(/\.\./g, '');
return resolved.startsWith('/opt/zhuyuan');
}
// ─── 工具注册工厂 ───
function registerTools(server) {
// 1. 服务器健康检查
server.tool('server_health', '检查服务器健康状态(负载·内存·磁盘·Node版本)', {}, async () => {
const out = await safeExec([
'echo "── 系统 ──" && uptime',
'echo "── 内存 ──" && free -h',
'echo "── 磁盘 ──" && df -h /',
'echo "── Node ──" && node -v'
].join(' && '));
return { content: [{ type: 'text', text: out }] };
});
// 2. PM2进程列表
server.tool('pm2_list', '列出所有PM2进程及运行状态', {}, async () => {
const raw = await safeExec('pm2 jlist 2>/dev/null');
try {
const procs = JSON.parse(raw);
const lines = procs.map(p =>
`${p.name} | ${p.pm2_env?.status} | CPU:${p.monit?.cpu}% | MEM:${Math.round((p.monit?.memory || 0) / 1048576)}MB | 重启:${p.pm2_env?.restart_time}`
);
return { content: [{ type: 'text', text: lines.join('\n') || '(无进程)' }] };
} catch {
return { content: [{ type: 'text', text: raw }] };
}
});
// 3. PM2重启进程
server.tool('pm2_restart', '重启指定PM2进程', {
name: z.string().describe('进程名: zhuyuan-server / zhuyuan-preview / novel-api / zhuyuan-mcp / age-os-brain')
}, async ({ name }) => {
const allow = ['zhuyuan-server', 'zhuyuan-preview', 'novel-api', 'zhuyuan-mcp', 'age-os-brain'];
if (!allow.includes(name)) {
return { content: [{ type: 'text', text: `🔒 安全限制: 只能重启 ${allow.join(' / ')}` }] };
}
const out = await safeExec(`pm2 restart ${name} 2>&1`);
return { content: [{ type: 'text', text: `✅ 已重启 ${name}\n${out}` }] };
});
// 4. 读取日志
server.tool('read_logs', '读取服务器日志(最近N行)', {
source: z.enum(['server', 'preview', 'novel', 'error', 'mcp']).describe('日志源'),
lines: z.number().optional().describe('行数(默认50·最大200)')
}, async ({ source, lines }) => {
const n = Math.min(lines || 50, 200);
const map = {
server: '/opt/zhuyuan/data/logs/pm2-combined.log',
preview: '/opt/zhuyuan/data/logs/pm2-preview-combined.log',
novel: '/opt/zhuyuan/data/logs/novel-api-combined.log',
error: '/opt/zhuyuan/data/logs/pm2-error.log',
mcp: '/opt/zhuyuan/data/logs/zhuyuan-mcp.log'
};
const out = await safeExec(`tail -n ${n} "${map[source]}" 2>&1`);
return { content: [{ type: 'text', text: out }] };
});
// 5. 部署(Git Pull + Restart)
server.tool('deploy', '从GitHub拉取最新代码并重启服务', {
target: z.enum(['app', 'novel-db']).describe('部署目标')
}, async ({ target }) => {
const cfg = {
app: { dir: '/opt/zhuyuan/app', pm2: 'zhuyuan-server' },
'novel-db': { dir: '/opt/zhuyuan/novel-db', pm2: 'novel-api' }
};
const t = cfg[target];
const steps = [];
steps.push('── git pull ──\n' + await safeExec(`cd ${t.dir} && git pull origin main 2>&1`, 30000));
steps.push('── npm install ──\n' + await safeExec(`cd ${t.dir} && npm install --production 2>&1`, 60000));
steps.push('── pm2 restart ──\n' + await safeExec(`pm2 restart ${t.pm2} 2>&1`));
return { content: [{ type: 'text', text: `✅ 部署完成 [${target}]\n\n${steps.join('\n\n')}` }] };
});
// 6. 铸渊大脑状态
server.tool('brain_status', '查看铸渊大脑状态(identity/consciousness/health)', {}, async () => {
const files = ['identity.json', 'consciousness.json', 'health.json'];
const out = [];
for (const f of files) {
try {
const content = await readFile(`/opt/zhuyuan/brain/${f}`, 'utf-8');
out.push(`── ${f} ──\n${content}`);
} catch {
out.push(`── ${f} ──\n(未找到)`);
}
}
return { content: [{ type: 'text', text: out.join('\n\n') }] };
});
// 7. 列出目录
server.tool('list_dir', '列出服务器目录内容(限/opt/zhuyuan/内)', {
path: z.string().describe('目录路径')
}, async ({ path: dir }) => {
if (!isPathSafe(dir)) {
return { content: [{ type: 'text', text: '🔒 安全限制: 只能访问 /opt/zhuyuan/ 内的目录' }] };
}
const out = await safeExec(`ls -la "${dir}" 2>&1`);
return { content: [{ type: 'text', text: out }] };
});
// 8. 读取文件
server.tool('read_file', '读取服务器文件内容(限/opt/zhuyuan/内)', {
path: z.string().describe('文件路径'),
max_lines: z.number().optional().describe('最大行数(默认100·最大500)')
}, async ({ path: file, max_lines }) => {
if (!isPathSafe(file)) {
return { content: [{ type: 'text', text: '🔒 安全限制: 只能访问 /opt/zhuyuan/ 内的文件' }] };
}
const n = Math.min(max_lines || 100, 500);
const out = await safeExec(`head -n ${n} "${file}" 2>&1`);
return { content: [{ type: 'text', text: out }] };
});
// 9. 系统资源统计
server.tool('system_stats', '系统资源详细统计(磁盘·内存·负载·网络)', {}, async () => {
const out = await safeExec([
'echo "── 磁盘 ──" && df -h',
'echo "── 内存 ──" && free -h',
'echo "── 负载 ──" && cat /proc/loadavg',
'echo "── 连接 ──" && ss -s'
].join(' && '));
return { content: [{ type: 'text', text: out }] };
});
// 10. Nginx状态
server.tool('nginx_status', '查看Nginx运行状态和配置检查', {}, async () => {
const out = await safeExec([
'echo "── 状态 ──" && systemctl status nginx --no-pager 2>&1 | head -15',
'echo "── 配置测试 ──" && nginx -t 2>&1'
].join(' && '));
return { content: [{ type: 'text', text: out }] };
});
}
// ─── MCP Streamable HTTP (stateless mode) ───
app.post('/mcp', auth, async (req, res) => {
try {
const server = new McpServer({ name: 'zhuyuan-mcp', version: '1.0.0' });
registerTools(server);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (e) {
if (!res.headersSent) {
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message } });
}
}
});
app.get('/mcp', (_req, res) => res.status(405).json({ error: 'Method not allowed · stateless mode' }));
app.delete('/mcp', (_req, res) => res.status(405).json({ error: 'Method not allowed · stateless mode' }));
// Health endpoint
app.get('/health', (_req, res) => {
res.json({ status: 'ok', name: 'zhuyuan-mcp', version: '1.0.0', tools: 10, uptime: process.uptime() });
});
// ─── 启动 ───
app.listen(PORT, () => {
console.log(`\n🤖 铸渊MCP Server v1.0 · 光湖第二只手`);
console.log(` 端口: ${PORT}`);
console.log(` 认证: ${MCP_SECRET ? '✅ Bearer Token' : '⚠️ 本地模式(仅127.0.0.1)'}`);
console.log(` 工具: 10个 · server_health / pm2_list / pm2_restart / read_logs / deploy / brain_status / list_dir / read_file / system_stats / nginx_status`);
console.log(` 时间: ${new Date().toISOString()}\n`);
});