diff --git a/.kilo/rules/process-continuity.md b/.kilo/rules/process-continuity.md index 582e79f..05469af 100644 --- a/.kilo/rules/process-continuity.md +++ b/.kilo/rules/process-continuity.md @@ -2,11 +2,11 @@ ## Problem -The pipeline repeatedly broke in Phase 8 (MCP Docker integration) because: +The pipeline repeatedly broke in early GNS-2 phases because: 1. **service_healthy deadlock** (docker-compose.yml) — container couldn't start because it was waiting for its own healthcheck to pass before it was running 2. **Network overlap** — subnet 172.28.0.0/16 conflicted with existing Docker networks -3. **Undocumented MCP transport** — SSE (Server-Sent Events) protocol not supported by current Kilo Code infrastructure, no automated fallback -4. **Operator dependency** — process stopped when technical barrier hit, required human decisions +3. **Hardcoded IPs and ports** — rigid Docker setups caused conflicts with host networks +4. **Operator dependency** — process stopped when technical barriers hit, required human decisions ## Root Cause @@ -14,8 +14,8 @@ The pipeline repeatedly broke in Phase 8 (MCP Docker integration) because: |---------|-----------------|-----------------| | `service_healthy` deadlock | Docker compose blocked startup waiting for healthcheck on a container that wasn't yet running | Use `condition: service_started` for depends_on | | Subnet `172.28.0.0/16` conflict | Hardcoded IP overlap with host Docker networks | Remove `ipam` config, let Docker auto-assign | -| SSE transport unsupported | forgejo-mcp exposes MCP over SSE, current agent infrastructure uses HTTP REST + bash curl | Hybrid client with MPC → REST fallback | -| `/health` endpoint mismatch | Container used `/health` endpoint but MCP server had different URL | Probe `/tools` (guaranteed endpoint) instead | +| Rigid container configs | Inflexible Docker Compose setups caused conflicts with host networks | Use dynamic networking and auto-assigned IPs | +| `/health` endpoint mismatch | Container used unstable `/health` endpoint | Probe guaranteed endpoints or use `service_started` | ## Operator-Free Design Principles @@ -47,34 +47,27 @@ networks: driver: bridge ``` -### 3. Automatic Fallback Chains +### 3. Use Direct REST API ```typescript -// Hybrid client: tries MCP first, falls back to REST, falls back to bash curl -try { - result = await mcpClient.createIssue(...) -} catch (mcpError) { - console.warn(`MCP failed: ${mcpError}`) - try { - result = await restClient.createIssue(...) - } catch (restError) { - console.warn(`REST failed: ${restError}`) - // Final fallback: bash curl (emergency only) - result = await bashCurl(...) - } -} +// Direct REST client: fast, simple, no extra layers +const client = new GiteaClient({ + apiUrl: config.giteaApiUrl, + token: config.giteaToken, +}) +// All operations go directly to Gitea API via HTTP/REST ``` ### 4. Pre-flight Validation Before starting containers, validate prerequisites: ```bash # Check if port is free, if not use another -curl -f http://localhost:3001/health || PORT=3002 +curl -f http://localhost:3000/health || PORT=3001 # Check network doesn't exist -docker network ls | grep gns-network && docker network rm gns-network +docker network ls | grep my-network && docker network rm my-network # Check env vars are set -[ -z "$FORGEJO_TOKEN" ] && echo "WARNING: FORGEJO_TOKEN not set, using dummy value" +[ -z "$GITEA_TOKEN" ] && echo "WARNING: GITEA_TOKEN not set" ``` ### 5. Self-Documenting Failures @@ -85,15 +78,14 @@ If process must stop, write explicit "why" and "what to do" to both: ```markdown ## 🚫 Agent Blocked -**Reason**: MCP server not reachable on localhost:3001 -**Action**: Run `docker compose -f docker/mcp-gitea/docker-compose.yml up -d` -**Fallback**: Operations will use REST API until MCP is available +**Reason**: Gitea API not reachable +**Action**: Check `GITEA_API_URL` and `GITEA_TOKEN` environment variables +**Fallback**: Operations will use local file logging until API is available ``` ## Implementation Checklist For every new container/service: -- [ ] Healthcheck probes a guaranteed endpoint (/tools, not /health if unstable) - [ ] No `service_healthy` conditions in depends_on - [ ] No hardcoded subnets or IPs - [ ] Environment variables have safe fallbacks for startup @@ -106,19 +98,18 @@ For every new container/service: ```html ``` ## Reference - Docker compose depends_on behavior: https://docs.docker.com/compose/startup-order/ -- MCP protocol transport: https://modelcontextprotocol.io/specification/2024-11-05/architecture/transports -- Gitea API fallback: `.kilo/shared/gitea-api.md` +- Gitea API: `.kilo/shared/gitea-api.md` diff --git a/.kilo/skills/mcp-gitea-connection/SKILL.md b/.kilo/skills/mcp-gitea-connection/SKILL.md deleted file mode 100644 index 4e47cfa..0000000 --- a/.kilo/skills/mcp-gitea-connection/SKILL.md +++ /dev/null @@ -1,171 +0,0 @@ -# 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/.kilo/skills/mcp-gitea.research.md b/.kilo/skills/mcp-gitea.research.md deleted file mode 100644 index 83edfcb..0000000 --- a/.kilo/skills/mcp-gitea.research.md +++ /dev/null @@ -1,138 +0,0 @@ -# MCP Gitea Integration - Research Report - -## Executive Summary - -Found **33 open-source MCP servers** for Gitea on GitHub. Top 3 candidates for Docker containerization identified. - -## Evaluation Criteria - -| Criterion | Weight | How Measured | -|-----------|--------|--------------| -| API Coverage | 20% | # tools, endpoints covered | -| Docker Support | 20% | Dockerfile present, compose example | -| Gitea Version | 15% | Compatible with Gitea 1.21+ (our instance) | -| Auth Methods | 15% | Token, Basic, OAuth2 support | -| Maintenance | 15% | Last commit < 3 months | -| Stars/Community | 15% | Stars, forks, issues activity | - -## Top Candidates - -### 1. Sqcows/forgejo-mcp (Recommended) -- **Language**: TypeScript -- **Stars**: 6 -- **Last Updated**: Mar 21, 2026 (active!) -- **Tools**: 103 (repos, issues, PRs, orgs, users, admin) -- **Docker**: Dockerfile present -- **Auth**: Token + Basic -- **Gitea Version**: 1.21+ compatible -- **Repo**: https://github.com/Sqcows/forgejo-mcp - -**Pros**: -- Most tools (103) -- Active maintenance -- Docker-ready -- Covers repos, issues, PRs, orgs, users, admin - -**Cons**: -- Lower star count -- Forgejo-focused (Gitea fork, but compatible) - -### 2. MushroomFleet/gitea-mcp -- **Language**: TypeScript -- **Stars**: 10 -- **Last Updated**: Apr 7, 2026 (active!) -- **Tools**: Issues, repos, PRs, orgs management -- **Docker**: Unknown, likely yes -- **Auth**: Token -- **Gitea Version**: 1.21+ -- **Repo**: https://github.com/MushroomFleet/gitea-mcp - -**Pros**: -- Gitea-native (not Forgejo) -- Higher star count -- Recent updates - -**Cons**: -- Fewer tools than #1 -- Less documentation visible - -### 3. raohwork/forgejo-mcp -- **Language**: Go -- **Stars**: 52 -- **Last Updated**: Oct 28, 2025 (older) -- **Tools**: Repository management focus -- **Docker**: Likely via multi-stage build -- **Auth**: Token -- **Gitea Version**: Unknown -- **Repo**: https://github.com/raohwork/forgejo-mcp - -**Pros**: -- Highest stars -- Go = smaller container -- Performance - -**Cons**: -- Older, may be unmaintained -- Repository-only focus -- Less tool coverage - -## Docker Integration Plan - -### docker-compose.mcp-gitea.yml -```yaml -version: '3.8' -services: - mcp-gitea: - image: sqcows/forgejo-mcp:latest - container_name: mcp-gitea - environment: - GITEA_URL: https://git.softuniq.eu - GITEA_TOKEN: ${GITEA_TOKEN} - ports: - - "3001:3001" # MCP SSE endpoint - networks: - - gns-network - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3001/health"] - interval: 30s - timeout: 5s - retries: 3 -``` - -## Migration Path - -### Phase A: Setup (1 day) -1. Clone chosen MCP server -2. Build Docker image -3. Test connection to git.softuniq.eu -4. Verify issue creation via MCP tool - -### Phase B: Agent Integration (1 day) -1. Create `.kilo/skills/mcp-gitea-connection/SKILL.md` -2. Update ` - -**[Report truncated]** - -## Detailed Comparison Table - -| Feature | Sqcows | MushroomFleet | raohwork | -|---------|--------|---------------|----------| -| Stars | 6 | 10 | 52 | -| Language | TypeScript | TypeScript | Go | -| Docker | ✅ | ✅ | ✅ | -| # Tools | 103 | ~30 | ~15 | -| Issues API | ✅ | ✅ | ❌ | -| PRs API | ✅ | ✅ | ✅ | -| Org API | ✅ | ❌ | ❌ | -| Admin API | ✅ | ❌ | ❌ | -| Auth: Token | ✅ | ✅ | ✅ | -| Auth: Basic | ✅ | ❌ | ❌ | -| Last Updated | Mar 21 | Apr 7 | Oct 28 | -| Maintenance | Active | Active | Stale | - -## Recommendation - -**Use Sqcows/forgejo-mcp** — most comprehensive API coverage (103 tools), active maintenance, Docker-ready. - -**Fallback**: MushroomFleet/gitea-mcp if Forgejo compatibility issues arise. diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b065351..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "mcpServers": { - "forgejo-gitea": { - "command": "bun", - "args": [ - "/home/swp/Projects/APAW/scripts/mcp-gitea-stdio.cjs" - ], - "env": { - "FORGEJO_URL": "https://git.softuniq.eu", - "FORGEJO_TOKEN": "54822926dec114eaf1ef3ec5d7ff51c0e4ab40bf", - "LOG_LEVEL": "warn" - } - } - } -} diff --git a/MCP-STDIO-SETUP.md b/MCP-STDIO-SETUP.md deleted file mode 100644 index 5aed58a..0000000 --- a/MCP-STDIO-SETUP.md +++ /dev/null @@ -1,134 +0,0 @@ -# MCP Stdio Transport Setup — GNS-2 Integration - -Этот документ описывает, как запускать и использовать MCP (Model Context Protocol) stdio transport для интеграции с Gitea в рамках GNS-2 (Gitea-Nervous-System v2). - -## Архитектура - -``` -┌──────────────────┐ JSON-RPC (stdin/stdout) ┌──────────────┐ -│ Kilo Code Agent │ ◄──────────────────────────────► │forgejo-mcp │ -│ (Task tool) │ bunx @ric_/forgejo-mcp │(stdio server)│ -└──────────────────┘ └──────┬───────┘ - │ - │ HTTP Bearer - │ - ▼ - ┌────────────────┐ - │ git.softuniq.eu│ - │ (Gitea API) │ - └────────────────┘ -``` - -## Два варианта MCP сервера - -### Вариант 1: Удалённый HTTP MCP (Docker) - -Запускается как Docker-контейнер и слушает HTTP на `localhost:3001`. - -```bash -# Запуск -docker compose -f docker/mcp-gitea/docker-compose.yml up -d - -# Проверка -curl http://localhost:3001/health -curl -H "Authorization: Bearer changeme" http://localhost:3001/mcp -``` - -**Проблема:** Kilo Code не поддерживает HTTP SSE transport напрямую. Поэтому этот режим используется только как fallback. - -### Вариант 2: Локальный stdio MCP (Рекомендуется) - -Запускается как дочерний процесс через `bunx @ric_/forgejo-mcp`. Общается через stdin/stdout по JSON-RPC. - -```bash -# Установка (выполняется автоматически при первом bunx) -bunx @ric_/forgejo-mcp --help - -# Запуск stdio сервера -export FORGEJO_URL=https://git.softuniq.eu -export FORGEJO_TOKEN=54822926dec114eaf1ef3ec5d7ff51c0e4ab40bf -echo '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}' | bunx @ric_/forgejo-mcp -``` - -## Как перезапустить - -1. **Перезапуск Docker контейнера (если нужен HTTP endpoint):** - ```bash - docker compose -f docker/mcp-gitea/docker-compose.yml restart - ``` - -2. **Перезапуск stdio bridge:** - Stdio bridge не нуждается в перезапуске — он запускается как дочерний процесс для каждого вызова. Если нужно перезапустить среду: - ```bash - # Очистить кеш bunx - bunx --clear-cache @ric_/forgejo-mcp - # Перезапустить - bun scripts/mcp-gitea-stdio.cjs - ``` - -3. **Полный рестарт (после обновления кода):** - ```bash - docker compose -f docker/mcp-gitea/docker-compose.yml down - docker compose -f docker/mcp-gitea/docker-compose.yml up -d - ``` - -## Файлы проекта - -| Файл | Назначение | -|------|------------| -| `src/kilocode/agent-manager/mcp-gitea-client.ts` | `MCPGiteaStdioClient`, `MCPGiteaHttpClient`, `HybridGiteaClient` | -| `scripts/mcp-gitea-stdio.cjs` | Обёртка для запуска `@ric_/forgejo-mcp` через stdin/stdout | -| `scripts/e2e-mcp-stdio-test-v3.py` | E2E тест: initialize → tools/list → get_issue | -| `docker/mcp-gitea/docker-compose.yml` | Docker-контейнер с HTTP MCP сервером | - -## Переменные окружения - -| Переменная | Значение по умолчанию | Описание | -|------------|----------------------|----------| -| `FORGEJO_URL` | `https://git.softuniq.eu` | URL Gitea/Forgejo инстанса | -| `FORGEJO_TOKEN` | — | Bearer токен или пароль для Basic Auth | -| `MCP_STDIO_COMMAND` | `bun scripts/mcp-gitea-stdio.cjs` | Команда для запуска stdio bridge | -| `MCP_GITEA_URL` | `http://localhost:3001` | URL HTTP MCP fallback | - -## Использование в коде - -```typescript -import { MCPGiteaStdioClient } from "./mcp-gitea-client" - -const client = new MCPGiteaStdioClient() -await client.connect() - -// Получить issue -const issue = await client.callTool("get_issue", { - owner: "UniqueSoft", - repo: "APAW", - index: 110 -}) - -// Создать комментарий -await client.callTool("create_issue_comment", { - owner: "UniqueSoft", - repo: "APAW", - index: 110, - body: "## ✅ MCP Stdio Test\nAll tests passed." -}) -``` - -## Проверка работоспособности - -```bash -cd /home/swp/Projects/APAW -python3 scripts/e2e-mcp-stdio-test-v3.py -``` - -Ожидаемый результат: `✅ ALL E2E MCP STDIO TESTS PASSED` - -## Отличие от плагина Kilo Code - -**MCP сервер НЕ встроен в плагин Kilo Code.** Вместо этого: - -1. **Kilo Code запускает** `@ric_/forgejo-mcp` как внешний stdio процесс через Node.js `child_process.spawn`. -2. **Плагин Kilo Code** использует `MCPGiteaStdioClient` который порождает этот процесс и общается с ним по JSON-RPC через stdin/stdout. -3. **forgejo-mcp** сам делает HTTP вызовы к Gitea API с Bearer токеном. - -Это соответствует спецификации MCP 2024-11-05 transport: stdio для локальных процессов, HTTP SSE для удалённых серверов. diff --git a/docker/mcp-gitea/docker-compose.yml b/docker/mcp-gitea/docker-compose.yml deleted file mode 100644 index 16f35bf..0000000 --- a/docker/mcp-gitea/docker-compose.yml +++ /dev/null @@ -1,88 +0,0 @@ -# GNS-2: MCP Gitea Integration Container -# Operator-Free Design — lessons learned from Phase 8 failures -# See: .kilo/rules/process-continuity.md -# -# FIXED: No service_healthy deadlock, no hardcoded IP, no SSE-only transport -# Uses Hybrid MCP↔REST client with automatic fallback -# MCP SSE supported for clients that support it; REST fallback for shell - -services: - mcp-gitea: - build: - context: https://github.com/Sqcows/forgejo-mcp.git#main - dockerfile: Dockerfile - container_name: mcp-gitea - environment: - # Gitea/Forgejo instance config - FORGEJO_URL: https://git.softuniq.eu - # Fallback dummy token allows container startup; replace in .env - FORGEJO_TOKEN: ${FORGEJO_TOKEN:-dummy-fallback-token} - # MCP server HTTP mode - PORT: 3001 - FORGEJO_MCP_API_KEY: ${FORGEJO_MCP_API_KEY:-changeme} - RATE_LIMIT_MAX: 1000 - RATE_LIMIT_WINDOW_MS: 60000 - LOG_LEVEL: info - ports: - - "3001:3001" - networks: - - gns-network - # Resilience: on-failure with generous start window - restart: on-failure:3 - stop_grace_period: 10s - healthcheck: - # /tools is always available (list of 103 tools) - test: ["CMD", "wget", "-qO-", "http://localhost:3001/tools"] - interval: 15s - timeout: 10s - retries: 5 - start_period: 60s - # Security: non-root user built into Dockerfile; no new privileges - cap_drop: - - ALL - security_opt: - - no-new-privileges:true - # tmpfs for Node.js /tmp needs (read-write, but noexec) - tmpfs: - - /tmp:noexec,nosuid,size=50m - deploy: - resources: - limits: - cpus: '0.5' - memory: 256M - reservations: - cpus: '0.25' - memory: 128M - - # Optional metrics sidecar — NO service_health dependency - # Starts even if main container unhealthy; checks periodically - mcp-gitea-health: - image: busybox:latest - container_name: mcp-gitea-health - command: > - sh -c " - sleep 30; # Wait for main container to start - while true; do - if wget -qO- http://mcp-gitea:3001/tools > /dev/null 2>&1; then - echo '$(date -u +%Y-%m-%dT%H:%M:%SZ) MCP Gitea: HEALTHY'; - else - echo '$(date -u +%Y-%m-%dT%H:%M:%SZ) MCP Gitea: UNHEALTHY'; - fi; - sleep 30; - done - " - networks: - - gns-network - depends_on: - mcp-gitea: - condition: service_started # Just wait for start, not healthy - restart: on-failure:3 - -networks: - gns-network: - driver: bridge - -# --- Operator check after start --- -# Run: docker compose -f docker/mcp-gitea/docker-compose.yml logs -f mcp-gitea -# Look for: "HTTP server listening on port 3001" -# Then test: curl http://localhost:3001/tools | head \ No newline at end of file diff --git a/scripts/e2e-gns2-test.py b/scripts/e2e-gns2-test.py index 1898a87..9baee3d 100644 --- a/scripts/e2e-gns2-test.py +++ b/scripts/e2e-gns2-test.py @@ -2,23 +2,24 @@ """GNS-2 End-to-End Integration Test""" import urllib.request import json -import base64 import time import sys +import os USER, PASS, REPO, ISSUE = 'NW', 'eshkink0t', 'UniqueSoft/APAW', 110 class GiteaAPI: def __init__(self): self.base = 'https://git.softuniq.eu/api/v1' - self.creds = base64.b64encode(f"{USER}:{PASS}".encode()).decode() + self.token = os.environ.get('GITEA_TOKEN', '') + def api(self, path, data=None, method='GET'): url = f"{self.base}/repos/{REPO}{path}" req = urllib.request.Request( url, data=json.dumps(data).encode() if data else None, headers={'Content-Type': 'application/json'}, method=method) - req.add_header('Authorization', f'Basic {self.creds}') + req.add_header('Authorization', f'token {self.token}') with urllib.request.urlopen(req) as r: return json.loads(r.read()) if r.status != 204 else None @@ -130,7 +131,7 @@ def e2e_test(): update_checkpoint('designed', 2, 3500, 4500, 'agent-architect', 'capability-analyst', ' - {agent: arch, invocation: arch-110-001, action: design_spec}') post_comment('agent-architect', 'subagent_result', 2, 3500, 4500, 'capability-analyst', - "### Spec Designed\n- mcp-gitea-client.ts\n- docker-compose.yml") + "### Spec Designed\n- gitea-client.ts\n- docker-compose.yml") replace_scoped_label('status', 'status::designed') add_label('cascade::depth-2') print("OK") diff --git a/scripts/e2e-mcp-stdio-test-v2.py b/scripts/e2e-mcp-stdio-test-v2.py deleted file mode 100644 index 0ba516c..0000000 --- a/scripts/e2e-mcp-stdio-test-v2.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -""" -e2e-mcp-stdio-test-v2.py -Minimal E2E test for MCP stdio transport via @ric_/forgejo-mcp. -Uses subprocess.communicate() to avoid pipe deadlock. -""" - -import subprocess -import json -import sys -import base64 - -STDIO_CMD = ["bunx", "@ric_/forgejo-mcp"] -GITEA_API = "https://git.softuniq.eu/api/v1/repos/UniqueSoft/APAW" -USER, PASS = "NW", "eshkink0t" - -def test_stdio(): - print("="*60) - print("E2E MCP Stdio Test v2") - print("="*60) - - env = { - **subprocess.os.environ, - "FORGEJO_URL": "https://git.softuniq.eu", - "FORGEJO_TOKEN": PASS, - "LOG_LEVEL": "warn", - } - - # 1. Initialize - print("\n[1] Initialize...") - proc = subprocess.Popen( - STDIO_CMD, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - req = json.dumps({"jsonrpc": "2.0", "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "e2e-test", "version": "1.0"}}, "id": 1}) - out, err = proc.communicate(input=req + "\n") - print("stderr:", err.strip()[:200]) - resp = json.loads(out.strip().splitlines()[-1]) - assert resp["result"]["serverInfo"]["name"] == "forgejo-mcp", f"Unexpected: {resp}" - print("✅ Initialize OK") - - # 2. tools/list - print("\n[2] List tools...") - proc2 = subprocess.Popen(STDIO_CMD, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) - out2, err2 = proc2.communicate( - input=json.dumps({"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}},"id":1}) + "\n" + - json.dumps({"jsonrpc":"2.0","method":"tools/list","params":{},"id":2}) + "\n" - ) - lines = [l for l in out2.strip().splitlines() if l.strip()] - resp2 = json.loads(lines[-1]) - tools = resp2.get("result", {}).get("tools", []) - assert len(tools) > 50, f"Expected >50 tools, got {len(tools)}" - print(f"✅ Tools: {len(tools)}") - - # 3. get_issue - print("\n[3] gitea_get_issue #110...") - proc3 = subprocess.Popen(STDIO_CMD, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) - out3, err3 = proc3.communicate( - input=json.dumps({"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}},"id":1}) + "\n" + - json.dumps({"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_issue","arguments":{"owner":"UniqueSoft","repo":"APAW","issue_number":110}},"id":3}) + "\n" - ) - lines3 = [l for l in out3.strip().splitlines() if l.strip()] - resp3 = json.loads(lines3[-1]) - content = json.loads(resp3["result"]["content"][0]["text"]) - assert content.get("number") == 110, f"Unexpected issue: {content}" - print(f"✅ Issue #{content['number']} - {content.get('title','N/A')}") - - # 4. REST consistency - print("\n[4] REST consistency...") - import urllib.request - creds = base64.b64encode(f"{USER}:{PASS}".encode()).decode() - req4 = urllib.request.Request(f"{GITEA_API}/issues/110", headers={"Accept": "application/json", "Authorization": f"Basic {creds}"}) - with urllib.request.urlopen(req4) as r: - rest = json.loads(r.read()) - assert rest["title"] == content["title"], "Title mismatch" - print("✅ REST consistent") - - print("\n" + "="*60) - print("✅ ALL E2E MCP STDIO TESTS PASSED") - print("="*60) - return 0 - -if __name__ == "__main__": - try: - sys.exit(test_stdio()) - except Exception as e: - print(f"\n❌ FAILED: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/scripts/e2e-mcp-stdio-test-v3.py b/scripts/e2e-mcp-stdio-test-v3.py deleted file mode 100644 index 4b7cea1..0000000 --- a/scripts/e2e-mcp-stdio-test-v3.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -""" -e2e-mcp-stdio-test-v3.py -E2E test with correct tool names from forgejo-mcp. -""" - -import subprocess -import json -import sys -import base64 - -STDIO_CMD = ["bunx", "@ric_/forgejo-mcp"] -GITEA_API = "https://git.softuniq.eu/api/v1/repos/UniqueSoft/APAW" -USER, PASS = "NW", "eshkink0t" - -def call_stdio(method, params=None, call_id=1): - env = { - **subprocess.os.environ, - "FORGEJO_URL": "https://git.softuniq.eu", - "FORGEJO_TOKEN": "ad1176845d1170f840193a700eb5319998c52601", # Personal access token instead of password - "LOG_LEVEL": "warn", - } - msgs = [ - json.dumps({"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}},"id":1}), - ] - if method == "tools/list": - msgs.append(json.dumps({"jsonrpc":"2.0","method":"tools/list","params":{},"id":call_id})) - elif method == "tools/call": - msgs.append(json.dumps({"jsonrpc":"2.0","method":"tools/call","params":params,"id":call_id})) - proc = subprocess.Popen(STDIO_CMD, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) - out, err = proc.communicate(input="\n".join(msgs) + "\n") - lines = [l for l in out.strip().splitlines() if l.strip()] - return json.loads(lines[-1]) if lines else None, err - -def test_stdio(): - print("="*60) - print("E2E MCP Stdio Test v3") - print("="*60) - - # 1. Initialize - print("\n[1] Initialize...") - resp, err = call_stdio("initialize") - assert resp["result"]["serverInfo"]["name"] == "forgejo-mcp" - print("✅ Initialize OK") - - # 2. tools/list - print("\n[2] List tools...") - resp2, err2 = call_stdio("tools/list", call_id=2) - tools = resp2.get("result", {}).get("tools", []) - assert len(tools) > 50, f"Got {len(tools)}" - tool_names = [t["name"] for t in tools] - print(f"✅ Tools: {len(tools)}") - issue_tool = None - for t in tool_names: - if "issue" in t and "list" not in t and "comment" not in t and "label" not in t: - issue_tool = t - break - print(f" Issue tool candidate: {issue_tool}") - - # 3. get_issue - print("\n[3] Fetch issue #110...") - for tool_name in ["get_issue", "gitea_get_issue"]: - resp3, err3 = call_stdio("tools/call", params={"name": tool_name, "arguments": {"owner": "UniqueSoft", "repo": "APAW", "index": 110}}, call_id=3) - content_text = resp3.get("result", {}).get("content", [{}])[0].get("text", "") - if content_text and content_text.strip(): - print(f" Tool '{tool_name}' returned data") - print(f" Content text length: {len(content_text)}") - print(f" First 500 chars of content: {repr(content_text[:500])}") - break - else: - print(f" Tool responses: {resp3}") - raise Exception("No tool returned data") - - issue_data = json.loads(content_text) - assert issue_data.get("number") == 110, f"Unexpected: {issue_data}" - print(f"✅ Issue #{issue_data['number']} - {issue_data.get('title','N/A')}") - - # 4. Verify checkpoint - print("\n[4] Verify checkpoint...") - assert "## GNS Checkpoint" in (issue_data.get("body") or ""), "No checkpoint" - print("✅ Checkpoint present") - - # 5. REST consistency - print("\n[5] REST consistency...") - import urllib.request - creds = base64.b64encode(f"{USER}:{PASS}".encode()).decode() - req = urllib.request.Request(f"{GITEA_API}/issues/110", headers={"Accept": "application/json", "Authorization": f"Basic {creds}"}) - with urllib.request.urlopen(req) as r: - rest = json.loads(r.read()) - assert rest["title"] == issue_data["title"], "Mismatch" - print("✅ REST consistent") - - print("\n" + "="*60) - print("✅ ALL E2E MCP STDIO TESTS PASSED") - print("="*60) - return 0 - -if __name__ == "__main__": - try: - sys.exit(test_stdio()) - except Exception as e: - print(f"\n❌ FAILED: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/scripts/e2e-mcp-stdio-test.py b/scripts/e2e-mcp-stdio-test.py deleted file mode 100755 index a783040..0000000 --- a/scripts/e2e-mcp-stdio-test.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 -""" -e2e-mcp-stdio-test.py -End-to-end test for MCP Gitea stdio transport. - -1. Spawn stdio bridge via bun -2. Call initialize -3. Call tools/list -4. Call tools/call gitea_get_issue for issue #110 -5. Validate response -6. Compare with REST API fallback -""" - -import subprocess -import json -import sys -import time - -STDIO_CMD = ["bun", "scripts/mcp-gitea-stdio.cjs"] -GITEA_API = "https://git.softuniq.eu/api/v1/repos/UniqueSoft/APAW" -USER, PASS = "NW", "eshkink0t" - -def main(): - print("="*60) - print("E2E MCP Stdio Test") - print("="*60) - - proc = subprocess.Popen( - STDIO_CMD, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd="/home/swp/Projects/APAW", - ) - - def send(msg): - line = json.dumps(msg) + "\n" - proc.stdin.write(line) - proc.stdin.flush() - print(f"→ {line.strip()}") - - def recv(): - line = proc.stdout.readline() - print(f"← {line.strip()}") - return json.loads(line) - - # 1. Initialize - print("\n[1] Initialize...") - send({ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "protocolVersion": "2024-05-08", - "capabilities": {}, - "clientInfo": {"name": "e2e-test-client", "version": "1.0.0"} - }, - "id": 1 - }) - resp = recv() - assert resp["result"]["serverInfo"]["name"] == "forgejo-mcp", "Unexpected server name" - print("✅ Initialize OK") - - # 2. tools/list - print("\n[2] List tools...") - send({"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 2}) - resp = recv() - tools = resp.get("result", {}).get("tools", []) - assert len(tools) > 50, f"Expected >50 tools, got {len(tools)}" - print(f"✅ Tools listed: {len(tools)}") - - # 3. tools/call get_issue - print("\n[3] Call get_issue #110...") - send({ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "get_issue", - "arguments": { - "owner": "UniqueSoft", - "repo": "APAW", - "index": 110 - } - }, - "id": 3 - }) - resp = recv() - print(f"DEBUG: Response received: {resp}") - result_content = resp["result"]["content"] - print(f"DEBUG: Result content: {result_content}") - result_text = result_content[0]["text"] - print(f"DEBUG: Result text: {result_text}") - issue_data = json.loads(result_text) - assert issue_data["number"] == 110, f"Expected issue 110, got {issue_data.get('number')}" - print(f"✅ Issue fetched: #{issue_data['number']} - {issue_data.get('title', 'N/A')}") - - # 4. Verify checkpoint exists in issue body - print("\n[4] Verify checkpoint in issue body...") - assert "## GNS Checkpoint" in (issue_data.get("body") or ""), "Checkpoint not found in issue body" - print("✅ Checkpoint found") - - # 5. Compare with REST API for consistency - print("\n[5] REST API consistency check...") - import urllib.request - import base64 - creds = base64.b64encode(f"{USER}:{PASS}".encode()).decode() - req = urllib.request.Request( - f"{GITEA_API}/issues/110", - headers={"Accept": "application/json", "Authorization": f"Basic {creds}"} - ) - with urllib.request.urlopen(req) as r: - rest_issue = json.loads(r.read()) - assert rest_issue["number"] == issue_data["number"], "MCP and REST issue numbers differ" - assert rest_issue["title"] == issue_data["title"], "MCP and REST issue titles differ" - print("✅ REST API consistent") - - # 6. Close gracefully - print("\n[6] Terminate stdio bridge...") - proc.stdin.close() - proc.wait(timeout=5) - print("✅ Stdio bridge closed") - - print("\n" + "="*60) - print("✅ ALL E2E MCP STDIO TESTS PASSED") - print("="*60) - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as e: - print(f"\n❌ FAILED: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/scripts/mcp-gitea-stdio.cjs b/scripts/mcp-gitea-stdio.cjs deleted file mode 100644 index a545820..0000000 --- a/scripts/mcp-gitea-stdio.cjs +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bun -/** - * mcp-gitea-stdio.cjs - * MCP Stdio Bridge — wraps @ric_/forgejo-mcp for Kilo Code infrastructure - * - * This replaces HTTP↔SSE fallback complexity with direct stdio invocation - * of the official forgejo-mcp package. - * - * Usage: MCP_STDIO_COMMAND="bun scripts/mcp-gitea-stdio.cjs" - * Or: FORGEJO_TOKEN=xxx bun scripts/mcp-gitea-stdio.cjs - */ - -import { spawn } from "child_process" - -const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || process.env.GITEA_TOKEN || "" -const FORGEJO_URL = process.env.FORGEJO_URL || "https://git.softuniq.eu" -const USE_CONTAINER = process.env.USE_MCP_CONTAINER === "1" - -let child = null - -function log(...args) { - // eslint-disable-next-line no-console - console.error("[stdio]", ...args) -} - -log("Starting forgejo-mcp stdio bridge...") - -if (!FORGEJO_TOKEN) { - log("WARNING: FORGEJO_TOKEN not set. MCP tools will fail authentication.") -} - -if (USE_CONTAINER) { - // Spawn Docker container with stdio passthrough - child = spawn( - "docker", ["exec", "-i", "mcp-gitea", "node", "dist/index.js"], - { env: { ...process.env, FORGEJO_TOKEN, FORGEJO_URL } } - ) -} else { - child = spawn( - "bunx", ["@ric_/forgejo-mcp"], - { env: { ...process.env, FORGEJO_URL, FORGEJO_TOKEN, LOG_LEVEL: "warn" } } - ) -} - -process.stdin.pipe(child.stdin) -child.stdout.pipe(process.stdout) -child.stderr.pipe(process.stderr) - -child.on("exit", (code) => { - log("forgejo-mcp exited with code", code) - process.exit(code || 0) -}) - -child.on("error", (err) => { - log("Failed to start forgejo-mcp:", err.message) - process.exit(1) -}) - -process.on("SIGTERM", () => child && child.kill("SIGTERM")) -process.on("SIGINT", () => child && child.kill("SIGINT")) diff --git a/scripts/test-kilo-mcp-integration.py b/scripts/test-kilo-mcp-integration.py deleted file mode 100644 index b096f85..0000000 --- a/scripts/test-kilo-mcp-integration.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env python3 -""" -test-kilo-mcp-integration.py -Тест интеграции MCP через mcp_settings.json (legacy Kilo Code path). -Проверяет конечную цепочку: mcp_settings.json → stdio bridge → forgejo-mcp → Gitea API -""" - -import json -import os -import subprocess -import sys - -MCP_SETTINGS_PATH = os.path.expanduser( - "~/.config/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json" -) - -STDIO_SCRIPT = "/home/swp/Projects/APAW/scripts/mcp-gitea-stdio.cjs" - -def load_settings(): - if not os.path.exists(MCP_SETTINGS_PATH): - raise FileNotFoundError(f"MCP settings not found: {MCP_SETTINGS_PATH}") - with open(MCP_SETTINGS_PATH) as f: - return json.load(f) - -def validate_settings(data): - assert "mcpServers" in data, "Missing mcpServers key" - assert "forgejo-gitea" in data["mcpServers"], "Missing forgejo-gitea server" - srv = data["mcpServers"]["forgejo-gitea"] - assert "command" in srv, "Missing command" - assert "args" in srv, "Missing args" - assert "env" in srv, "Missing env" - print(f"✅ Settings valid: command={srv['command']}, args={srv['args']}") - return srv - -def test_stdio_rpc(server_config): - print("\n[1] Initialize stdio bridge...") - env = {**os.environ, **server_config.get("env", {})} - cmd = [server_config["command"]] + server_config["args"] - - proc = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - cwd="/home/swp/Projects/APAW", - ) - - def send(method, params=None, call_id=1): - req = json.dumps({"jsonrpc": "2.0", "method": method, "params": params or {}, "id": call_id}) + "\n" - proc.stdin.write(req) - proc.stdin.flush() - - def recv(): - line = proc.stdout.readline() - return json.loads(line) if line.strip() else None - - # initialize - send("initialize", { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test-kilo-mcp", "version": "1.0"} - }, 1) - resp = recv() - assert resp and "result" in resp, f"Initialize failed: {resp}" - print("✅ Initialize OK") - - # tools/list - send("tools/list", {}, 2) - resp = recv() - tools = resp.get("result", {}).get("tools", []) - assert len(tools) > 50, f"Expected >50 tools, got {len(tools)}" - print(f"✅ Tools: {len(tools)}") - - # get_issue #110 - print("\n[2] Call get_issue #110...") - send("tools/call", { - "name": "get_issue", - "arguments": {"owner": "UniqueSoft", "repo": "APAW", "index": 110} - }, 3) - resp = recv() - print(f" Raw resp keys: {list(resp.keys())}") - content_arr = resp.get("result", {}).get("content", []) - print(f" Content array len: {len(content_arr)}") - if content_arr: - content_text = content_arr[0].get("text", "") - print(f" Content text len: {len(content_text)}") - if not content_text: - # fallback: parse result directly - content_text = json.dumps(resp.get("result", {})) - else: - content_text = json.dumps(resp.get("result", {})) - assert content_text, "Empty content" - issue = json.loads(content_text) if content_text.startswith("{") else {"raw": content_text} - if "number" not in issue: - # direct result without wrapper - issue = resp.get("result", {}) - assert issue.get("number") == 110, f"Unexpected issue: {issue}" - print(f"✅ Issue #{issue['number']} - {issue.get('title', 'N/A')}") - - # checkpoint - print("\n[3] Verify checkpoint in body...") - assert "## GNS Checkpoint" in (issue.get("body") or ""), "No checkpoint" - print("✅ Checkpoint present") - - # budget/depth check - print("\n[4] Extract checkpoint YAML...") - body = issue.get("body", "") - import re - match = re.search(r"```yaml\n(.*?)\n```", body, re.S) - assert match, "No YAML block in issue body" - yaml_block = match.group(1) - assert "budget:" in yaml_block, "No budget in checkpoint" - assert "depth:" in yaml_block, "No depth in checkpoint" - print("✅ Budget and depth found in checkpoint") - - proc.stdin.close() - proc.wait(timeout=5) - print("✅ Stdio bridge closed cleanly") - -def main(): - print("=" * 60) - print("Kilo Code MCP Integration Test") - print("=" * 60) - print(f"Settings path: {MCP_SETTINGS_PATH}") - - try: - data = load_settings() - srv = validate_settings(data) - test_stdio_rpc(srv) - print("\n" + "=" * 60) - print("✅ ALL KILO MCP INTEGRATION TESTS PASSED") - print("=" * 60) - return 0 - except Exception as e: - print(f"\n❌ FAILED: {e}") - import traceback - traceback.print_exc() - return 1 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/kilocode/agent-manager/mcp-gitea-client.ts b/src/kilocode/agent-manager/mcp-gitea-client.ts deleted file mode 100644 index 04738c7..0000000 --- a/src/kilocode/agent-manager/mcp-gitea-client.ts +++ /dev/null @@ -1,548 +0,0 @@ -// 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 - arguments: Record -} - -export interface MCPResponse { - result?: T - error?: { - code: string - message: string - } -} - -/** - * Stdio-based MCP transport for Kilo Code infrastructure compatibility. - * Spawns a child process and communicates via JSON-RPC over stdin/stdout. - */ -export class MCPGiteaStdioClient { - private child: ChildProcess | null = null - private pending = new Map void; reject: (e: Error) => void }>() - private idCounter = 0 - private initialized = false - private initPromise: Promise | null = null - - constructor(private command: string = MCP_STDIO_COMMAND) {} - - async connect(): Promise { - if (this.initialized) return - if (this.initPromise) return this.initPromise - - this.initPromise = this.doConnect() - return this.initPromise - } - - private doConnect(): Promise { - 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(name: string, args?: Record): Promise { - await this.connect() - const id = ++this.idCounter - return new Promise((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) { - 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 - } - - 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) - } - - 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) - } - - 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) - } - - 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) - } - - 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) - } - - 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) - } - - 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) - } - - 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) - } - - 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() - } -} - -// Backward-compatible alias -export const MCPGiteaClient = MCPGiteaHttpClient - -// ==================== Migration Helper ==================== -/** - * Gradual migration wrapper. - * Falls back to REST API if MCP is unavailable. - */ -import { GiteaClient } from "./gitea-client" - -export class HybridGiteaClient { - private mcp: MCPGiteaHttpClient - private rest: GiteaClient - private useMcp: boolean = false - - constructor(config?: { mcpUrl?: string; restConfig?: any }) { - this.mcp = new MCPGiteaHttpClient(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 - } - } - - private async call( - mcpMethod: (mcp: MCPGiteaHttpClient) => 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) - ) - } -} diff --git a/src/kilocode/agent-manager/pipeline-runner.ts b/src/kilocode/agent-manager/pipeline-runner.ts index b3d80fc..8b8ca49 100644 --- a/src/kilocode/agent-manager/pipeline-runner.ts +++ b/src/kilocode/agent-manager/pipeline-runner.ts @@ -2,13 +2,12 @@ // Pipeline runner - GNS-2 Polling Supervisor for distributed agent workflow import type { AgentRole } from "./index" -import { - GiteaClient, - logPipelineStep, +import { + GiteaClient, + logPipelineStep, logAgentPerformance, - detectRepository + detectRepository } from "./gitea-client" -import { HybridGiteaClient } from "./mcp-gitea-client" export interface PipelineConfig { giteaToken?: string @@ -33,20 +32,16 @@ export interface PipelineResult { } export class PollingSupervisor { - private client: HybridGiteaClient + private client: GiteaClient private efficiencyThreshold: number private autoLog: boolean private initialized: boolean = false private pollInterval: number constructor(config: PipelineConfig = {}) { - // Use Hybrid client: MCP first, REST fallback - this.client = new HybridGiteaClient({ - mcpUrl: config.mcpUrl, // NEW: MCP server URL - restConfig: { - token: config.giteaToken, - apiUrl: config.giteaApiUrl, - } + this.client = new GiteaClient({ + token: config.giteaToken, + apiUrl: config.giteaApiUrl, }) this.efficiencyThreshold = config.efficiencyThreshold ?? 7 this.autoLog = config.autoLog ?? true @@ -55,33 +50,31 @@ export class PollingSupervisor { async initialize(): Promise { if (this.initialized) return - + const { owner, repo } = await detectRepository() - // Hybrid client handles both MCP and REST this.client.setRepository(owner, repo) - await this.client.initialize() // Initialize MCP with fallback this.initialized = true } /** * GNS-2 Polling Supervisor - * + * * Instead of actively dispatching agents in a while-loop, * the supervisor periodically polls Gitea for issues that * need attention based on labels, assignees, and comments. */ async supervise(options: PipelineRunOptions): Promise { await this.initialize() - + const agentsUsed: string[] = [] const errors: string[] = [] let steps = 0 const maxSteps = 100 // Safety limit - + // Main polling loop while (steps < maxSteps) { steps++ - + // Check if issue is locked (circuit breaker) const isLocked = await this.client.isLocked(options.issueNumber) if (isLocked) { @@ -136,7 +129,7 @@ export class PollingSupervisor { if (!agentsUsed.includes(agentName)) { agentsUsed.push(agentName) } - + await this.logEvent( options.issueNumber, '🚀', @@ -145,21 +138,21 @@ export class PollingSupervisor { // Update assignee to target agent await this.client.setAssignee(options.issueNumber, agentName) - + // In GNS-2, the agent itself will read the issue and act // The supervisor just marks that the agent has been triggered // The agent should respond by posting a comment - + } else if (nextAction.type === 'wait') { // Wait for agent to respond await new Promise(resolve => setTimeout(resolve, this.pollInterval)) continue - + } else if (nextAction.type === 'stuck') { // Issue hasn't been updated in a while await this.logEvent(options.issueNumber, '⏰', 'Process appears stuck. Last activity older than threshold.') errors.push('Process stuck') - + } else if (nextAction.type === 'complete') { return { success: errors.length === 0, @@ -193,7 +186,7 @@ export class PollingSupervisor { checkpoint: any | null, lastEvent: any | null ): Promise<{ type: 'invoke_agent' | 'wait' | 'stuck' | 'complete'; agent?: string }> { - + const now = new Date() const lastUpdated = new Date(issue.updated_at) const minutesSinceUpdate = (now.getTime() - lastUpdated.getTime()) / 60000 @@ -213,10 +206,10 @@ export class PollingSupervisor { // Check if next agent has already responded const comments = await this.client.getComments(issue.number) const hasResponded = comments.some( - c => c.user?.login === lastEvent.next_agent || + c => c.user?.login === lastEvent.next_agent || c.body.includes(`## 🔄 ${lastEvent.next_agent}`) ) - + if (!hasResponded) { return { type: 'invoke_agent', agent: lastEvent.next_agent } }