/** * repo-mcp-server v1.1 * 光湖·仓库执行手 — 升级版:repo_write 支持显式 SHA 参数 * * 升级内容(v1.0→v1.1): * - repo_write 新增可选的 `sha` 参数,调用方可显式传入现有文件的 SHA * - 如果传了 sha,跳过自动获取,直接用传入的值 * - 如果不传 sha,自动尝试获取(与 v1.0 兼容) * - 新增 `repo_read_with_sha` 工具,读取文件同时返回 SHA */ 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 || 'main'; 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,仅允许本地访问' } }); } 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(); } 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('文件路径'), branch: z.string().optional().describe('分支,默认 main') }, 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_read_with_sha — NEW: 读取文件同时返回文件的 SHA(用于后续写入) server.tool( 'repo_read_with_sha', '读取仓库文件内容并返回 SHA(用于后续 repo_write)', { path: z.string().describe('文件路径'), branch: z.string().optional().describe('分支,默认 main') }, 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 }, { type: 'resource', resource: { text: data.sha, uri: 'sha://file', mimeType: 'text/plain' } } ] }; } ); // 2b. repo_write — UPGRADED: 新增可选 sha 参数 server.tool( 'repo_write', '写入或更新仓库文件。如果是更新已有文件,可传 sha 参数(从 repo_read_with_sha 获取),不传则自动尝试获取', { path: z.string().describe('文件路径'), content: z.string().describe('文件内容'), message: z.string().describe('提交信息'), branch: z.string().optional().describe('目标分支,默认 main'), sha: z.string().optional().describe('现有文件的 SHA(更新时必传,否则可能失败)') }, async ({ path, content, message, branch, sha }) => { const ref = branch || DEFAULT_BRANCH; let finalSha = sha || null; // 如果没有传入 sha,尝试自动获取 if (!finalSha) { const existing = await repoFetch(`/contents/${encodeURIComponent(path)}?ref=${ref}`); if (!existing.error && existing.sha) { finalSha = existing.sha; } } const body = { content: Buffer.from(content).toString('base64'), message, branch: ref }; if (finalSha) body.sha = finalSha; 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('分支') }, 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-14 略(与 v1.0 相同)— 仅复制工具 4 到 13 // 4. repo_list server.tool('repo_list','列出仓库目录内容',{ path: z.string().optional().describe('目录路径'), branch: z.string().optional().describe('分支') },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||'/'} 是一个文件`}]};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`;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('来源分支')},async({branch,from})=>{const shaData=await repoFetch(`/git/refs/heads/${from||DEFAULT_BRANCH}`);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||DEFAULT_BRANCH})`}]}}); // 8. repo_pr_create server.tool('repo_pr_create','创建 Pull Request',{title:z.string().describe('标题'),body:z.string().optional().describe('描述'),head:z.string().describe('源分支'),base:z.string().optional().describe('目标分支')},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','列出 PR',{state:z.string().optional().describe('状态')},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 列表:\n`;for(const pr of data)output+=`#${pr.number} [${pr.state}] ${pr.title}\n`;return{content:[{type:'text',text:output}]}}); // 10. repo_workflow_run server.tool('repo_workflow_run','触发工作流',{workflow:z.string().describe('文件名'),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 格式错误'}]}}}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}`}]}}); // 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||'未知'}`].join('\n')}]}}); // 12. repo_log server.tool('repo_log','查看提交历史',{limit:z.number().optional().describe('数量'),branch:z.string().optional().describe('分支')},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`;for(const c of data){const d=c.commit?.committer?.date?.slice(0,10)||'';const m=(c.commit?.message||'').split('\n')[0];output+=`[${(c.sha||'').slice(0,7)}] ${d} ${m}\n`}return{content:[{type:'text',text:output}]}}); // 13. repo_file_tree server.tool('repo_file_tree','递归列出目录',{path:z.string().optional().describe('路径'),branch:z.string().optional().describe('分支'),depth:z.number().optional().describe('深度')},async({path:startPath,branch,depth})=>{const sp=startPath||'brain';const md=Math.min(depth||2,4);const ref=branch||DEFAULT_BRANCH;async function walk(dp,cd){if(cd>md)return'';const up=dp?`/contents/${encodeURIComponent(dp)}`:'/contents';const d=await repoFetch(`${up}?ref=${ref}`);if(!Array.isArray(d))return'';let r='';for(const i of d){r+=' '.repeat(cd)+(i.type==='dir'?'📁':'📄')+` ${i.name}\n`;if(i.type==='dir')r+=await walk(`${dp}/${i.name}`,cd+1)}return r}let output=`🌳 ${sp}/ (深度 ${md})\n\n`;output+=await walk(sp,0);return{content:[{type:'text',text:output}]}}); } // ─── HTTP Server Setup ─── app.post('/mcp', auth, async (req, res) => { try { const server = new McpServer({ name: 'repo-mcp-server', version: '1.1.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: 'Only POST allowed' })); app.delete('/mcp', (_req, res) => res.status(405).json({ error: 'Only POST allowed' })); app.get('/health', (_req, res) => { res.json({ status: 'ok', server: 'repo-mcp-server', version: '1.1.0', repo: `${REPO_OWNER}/${REPO_NAME}`, tools: 14, auth: MCP_SECRET ? 'Bearer token' : 'local only', uptime: process.uptime() }); }); app.listen(PORT, () => { console.log(`\n╔══════════════════════════════════════════════╗`); console.log(`║ 仓库执行手 repo-mcp-server v1.1 ║`); console.log(`║ 端口: ${PORT} ║`); console.log(`║ 14 个工具就绪(含 sha 参数支持) ║`); console.log(`╚══════════════════════════════════════════════╝\n`); });