feat(gns-2): stdio MCP transport with hybrid fallback

This commit is contained in:
NW
2026-05-09 00:28:57 +01:00
parent 106a0291a4
commit af08e74f72
6 changed files with 530 additions and 39 deletions

View File

@@ -533,11 +533,11 @@ export class GiteaClient {
// ==================== Issue Lock / Circuit Breaker ====================
async lockIssue(issueNumber: number): Promise<Issue> {
return this.updateIssue(issueNumber, { is_locked: true })
return this.updateIssue(issueNumber, { is_locked: true } as any)
}
async unlockIssue(issueNumber: number): Promise<Issue> {
return this.updateIssue(issueNumber, { is_locked: false })
return this.updateIssue(issueNumber, { is_locked: false } as any)
}
async isLocked(issueNumber: number): Promise<boolean> {

View File

@@ -1,8 +1,13 @@
// 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
// Updated: stdio transport support for Kilo Code infrastructure compatibility
import { spawn, ChildProcess } from "child_process"
import type { Stream } from "stream"
const MCP_BASE_URL = process.env.MCP_GITEA_URL || "http://localhost:3001"
const MCP_STDIO_COMMAND = process.env.MCP_STDIO_COMMAND || "bun scripts/mcp-gitea-stdio.cjs"
export interface MCPToolCall {
name: string
@@ -18,20 +23,127 @@ export interface MCPResponse <T> {
}
/**
* 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"
* })
* ```
* Stdio-based MCP transport for Kilo Code infrastructure compatibility.
* Spawns a child process and communicates via JSON-RPC over stdin/stdout.
*/
export class MCPGiteaClient {
export class MCPGiteaStdioClient {
private child: ChildProcess | null = null
private pending = new Map<number | string, { resolve: (v: any) => void; reject: (e: Error) => void }>()
private idCounter = 0
private initialized = false
private initPromise: Promise<void> | null = null
constructor(private command: string = MCP_STDIO_COMMAND) {}
async connect(): Promise<void> {
if (this.initialized) return
if (this.initPromise) return this.initPromise
this.initPromise = this.doConnect()
return this.initPromise
}
private doConnect(): Promise<void> {
return new Promise((resolve, reject) => {
const [cmd, ...args] = this.command.split(" ")
const cwd = process.cwd()
this.child = spawn(cmd, args, {
cwd,
env: { ...process.env, LOG_LEVEL: "warn" },
})
let stderr = ""
this.child.stderr?.on("data", (d) => {
stderr += d.toString()
})
this.child.on("error", (err) => reject(new Error(`Stdio spawn failed: ${err.message}`)))
this.child.on("exit", (code) => {
if (code !== 0 && code !== null) {
reject(new Error(`Stdio process exited ${code}: ${stderr}`))
}
})
this.child.stdout?.setEncoding("utf8")
this.child.stdout?.on("data", (chunk: string) => this.handleData(chunk))
// Send initialize
const reqId = ++this.idCounter
this.pending.set(reqId, {
resolve: () => {
this.initialized = true
resolve()
},
reject,
})
this.send({ jsonrpc: "2.0", method: "initialize", params: {}, id: reqId })
})
}
private send(msg: unknown) {
const line = JSON.stringify(msg)
this.child?.stdin?.write(line + "\n")
}
private handleData(chunk: string) {
const lines = chunk.split("\n")
for (const line of lines) {
if (!line.trim()) continue
try {
const msg = JSON.parse(line)
if (msg.id !== undefined && msg.id !== null) {
const pending = this.pending.get(msg.id)
if (!pending) continue
this.pending.delete(msg.id)
if (msg.error) {
pending.reject(new Error(msg.error.message || String(msg.error.code)))
} else {
pending.resolve(msg.result)
}
}
} catch {
// ignore non-JSON lines (stderr passthrough handled above)
}
}
}
async callTool<T = any>(name: string, args?: Record<string, any>): Promise<T> {
await this.connect()
const id = ++this.idCounter
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.send({ jsonrpc: "2.0", method: "tools/call", params: { name, arguments: args || {} }, id })
})
}
async health(): Promise<{ status: string; tools: number }> {
try {
await this.connect()
const tools: any[] = await new Promise((resolve, reject) => {
const id = ++this.idCounter
this.pending.set(id, { resolve, reject })
this.send({ jsonrpc: "2.0", method: "tools/list", params: {}, id })
})
return { status: "ok", tools: tools.length }
} catch {
return { status: "unavailable", tools: 0 }
}
}
close() {
if (this.child) {
this.child.kill("SIGTERM")
this.child = null
this.initialized = false
this.initPromise = null
}
}
}
/**
* HTTP-based MCP client (fallback when stdio unavailable or for direct HTTP SSE)
*/
export class MCPGiteaHttpClient {
private baseUrl: string
constructor(baseUrl?: string) {
@@ -40,7 +152,7 @@ export class MCPGiteaClient {
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: {
@@ -56,7 +168,7 @@ export class MCPGiteaClient {
}
const data: MCPResponse<T> = await response.json()
if (data.error) {
throw new Error(`MCP tool '${name}' error: ${data.error.code} - ${data.error.message}`)
}
@@ -68,8 +180,6 @@ export class MCPGiteaClient {
return data.result
}
// ==================== Issue Management ====================
async createIssue(args: {
owner: string
repo: string
@@ -120,8 +230,6 @@ export class MCPGiteaClient {
return this.callTool("gitea_reopen_issue", args)
}
// ==================== Comments ====================
async getComments(args: {
owner: string
repo: string
@@ -156,8 +264,6 @@ export class MCPGiteaClient {
return this.callTool("gitea_delete_comment", args)
}
// ==================== Labels ====================
async getRepoLabels(args: {
owner: string
repo: string
@@ -203,8 +309,6 @@ export class MCPGiteaClient {
return this.callTool("gitea_remove_label", args)
}
// ==================== Milestones ====================
async getMilestones(args: {
owner: string
repo: string
@@ -244,8 +348,6 @@ export class MCPGiteaClient {
return this.callTool("gitea_update_milestone", args)
}
// ==================== Timeline & Events ====================
async getTimeline(args: {
owner: string
repo: string
@@ -262,8 +364,6 @@ export class MCPGiteaClient {
return this.callTool("gitea_parse_events", args)
}
// ==================== GNS-2 Checkpoint Protocol ====================
async getCheckpoint(args: {
owner: string
repo: string
@@ -281,8 +381,6 @@ export class MCPGiteaClient {
return this.callTool("gitea_update_checkpoint", args)
}
// ==================== Circuit Breaker ====================
async lockIssue(args: {
owner: string
repo: string
@@ -299,8 +397,6 @@ export class MCPGiteaClient {
return this.callTool("gitea_unlock_issue", args)
}
// ==================== Polling: Triggered Issues ====================
async getTriggeredIssues(args: {
owner: string
repo: string
@@ -313,8 +409,6 @@ export class MCPGiteaClient {
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) {
@@ -332,6 +426,9 @@ export class MCPGiteaClient {
}
}
// Backward-compatible alias
export const MCPGiteaClient = MCPGiteaHttpClient
// ==================== Migration Helper ====================
/**
* Gradual migration wrapper.
@@ -340,12 +437,12 @@ export class MCPGiteaClient {
import { GiteaClient } from "./gitea-client"
export class HybridGiteaClient {
private mcp: MCPGiteaClient
private mcp: MCPGiteaHttpClient
private rest: GiteaClient
private useMcp: boolean = false
constructor(config?: { mcpUrl?: string; restConfig?: any }) {
this.mcp = new MCPGiteaClient(config?.mcpUrl)
this.mcp = new MCPGiteaHttpClient(config?.mcpUrl)
this.rest = new GiteaClient(config?.restConfig)
}
@@ -359,12 +456,11 @@ export class HybridGiteaClient {
} 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>,
mcpMethod: (mcp: MCPGiteaHttpClient) => Promise<T>,
restMethod: (rest: GiteaClient) => Promise<T>
): Promise<T> {
if (this.useMcp) {