diff --git a/.gitignore b/.gitignore index ddad418..248e315 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,9 @@ __pycache__/ .worktrees/ # Internal Planning -docs/planning/ \ No newline at end of file +docs/planning/ + +# Go build artifacts +bin/ +*.test +coverage.out \ No newline at end of file diff --git a/cmd/rune-mcp/main.go b/cmd/rune-mcp/main.go index 0a56c35..50b61fc 100644 --- a/cmd/rune-mcp/main.go +++ b/cmd/rune-mcp/main.go @@ -6,36 +6,69 @@ // Tools: 8 MCP tools (capture, recall, batch_capture, capture_history, // delete_capture, vault_status, diagnostics, reload_pipelines). // +// Phase A (current): MCP handshake + tools/list only. All 8 handlers return +// "not yet implemented" CallToolResult. RunBootLoop · Vault · envector · +// embedder are not wired. Phase 4-5 brings real adapters + service logic. +// // Python reference: mcp/server/server.py (2002 LoC) package main import ( "context" - "log" + "errors" + "log/slog" "os" "os/signal" "syscall" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/envector/rune-go/internal/mcp" ) +// version is the rune-mcp protocol version surfaced in MCP `initialize`. +// Phase A is "0.4.0-alpha" until adapters are wired. +const version = "0.4.0-alpha" + func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // TODO Phase 1: config.Load("~/.rune/config.json") — 3-section schema - // TODO Phase 2: lifecycle.RunBootLoop(ctx, deps) — Vault.GetPublicKey retry - // TODO Phase 3: mcp.NewServer + RegisterTools + Serve(stdio) - // TODO Phase 4: graceful shutdown (30s) on stdin EOF / SIGTERM - // - // Python reference: server.py:main() + RunMCPServer lifecycle. - + // SIGINT / SIGTERM → cancel ctx → srv.Run unblocks. + // stdin EOF (Claude window closed) also unblocks Run via the StdioTransport. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + select { + case <-sigCh: + cancel() + case <-ctx.Done(): + } + }() - log.Println("rune-mcp skeleton — not yet implemented") + // Phase A: empty Deps. RunBootLoop / config.Load / adapter wiring deferred. + deps := &mcp.Deps{} - select { - case <-ctx.Done(): - case <-sigCh: - cancel() + srv := sdkmcp.NewServer(&sdkmcp.Implementation{ + Name: "rune-mcp", + Version: version, + }, nil) + + if err := mcp.Register(srv, deps); err != nil { + slog.Error("rune-mcp register failed", "err", err) + os.Exit(1) + } + + if err := srv.Run(ctx, &sdkmcp.StdioTransport{}); err != nil && !isNormalShutdown(err) { + slog.Error("rune-mcp serve error", "err", err) + os.Exit(1) } } + +// isNormalShutdown reports whether err corresponds to expected stdio teardown. +// The SDK's `Connection.Wait` filters io.EOF to nil before returning, so on +// stdin EOF Run returns nil. The only other expected exit is ctx cancel from +// SIGINT/SIGTERM, which surfaces as context.Canceled. +func isNormalShutdown(err error) bool { + return err == nil || errors.Is(err, context.Canceled) +} diff --git a/docs/v04/README.md b/docs/v04/README.md index f363910..89252dd 100644 --- a/docs/v04/README.md +++ b/docs/v04/README.md @@ -40,9 +40,14 @@ docs/v04/ │ │ └── envector.md # envector-go SDK │ └── python-mapping.md # Python 파일/LoC → Go 구조 매핑 │ -└── notes/ # 내부 작업 노트 (참고용) - ├── verification-matrix.md # Python↔Go bit-identical 대조 검증 로그 - └── implementability-report.md # Go 개발자 진입 가능성 검증 리포트 +├── notes/ # 내부 작업 노트 (참고용) +│ ├── verification-matrix.md # Python↔Go bit-identical 대조 검증 로그 +│ ├── implementability-report.md # Go 개발자 진입 가능성 검증 리포트 +│ └── flow-matrix.md # 10 flow × 파일 매트릭스 + Tier S/A/B 공통 모듈 +│ +└── progress/ # 실제 개발 진행 추적 (vertical slice 단위) + ├── README.md # 인덱스 + 마일스톤별 상태표 + └── phase-a-mcp-boot.md # MCP handshake + tools/list (`19b7bf6`) ``` ## 읽는 순서 diff --git a/docs/v04/notes/flow-matrix.md b/docs/v04/notes/flow-matrix.md index 64dd844..ab86ee8 100644 --- a/docs/v04/notes/flow-matrix.md +++ b/docs/v04/notes/flow-matrix.md @@ -75,6 +75,8 @@ | 11 | `adapters/logio/capture_log.go::Append` | 3/10 | write tool 3개 (CAP·BAT·DEL) | > **TM** = teammate scope. 타입 시그니처는 팀원 확정에 의존하지만, 내 구현 코드의 거의 모든 경로가 이 에러 타입을 참조한다. +> +> **현재 상태 (2026-04-25, Phase A 합격 후)**: Tier S 1·2·4 (`Deps`, `obs/slog`, `lifecycle/boot`)는 본 매트릭스가 "stdlib-only로 즉시 시작 가능"이라 분류했지만, 실제로는 Phase A가 vertical slice 우선 (SDK 연결)로 진행되며 **셋 다 still skeleton 상태**다. `Deps`는 빈 struct, `obs/slog`은 `NewRequestID() == ""` stub, `lifecycle/boot.go::RunBootLoop`은 `_ = ctx`. Phase A.5 또는 Phase 4 진입 전에 보강 필요. 자세한 phase별 상태는 [`docs/v04/progress/`](../progress/). --- diff --git a/docs/v04/progress/README.md b/docs/v04/progress/README.md new file mode 100644 index 0000000..8004e95 --- /dev/null +++ b/docs/v04/progress/README.md @@ -0,0 +1,46 @@ +# `progress/` — 실제 개발 진행 추적 + +본 디렉토리는 **`docs/v04/`의 spec(How)·overview(Why)와 별개로**, 실제 구현이 어디까지 진행됐는지·어떤 단면이 동작하는지·어떻게 검증할 수 있는지를 시간순으로 기록한다. + +- **spec/** — "어떻게 만들어야 하는가" (변하지 않는 계약) +- **overview/** — "왜 이렇게 만드는가" (결정·근거) +- **notes/** — bit-identical 검증 로그 등 일회성 작업 노트 +- **progress/** ← **여기**: "지금 어디까지 동작하는가 + 어떻게 직접 확인하는가" + +## 진행 추적 단위 + +README의 7-Phase 로드맵(Phase 1 외부 deps → Phase 7 검증)이 **horizontal slice**라면, progress 문서는 **vertical slice** 단위로도 작성될 수 있다. 예를 들어 "MCP handshake만 통과시키는 Phase A"는 7-Phase 어디에도 정확히 매핑되지 않지만, end-to-end 단면이 동작하는 **첫 마일스톤**으로서 별도 문서를 갖는다. + +## 문서 명명 규칙 + +- 하나의 마일스톤 = 하나의 파일 = `-<짧은 설명>.md` + - `phase-a-mcp-boot.md` (handshake + tools/list) + - `phase-1-deps.md` (외부 deps 추가) + - `phase-4a-vault-client.md` (Phase 4 중 Vault 부분) +- 각 문서 상단에 **관련 커밋 SHA · 브랜치 · PR 링크** 명시 +- "기능" + "확인법(여러 레벨)" 두 섹션은 필수 +- "한계" + "Troubleshooting"은 권장 + +## 현재 인덱스 + +| 마일스톤 | 상태 | 문서 | 관련 커밋 | +|---|---|---|---| +| Phase A — MCP boot (handshake + tools/list) | ✅ 합격 | [phase-a-mcp-boot.md](phase-a-mcp-boot.md) | `19b7bf6` (브랜치 `yg/first-mcp-boot`) | +| Phase A.5 — smoke test 추가 (CI 회귀 방지) | ⏳ 예정 | — | — | +| Phase B — `rune_diagnostics` environment 섹션 진짜 응답 (stdlib only) | ⏳ 예정 | — | — | +| Phase 1 — `go.mod` 외부 deps 본격 추가 (gRPC · envector SDK · embedder proto) | ⏳ 예정 | — | — | +| Phase 2 — `internal/domain` + `internal/policy` 순수 로직 (TM scope) | ⏳ 예정 | — | — | +| Phase 3 — `record_builder` 703 LoC + `payload_text` 364 LoC 포팅 (TM scope) | ⏳ 예정 | — | — | +| Phase 4a — Vault 클라이언트 + 부팅 시퀀스 연결 | ⏳ 예정 | — | — | +| Phase 4b — envector SDK 연결 (Q4 PR 머지 후) | ⏳ 예정 | — | — | +| Phase 4c — embedder 클라이언트 | ⏳ 예정 | — | — | +| Phase 5 — service 레이어 오케스트레이션 (`stubHandler` → 실제 service 호출) | ⏳ 예정 | — | — | +| Phase 7 — golden fixture 기반 bit-identical 검증 | ⏳ 예정 | — | — | + +> Phase 6 (MCP wiring)은 Phase A에서 부분 선행됐으므로 별도 마일스톤으로 빼지 않음. Phase 5의 service 호출 교체에 흡수됨. + +## 사용 방식 + +- **개발자**: 새 vertical slice를 시작할 때 이 디렉토리에 새 파일을 만들고, 합격 기준을 명시한 뒤 그 기준에 맞춰 작업한다. 파일은 PR과 함께 같은 커밋에 묶는다. +- **리뷰어**: PR을 받았을 때 이 문서의 "확인법"을 그대로 따라 해보면 변경사항이 제대로 동작하는지 검증할 수 있다. +- **다른 팀원**: 어디까지 동작하고 어디부터 미구현인지 한 곳에서 본다 (spec과 다름 — spec은 "최종 모양", progress는 "지금 모양"). diff --git a/docs/v04/progress/phase-a-mcp-boot.md b/docs/v04/progress/phase-a-mcp-boot.md new file mode 100644 index 0000000..6788666 --- /dev/null +++ b/docs/v04/progress/phase-a-mcp-boot.md @@ -0,0 +1,182 @@ +# Phase A — MCP boot + +> ✅ 통과 (2026-04-25) · 커밋 `19b7bf6` + `e80d6ea` · 브랜치 `yg/first-mcp-boot` +> 핵심 파일 2개: `cmd/rune-mcp/main.go` (74줄) · `internal/mcp/tools.go` (134줄) + +## 1. 한 줄 요약 + +`rune-mcp` Go 바이너리가 **MCP 프로토콜 표면**(handshake + tools/list + tools/call)만 살아있는 상태. 외부 deps 0, 비즈니스 로직 0, 8개 tool 모두 stub. + +**가치** — Claude Code가 이 바이너리를 spawn 하면 8 tool 카탈로그를 정상 인식. 이후 phase는 stub 본체만 채우면 끝. 검증 회로는 매 phase 재사용. + +## 2. 동작하는 것 / 안 하는 것 + +**동작** + +- `go build` 한 번에 8.3 MB 정적 바이너리 (Go 1.25+) +- MCP `initialize` handshake — `serverInfo: rune-mcp 0.4.0-alpha` +- `tools/list` — 8 tool 광고 (input/output schema는 Go struct에서 자동 추론) +- `tools/call` — 모두 `isError:true` + "not yet implemented" 응답 (JSON-RPC 자체는 valid) +- 종료: stdin EOF · SIGINT · SIGTERM 모두 exit 0 + +**안 함 (의도된 한계)** + +| 영역 | 가능 시점 | +|---|---| +| 비즈니스 로직 (capture/recall 등) | Phase 5 | +| Vault / envector / embedder adapter | Phase 4 | +| `lifecycle.Manager` 상태 머신 | Phase 4 | +| `config.json` 로딩, `capture_log.jsonl` IO | Phase 4 | +| `request_id` 로깅, `SensitiveFilter` redaction | Phase 4 | + +→ Phase A의 정확한 범위는 **"MCP 프로토콜 표면이 정상 동작한다"** 까지. + +## 3. 8 tool 카탈로그 + +``` +rune_batch_capture, rune_capture, rune_capture_history, rune_delete_capture, +rune_diagnostics, rune_recall, rune_reload_pipelines, rune_vault_status +``` + +(SDK가 알파벳순 정렬해 광고 — Python 원본과 bit-identical한 8 이름) + +각 tool의 input/output schema는 `internal/domain/*` · `internal/service/*` 의 Go struct에서 SDK가 자동 추론. **Go 타입 = MCP API 계약**, 별도 IDL 없음. + +## 4. 검증 — 5분 컷 + +### 4.1. 빌드 & 헬스 체크 + +```bash +cd +go build -o bin/rune-mcp ./cmd/rune-mcp +./bin/rune-mcp < /dev/null; echo "exit=$?" # → exit=0 +``` + +### 4.2. MCP 시퀀스 — `tools/list` 까지 + +순서: `initialize` → `notifications/initialized` → `tools/list` + +```bash +{ + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' + sleep 0.3 + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' + sleep 0.1 + printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + sleep 0.5 +} | ./bin/rune-mcp 2>/dev/null | jq -r 'select(.id==2) | .result.tools[].name' +``` + +**기대**: 위 §3의 8 이름. + +### 4.3. tool 한 개 호출 (stub 응답) + +```bash +{ + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' + sleep 0.3 + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' + sleep 0.1 + printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"rune_diagnostics","arguments":{}}}' + sleep 0.5 +} | ./bin/rune-mcp 2>/dev/null | jq 'select(.id==3).result | {isError, text:.content[0].text}' +``` + +**기대**: `isError:true`, text는 `"rune_diagnostics is not yet implemented..."`. + +> **MCP framing 핵심 3개** +> ① `initialize` → `notifications/initialized` → `tools/*` **순서 필수** +> ② 각 메시지는 `\n` 종결 (LSP의 Content-Length 미사용) +> ③ 마지막 `sleep 0.3~0.5` 없으면 EOF로 응답 끊김 + +### 4.4. Claude Code 등록 (선택) + +`~/.claude/mcp.json`: + +```json +{ + "mcpServers": { + "rune-go-dev": { + "command": "/bin/rune-mcp" + } + } +} +``` + +> `` 는 본인 체크아웃 경로의 **절대 경로**로 치환 (`~`/상대경로 미지원). + +Claude 재시작 후 `/mcp` 에서 `rune-go-dev` 가 connected 표시되면 합격. tool 호출하면 빨간 "not implemented" 응답 — **이게 정상**. + +> ⚠️ 기존 Python `envector` MCP가 같은 8 이름 광고. namespace는 `mcp__rune-go-dev__*` vs `mcp__envector__*` 로 분리돼 충돌은 없지만 카탈로그가 중복 보임. + +### 4.5. MCP Inspector (시각적, 선택) + +```bash +npx -y @modelcontextprotocol/inspector ./bin/rune-mcp +``` + +브라우저(`localhost:6274`)에 8 tool 리스트 + schema + raw JSON-RPC. + +## 5. 8 tool 한 번씩 호출 — `mcp_call` 헬퍼 + +현재 셸에 paste: + +```bash +mcp_call() { + local tool="$1"; local args="$2"; [ -z "$args" ] && args='{}' + { + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' + sleep 0.3 + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' + sleep 0.1 + printf '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"%s","arguments":%s}}\n' "$tool" "$args" + sleep 0.5 + } | ./bin/rune-mcp 2>/dev/null | jq -c 'select(.id==2)' +} +``` + +> ⚠️ default value를 `${2:-{}}` 로 쓰면 bash parameter expansion이 깨짐. 위 `[ -z ... ] && args='{}'` 패턴 필수. + +```bash +# 인자 없는 4개 +mcp_call rune_diagnostics +mcp_call rune_vault_status +mcp_call rune_reload_pipelines +mcp_call rune_capture_history + +# 인자 필수 4개 +mcp_call rune_recall '{"query":"hello"}' +mcp_call rune_capture '{"text":"hi","source":"test","extracted":{}}' +mcp_call rune_delete_capture '{"record_id":"dec_test"}' +mcp_call rune_batch_capture '{"items":"[]"}' +``` + +8개 모두 동일한 stub 응답. + +## 6. Troubleshooting + +| 증상 | 해결 | +|---|---| +| `go build`: `go >= 1.25.0 required` | `go install golang.org/dl/go1.25@latest && go1.25 download` 또는 brew 업그레이드 | +| `go build`: `missing go.sum entry` | `go mod tidy` 후 재빌드 | +| 응답이 안 옴 / 끊김 | 마지막 `sleep` 을 0.5+ 로 | +| Claude Code 에서 tool 미인식 | `cat ~/.claude/mcp.json \| jq .` 로 JSON 검증 + `chmod +x bin/rune-mcp` + 절대 경로 | +| coproc syntax error (macOS bash 3.2) | `brew install bash` 또는 zsh 사용 | + +## 7. 다음 마일스톤 + +**가벼운 후속** + +- **Phase A.5 (smoke test)** — `internal/mcp/register_test.go` 에 `mcp.NewInMemoryTransports()` 로 in-memory 서버 띄워 tools/list 8개 회귀 가드. ~50 LoC, CI에서 `AddTool` schema-inference 회귀 자동 감지 +- **Phase B** — `rune_diagnostics` environment 섹션 stdlib 응답 (`runtime.GOOS` · `runtime.Version` · `os.Getwd`). 첫 진짜 응답 흐름, 2-3시간 PR + +**7-Phase 로드맵 본격 진입** (서로 병렬 가능) + +- **Phase 1** — 외부 deps (gRPC · protobuf · envector-go SDK · embedder proto stub) +- **Phase 2** — `internal/domain` + `internal/policy` 순수 로직 (TM scope, 외부 deps 0) +- **Phase 3** — `record_builder` 703 LoC + `payload_text` 364 LoC 라인 단위 포팅 +- **Phase 4a/b/c** — Vault / envector / embedder adapter (Phase 1 머지 후) +- **Phase 5** — service 오케스트레이션. `stubHandler` → `service.X.Handle` 교체 +- **Phase 7** — 검증 (golden fixture byte-identical · bufconn · Python↔Go shadow run) + +→ 의존성 그래프는 `flow-matrix.md §5-d`. Phase 6은 본 문서가 부분 선행이라 Phase 5에 흡수. diff --git a/go.mod b/go.mod index c8f9c9b..499790f 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,23 @@ module github.com/envector/rune-go -go 1.24 +go 1.25.9 -// External dependencies to be added as implementation progresses: +// External dependencies, in implementation order: // -// github.com/modelcontextprotocol/go-sdk v1.5.0 — MCP protocol (D2) -// google.golang.org/grpc v1.65.0 — Vault / envector / embedder clients -// google.golang.org/protobuf v1.34.0 — generated stubs -// github.com/CryptoLabInc/envector-go-sdk — envector FHE client (Q4 PR pending) +// github.com/modelcontextprotocol/go-sdk v1.5.0 — MCP protocol (D2) ✅ Phase A +// google.golang.org/grpc v1.65.0 — Vault / envector / embedder clients (Phase 4) +// google.golang.org/protobuf v1.34.0 — generated stubs (Phase 4) +// github.com/CryptoLabInc/envector-go-sdk — envector FHE client (Q4 PR pending) // -// Skeleton stage: stdlib only. No external imports yet. +// go 1.25.0 + toolchain pin required by the MCP SDK. + +require github.com/modelcontextprotocol/go-sdk v1.5.0 + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cdfab14 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= +github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index bc277e5..08c263e 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -1,107 +1,159 @@ -// Package mcp holds the 8 MCP tool handlers + stdio server wiring. +// Package mcp wires the 8 MCP tool handlers onto the official Go SDK and +// owns Deps injection + state-aware response shaping. +// // Spec: -// docs/v04/spec/flows/capture.md (7-phase capture) -// docs/v04/spec/flows/recall.md (7-phase recall) -// docs/v04/spec/flows/lifecycle.md (6 other tools) -// Python: mcp/server/server.py (2002 LoC). +// docs/v04/spec/components/rune-mcp.md (MCP server 구현) +// docs/v04/spec/flows/{capture,recall,lifecycle}.md +// +// SDK: github.com/modelcontextprotocol/go-sdk v1.5.0+ (D2). Stdio transport. +// Input schema is auto-inferred from the Go input struct (jsonschema tags +// optional but recommended; will be tightened in Phase 5). // -// MCP SDK: github.com/modelcontextprotocol/go-sdk v1.5.0+ (D2). -// Stdio transport, tools/call dispatch. Input schema auto-generated from Go -// structs with jsonschema tags. +// Phase A (current): handshake + tools/list only. Every handler returns a +// stubResult ("not yet implemented") so Claude Code can discover the catalog +// without any adapter being wired. Phase 5 replaces each stub with a +// service-layer call (CheckState → service.X.Handle → response wrap). package mcp import ( "context" + "fmt" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/envector/rune-go/internal/domain" + "github.com/envector/rune-go/internal/service" ) -// Deps — injected into all handlers. TODO: fill as adapters stabilize. -type Deps struct { - // Vault vault.Client - // Envector envector.Client - // Embedder embedder.Client - // CaptureLog *logio.CaptureLog - // State *lifecycle.Manager - // Cfg *config.Config -} - -// ───────────────────────────────────────────────────────────────────────────── -// 8 MCP tools — Python bit-identical names/shapes -// ───────────────────────────────────────────────────────────────────────────── +// Deps — injected into all 8 MCP handlers. +// +// Phase A: empty struct. Adapter clients · state machine · config will be +// added as Phase 4 (adapters) and Phase 5 (service orchestration) land. +// stubHandler already takes deps as an argument, so Phase 5 will only need +// to swap the closure body, not the signature. +// +// Future fields (commented as a contract sketch — to be activated as the +// owning adapter PR lands): +// +// Vault vault.Client +// Envector envector.Client +// Embedder embedder.Client +// CaptureLog *logio.CaptureLog +// State *lifecycle.Manager +// Cfg *config.Config +type Deps struct{} -// ToolCapture — rune_capture. Python: server.py:L698-806 + L1208-1407 _capture_single. -// 7-phase flow (spec/flows/capture.md). -func ToolCapture(ctx context.Context, deps *Deps, req *domain.CaptureRequest) (*domain.CaptureResponse, error) { - // TODO Phase 1: state gate - // TODO Phase 2: validate + tier2 check + text_to_embed pick - // TODO Phase 3: embedder.EmbedSingle(text) - // TODO Phase 4: envector.Score + Vault.DecryptScores → novelty (D11 thresholds) - // TODO Phase 5: record_builder.BuildPhases + embedder.EmbedBatch + AES Seal - // TODO Phase 6: envector.Insert (batch) - // TODO Phase 7: capture_log append + respond - return nil, nil -} +// emptyArgs — input type for tools that take no arguments. +type emptyArgs struct{} -// ToolRecall — rune_recall. Python: server.py:L910-1034. 7-phase. -func ToolRecall(ctx context.Context, deps *Deps, args *domain.RecallArgs) (*domain.RecallResult, error) { - // TODO Phase 1: state gate + topk validation (max 10) - // TODO Phase 2: policy.Parse (English only — D21) - // TODO Phase 3: embedder.EmbedBatch(expansions[:3]) (D22/D23) - // TODO Phase 4: sequential 4-RPC per expansion (D25): Score → DecryptScores - // → GetMetadata → DecryptMetadata (service layer calls Vault directly) - // TODO Phase 5: metadata 3-way classify (AES/plain/legacy base64 per D26) - // TODO Phase 6: phase_chain expansion (D27) + group assemble + filter - // + recency weighting (half-life 90d, status mul) - // TODO Phase 7: build response (synthesized=false per D28) - return nil, nil -} +// Register binds all 8 MCP tools onto the provided SDK server. +// +// Tool names are bit-identical to Python `mcp/server/server.py`. SDK sorts +// tools alphabetically in `tools/list` output, so order here is for readability. +// +// Failure modes that Register surfaces as a startup error (via panic + +// recover): +// 1. mustAddTool name validation (SDK's validateToolName has a log-only +// branch — server.go:238-241 — that we bypass by panicking up-front). +// 2. SDK schema-inference panic (toolForErr). +// 3. SDK schema-shape panic (Server.AddTool). +// +// Result: every registration either succeeds completely or returns an error. +// No silent half-registrations. +func Register(srv *sdkmcp.Server, deps *Deps) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("mcp.Register: %v", r) + } + }() -// ToolBatchCapture — rune_batch_capture. Python: server.py:L810-896. -// Per-item independent processing (skipped/captured/near_duplicate/error). -func ToolBatchCapture(ctx context.Context, deps *Deps, items []domain.CaptureRequest) (any, error) { - // TODO: per-item _capture_single call + summary (captured/skipped/errors) - return nil, nil -} + // Write tools (state gate applies in Phase 5). + mustAddTool[domain.CaptureRequest, domain.CaptureResponse](srv, deps, + "rune_capture", + "Capture a decision record (agent-delegated extraction required).") + mustAddTool[service.BatchCaptureArgs, service.BatchCaptureResult](srv, deps, + "rune_batch_capture", + "Capture a batch of decision records (e.g. session-end sweep).") + mustAddTool[domain.RecallArgs, domain.RecallResult](srv, deps, + "rune_recall", + "Query organizational memory by natural-language question.") + mustAddTool[service.DeleteCaptureArgs, service.DeleteCaptureResult](srv, deps, + "rune_delete_capture", + "Soft-delete a record by ID (sets status=reverted, re-inserts).") -// ToolCaptureHistory — rune_capture_history. Python: server.py:L1092-1111. -// Read ~/.rune/capture_log.jsonl reverse, filter by domain/since, limit (default 20, max 100). -func ToolCaptureHistory(ctx context.Context, deps *Deps, limit int, domainFilter, since *string) (any, error) { - // TODO: logio.Tail - return nil, nil -} + // Read / diagnostic tools (state gate bypass). + mustAddTool[service.CaptureHistoryArgs, service.CaptureHistoryResult](srv, deps, + "rune_capture_history", + "List recent captures from local capture_log.jsonl (read-only).") + mustAddTool[emptyArgs, service.VaultStatusResult](srv, deps, + "rune_vault_status", + "Probe Vault connectivity and report secure-search mode.") + mustAddTool[emptyArgs, service.DiagnosticsResult](srv, deps, + "rune_diagnostics", + "Collect a 7-section health snapshot (env / state / vault / keys / pipelines / embedding / envector).") + mustAddTool[emptyArgs, service.ReloadPipelinesResult](srv, deps, + "rune_reload_pipelines", + "Re-initialize Vault + envector pipelines (BOOT replay) with envector warmup.") -// ToolDeleteCapture — rune_delete_capture. Python: server.py:L1123-1206. -// Soft-delete: search_by_id → set status=reverted → re-embed → re-insert → log. -func ToolDeleteCapture(ctx context.Context, deps *Deps, recordID string) (any, error) { - // TODO: soft-delete flow - return nil, nil + return nil } -// ToolVaultStatus — rune_vault_status. Python: server.py:L496-528. Read-only. -func ToolVaultStatus(ctx context.Context, deps *Deps) (any, error) { - // TODO: vault.HealthCheck + mode/endpoint response - return nil, nil +// mustAddTool wraps sdkmcp.AddTool with up-front name validation. +// +// The SDK's Server.AddTool only LOGS on invalid tool names +// (go-sdk/mcp/server.go:238-241) — it does not panic, so Register's +// defer recover() would miss it and the bad-named tool would silently +// register. mustAddTool panics on invalid names, unifying the failure +// path so recover() catches everything. +func mustAddTool[In, Out any](srv *sdkmcp.Server, deps *Deps, name, description string) { + if !isValidToolName(name) { + panic(fmt.Errorf("mustAddTool: invalid tool name %q (allowed: [A-Za-z0-9_-], 1..128 chars)", name)) + } + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: name, + Description: description, + }, stubHandler[In, Out](deps, name)) } -// ToolDiagnostics — rune_diagnostics. Python: server.py:L540-684. -// 7 sections: environment / state / vault / keys / pipelines / embedding / envector. -func ToolDiagnostics(ctx context.Context, deps *Deps) (any, error) { - // TODO: collect 7 sections + 5s envector timeout - return nil, nil +// isValidToolName mirrors the SDK's validateToolName rules +// (go-sdk/mcp/tool.go:109): non-empty, ≤128 chars, only [A-Za-z0-9_-]. +// Update this when bumping the SDK if its validation tightens. +func isValidToolName(name string) bool { + if name == "" || len(name) > 128 { + return false + } + for _, r := range name { + ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '_' || r == '-' + if !ok { + return false + } + } + return true } -// ToolReloadPipelines — rune_reload_pipelines. Python: server.py:L1046-1089. -// Re-init scribe/retriever pipelines + envector GetIndexList warmup (60s timeout). -func ToolReloadPipelines(ctx context.Context, deps *Deps) (any, error) { - // TODO: AwaitInitDone + ReinitPipelines + 60s warmup - return nil, nil +// stubHandler returns a SDK ToolHandlerFor that always responds with a +// not-yet-implemented isError result. Output type is preserved so tools/list +// can still publish the inferred output schema. +// +// deps is captured but unused in Phase A. Phase 5 will dereference it for +// CheckState / service dispatch — the closure shape stays the same. +func stubHandler[In, Out any](deps *Deps, toolName string) sdkmcp.ToolHandlerFor[In, Out] { + _ = deps // captured for Phase 5; intentionally unused now + return func(_ context.Context, _ *sdkmcp.CallToolRequest, _ In) (*sdkmcp.CallToolResult, Out, error) { + var zero Out + return stubResult(toolName), zero, nil + } } -// Register — called from main() to bind all 8 tools to the MCP SDK server. -// TODO: uses github.com/modelcontextprotocol/go-sdk/mcp.AddTool for each. -func Register(/* srv *mcp.Server, */ deps *Deps) { - // TODO: - // mcp.AddTool(srv, &mcp.Tool{Name: "rune_capture", ...}, ToolCapture adapter) - // ... 8 tools +// stubResult composes the Phase-A "not implemented" response. +func stubResult(toolName string) *sdkmcp.CallToolResult { + return &sdkmcp.CallToolResult{ + IsError: true, + Content: []sdkmcp.Content{ + &sdkmcp.TextContent{ + Text: toolName + " is not yet implemented (skeleton phase A — MCP handshake + tools/list only).", + }, + }, + } } diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go new file mode 100644 index 0000000..3d09725 --- /dev/null +++ b/internal/mcp/tools_test.go @@ -0,0 +1,45 @@ +// Internal-package tests for the panic-guard wrapper around sdkmcp.AddTool. +// (Phase A.5 in-memory smoke is in register_test.go which uses package mcp_test.) + +package mcp + +import ( + "strings" + "testing" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestIsValidToolName(t *testing.T) { + if !isValidToolName("rune_capture") { + t.Error("rune_capture should be valid") + } + if isValidToolName("") { + t.Error("empty should be invalid") + } + if isValidToolName("rune capture") { + t.Error("name with space should be invalid") + } + if isValidToolName(strings.Repeat("a", 129)) { + t.Error("name >128 chars should be invalid") + } +} + +func TestMustAddTool_PanicsOnInvalidName(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("mustAddTool with invalid name did not panic") + } + }() + srv := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "x", Version: "0"}, nil) + mustAddTool[emptyArgs, emptyArgs](srv, &Deps{}, "rune capture", "test") +} + +func TestRegister_AllHardcodedNamesValid(t *testing.T) { + // Sanity: Register's 8 hardcoded names all pass mustAddTool's check. + // Catches an accidental typo in tools.go before Phase A.5 integration test runs. + srv := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "x", Version: "0"}, nil) + if err := Register(srv, &Deps{}); err != nil { + t.Errorf("Register returned error: %v", err) + } +}