Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

430 lines
17 KiB
JavaScript
Raw Permalink 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.

/**
* repo-mcp-server v1.0
* 光湖·仓库执行手 — 给 Notion 人格体操作代码仓库的 MCP 桥梁
*
* 霜砚(AG-SY-01) → 通过此 MCP 工具 → 直接读写代码仓库
* 自己做 · 零翻译成本
*
* 协议: MCP Streamable HTTP (stateless)
* 部署: CVM (guanghulab.com)
* 认证: Bearer Token (REPO_MCP_SECRET)
* 后端: Gitea API (guanghulab.com/api/v1)
*
* 版权: 国作登字-2026-A-00037559
* 主权: TCS-0002∞ 冰朔 | 守护: 铸渊 ICE-GL-ZY001
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { timingSafeEqual } from 'crypto';
import { z } from 'zod';
const app = express();
app.use(express.json({ limit: '10mb' }));
const PORT = process.env.REPO_MCP_PORT || 3903;
const MCP_SECRET = process.env.REPO_MCP_SECRET;
const GITEA_TOKEN = process.env.GITEA_TOKEN;
const GITEA_URL = process.env.GITEA_URL || 'https://guanghulab.com';
const REPO_OWNER = process.env.REPO_OWNER || 'bingshuo';
const REPO_NAME = process.env.REPO_NAME || 'guanghulab';
const DEFAULT_BRANCH = process.env.REPO_DEFAULT_BRANCH || 'notion/shuangyan'; // Notion 人格体专属分支
// ─── 常量时间比较(防时序攻击) ───
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) {
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: '未配置 REPO_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();
}
// ─── Gitea API 工具函数 ───
const GITEA_API = `${GITEA_URL}/api/v1`;
async function giteaFetch(path, options = {}) {
const url = `${GITEA_API}${path}`;
const res = await fetch(url, {
...options,
headers: {
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
...(options.headers || {})
}
});
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch { data = text; }
if (!res.ok) {
return { error: true, status: res.status, message: typeof data === 'string' ? data : data.message || JSON.stringify(data) };
}
return data;
}
async function repoFetch(path, options = {}) {
return giteaFetch(`/repos/${REPO_OWNER}/${REPO_NAME}${path}`, options);
}
// ─── 注册所有工具 ───
function registerTools(server) {
// 1. repo_read
server.tool(
'repo_read',
'读取仓库文件内容',
{ path: z.string().describe('文件路径,如 brain/progress.json'), branch: z.string().optional().describe('分支,默认 notion/shuangyan') },
async ({ path, branch }) => {
const data = await repoFetch(`/contents/${encodeURIComponent(path)}?ref=${branch || DEFAULT_BRANCH}`);
if (data.error) return { content: [{ type: 'text', text: `❌ 读取失败: ${data.message}` }] };
const content = Buffer.from(data.content, 'base64').toString('utf-8');
return { content: [{ type: 'text', text: content }] };
}
);
// 2. repo_write
server.tool(
'repo_write',
'写入或更新仓库文件(直接提交到分支)',
{
path: z.string().describe('文件路径'),
content: z.string().describe('文件内容'),
message: z.string().describe('提交信息'),
branch: z.string().optional().describe('目标分支,默认 notion/shuangyan')
},
async ({ path, content, message, branch }) => {
const ref = branch || DEFAULT_BRANCH;
let sha = null;
const existing = await repoFetch(`/contents/${encodeURIComponent(path)}?ref=${ref}`);
if (!existing.error) sha = existing.sha;
const body = { content: Buffer.from(content).toString('base64'), message, branch: ref };
if (sha) body.sha = sha;
const result = await repoFetch(`/contents/${encodeURIComponent(path)}`, {
method: 'PUT',
body: JSON.stringify(body)
});
if (result.error) return { content: [{ type: 'text', text: `❌ 写入失败: ${result.message}` }] };
return { content: [{ type: 'text', text: `✅ 已写入 ${path}\n提交: ${result.commit?.sha?.slice(0, 7) || '完成'}` }] };
}
);
// 3. repo_delete
server.tool(
'repo_delete',
'删除仓库文件',
{
path: z.string().describe('文件路径'),
message: z.string().describe('提交信息'),
branch: z.string().optional().describe('分支,默认 notion/shuangyan')
},
async ({ path, message, branch }) => {
const ref = branch || DEFAULT_BRANCH;
const existing = await repoFetch(`/contents/${encodeURIComponent(path)}?ref=${ref}`);
if (existing.error) return { content: [{ type: 'text', text: `❌ 文件不存在: ${path}` }] };
const result = await repoFetch(`/contents/${encodeURIComponent(path)}`, {
method: 'DELETE',
body: JSON.stringify({ sha: existing.sha, message, branch: ref })
});
if (result.error) return { content: [{ type: 'text', text: `❌ 删除失败: ${result.message}` }] };
return { content: [{ type: 'text', text: `✅ 已删除 ${path}` }] };
}
);
// 4. repo_list
server.tool(
'repo_list',
'列出仓库目录内容',
{ path: z.string().optional().describe('目录路径,默认根目录'), branch: z.string().optional().describe('分支,默认 notion/shuangyan') },
async ({ path, branch }) => {
const dir = path || '';
const urlPath = dir ? `/contents/${encodeURIComponent(dir)}` : '/contents';
const data = await repoFetch(`${urlPath}?ref=${branch || DEFAULT_BRANCH}`);
if (data.error) return { content: [{ type: 'text', text: `❌ 列出失败: ${data.message}` }] };
if (!Array.isArray(data)) {
return { content: [{ type: 'text', text: `📄 ${dir || '/'} 是一个文件,请用 repo_read 读取` }] };
}
let output = `📂 ${dir || '/'}\n`;
for (const item of data) {
output += `${item.type === 'dir' ? '📁' : '📄'} ${item.name}\n`;
}
return { content: [{ type: 'text', text: output }] };
}
);
// 5. repo_search
server.tool(
'repo_search',
'在仓库中搜索代码',
{ query: z.string().describe('搜索关键词') },
async ({ query }) => {
const data = await giteaFetch(`/repos/${REPO_OWNER}/${REPO_NAME}/search?q=${encodeURIComponent(query)}`);
if (data.error) return { content: [{ type: 'text', text: `❌ 搜索失败: ${data.message}` }] };
const results = Array.isArray(data) ? data : data.results || [];
if (results.length === 0) return { content: [{ type: 'text', text: `🔍 未找到匹配 "${query}"` }] };
let output = `🔍 找到 ${results.length} 个结果:\n\n`;
for (const r of results.slice(0, 30)) {
output += `📄 ${r.filename || r.path || r.name}\n`;
}
return { content: [{ type: 'text', text: output }] };
}
);
// 6. repo_branch_list
server.tool(
'repo_branch_list',
'列出所有分支',
{},
async () => {
const data = await repoFetch('/branches');
if (data.error) return { content: [{ type: 'text', text: `❌ 获取分支失败: ${data.message}` }] };
let output = '🌿 分支列表:\n';
for (const b of data) {
output += `- ${b.name} (${(b.commit?.sha || '').slice(0, 7)})\n`;
}
return { content: [{ type: 'text', text: output }] };
}
);
// 7. repo_branch_create
server.tool(
'repo_branch_create',
'创建新分支',
{
branch: z.string().describe('新分支名称'),
from: z.string().optional().describe('来源分支,默认 main')
},
async ({ branch, from }) => {
const shaData = await repoFetch(`/git/refs/heads/${from || 'main'}`);
if (shaData.error) return { content: [{ type: 'text', text: `❌ 获取来源分支失败: ${shaData.message}` }] };
const result = await giteaFetch(`/repos/${REPO_OWNER}/${REPO_NAME}/git/refs`, {
method: 'POST',
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: shaData.object.sha })
});
if (result.error) return { content: [{ type: 'text', text: `❌ 创建分支失败: ${result.message}` }] };
return { content: [{ type: 'text', text: `✅ 已创建分支 ${branch} (来自 ${from || 'main'})` }] };
}
);
// 8. repo_pr_create
server.tool(
'repo_pr_create',
'创建 Pull Request',
{
title: z.string().describe('PR 标题'),
body: z.string().optional().describe('PR 描述'),
head: z.string().describe('源分支'),
base: z.string().optional().describe('目标分支,默认 notion/shuangyan')
},
async ({ title, body, head, base }) => {
const result = await repoFetch('/pulls', {
method: 'POST',
body: JSON.stringify({ title, body: body || '', head, base: base || DEFAULT_BRANCH })
});
if (result.error) return { content: [{ type: 'text', text: `❌ 创建 PR 失败: ${result.message}` }] };
return {
content: [{
type: 'text',
text: `✅ PR #${result.number} 已创建\n标题: ${result.title}\n链接: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/pulls/${result.number}`
}]
};
}
);
// 9. repo_pr_list
server.tool(
'repo_pr_list',
'列出 Pull Request',
{ state: z.string().optional().describe('状态: open/closed/all默认 open') },
async ({ state }) => {
const data = await repoFetch(`/pulls?state=${state || 'open'}&limit=20`);
if (data.error) return { content: [{ type: 'text', text: `❌ 获取 PR 失败: ${data.message}` }] };
if (data.length === 0) return { content: [{ type: 'text', text: '📭 没有 PR' }] };
let output = `📋 PR 列表 (${state || 'open'}):\n\n`;
for (const pr of data) {
output += `#${pr.number} [${pr.state}] ${pr.title}\n 源: ${pr.head?.label}${pr.base?.label}\n 作者: ${pr.user?.login}\n\n`;
}
return { content: [{ type: 'text', text: output }] };
}
);
// 10. repo_workflow_run
server.tool(
'repo_workflow_run',
'触发 Forgejo Actions 工作流',
{
workflow: z.string().describe('工作流文件名,如 deploy-brand-ui.yaml'),
inputs: z.string().optional().describe('JSON 格式的输入参数')
},
async ({ workflow, inputs }) => {
const body = {};
if (inputs) {
try { body.inputs = JSON.parse(inputs); }
catch { return { content: [{ type: 'text', text: '❌ inputs 不是有效的 JSON 格式' }] }; }
}
const result = await repoFetch(`/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, {
method: 'PUT',
body: JSON.stringify(body)
});
if (result.error) return { content: [{ type: 'text', text: `❌ 触发工作流失败: ${result.message}` }] };
return { content: [{ type: 'text', text: `✅ 已触发工作流 ${workflow}\n查看: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/actions` }] };
}
);
// 11. repo_status
server.tool(
'repo_status',
'查看仓库概览状态',
{},
async () => {
const repo = await repoFetch('');
if (repo.error) return { content: [{ type: 'text', text: `❌ 获取仓库状态失败: ${repo.message}` }] };
return {
content: [{
type: 'text',
text: [
`📊 仓库状态: ${REPO_OWNER}/${REPO_NAME}`,
`━━━━━━━━━━━━━━━━━━━━`,
`描述: ${repo.description || '无'}`,
`默认分支: ${repo.default_branch}`,
`大小: ${((repo.size || 0) / 1024).toFixed(1)} MB`,
`最后更新: ${repo.updated_at || '未知'}`,
`━━━━━━━━━━━━━━━━━━━━`,
`🔗 ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}`
].join('\n')
}]
};
}
);
// 12. repo_log
server.tool(
'repo_log',
'查看最近提交历史',
{
limit: z.number().optional().describe('数量,默认 10'),
branch: z.string().optional().describe('分支,默认 notion/shuangyan')
},
async ({ limit, branch }) => {
const data = await repoFetch(`/commits?sha=${branch || DEFAULT_BRANCH}&limit=${limit || 10}`);
if (data.error) return { content: [{ type: 'text', text: `❌ 获取提交历史失败: ${data.message}` }] };
let output = `📜 最近提交 (${branch || DEFAULT_BRANCH}):\n\n`;
for (const c of data) {
const date = c.commit?.committer?.date?.slice(0, 10) || '';
const msg = (c.commit?.message || '').split('\n')[0];
output += `[${(c.sha || '').slice(0, 7)}] ${date} ${msg}\n`;
}
return { content: [{ type: 'text', text: output }] };
}
);
// 13. repo_file_tree
server.tool(
'repo_file_tree',
'递归列出目录结构(探索仓库骨架)',
{
path: z.string().optional().describe('起始路径,默认 brain'),
branch: z.string().optional().describe('分支,默认 notion/shuangyan'),
depth: z.number().optional().describe('递归深度,默认 2最大 4')
},
async ({ path, branch, depth }) => {
const startPath = path || 'brain';
const maxDepth = Math.min(depth || 2, 4);
const ref = branch || DEFAULT_BRANCH;
async function walk(dirPath, currentDepth) {
if (currentDepth > maxDepth) return '';
const urlPath = dirPath ? `/contents/${encodeURIComponent(dirPath)}` : '/contents';
const data = await repoFetch(`${urlPath}?ref=${ref}`);
if (!Array.isArray(data)) return '';
let result = '';
for (const item of data) {
const indent = ' '.repeat(currentDepth);
if (item.type === 'dir') {
result += `${indent}📁 ${item.name}/\n`;
result += await walk(`${dirPath}/${item.name}`, currentDepth + 1);
} else {
result += `${indent}📄 ${item.name}\n`;
}
}
return result;
}
let output = `🌳 文件树: ${startPath}/ (深度 ${maxDepth})\n\n`;
output += await walk(startPath, 0);
return { content: [{ type: 'text', text: output }] };
}
);
}
// ─── MCP Streamable HTTP (stateless mode) ───
app.post('/mcp', auth, async (req, res) => {
try {
const server = new McpServer({ name: 'repo-mcp-server', 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 } });
}
}
});
// ─── 其他 MCP 路由 ───
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' }));
// ─── 健康检查 ───
app.get('/health', (_req, res) => {
res.json({
status: 'ok',
server: 'repo-mcp-server',
version: '1.0.0',
repo: `${REPO_OWNER}/${REPO_NAME}`,
tools: 13,
auth: MCP_SECRET ? 'Bearer token' : 'local only',
uptime: process.uptime()
});
});
// ─── 启动 ───
app.listen(PORT, () => {
console.log(`\n╔══════════════════════════════════════════════╗`);
console.log(`║ 仓库执行手 repo-mcp-server v1.0 ║`);
console.log(`║ 端口: ${PORT}`);
console.log(`║ 给 Notion 人格体操作代码仓库的 MCP 工具 ║`);
console.log(`║ 13 个工具就绪 ║`);
console.log(`╚══════════════════════════════════════════════╝\n`);
console.log(`目标仓库: ${REPO_OWNER}/${REPO_NAME}`);
console.log(`Gitea: ${GITEA_URL}`);
console.log(`健康: http://localhost:${PORT}/health`);
console.log(`MCP: POST http://localhost:${PORT}/mcp`);
console.log(`认证: ${MCP_SECRET ? '✅ Bearer Token' : '⚠️ 本地模式(仅127.0.0.1)'}`);
});