feat(gitea-mcp): add custom Gitea MCP server for Antigravity compatibility
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧪 Test (push) Failing after 50s
Monorepo Pipeline / 🧹 Lint (push) Failing after 3m6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m13s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧪 Test (push) Failing after 50s
Monorepo Pipeline / 🧹 Lint (push) Failing after 3m6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m13s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
This commit is contained in:
19
packages/gitea-mcp/Dockerfile
Normal file
19
packages/gitea-mcp/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
RUN corepack enable pnpm && pnpm install
|
||||||
|
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Use node to run the compiled index.js
|
||||||
|
ENTRYPOINT ["node", "dist/index.js"]
|
||||||
20
packages/gitea-mcp/package.json
Normal file
20
packages/gitea-mcp/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/gitea-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Native Gitea MCP server for 100% Antigravity compatibility",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"axios": "^1.7.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"@types/node": "^20.14.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
265
packages/gitea-mcp/src/index.ts
Normal file
265
packages/gitea-mcp/src/index.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
ListResourcesRequestSchema,
|
||||||
|
ReadResourceRequestSchema,
|
||||||
|
SubscribeRequestSchema,
|
||||||
|
UnsubscribeRequestSchema,
|
||||||
|
Tool,
|
||||||
|
Resource,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const GITEA_HOST = process.env.GITEA_HOST || "https://git.infra.mintel.me";
|
||||||
|
const GITEA_ACCESS_TOKEN = process.env.GITEA_ACCESS_TOKEN;
|
||||||
|
|
||||||
|
if (!GITEA_ACCESS_TOKEN) {
|
||||||
|
console.error("Error: GITEA_ACCESS_TOKEN environment variable is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const giteaClient = axios.create({
|
||||||
|
baseURL: `${GITEA_HOST.replace(/\/$/, '')}/api/v1`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${GITEA_ACCESS_TOKEN}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const LIST_PIPELINES_TOOL: Tool = {
|
||||||
|
name: "gitea_list_pipelines",
|
||||||
|
description: "List recent action runs (pipelines) for a specific repository",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
owner: { type: "string", description: "Repository owner (e.g., 'mmintel')" },
|
||||||
|
repo: { type: "string", description: "Repository name (e.g., 'at-mintel')" },
|
||||||
|
limit: { type: "number", description: "Number of runs to fetch (default: 5)" },
|
||||||
|
},
|
||||||
|
required: ["owner", "repo"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const GET_PIPELINE_LOGS_TOOL: Tool = {
|
||||||
|
name: "gitea_get_pipeline_logs",
|
||||||
|
description: "Get detailed logs for a specific pipeline run or job",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
owner: { type: "string", description: "Repository owner" },
|
||||||
|
repo: { type: "string", description: "Repository name" },
|
||||||
|
run_id: { type: "number", description: "ID of the action run" },
|
||||||
|
},
|
||||||
|
required: ["owner", "repo", "run_id"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscription State
|
||||||
|
const subscriptions = new Set<string>();
|
||||||
|
const runStatusCache = new Map<string, string>(); // uri -> status
|
||||||
|
|
||||||
|
const server = new Server(
|
||||||
|
{
|
||||||
|
name: "gitea-mcp-native",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
resources: { subscribe: true }, // Enable subscriptions
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Tools ---
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
tools: [LIST_PIPELINES_TOOL, GET_PIPELINE_LOGS_TOOL],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
if (request.params.name === "gitea_list_pipelines") {
|
||||||
|
// ... (Keeping exact same implementation as before for brevity)
|
||||||
|
const { owner, repo, limit = 5 } = request.params.arguments as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
|
||||||
|
const runs = runsResponse.data;
|
||||||
|
const enhancedRuns = await Promise.all(
|
||||||
|
runs.map(async (run: any) => {
|
||||||
|
try {
|
||||||
|
const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run.id}/jobs`);
|
||||||
|
return {
|
||||||
|
id: run.id,
|
||||||
|
name: run.name,
|
||||||
|
status: run.status,
|
||||||
|
created_at: run.created_at,
|
||||||
|
jobs: jobsResponse.data.map((job: any) => ({
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
status: job.status,
|
||||||
|
conclusion: job.conclusion
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { id: run.id, name: run.name, status: run.status, created_at: run.created_at, jobs: [] };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(enhancedRuns, null, 2) }],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return { isError: true, content: [{ type: "text", text: `Error fetching pipelines: ${error.message}` }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.name === "gitea_get_pipeline_logs") {
|
||||||
|
const { owner, repo, run_id } = request.params.arguments as any;
|
||||||
|
try {
|
||||||
|
const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`);
|
||||||
|
const jobs = jobsResponse.data;
|
||||||
|
const logs = jobs.map((job: any) => ({
|
||||||
|
job_id: job.id,
|
||||||
|
job_name: job.name,
|
||||||
|
status: job.status,
|
||||||
|
conclusion: job.conclusion,
|
||||||
|
steps: job.steps.map((step: any) => ({
|
||||||
|
name: step.name,
|
||||||
|
status: step.status,
|
||||||
|
conclusion: step.conclusion
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(logs, null, 2) }] };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Resources & Subscriptions ---
|
||||||
|
// We will expose a dynamic resource URI pattern: gitea://runs/{owner}/{repo}/{run_id}
|
||||||
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
uri: "gitea://runs",
|
||||||
|
name: "Gitea Pipeline Runs",
|
||||||
|
description: "Dynamic resource for subscribing to pipeline runs. Format: gitea://runs/{owner}/{repo}/{run_id}",
|
||||||
|
mimeType: "application/json",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||||
|
const uri = request.params.uri;
|
||||||
|
const match = uri.match(/^gitea:\/\/runs\/([^\/]+)\/([^\/]+)\/(\d+)$/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid resource URI. Must be gitea://runs/{owner}/{repo}/{run_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, owner, repo, run_id] = match;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`);
|
||||||
|
const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`);
|
||||||
|
|
||||||
|
const resourceContent = {
|
||||||
|
run: runResponse.data,
|
||||||
|
jobs: jobsResponse.data
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update internal cache when read
|
||||||
|
runStatusCache.set(uri, runResponse.data.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
uri,
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify(resourceContent, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to read Gitea resource: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(SubscribeRequestSchema, async (request) => {
|
||||||
|
const uri = request.params.uri;
|
||||||
|
if (!uri.startsWith("gitea://runs/")) {
|
||||||
|
throw new Error("Only gitea://runs resources can be subscribed to");
|
||||||
|
}
|
||||||
|
subscriptions.add(uri);
|
||||||
|
console.error(`Client subscribed to ${uri}`);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
|
||||||
|
const uri = request.params.uri;
|
||||||
|
subscriptions.delete(uri);
|
||||||
|
console.error(`Client unsubscribed from ${uri}`);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The server polling mechanism that pushes updates to subscribed clients
|
||||||
|
async function pollSubscriptions() {
|
||||||
|
for (const uri of subscriptions) {
|
||||||
|
const match = uri.match(/^gitea:\/\/runs\/([^\/]+)\/([^\/]+)\/(\d+)$/);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const [, owner, repo, run_id] = match;
|
||||||
|
try {
|
||||||
|
const runResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`);
|
||||||
|
const currentStatus = runResponse.data.status;
|
||||||
|
const prevStatus = runStatusCache.get(uri);
|
||||||
|
|
||||||
|
// If status changed (e.g. running -> completed), notify client
|
||||||
|
if (prevStatus !== currentStatus) {
|
||||||
|
runStatusCache.set(uri, currentStatus);
|
||||||
|
|
||||||
|
server.notification({
|
||||||
|
method: "notifications/resources/updated",
|
||||||
|
params: { uri }
|
||||||
|
});
|
||||||
|
console.error(`Pushed update for ${uri}: ${prevStatus} -> ${currentStatus}`);
|
||||||
|
|
||||||
|
// Auto-unsubscribe if completed/failed so we don't poll forever?
|
||||||
|
// Let the client decide, or we can handle it here if requested.
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error polling subscription ${uri}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll every 5 seconds
|
||||||
|
setTimeout(pollSubscriptions, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
console.error("Gitea MCP Native Server running on stdio");
|
||||||
|
|
||||||
|
// Start the background poller
|
||||||
|
pollSubscriptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error("Fatal error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
16
packages/gitea-mcp/tsconfig.json
Normal file
16
packages/gitea-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
489
pnpm-lock.yaml
generated
489
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user