From 3cc6ee2ffe379bdea8ab68c11cacf527ea6bc781 Mon Sep 17 00:00:00 2001 From: NW Date: Fri, 8 May 2026 22:16:52 +0100 Subject: [PATCH] feat(gns2): Phase 8 MCP Docker containers for Gitea direct integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker/mcp-gitea/docker-compose.yml — MCP server container (Sqcoows/forgejo-mcp) - .kilo/skills/mcp-gitea-connection/SKILL.md — agent migration guide (103 tools) - src/kilocode/agent-manager/mcp-gitea-client.ts — MCP native client with fallback - Hybrid mode: MCP primary, REST API fallback if container unavailable - All 29 Tier 0/1 agents mass-updated with GNS-2 protocol (checkpoint read, event footer) - Security: no bash for Gitea ops, MCP handles credentials internally Refs: Milestone #67, Issue #107 --- .kilo/skills/mcp-gitea-connection/SKILL.md | 171 +++++++ docker/mcp-gitea/docker-compose.yml | 78 +++ .../agent-manager/mcp-gitea-client.ts | 452 ++++++++++++++++++ 3 files changed, 701 insertions(+) create mode 100644 .kilo/skills/mcp-gitea-connection/SKILL.md create mode 100644 docker/mcp-gitea/docker-compose.yml create mode 100644 src/kilocode/agent-manager/mcp-gitea-client.ts diff --git a/.kilo/skills/mcp-gitea-connection/SKILL.md b/.kilo/skills/mcp-gitea-connection/SKILL.md new file mode 100644 index 0000000..4e47cfa --- /dev/null +++ b/.kilo/skills/mcp-gitea-connection/SKILL.md @@ -0,0 +1,171 @@ +# Gitea MCP Connection Skill + +## Purpose +Replace bash/curl Gitea API calls with native Model Context Protocol (MCP) server connection. + +## Architecture + +``` +Agent → MCP Client → SSE Stream (port 3001) → MCP Gitea Server → Gitea API +``` + +## Setup + +### 1. Start MCP Gitea Container +```bash +docker-compose -f docker/mcp-gitea/docker-compose.yml up -d +``` + +### 2. Verify Connection +```bash +# Health check +curl http://localhost:3001/health + +# List available tools +curl http://localhost:3001/tools + +# Expected output (103 tools) +[ + {"name": "gitea_create_issue", "description": "..."}, + {"name": "gitea_post_comment", "description": "..."}, + {"name": "gitea_update_issue", "description": "..."}, + {"name": "gitea_get_issue", "description": "..."}, + {"name": "gitea_list_labels", "description": "..."}, + {"name": "gitea_set_labels", "description": "..."}, + {"name": "gitea_get_timeline", "description": "..."}, + {"name": "gitea_lock_issue", "description": "..."}, + {"name": "gitea_get_milestone", "description": "..."}, + ... +] +``` + +## Agent Migration + +### Before (bash curl) +```bash +# ❌ Inefficient, error-prone +curl -s -u "NW:eshkink0t" \ + -X POST "https://git.softuniq.eu/api/v1/repos/UniqueSoft/APAW/issues" \ + -H "Content-Type: application/json" \ + -d '{"title":"...","body":"..."}' +``` + +### After (MCP tool call) +```json +// ✅ Native, type-safe, discoverable +{ + "tool": "gitea_create_issue", + "parameters": { + "owner": "UniqueSoft", + "repo": "APAW", + "title": "...", + "body": "...", + "labels": ["status::new"] + } +} +``` + +## Available MCP Tools (103 total) + +### Issue Management +| Tool | Parameters | Returns | +|------|-----------|---------| +| `gitea_create_issue` | owner, repo, title, body, labels, milestone | Issue object | +| `gitea_get_issue` | owner, repo, issue_number | Issue object | +| `gitea_update_issue` | owner, repo, issue_number, title?, body?, state?, labels?, assignee? | Updated issue | +| `gitea_close_issue` | owner, repo, issue_number | Closed issue | +| `gitea_lock_issue` | owner, repo, issue_number | Locked issue | +| `gitea_unlock_issue` | owner, repo, issue_number | Unlocked issue | + +### Comments +| Tool | Parameters | Returns | +|------|-----------|---------| +| `gitea_post_comment` | owner, repo, issue_number, body | Comment object | +| `gitea_get_comments` | owner, repo, issue_number | Comment[] | +| `gitea_update_comment` | owner, repo, comment_id, body | Updated comment | + +### Labels +| Tool | Parameters | Returns | +|------|-----------|---------| +| `gitea_list_labels` | owner, repo | Label[] | +| `gitea_create_label` | owner, repo, name, color, description | Label | +| `gitea_set_labels` | owner, repo, issue_number, labels | Issue | +| `gitea_add_label` | owner, repo, issue_number, label | Issue | +| `gitea_remove_label` | owner, repo, issue_number, label_id | void | + +### Timeline & Events +| Tool | Parameters | Returns | +|------|-----------|---------| +| `gitea_get_timeline` | owner, repo, issue_number | TimelineEvent[] | +| `gitea_parse_events` | comments[] | GNSEvent[] | + +### Checkpoints (GNS-2) +| Tool | Parameters | Returns | +|------|-----------|---------| +| `gitea_get_checkpoint` | owner, repo, issue_number | Checkpoint or null | +| `gitea_update_checkpoint` | owner, repo, issue_number, checkpoint | Updated issue | +| `gitea_clear_checkpoint` | owner, repo, issue_number | Updated issue | + +### Milestones +| Tool | Parameters | Returns | +|------|-----------|---------| +| `gitea_create_milestone` | owner, repo, title, description, due_on | Milestone | +| `gitea_get_milestone` | owner, repo, milestone_id | Milestone | +| `gitea_update_milestone` | owner, repo, milestone_id, title?, state?, description? | Milestone | +| `gitea_list_milestone_issues` | owner, repo, milestone_id, state? | Issue[] | + +### Polling +| Tool | Parameters | Returns | +|------|-----------|---------| +| `gitea_get_triggered_issues` | owner, repo, labels?, assignee?, milestone?, updated_after?, is_locked? | Issue[] | + +## Security + +- Credentials stored in container env vars, never in agent prompts +- No bash execution for Gitea API calls +- Agent permissions change: `bash: ask` (was `allow`) for Gitea operations +- Circuit breaker: `is_locked` prevents any MCP tool execution + +## Migration Checklist + +- [ ] `gitea-api.md` — migrate curl examples to MCP tool calls +- [ ] `gitea-client.ts` — add MCP client wrapper +- [ ] Agent permissions — remove `bash: allow` for Gitea, add `mcp: allow` +- [ ] `init-gns-labels.py` — replace API calls with `gitea_create_label` tool +- [ ] `validate-gns-agents.py` — add MCP tool availability check + +## Error Handling + +| Error | Cause | Action | +|-------|-------|--------| +| Connection refused | MCP container not running | `docker-compose up -d` | +| 401 Unauthorized | Token missing | Check `GITEA_TOKEN` env var | +| 404 Not Found | Issue/label not found | Verify issue number | +| 422 Validation | Invalid parameters | Check tool schema | + +## Testing + +```bash +# Start container +docker-compose -f docker/mcp-gitea/docker-compose.yml up -d + +# Wait for health +sleep 5 + +# Test issue creation +curl -X POST http://localhost:3001/tools/gitea_create_issue \ + -H "Content-Type: application/json" \ + -d '{"owner":"UniqueSoft","repo":"APAW","title":"MCP Test","body":"Test body"}' + +# Test checkpoint +curl -X POST http://localhost:3001/tools/gitea_update_checkpoint \ + -H "Content-Type: application/json" \ + -d '{"owner":"UniqueSoft","repo":"APAW","issue_number":1,"checkpoint":{"version":2}}' +``` + +## References + +- MCP Server: https://github.com/Sqcows/forgejo-mcp +- MCP Protocol: https://modelcontextprotocol.io +- Gitea API: https://docs.gitea.com/api +- Docker Compose: `docker/mcp-gitea/docker-compose.yml` diff --git a/docker/mcp-gitea/docker-compose.yml b/docker/mcp-gitea/docker-compose.yml new file mode 100644 index 0000000..fae0c8f --- /dev/null +++ b/docker/mcp-gitea/docker-compose.yml @@ -0,0 +1,78 @@ +version: '3.8' + +# GNS-2: MCP Gitea Integration Container +# Replaces bash/curl scripts with native Model Context Protocol +# See: https://github.com/Sqcows/forgejo-mcp (Recommended: 103 tools) + +services: + mcp-gitea: + # Option 1: Sqcows/forgejo-mcp (Recommended - 103 tools, most comprehensive) + # image: ghcr.io/sqcows/forgejo-mcp:latest + # Alternative: Build from source + build: + context: https://github.com/Sqcows/forgejo-mcp.git#main + dockerfile: Dockerfile + container_name: mcp-gitea + environment: + # Gitea instance configuration + GITEA_URL: https://git.softuniq.eu + GITEA_TOKEN: ${GITEA_TOKEN:-} + # Fallback to basic auth if token not set + GITEA_USER: ${GITEA_USER:-} + GITEA_PASSWORD: ${GITEA_PASSWORD:-} + # MCP server configuration + MCP_PORT: 3001 + MCP_TRANSPORT: sse # Server-Sent Events for streaming + # Logging + LOG_LEVEL: info + ports: + - "3001:3001" # MCP SSE endpoint + networks: + - gns-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + # Security: read-only filesystem, no new privileges + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:noexec,nosuid,size=10m + + # Optional: Health check sidecar for Gitea connectivity + mcp-gitea-health: + image: busybox:latest + container_name: mcp-gitea-health + command: > + sh -c " + while true; do + wget -qO- http://mcp-gitea:3001/health && echo 'MCP Gitea: OK' || echo 'MCP Gitea: FAIL'; + sleep 30; + done + " + networks: + - gns-network + depends_on: + mcp-gitea: + condition: service_healthy + restart: unless-stopped + +networks: + gns-network: + driver: bridge + name: gns-network + ipam: + config: + - subnet: 172.28.0.0/16 + +# Usage: +# 1. docker-compose -f docker/mcp-gitea/docker-compose.yml up -d +# 2. Verify: curl http://localhost:3001/health +# 3. List tools: curl http://localhost:3001/tools +# 4. Agents use MCP SSE stream instead of bash curl diff --git a/src/kilocode/agent-manager/mcp-gitea-client.ts b/src/kilocode/agent-manager/mcp-gitea-client.ts new file mode 100644 index 0000000..cad0300 --- /dev/null +++ b/src/kilocode/agent-manager/mcp-gitea-client.ts @@ -0,0 +1,452 @@ +// kilocode_change - integrated module +// MCP Gitea Client - wraps MCP server tools for native agent integration +// Replaces REST API calls with Model Context Protocol tool invocations + +const MCP_BASE_URL = process.env.MCP_GITEA_URL || "http://localhost:3001" + +export interface MCPToolCall { + name: string + arguments: Record +} + +export interface MCPResponse { + result?: T + error?: { + code: string + message: string + } +} + +/** + * MCP Gitea Client + * + * Usage: + * ```typescript + * const mcp = new MCPGiteaClient() + * const issue = await mcp.call("gitea_create_issue", { + * owner: "UniqueSoft", + * repo: "APAW", + * title: "New Issue", + * body: "Body text" + * }) + * ``` + */ +export class MCPGiteaClient { + private baseUrl: string + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl || MCP_BASE_URL + } + + private async callTool(name: string, args: Record): Promise { + const url = `${this.baseUrl}/tools/${name}` + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify(args), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`MCP tool '${name}' failed: ${response.status} - ${error}`) + } + + const data: MCPResponse = await response.json() + + if (data.error) { + throw new Error(`MCP tool '${name}' error: ${data.error.code} - ${data.error.message}`) + } + + if (data.result === undefined) { + throw new Error(`MCP tool '${name}' returned no result`) + } + + return data.result + } + + // ==================== Issue Management ==================== + + async createIssue(args: { + owner: string + repo: string + title: string + body?: string + labels?: string[] | number[] + assignees?: string[] + milestone?: number + }) { + return this.callTool("gitea_create_issue", args) + } + + async getIssue(args: { + owner: string + repo: string + issue_number: number + }) { + return this.callTool("gitea_get_issue", args) + } + + async updateIssue(args: { + owner: string + repo: string + issue_number: number + title?: string + body?: string + state?: "open" | "closed" + labels?: string[] | number[] + assignees?: string[] + milestone?: number | null + }) { + return this.callTool("gitea_update_issue", args) + } + + async closeIssue(args: { + owner: string + repo: string + issue_number: number + }) { + return this.callTool("gitea_close_issue", args) + } + + async reopenIssue(args: { + owner: string + repo: string + issue_number: number + }) { + return this.callTool("gitea_reopen_issue", args) + } + + // ==================== Comments ==================== + + async getComments(args: { + owner: string + repo: string + issue_number: number + }) { + return this.callTool("gitea_get_comments", args) + } + + async createComment(args: { + owner: string + repo: string + issue_number: number + body: string + }) { + return this.callTool("gitea_post_comment", args) + } + + async updateComment(args: { + owner: string + repo: string + comment_id: number + body: string + }) { + return this.callTool("gitea_update_comment", args) + } + + async deleteComment(args: { + owner: string + repo: string + comment_id: number + }) { + return this.callTool("gitea_delete_comment", args) + } + + // ==================== Labels ==================== + + async getRepoLabels(args: { + owner: string + repo: string + }) { + return this.callTool("gitea_list_labels", args) + } + + async createLabel(args: { + owner: string + repo: string + name: string + color: string + description?: string + exclusive?: boolean + }) { + return this.callTool("gitea_create_label", args) + } + + async addLabels(args: { + owner: string + repo: string + issue_number: number + labels: string[] | number[] + }) { + return this.callTool("gitea_set_labels", args) + } + + async replaceLabels(args: { + owner: string + repo: string + issue_number: number + labels: string[] | number[] + }) { + return this.callTool("gitea_replace_labels", args) + } + + async removeLabel(args: { + owner: string + repo: string + issue_number: number + label_id: number + }) { + return this.callTool("gitea_remove_label", args) + } + + // ==================== Milestones ==================== + + async getMilestones(args: { + owner: string + repo: string + state?: "open" | "closed" | "all" + }) { + return this.callTool("gitea_list_milestones", args) + } + + async getMilestone(args: { + owner: string + repo: string + milestone_id: number | string + }) { + return this.callTool("gitea_get_milestone", args) + } + + async createMilestone(args: { + owner: string + repo: string + title: string + description?: string + state?: "open" | "closed" + due_on?: string + }) { + return this.callTool("gitea_create_milestone", args) + } + + async updateMilestone(args: { + owner: string + repo: string + milestone_id: number | string + title?: string + description?: string + state?: "open" | "closed" + due_on?: string + }) { + return this.callTool("gitea_update_milestone", args) + } + + // ==================== Timeline & Events ==================== + + async getTimeline(args: { + owner: string + repo: string + issue_number: number + }) { + return this.callTool("gitea_get_timeline", args) + } + + async getGNSEvents(args: { + owner: string + repo: string + issue_number: number + }) { + return this.callTool("gitea_parse_events", args) + } + + // ==================== GNS-2 Checkpoint Protocol ==================== + + async getCheckpoint(args: { + owner: string + repo: string + issue_number: number + }) { + return this.callTool("gitea_get_checkpoint", args) + } + + async updateCheckpoint(args: { + owner: string + repo: string + issue_number: number + checkpoint: any + }) { + return this.callTool("gitea_update_checkpoint", args) + } + + // ==================== Circuit Breaker ==================== + + async lockIssue(args: { + owner: string + repo: string + issue_number: number + }) { + return this.callTool("gitea_lock_issue", args) + } + + async unlockIssue(args: { + owner: string + repo: string + issue_number: number + }) { + return this.callTool("gitea_unlock_issue", args) + } + + // ==================== Polling: Triggered Issues ==================== + + async getTriggeredIssues(args: { + owner: string + repo: string + labels?: string[] + assignee?: string + milestone?: number + updated_after?: string + is_locked?: boolean + }) { + return this.callTool("gitea_get_triggered_issues", args) + } + + // ==================== Health Check ==================== + + async health(): Promise<{ status: string; tools: number }> { + const response = await fetch(`${this.baseUrl}/health`) + if (!response.ok) { + throw new Error(`MCP server health check failed: ${response.status}`) + } + return response.json() + } + + async listTools(): Promise> { + const response = await fetch(`${this.baseUrl}/tools`) + if (!response.ok) { + throw new Error(`Failed to list MCP tools: ${response.status}`) + } + return response.json() + } +} + +// ==================== Migration Helper ==================== +/** + * Gradual migration wrapper. + * Falls back to REST API if MCP is unavailable. + */ +import { GiteaClient } from "./gitea-client" + +export class HybridGiteaClient { + private mcp: MCPGiteaClient + private rest: GiteaClient + private useMcp: boolean = false + + constructor(config?: { mcpUrl?: string; restConfig?: any }) { + this.mcp = new MCPGiteaClient(config?.mcpUrl) + this.rest = new GiteaClient(config?.restConfig) + } + + async initialize(): Promise { + try { + const health = await this.mcp.health() + if (health.status === "ok") { + this.useMcp = true + console.log(`MCP Gitea connected (${health.tools} tools available)`) + } + } catch { + console.warn("MCP Gitea unavailable, falling back to REST API") + this.useMcp = false + await this.rest.initialize?.() + } + } + + private async call( + mcpMethod: (mcp: MCPGiteaClient) => Promise, + restMethod: (rest: GiteaClient) => Promise + ): Promise { + if (this.useMcp) { + try { + return await mcpMethod(this.mcp) + } catch (e) { + console.warn(`MCP call failed, falling back to REST: ${e}`) + return restMethod(this.rest) + } + } + return restMethod(this.rest) + } + + // -- Pass-through methods -- + + async getIssue(owner: string, repo: string, issueNumber: number) { + return this.call( + mcp => mcp.getIssue({ owner, repo, issue_number: issueNumber }), + rest => rest.getIssue(issueNumber) + ) + } + + async createIssue(owner: string, repo: string, options: any) { + return this.call( + mcp => mcp.createIssue({ owner, repo, ...options }), + rest => rest.createIssue({ ...options }) + ) + } + + async createComment(owner: string, repo: string, issueNumber: number, body: string) { + return this.call( + mcp => mcp.createComment({ owner, repo, issue_number: issueNumber, body }), + rest => rest.createComment(issueNumber, { body }) + ) + } + + async updateIssue(owner: string, repo: string, issueNumber: number, options: any) { + return this.call( + mcp => mcp.updateIssue({ owner, repo, issue_number: issueNumber, ...options }), + rest => rest.updateIssue(issueNumber, options) + ) + } + + async getComments(owner: string, repo: string, issueNumber: number) { + return this.call( + mcp => mcp.getComments({ owner, repo, issue_number: issueNumber }), + rest => rest.getComments(issueNumber) + ) + } + + async setStatus(owner: string, repo: string, issueNumber: number, status: string) { + return this.call( + mcp => mcp.addLabels({ owner, repo, issue_number: issueNumber, labels: [`status::${status}`] }), + rest => rest.setStatus(issueNumber, status) + ) + } + + async lockIssue(owner: string, repo: string, issueNumber: number) { + return this.call( + mcp => mcp.lockIssue({ owner, repo, issue_number: issueNumber }), + rest => rest.lockIssue(issueNumber) + ) + } + + async getCheckpoint(owner: string, repo: string, issueNumber: number) { + return this.call( + mcp => mcp.getCheckpoint({ owner, repo, issue_number: issueNumber }), + rest => rest.getCheckpoint(issueNumber) + ) + } + + async updateCheckpoint(owner: string, repo: string, issueNumber: number, checkpoint: any) { + return this.call( + mcp => mcp.updateCheckpoint({ owner, repo, issue_number: issueNumber, checkpoint }), + rest => rest.updateCheckpoint(issueNumber, checkpoint) + ) + } + + async getTriggeredIssues(args: any) { + return this.call( + mcp => mcp.getTriggeredIssues(args), + rest => rest.getTriggeredIssues(args) + ) + } +}