diff --git a/mcp-servers/repo-mcp-server/index.js b/mcp-servers/repo-mcp-server/index.js new file mode 100644 index 0000000..a29533a --- /dev/null +++ b/mcp-servers/repo-mcp-server/index.js @@ -0,0 +1,428 @@ +/** + * 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'; + +// ─── 常量时间比较(防时序攻击) ─── +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('分支,默认 main') }, + async ({ path, branch }) => { + const data = await repoFetch(`/contents/${encodeURIComponent(path)}?ref=${branch || 'main'}`); + 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('目标分支,默认 main') + }, + async ({ path, content, message, branch }) => { + const ref = branch || 'main'; + 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('分支,默认 main') + }, + async ({ path, message, branch }) => { + const ref = branch || 'main'; + 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('分支,默认 main') }, + async ({ path, branch }) => { + const dir = path || ''; + const urlPath = dir ? `/contents/${encodeURIComponent(dir)}` : '/contents'; + const data = await repoFetch(`${urlPath}?ref=${branch || 'main'}`); + 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('目标分支,默认 main') + }, + async ({ title, body, head, base }) => { + const result = await repoFetch('/pulls', { + method: 'POST', + body: JSON.stringify({ title, body: body || '', head, base: base || 'main' }) + }); + 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('分支,默认 main') + }, + async ({ limit, branch }) => { + const data = await repoFetch(`/commits?sha=${branch || 'main'}&limit=${limit || 10}`); + if (data.error) return { content: [{ type: 'text', text: `❌ 获取提交历史失败: ${data.message}` }] }; + let output = `📜 最近提交 (${branch || 'main'}):\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('分支,默认 main'), + 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 || 'main'; + + 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)'}`); +}); \ No newline at end of file