diff --git a/mcp-servers/repo-mcp-server/index-v2.js b/mcp-servers/repo-mcp-server/index-v2.js new file mode 100644 index 0000000..350902f --- /dev/null +++ b/mcp-servers/repo-mcp-server/index-v2.js @@ -0,0 +1,227 @@ +/** + * 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`); +});