/** * 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)'}`); });