revert: remove MCP Gitea integration, restore direct REST client
Remove all MCP-related infrastructure in favor of direct REST API calls. MCP added layers without value: Docker container, stdio bridge, hybrid fallback, healthchecks, SSE transport — all of which added failure modes and token overhead. Deleted: - docker/mcp-gitea/docker-compose.yml (MCP container config) - scripts/mcp-gitea-stdio.cjs (stdio bridge) - scripts/e2e-mcp-stdio-test*.py (MCP E2E tests) - scripts/test-kilo-mcp-integration.py - src/kilocode/agent-manager/mcp-gitea-client.ts (548 lines of MCP wrapper) - MCP-STDIO-SETUP.md (MCP documentation) - .vscode/settings.json (hardcoded MCP config with token) - .kilo/skills/mcp-gitea-connection/ and mcp-gitea.research.md Restored: - pipeline-runner.ts: HybridGiteaClient → GiteaClient (direct REST) Removed MCP dependency, imports, and initialization. No healthcheck waits, no container startup delays. - process-continuity.md: removed MCP-specific failure modes - e2e-gns2-test.py: removed Basic Auth, use token auth; fixed spec reference
This commit is contained in:
@@ -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
|
||||
<!-- GNS_EVENT: {
|
||||
"type": "system_failure",
|
||||
"failure_point": "mcp_container_startup",
|
||||
"failure_point": "api_connection",
|
||||
"requires_operator": true,
|
||||
"reason": "FORGEJO_TOKEN not set, container cannot connect to Gitea; used dummy token",
|
||||
"reason": "GITEA_TOKEN not set, cannot connect to Gitea API",
|
||||
"recovery_steps": [
|
||||
"Set FORGEJO_TOKEN in docker/mcp-gitea/.env",
|
||||
"Restart: docker compose -f docker/mcp-gitea/docker-compose.yml up -d"
|
||||
"Set GITEA_TOKEN environment variable",
|
||||
"Verify GITEA_API_URL is correct"
|
||||
],
|
||||
"fallback_active": "REST API (gitea-client.ts)",
|
||||
"fallback_active": "Local file logging",
|
||||
"timestamp": "2026-05-08T22:23:00Z"
|
||||
} -->
|
||||
```
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
15
.vscode/settings.json
vendored
15
.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 для удалённых серверов.
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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())
|
||||
@@ -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<string, any>
|
||||
}
|
||||
|
||||
export interface MCPResponse <T> {
|
||||
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<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) {
|
||||
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
|
||||
}
|
||||
|
||||
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<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()
|
||||
}
|
||||
}
|
||||
|
||||
// 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<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
|
||||
}
|
||||
}
|
||||
|
||||
private async call<T>(
|
||||
mcpMethod: (mcp: MCPGiteaHttpClient) => 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<PipelineResult> {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user