✨ repo-mcp-server: 仓库执行手核心代码 v1.0 — 13个仓库操作工具
This commit is contained in:
parent
e81e70758c
commit
c68839b9b9
428
mcp-servers/repo-mcp-server/index.js
Normal file
428
mcp-servers/repo-mcp-server/index.js
Normal 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)'}`);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user