repo-mcp-server: 仓库执行手核心代码 v1.0 — 13个仓库操作工具

This commit is contained in:
bingshuo 2026-05-14 01:32:01 +08:00
parent e81e70758c
commit c68839b9b9

View File

@ -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)'}`);
});