228 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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