[铸渊] repo-mcp-server v1.1 升级: repo_write 新增 sha 参数 + repo_read_with_sha 工具
This commit is contained in:
parent
a95aa48528
commit
2356e4b8d0
227
mcp-servers/repo-mcp-server/index-v2.js
Normal file
227
mcp-servers/repo-mcp-server/index-v2.js
Normal file
@ -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`);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user