feat(gns2): Phase 8 MCP Docker containers for Gitea direct integration

- 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
This commit is contained in:
NW
2026-05-08 22:16:52 +01:00
parent bd154f24d0
commit 3cc6ee2ffe
3 changed files with 701 additions and 0 deletions

View File

@@ -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<string, any>
}
export interface MCPResponse <T> {
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<T>(name: string, args: Record<string, any>): Promise<T> {
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<T> = 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<Array<{ name: string; description: string }>> {
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<void> {
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<T>(
mcpMethod: (mcp: MCPGiteaClient) => Promise<T>,
restMethod: (rest: GiteaClient) => Promise<T>
): Promise<T> {
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)
)
}
}