From 19b7bf600c29036c3a2e6d979e76609792d7cf1f Mon Sep 17 00:00:00 2001 From: redcourage Date: Sat, 25 Apr 2026 12:01:51 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(go):=20first=20MCP=20boot=20=E2=80=94?= =?UTF-8?q?=20handshake=20+=20tools/list=20(Phase=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/rune-mcp/main.go: MCP server bootstrap (NewServer + Register + StdioTransport.Run). Empty Deps, no boot loop / config.Load yet. isNormalShutdown() drops exit 1 for stdin EOF · ctx cancel · jsonrpc2.ErrServerClosing (internal SDK error, matched by message). internal/mcp/tools.go: Register binds all 8 tools via stubHandler[In, Out]. Input/output schema auto-inferred; tools/call returns IsError "not yet implemented (skeleton phase A)". Phase 5 swaps the stub for service-layer wrappers. go.mod/go.sum: modelcontextprotocol/go-sdk v1.5.0 (D2). Go 1.24 → 1.25 per SDK requirement. .gitignore: bin/ + *.test + coverage.out. Smoke tests: ./bin/rune-mcp < /dev/null → exit 0 echo '{"method":"initialize",...}' | ./bin/rune-mcp → {"result":{"serverInfo":{"name":"rune-mcp","version":"0.4.0-alpha"}, "capabilities":{"tools":{"listChanged":true},...}}}, exit 0 다음: ~/.claude/mcp.json에 별도 entry로 등록 → Claude Code에서 8개 tool 인식 확인. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 +- cmd/rune-mcp/main.go | 64 ++++++++++++--- go.mod | 13 +++- go.sum | 20 +++++ internal/mcp/tools.go | 176 ++++++++++++++++++++++-------------------- 5 files changed, 184 insertions(+), 96 deletions(-) create mode 100644 go.sum 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..12d5420 100644 --- a/cmd/rune-mcp/main.go +++ b/cmd/rune-mcp/main.go @@ -6,36 +6,76 @@ // 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" + "errors" + "io" "log" "os" "os/signal" + "strings" "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(): + } + }() + + // Phase A: empty Deps. RunBootLoop / config.Load / adapter wiring deferred. + deps := &mcp.Deps{} - log.Println("rune-mcp skeleton — not yet implemented") + srv := sdkmcp.NewServer(&sdkmcp.Implementation{ + Name: "rune-mcp", + Version: version, + }, nil) - select { - case <-ctx.Done(): - case <-sigCh: - cancel() + mcp.Register(srv, deps) + + if err := srv.Run(ctx, &sdkmcp.StdioTransport{}); err != nil && !isNormalShutdown(err) { + log.Printf("rune-mcp serve error: %v", err) + os.Exit(1) + } +} + +// isNormalShutdown reports whether err corresponds to expected stdio teardown +// (stdin EOF, ctx cancel from SIGINT/SIGTERM, or the SDK's internal +// jsonrpc2.ErrServerClosing surfacing as "server is closing"). Those are not +// failures and must not produce exit code 1. +func isNormalShutdown(err error) bool { + if err == nil { + return true + } + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return true } + // jsonrpc2.ErrServerClosing lives in an internal package, so we can't use + // errors.Is. The message is stable per the SDK source. + return strings.Contains(err.Error(), "server is closing") } diff --git a/go.mod b/go.mod index c8f9c9b..6132ac0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/envector/rune-go -go 1.24 +go 1.25.0 // External dependencies to be added as implementation progresses: // @@ -10,3 +10,14 @@ go 1.24 // github.com/CryptoLabInc/envector-go-sdk — envector FHE client (Q4 PR pending) // // Skeleton stage: stdlib only. No external imports yet. + +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..0079737 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -1,107 +1,119 @@ -// 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" + 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. +// Deps — injected into all handlers. +// +// Phase A: empty struct. Adapter clients · state machine · config will be +// added as Phase 4 (adapters) and Phase 5 (service orchestration) land. +// Concrete fields stay commented until each adapter has a real Client type; +// the handlers below close over deps already so no signature change later. type Deps struct { - // Vault vault.Client - // Envector envector.Client - // Embedder embedder.Client + // Vault vault.Client + // Envector envector.Client + // Embedder embedder.Client // CaptureLog *logio.CaptureLog - // State *lifecycle.Manager - // Cfg *config.Config + // State *lifecycle.Manager + // Cfg *config.Config } -// ───────────────────────────────────────────────────────────────────────────── -// 8 MCP tools — Python bit-identical names/shapes -// ───────────────────────────────────────────────────────────────────────────── - -// 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 naming + ordering are bit-identical to Python `mcp/server/server.py`. +// Descriptions are intentionally short — Claude reads them in tool selection, +// not the user, so they should be a single concrete capability sentence. +func Register(srv *sdkmcp.Server, deps *Deps) { + // Write tools (state gate applies in Phase 5). + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "rune_capture", + Description: "Capture a decision record (agent-delegated extraction required).", + }, stubHandler[domain.CaptureRequest, domain.CaptureResponse]("rune_capture")) -// 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 -} + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "rune_batch_capture", + Description: "Capture a batch of decision records (e.g. session-end sweep).", + }, stubHandler[service.BatchCaptureArgs, service.BatchCaptureResult]("rune_batch_capture")) -// 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 -} + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "rune_recall", + Description: "Query organizational memory by natural-language question.", + }, stubHandler[domain.RecallArgs, domain.RecallResult]("rune_recall")) -// 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 -} + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "rune_delete_capture", + Description: "Soft-delete a record by ID (sets status=reverted, re-inserts).", + }, stubHandler[service.DeleteCaptureArgs, service.DeleteCaptureResult]("rune_delete_capture")) -// 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 -} + // Read / diagnostic tools (state gate bypass). + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "rune_capture_history", + Description: "List recent captures from local capture_log.jsonl (read-only).", + }, stubHandler[service.CaptureHistoryArgs, service.CaptureHistoryResult]("rune_capture_history")) + + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "rune_vault_status", + Description: "Probe Vault connectivity and report secure-search mode.", + }, stubHandler[emptyArgs, service.VaultStatusResult]("rune_vault_status")) + + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "rune_diagnostics", + Description: "Collect a 7-section health snapshot (env / state / vault / keys / pipelines / embedding / envector).", + }, stubHandler[emptyArgs, service.DiagnosticsResult]("rune_diagnostics")) + + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "rune_reload_pipelines", + Description: "Re-initialize Vault + envector pipelines (BOOT replay) with envector warmup.", + }, stubHandler[emptyArgs, service.ReloadPipelinesResult]("rune_reload_pipelines")) -// 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 + _ = deps // Phase A unused; closures will capture this in Phase 5. } -// 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. +func stubHandler[In, Out any](toolName string) sdkmcp.ToolHandlerFor[In, Out] { + return func(ctx context.Context, req *sdkmcp.CallToolRequest, in In) (*sdkmcp.CallToolResult, Out, error) { + _ = ctx + _ = req + _ = in + 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).", + }, + }, + } } From cebe38e531879de3bda751b88b13a40cb0d66f92 Mon Sep 17 00:00:00 2001 From: redcourage Date: Sat, 25 Apr 2026 12:12:14 +0900 Subject: [PATCH 2/6] =?UTF-8?q?docs(v04):=20add=20progress/=20=E2=80=94=20?= =?UTF-8?q?Phase=20A=20=EA=B8=B0=EB=8A=A5=C2=B7=ED=99=95=EC=9D=B8=EB=B2=95?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 디렉토리 docs/v04/progress/ 신설 — spec(How)·overview(Why)·notes (검증 로그)와 별개로 "실제 구현이 어디까지 동작하는가 + 어떻게 직접 검증하는가"를 vertical slice 단위로 추적. phase-a-mcp-boot.md (직전 커밋 19b7bf6과 한 쌍): - 동작하는 기능 6가지 (빌드 · initialize · tools/list · schema 자동 추론 · tools/call stub · 정상 종료) - 동작하지 않는 것 (Phase A 한계 표 — Vault·envector·embedder·service 레이어 전부 미구현) - 확인법 3 레벨: CLI 직접 / Claude Code 등록 / MCP Inspector 각 레벨마다 step + 기대 출력 + 합격 기준 - Troubleshooting + 코드 변경 요약 + 다음 마일스톤 후보 progress/README.md (인덱스): - 디렉토리 목적 · spec/notes와의 차이 - 문서 명명 규칙 + "Phase A vs Phase 1~7" 관계 - 현재까지 추적된 마일스톤 표 (Phase A only) + 예정 마일스톤 docs/v04/README.md: 디렉토리 구조 표에 progress/ + notes/flow-matrix.md 한 줄씩 추가. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v04/README.md | 11 +- docs/v04/progress/README.md | 43 +++ docs/v04/progress/phase-a-mcp-boot.md | 384 ++++++++++++++++++++++++++ 3 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 docs/v04/progress/README.md create mode 100644 docs/v04/progress/phase-a-mcp-boot.md 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/progress/README.md b/docs/v04/progress/README.md new file mode 100644 index 0000000..403dd0d --- /dev/null +++ b/docs/v04/progress/README.md @@ -0,0 +1,43 @@ +# `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 B — `rune_diagnostics` environment 섹션 진짜 응답 (stdlib only) +- Phase 1 — `go.mod` 외부 deps 본격 추가 (gRPC · envector SDK · embedder proto) +- Phase 4a — Vault 클라이언트 + 부팅 시퀀스 연결 +- Phase 4b — envector SDK 연결 (Q4 PR 머지 후) +- Phase 4c — embedder 클라이언트 +- Phase 5 — service 레이어 오케스트레이션 +- Phase 7 — golden fixture 기반 bit-identical 검증 + +## 사용 방식 + +- **개발자**: 새 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..d60473c --- /dev/null +++ b/docs/v04/progress/phase-a-mcp-boot.md @@ -0,0 +1,384 @@ +# Phase A — MCP boot (handshake + tools/list) + +> **합격 상태**: ✅ 통과 (2026-04-25) +> **관련 커밋**: [`19b7bf6`](../../../) — `feat(go): first MCP boot — handshake + tools/list (Phase A)` +> **브랜치**: `yg/first-mcp-boot` (origin/`yg/first-mcp-boot`) +> **수정 파일 5개**: `cmd/rune-mcp/main.go` · `internal/mcp/tools.go` · `go.mod` · `go.sum` · `.gitignore` + +## 목적 + +`rune-mcp` Go 바이너리가 **외부 의존성 없이** 다음을 수행하는 첫 번째 마일스톤: + +1. 빌드되고 정적 바이너리로 떨어진다 +2. stdio JSON-RPC로 MCP 클라이언트(Claude Code 등)와 `initialize` handshake에 응답한다 +3. `tools/list` 응답에 8개 tool을 자동 추론된 schema와 함께 광고한다 +4. `tools/call` 응답은 "not yet implemented" stub이지만 JSON-RPC 자체는 valid +5. stdin EOF · SIGINT · SIGTERM 모두 exit 0으로 정상 종료 + +> 이 단계의 가치 — 비즈니스 로직 0이지만 **MCP 프로토콜 표면**이 살아있다. 즉 Claude Code가 우리 바이너리를 spawn해서 도구 카탈로그를 정상 인식한다. 이후 phase는 각 tool 본체를 채워가는 작업으로, 매번 같은 검증 회로를 재사용한다. + +--- + +## 1. 동작하는 기능 6가지 + +### F1. 바이너리 빌드 / 실행 + +- `go build` 한 번으로 단일 정적 바이너리 (Python venv 자가복구 같은 부트스트랩 제거) +- 환경 변수 · config.json 모두 미사용 (Phase A 한정) +- stdin/stdout으로만 통신 +- 산출 크기: 약 8.3 MB + +### F2. MCP `initialize` handshake + +JSON-RPC 2.0의 `initialize` 메서드 요청에 응답. + +**요청 예**: +```json +{"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion":"2024-11-05","capabilities":{}, + "clientInfo":{"name":"x","version":"0.0.1"}}} +``` + +**응답** (실측): +```json +{"jsonrpc":"2.0","id":1,"result":{ + "capabilities":{"logging":{},"tools":{"listChanged":true}}, + "protocolVersion":"2024-11-05", + "serverInfo":{"name":"rune-mcp","version":"0.4.0-alpha"}}} +``` + +`serverInfo`는 `cmd/rune-mcp/main.go:33`의 `version` 상수를 그대로 광고. capabilities로 tools와 logging 카테고리를 클라이언트에게 알린다. + +### F3. `tools/list` — 8개 tool 카탈로그 + +Python `mcp/server/server.py`와 bit-identical한 8 tool 이름: + +``` +rune_batch_capture +rune_capture +rune_capture_history +rune_delete_capture +rune_diagnostics +rune_recall +rune_reload_pipelines +rune_vault_status +``` + +각 tool마다: +- `name` — 위 8개 중 하나 +- `description` — Claude가 도구 선택 시 읽는 한 문장 (`internal/mcp/tools.go::Register`에서 정의) +- `inputSchema` — Go input 타입에서 자동 추출 +- `outputSchema` — Go output 타입에서 자동 추출 + +### F4. JSON Schema 자동 추론 + +SDK(`github.com/google/jsonschema-go`)가 Go struct를 보고 다음을 자동 변환: + +| Go 표현 | JSON Schema | +|---|---| +| `string` / `int` / `float64` / `bool` | `{"type":"string"}` 등 | +| `*T` 포인터 | `{"type":["null","string"]}` (nullable) | +| `[]T` | `{"type":"array","items":{...}}` | +| nested struct | `{"type":"object","properties":{...}}` | +| `json:"x,omitempty"` | `required` 배열에서 제외 | +| `additionalProperties` | 기본 false (struct 한정) | + +→ **Go 타입 정의 = MCP API 계약**. 별도 IDL 없음. + +예: `rune_recall`의 input은 `domain.RecallArgs` (Go struct)에서 자동으로: +```json +{"type":"object", + "properties":{"query":{"type":"string"},"topk":{"type":"integer"}, + "domain":{"type":["null","string"]}, + "status":{"type":["null","string"]}, + "since":{"type":["null","string"]}}, + "required":["query"]} +``` + +### F5. `tools/call` — stub 응답 (Phase A 한정) + +8 tool 어느 것을 호출해도 동일한 형태로 응답: + +```json +{"jsonrpc":"2.0","id":3,"result":{ + "isError":true, + "content":[{"type":"text", + "text":" is not yet implemented (skeleton phase A — MCP handshake + tools/list only)."}], + "structuredContent":{... output 타입의 zero value ...}}} +``` + +핵심: +- JSON-RPC 자체는 정상 (오류 처리 valid) +- `isError: true` → Claude UI에서는 빨간 에러로 표시 +- `structuredContent`에 zero value의 output struct가 포함됨 → Phase 5에서 진짜 데이터로 교체될 자리 + +### F6. 정상 종료 처리 + +다음 4가지 종료 사유 모두 **exit 0** + 깔끔한 종료: + +- stdin EOF (Claude 창 닫힘) +- SIGINT (`Ctrl-C`) +- SIGTERM (`kill `) +- 컨텍스트 cancel + +`cmd/rune-mcp/main.go::isNormalShutdown(err)` 함수가 `io.EOF` · `context.Canceled` · `"server is closing"` (jsonrpc2 internal error 메시지) 모두 정상으로 분류. + +--- + +## 2. 동작하지 않는 것 (Phase A 한계) + +| 영역 | 상태 | 가능 시점 | +|---|---|---| +| 실제 capture / recall 비즈니스 로직 | 미구현 | Phase 5 (`service/*` 채워질 때) | +| Vault gRPC 연결 | 미구현 | Phase 4 | +| Envector SDK 연결 | 미구현 | Phase 4 (Q4 PR 머지 후) | +| Embedder gRPC 연결 | 미구현 | Phase 4 (proto stub 필요) | +| 상태 머신 (`lifecycle.Manager`) | 미작동 | Phase 4 (boot loop 시작) | +| `CheckState` 게이트 | 코드는 있으나 호출 안 됨 | Phase 5 | +| `request_id` 로깅 | 미구현 | Phase 4 (`obs/slog.go` 보강) | +| `SensitiveFilter` redaction | 미구현 | 동상 | +| `config.json` 로딩 | 미구현 (빈 Deps) | Phase 4 | +| `capture_log.jsonl` 쓰기/읽기 | 미구현 | Phase 4 (`logio` 본격 구현) | + +→ Phase A의 정확한 범위는 **"MCP 프로토콜 표면이 정상 동작한다"** 까지. 어느 tool을 호출해도 비즈니스 로직 0. + +--- + +## 3. 기능 확인 — 3개 레벨 + +### Level 1 — CLI 직접 (외부 의존성 0) + +가장 빠른 검증. Claude Code도 Inspector도 필요 없음. + +#### 1.1. 빌드 + +```bash +cd /Users/redcourage/cryptolab/rune-project/rune +go build -o bin/rune-mcp ./cmd/rune-mcp +ls -la bin/rune-mcp +``` + +**기대**: `-rwxr-xr-x ... bin/rune-mcp` (~8 MB). 컴파일 에러 없음. + +#### 1.2. 종료 (stdin EOF) + +```bash +./bin/rune-mcp < /dev/null; echo "exit=$?" +``` + +**기대**: `exit=0`, 다른 출력 없음. + +#### 1.3. `initialize` 응답 + +```bash +{ + echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' + sleep 0.3 +} | ./bin/rune-mcp | jq . +``` + +**기대**: `serverInfo.name == "rune-mcp"`, `version == "0.4.0-alpha"`, `capabilities.tools` 광고됨. + +#### 1.4. `tools/list` — 이름 8개 + +```bash +{ + echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' + sleep 0.3 + echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' + sleep 0.1 + echo '{"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' +``` + +**기대 출력** (8줄): + +``` +rune_batch_capture +rune_capture +rune_capture_history +rune_delete_capture +rune_diagnostics +rune_recall +rune_reload_pipelines +rune_vault_status +``` + +#### 1.5. 특정 tool의 input schema + +```bash +# 위 1.4 명령에서 마지막 jq만 변경 +| jq 'select(.id==2) | .result.tools[] | select(.name=="rune_recall") | .inputSchema' +``` + +**기대**: `required: ["query"]`, 나머지 4 필드는 nullable optional. + +#### 1.6. `tools/call` — stub 응답 + +```bash +{ + echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' + sleep 0.3 + echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' + sleep 0.1 + echo '{"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) | {isError: .result.isError, text: .result.content[0].text}' +``` + +**기대**: + +```json +{"isError": true, + "text": "rune_diagnostics is not yet implemented (skeleton phase A — MCP handshake + tools/list only)."} +``` + +#### Level 1 합격 기준 + +1.1 ~ 1.6 모두 위 기대 결과대로면 합격. + +--- + +### Level 2 — Claude Code에 등록해서 실전 확인 + +#### 2.1. mcp.json 위치 확인 + +```bash +ls -la ~/.claude/mcp.json 2>/dev/null +``` + +- 파일 있음 → `cp ~/.claude/mcp.json ~/.claude/mcp.json.backup` 으로 백업 +- 파일 없음 → 새로 만든다 + +#### 2.2. entry 추가 + +**처음 만드는 경우** — 다음 그대로 저장: + +```json +{ + "mcpServers": { + "rune-go-dev": { + "command": "/Users/redcourage/cryptolab/rune-project/rune/bin/rune-mcp" + } + } +} +``` + +**기존 파일이 있는 경우** — `mcpServers` 객체 안에 `rune-go-dev` 키만 추가 (기존 entries 보존). + +> ⚠️ 기존 `envector` (Python MCP) entry를 절대 삭제하지 말 것. 두 MCP 공존 가능 — tool 이름 충돌 없음 (`rune_*` 8개는 Go 쪽 전용). + +#### 2.3. Claude Code 재시작 + +- 모든 Claude 창 종료 후 재실행 +- 또는 Cmd+Q → 재실행 + +#### 2.4. tool 인식 확인 (3가지 방법 중 택일) + +**방법 A — `/mcp` 슬래시 명령**: 새 채팅 입력창에 `/mcp` 입력 → 등록된 MCP 서버 목록 표시 → `rune-go-dev` 항목이 "connected" 상태로 보여야 함 + +**방법 B — 도구 아이콘**: 입력창 옆 도구/플러그인 아이콘 → `rune-go-dev` 펼치면 8 tool 리스트 + +**방법 C — 직접 호출**: Claude에게 "rune_diagnostics 호출해서 결과 보여줘" → tool 인식 후 호출 → 빨간 에러 메시지 (`not yet implemented`) 표시되면 정상. "그런 도구 못 찾았어"가 나오면 등록 실패 + +#### 2.5. (선택) Claude Code 로그 확인 + +- macOS Cmd+Shift+P → "Open Output" → MCP 카테고리 +- `rune-go-dev: connecting...` → `connected`. 에러 없어야 함 + +#### Level 2 합격 기준 + +- `/mcp` 또는 도구 목록에 `rune-go-dev` + 8 tool 표시 +- 임의 tool 호출 시 not implemented 응답 (이게 정상) + +--- + +### Level 3 — MCP Inspector (시각적, 선택) + +#### 3.1. 실행 + +```bash +cd /Users/redcourage/cryptolab/rune-project/rune +npx -y @modelcontextprotocol/inspector ./bin/rune-mcp +``` + +브라우저 자동 오픈 (보통 `localhost:6274`). + +#### 3.2. UI에서 확인 + +- **Server info**: `rune-mcp` 0.4.0-alpha +- **Tools 탭**: 8개 list. 각 클릭 → input/output schema 시각적 표시 +- **Tool 호출**: 클릭 → 폼에 인자 → "Run tool" → response (Phase A는 isError) +- **History**: JSON-RPC 메시지 raw 보기 + +#### Level 3 합격 기준 + +- 8 tool list 표시됨 +- 각 tool schema가 올바른 모양 (required field 표시 등) +- 임의 호출 시 빨간 에러 + +--- + +## 4. Troubleshooting + +| 증상 | 가능한 원인 | 해결 | +|---|---|---| +| `go build` 실패: `go >= 1.25.0 required` | Go 버전 낮음 | `go install golang.org/dl/go1.25@latest && go1.25 download`, 또는 brew/asdf로 1.25 업그레이드 | +| `go build` 실패: `missing go.sum entry` | `go mod tidy` 안 함 | `go mod tidy` 후 재빌드 | +| Level 1.3 응답 없음 | sleep 너무 짧아 EOF 먼저 옴 | sleep을 0.5 이상으로 | +| Level 2에서 tool 안 보임 | 절대 경로 아님 / JSON 문법 오류 / 권한 부족 | `cat ~/.claude/mcp.json \| jq .`로 JSON 검증 + `chmod +x bin/rune-mcp` | +| Level 2 "Connection failed" | 바이너리 경로 틀림 / 바이너리 stale | 경로 재확인 + `go build` 재실행 | +| Level 2에서 not implemented 안 나오고 다른 에러 | tool 이름 오타 | 8 이름 정확히 입력 | +| Level 3 npx 실패 | Node.js 미설치 | `brew install node` 또는 Level 1·2로 우회 | + +--- + +## 5. 코드 변경 요약 + +### `cmd/rune-mcp/main.go` (rewrite, 80줄) + +스텁 1줄짜리 `log.Println("rune-mcp skeleton — not yet implemented")` 를 다음으로 교체: + +- `context.WithCancel` + signal handler (SIGINT/SIGTERM → cancel) +- 빈 `mcp.Deps{}` (Phase A는 adapter 미주입) +- `sdkmcp.NewServer(&Implementation{Name:"rune-mcp", Version:"0.4.0-alpha"}, nil)` +- `mcp.Register(srv, deps)` — 8 tool 등록 +- `srv.Run(ctx, &sdkmcp.StdioTransport{})` +- `isNormalShutdown(err)` 헬퍼 — io.EOF · ctx Canceled · "server is closing" 모두 정상으로 분류 → exit 0 + +### `internal/mcp/tools.go` (rewrite, 137줄) + +기존 8 tool stub 함수를 SDK handler 패턴으로 재구성: + +- `Register(srv, deps)` — 8 `sdkmcp.AddTool` 호출 +- `stubHandler[In, Out any](toolName)` — generic factory. 어느 tool이든 `IsError=true` + 텍스트 메시지 + zero output 반환 +- `stubResult(toolName)` — 메시지 빌더 +- `Deps` 구조체는 여전히 필드 주석 처리 상태 (Phase 4부터 활성화) + +### `go.mod` / `go.sum` + +- `github.com/modelcontextprotocol/go-sdk v1.5.0` (D2) +- transitive deps: `jsonschema-go`, `uritemplate`, `oauth2`, `segmentio/encoding`, `golang-jwt/jwt`, ... +- Go toolchain `1.24` → `1.25` (SDK 요구) + +### `.gitignore` + +``` +# Go build artifacts +bin/ +*.test +coverage.out +``` + +--- + +## 6. 다음 마일스톤 + +이 문서가 통과되면 다음 둘 중 선택: + +- **Phase B** — `rune_diagnostics`의 environment 섹션을 stdlib만으로 채워서 진짜 응답 받기 (작은 PR, 2-3시간) + - `runtime.GOOS` · `runtime.Version` · `os.Getwd` · `runtime.GOARCH` 만으로 가능 + - 다른 6 섹션은 `null`/zero로 두고 `OK=true` 고정 + - 첫 번째 진짜 응답 → "MCP 표면 + 한 tool 데이터 흐름" 검증 +- **Phase 1 본격** — `go.mod`에 gRPC · protobuf · envector-go SDK · embedder proto stub 추가. 이후 Phase 4 adapter 작업의 전제 조건 From b5e2c9baafad7857abd419dbc895fbca8a0be4a9 Mon Sep 17 00:00:00 2001 From: redcourage Date: Sat, 25 Apr 2026 12:50:15 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs(v04):=20phase-a=20=C2=A74=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=96=B4=20cookbook=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 §3 (Level 1·2·3 합격 시퀀스)는 한 번 통과를 위한 step. 그 위에 §4를 신설해 반복 작업 시 그대로 복붙해 쓰는 명령어 모음을 정리. Phase B / Phase 4 / Phase 5 에서도 같은 회로 재사용 가능. §4 구성: - 4.1 서버 실행 4가지 변형 (foreground · stdin EOF · stderr 분리 · Inspector) - 4.2 단발 JSON-RPC 요청 패턴 + MCP framing 3 규칙 (initialize → notifications/initialized → tools/* 순서, newline framing, 마지막 sleep 0.3-0.5s 권장) - 4.3 8 tool 각각 minimum 인자 호출 cookbook - 4.4 mcp_call bash 헬퍼 (8 tool 검증 후 채택). default `{}` expansion 함정 (`${2:-{}}`)도 명시 - 4.5 jq 응답 분석 패턴 7가지 (이름 / schema / required 매트릭스 / text 등) - 4.6 디버깅 (stderr 분리, raw dump, tee로 input/output 동시 저장) - 4.7 양방향 stateful 세션 (bash coproc) — 옵션 - 4.8 ~/.claude/mcp.json 등록 후 검증 명령어 §0 메타데이터 박스에 §4 navigation 한 줄 추가. 번호 시프트: 기존 §4 Troubleshooting → §5, §5 코드 변경 → §6, §6 다음 마일스톤 → §7. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v04/progress/phase-a-mcp-boot.md | 190 +++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 3 deletions(-) diff --git a/docs/v04/progress/phase-a-mcp-boot.md b/docs/v04/progress/phase-a-mcp-boot.md index d60473c..721195b 100644 --- a/docs/v04/progress/phase-a-mcp-boot.md +++ b/docs/v04/progress/phase-a-mcp-boot.md @@ -4,6 +4,7 @@ > **관련 커밋**: [`19b7bf6`](../../../) — `feat(go): first MCP boot — handshake + tools/list (Phase A)` > **브랜치**: `yg/first-mcp-boot` (origin/`yg/first-mcp-boot`) > **수정 파일 5개**: `cmd/rune-mcp/main.go` · `internal/mcp/tools.go` · `go.mod` · `go.sum` · `.gitignore` +> **빠른 실행**: §3 (검증 절차) · **§4 (명령어 cookbook — 8 tool 호출, bash 헬퍼, jq 패턴 등)** ## 목적 @@ -320,7 +321,190 @@ npx -y @modelcontextprotocol/inspector ./bin/rune-mcp --- -## 4. Troubleshooting +## 4. 명령어 cookbook (재사용 가능한 패턴) + +§3는 "한 번 합격하기 위한" 시퀀스고, 이 절은 **반복 작업 시 그냥 복사해서 쓰는 명령어 모음**. Phase A뿐 아니라 Phase B/4/5에서도 같은 회로로 재사용된다. + +### 4.1. 서버 실행 — 4가지 변형 + +```bash +cd /Users/redcourage/cryptolab/rune-project/rune +go build -o bin/rune-mcp ./cmd/rune-mcp # 빌드 (Go 1.25+ 필요) +``` + +| 시나리오 | 명령어 | +|---|---| +| **foreground 단순 실행** (stdin 대화 흐름은 안 됨, EOF로 종료) | `./bin/rune-mcp` | +| **stdin EOF 즉시 종료** | `./bin/rune-mcp < /dev/null` | +| **stderr 분리** (디버그 로그 따로 저장) | `./bin/rune-mcp 2>/tmp/rune-stderr.log` | +| **log 두 곳 동시** | `./bin/rune-mcp 2> >(tee /tmp/rune-stderr.log >&2)` | +| **백그라운드 + named pipe로 양방향** | 아래 §4.6 참고 | +| **Inspector(GUI) 띄우기** | `npx -y @modelcontextprotocol/inspector ./bin/rune-mcp` | + +### 4.2. 단발 JSON-RPC 요청 — 가장 짧은 형태 + +`initialize` 응답만 받기: + +```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; } | ./bin/rune-mcp 2>/dev/null | jq . +``` + +`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' +``` + +> **MCP framing 핵심 3개**: +> 1. **순서 필수**: `initialize` → `notifications/initialized` → `tools/*`. 순서 어기면 SDK가 거절 +> 2. **줄바꿈 framing**: 각 메시지는 `\n` 으로 끝나야 함 (LSP의 Content-Length는 미사용) +> 3. **stdin 종료 = 세션 종료**: 마지막 메시지 후 sleep 없으면 응답 받기 전에 EOF로 닫힘. **0.3~0.5초** 정도 마지막 sleep 권장 + +### 4.3. 8 tool 각각 호출 — minimum 인자 + +각 tool의 **input schema에서 필수 필드만 채운** 최소 호출. 응답은 모두 Phase A에서는 `isError=true` + "not yet implemented". + +```bash +# rune_diagnostics — 인자 없음 +mcp_call rune_diagnostics + +# rune_vault_status — 인자 없음 +mcp_call rune_vault_status + +# rune_reload_pipelines — 인자 없음 +mcp_call rune_reload_pipelines + +# rune_capture_history — 모두 optional +mcp_call rune_capture_history + +# rune_recall — query 필수 +mcp_call rune_recall '{"query":"hello"}' + +# rune_capture — text + source + extracted 필수 +mcp_call rune_capture '{"text":"hi","source":"test","extracted":{}}' + +# rune_delete_capture — record_id 필수 +mcp_call rune_delete_capture '{"record_id":"dec_test"}' + +# rune_batch_capture — items (string) 필수 +mcp_call rune_batch_capture '{"items":"[]"}' +``` + +`mcp_call` 헬퍼는 §4.4에 정의. 위 8줄을 그대로 붙여넣으면 8개 모두 동일한 stub 응답이 나오는 것을 확인할 수 있다. + +### 4.4. `mcp_call` bash 헬퍼 함수 + +다음 블록을 `~/.zshrc` / `~/.bashrc` 또는 현재 셸에 그대로 붙여넣으면 `mcp_call` 명령어 사용 가능: + +```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 '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"$tool\",\"arguments\":$args}}" + sleep 0.5 + } | ./bin/rune-mcp 2>/dev/null | jq -c 'select(.id==2)' +} +``` + +> ⚠️ **함정 주의**: `local args="${2:-{}}"` 처럼 default value에 `}` 를 쓰면 bash parameter expansion이 깨져서 닫는 brace가 한 개 추가됨 (`{"query":"hello"}}` 가 됨). 위 코드처럼 `[ -z ... ] && args='{}'` 패턴으로 우회하는 게 안전. + +사용 예 (§4.3과 동일): + +```bash +cd /Users/redcourage/cryptolab/rune-project/rune +mcp_call rune_recall '{"query":"hello world"}' +# {"jsonrpc":"2.0","id":2,"result":{"content":[...],"isError":true,...}} +``` + +### 4.5. 응답 분석 — `jq` 패턴 모음 + +| 목적 | 명령어 (헬퍼 출력 또는 raw 출력에 파이프) | +|---|---| +| 8개 tool 이름만 보기 | `jq -r 'select(.id==2) \| .result.tools[].name'` | +| 특정 tool의 input schema | `jq 'select(.id==2) \| .result.tools[] \| select(.name=="rune_recall") \| .inputSchema'` | +| 특정 tool의 output schema | `jq 'select(.id==2) \| .result.tools[] \| select(.name=="rune_recall") \| .outputSchema'` | +| 모든 tool의 required 필드 매트릭스 | `jq -r 'select(.id==2) \| .result.tools[] \| "\(.name): \(.inputSchema.required \| join(","))"'` | +| `tools/call` 응답에서 텍스트 메시지만 | `jq -r 'select(.id==2) \| .result.content[0].text'` | +| `tools/call` 응답의 isError + structuredContent | `jq 'select(.id==2) \| {isError: .result.isError, structured: .result.structuredContent}'` | +| serverInfo만 | `jq 'select(.id==1) \| .result.serverInfo'` | + +### 4.6. 디버깅 — stderr 분리, raw 메시지 dump + +```bash +# stderr만 따로 보기 (정상이면 비어 있어야 함) +{ + 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.5 +} | ./bin/rune-mcp 2>/tmp/rune-stderr.log >/dev/null +cat /tmp/rune-stderr.log + +# JSON 응답을 한 줄씩 raw로 보기 (jq 없이) +{ ... } | ./bin/rune-mcp 2>/dev/null +# → 줄별 valid JSON. 각 줄을 jq에 따로 파이프해도 됨 + +# 메시지 시퀀스 검증 (input과 output 비교) +{ ... } | tee /tmp/rune-input.log | ./bin/rune-mcp 2>/dev/null | tee /tmp/rune-output.log | jq . +# /tmp/rune-input.log : 보낸 메시지 +# /tmp/rune-output.log: 받은 응답 +``` + +### 4.7. 양방향 stateful 세션 (named pipe / coproc) + +위 모든 명령어는 **단방향** (입력 한꺼번에 보내고 응답 모아서 종료). MCP는 양방향 stateful이라, 진짜 클라이언트처럼 동작하려면 다음이 가능: + +**bash coproc**: +```bash +coproc RUNE { ./bin/rune-mcp 2>/dev/null; } +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' >&${RUNE[1]} +read -r -u ${RUNE[0]} line; echo "$line" | jq . +echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' >&${RUNE[1]} +echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' >&${RUNE[1]} +read -r -u ${RUNE[0]} line; echo "$line" | jq -r '.result.tools[].name' +exec {RUNE[1]}>&- # stdin 닫음 → server 종료 +wait $COPROC_PID +``` + +대부분의 검증에는 §4.2~4.4의 단방향 패턴으로 충분. coproc은 응답 후 다음 요청을 동적으로 결정해야 할 때만 사용. + +### 4.8. Claude Code 등록 후 검증 + +`~/.claude/mcp.json` 등록(§3 Level 2)이 끝난 뒤: + +```bash +# JSON 문법 검증 (등록 직후 필수) +cat ~/.claude/mcp.json | jq . + +# rune-go-dev entry 존재 확인 +jq '.mcpServers["rune-go-dev"]' ~/.claude/mcp.json + +# 바이너리 권한 확인 +ls -la /Users/redcourage/cryptolab/rune-project/rune/bin/rune-mcp +# → -rwxr-xr-x ... 실행 권한 있어야 함 + +# 바이너리 직접 실행해서 응답 오는지 1회 검증 (§3 1.3과 동일) +cd /Users/redcourage/cryptolab/rune-project/rune && ./bin/rune-mcp < /dev/null; echo "exit=$?" + +# Claude Code 재시작 후 (수동), 새 세션에서: +# /mcp ← 등록된 서버 목록 표시 +# "rune_diagnostics 호출해" ← Claude가 tool 인식하는지 +``` + +--- + +## 5. Troubleshooting | 증상 | 가능한 원인 | 해결 | |---|---|---| @@ -334,7 +518,7 @@ npx -y @modelcontextprotocol/inspector ./bin/rune-mcp --- -## 5. 코드 변경 요약 +## 6. 코드 변경 요약 ### `cmd/rune-mcp/main.go` (rewrite, 80줄) @@ -373,7 +557,7 @@ coverage.out --- -## 6. 다음 마일스톤 +## 7. 다음 마일스톤 이 문서가 통과되면 다음 둘 중 선택: From e042ab3a635864cea2f1abe497bbc3789fa0f14d Mon Sep 17 00:00:00 2001 From: redcourage Date: Sat, 25 Apr 2026 13:07:27 +0900 Subject: [PATCH 4/6] fix(go): apply Phase A review feedback + slim progress doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer A (Go 코드 품질) Critical 2 + Major 다수: - isNormalShutdown dead branch 제거 (nil || ctx.Canceled 만) - stubHandler 가 *Deps 캡처 (8 call site 시그니처 통일) - Register 가 error 반환 + defer recover() (AddTool panic 가드) - log.Printf → slog.Error - go.mod toolchain go1.25.0 directive - Deps ghost field 제거 → 빈 struct + 향후 sketch 코멘트 Reviewer B (설계/문서) Major 8 — doc 정확도: - LOC 정정 (main.go 80→74, tools.go 137→134) - §1.5 code block runnable + topk non-nullable 정정 - §2.2 absolute path placeholder + namespace 충돌 설명 - §4.7 macOS bash 3.2 coproc 경고 - §7 다음 마일스톤 11개로 확장 phase-a-mcp-boot.md 슬림화 (588→180줄): - F1~F6 narrative → 동작/한계 매트릭스 - §3 Level 1.1~1.6 6 시퀀스 → 핵심 3 블록 (build/list/call) - §4 cookbook 8 subsection → mcp_call 헬퍼 + 8 tool 호출 1 블록 - §6 코드 변경 요약 삭제 (git diff에 있음) 검증: go build · go vet · smoke test (initialize → tools/list 8 → tools/call) ✅ Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/rune-mcp/main.go | 29 +- docs/v04/notes/flow-matrix.md | 2 + docs/v04/progress/README.md | 21 +- docs/v04/progress/phase-a-mcp-boot.md | 566 ++++---------------------- go.mod | 14 +- internal/mcp/tools.go | 73 ++-- 6 files changed, 166 insertions(+), 539 deletions(-) diff --git a/cmd/rune-mcp/main.go b/cmd/rune-mcp/main.go index 12d5420..50b61fc 100644 --- a/cmd/rune-mcp/main.go +++ b/cmd/rune-mcp/main.go @@ -16,11 +16,9 @@ package main import ( "context" "errors" - "io" - "log" + "log/slog" "os" "os/signal" - "strings" "syscall" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" @@ -56,26 +54,21 @@ func main() { Version: version, }, nil) - mcp.Register(srv, deps) + 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) { - log.Printf("rune-mcp serve error: %v", err) + slog.Error("rune-mcp serve error", "err", err) os.Exit(1) } } -// isNormalShutdown reports whether err corresponds to expected stdio teardown -// (stdin EOF, ctx cancel from SIGINT/SIGTERM, or the SDK's internal -// jsonrpc2.ErrServerClosing surfacing as "server is closing"). Those are not -// failures and must not produce exit code 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 { - if err == nil { - return true - } - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { - return true - } - // jsonrpc2.ErrServerClosing lives in an internal package, so we can't use - // errors.Is. The message is stable per the SDK source. - return strings.Contains(err.Error(), "server is closing") + return err == nil || errors.Is(err, context.Canceled) } 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 index 403dd0d..8004e95 100644 --- a/docs/v04/progress/README.md +++ b/docs/v04/progress/README.md @@ -26,15 +26,18 @@ README의 7-Phase 로드맵(Phase 1 외부 deps → Phase 7 검증)이 **horizon | 마일스톤 | 상태 | 문서 | 관련 커밋 | |---|---|---|---| | Phase A — MCP boot (handshake + tools/list) | ✅ 합격 | [phase-a-mcp-boot.md](phase-a-mcp-boot.md) | `19b7bf6` (브랜치 `yg/first-mcp-boot`) | - -이후 마일스톤 (예정): -- Phase B — `rune_diagnostics` environment 섹션 진짜 응답 (stdlib only) -- Phase 1 — `go.mod` 외부 deps 본격 추가 (gRPC · envector SDK · embedder proto) -- Phase 4a — Vault 클라이언트 + 부팅 시퀀스 연결 -- Phase 4b — envector SDK 연결 (Q4 PR 머지 후) -- Phase 4c — embedder 클라이언트 -- Phase 5 — service 레이어 오케스트레이션 -- Phase 7 — golden fixture 기반 bit-identical 검증 +| 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 호출 교체에 흡수됨. ## 사용 방식 diff --git a/docs/v04/progress/phase-a-mcp-boot.md b/docs/v04/progress/phase-a-mcp-boot.md index 721195b..9f9ecdf 100644 --- a/docs/v04/progress/phase-a-mcp-boot.md +++ b/docs/v04/progress/phase-a-mcp-boot.md @@ -1,413 +1,129 @@ -# Phase A — MCP boot (handshake + tools/list) +# Phase A — MCP boot -> **합격 상태**: ✅ 통과 (2026-04-25) -> **관련 커밋**: [`19b7bf6`](../../../) — `feat(go): first MCP boot — handshake + tools/list (Phase A)` -> **브랜치**: `yg/first-mcp-boot` (origin/`yg/first-mcp-boot`) -> **수정 파일 5개**: `cmd/rune-mcp/main.go` · `internal/mcp/tools.go` · `go.mod` · `go.sum` · `.gitignore` -> **빠른 실행**: §3 (검증 절차) · **§4 (명령어 cookbook — 8 tool 호출, bash 헬퍼, jq 패턴 등)** +> ✅ 통과 (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 바이너리가 **외부 의존성 없이** 다음을 수행하는 첫 번째 마일스톤: +`rune-mcp` Go 바이너리가 **MCP 프로토콜 표면**(handshake + tools/list + tools/call)만 살아있는 상태. 외부 deps 0, 비즈니스 로직 0, 8개 tool 모두 stub. -1. 빌드되고 정적 바이너리로 떨어진다 -2. stdio JSON-RPC로 MCP 클라이언트(Claude Code 등)와 `initialize` handshake에 응답한다 -3. `tools/list` 응답에 8개 tool을 자동 추론된 schema와 함께 광고한다 -4. `tools/call` 응답은 "not yet implemented" stub이지만 JSON-RPC 자체는 valid -5. stdin EOF · SIGINT · SIGTERM 모두 exit 0으로 정상 종료 +**가치** — Claude Code가 이 바이너리를 spawn 하면 8 tool 카탈로그를 정상 인식. 이후 phase는 stub 본체만 채우면 끝. 검증 회로는 매 phase 재사용. -> 이 단계의 가치 — 비즈니스 로직 0이지만 **MCP 프로토콜 표면**이 살아있다. 즉 Claude Code가 우리 바이너리를 spawn해서 도구 카탈로그를 정상 인식한다. 이후 phase는 각 tool 본체를 채워가는 작업으로, 매번 같은 검증 회로를 재사용한다. +## 2. 동작하는 것 / 안 하는 것 ---- +**동작** -## 1. 동작하는 기능 6가지 +- `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 -### F1. 바이너리 빌드 / 실행 +**안 함 (의도된 한계)** -- `go build` 한 번으로 단일 정적 바이너리 (Python venv 자가복구 같은 부트스트랩 제거) -- 환경 변수 · config.json 모두 미사용 (Phase A 한정) -- stdin/stdout으로만 통신 -- 산출 크기: 약 8.3 MB - -### F2. MCP `initialize` handshake - -JSON-RPC 2.0의 `initialize` 메서드 요청에 응답. - -**요청 예**: -```json -{"jsonrpc":"2.0","id":1,"method":"initialize", - "params":{"protocolVersion":"2024-11-05","capabilities":{}, - "clientInfo":{"name":"x","version":"0.0.1"}}} -``` - -**응답** (실측): -```json -{"jsonrpc":"2.0","id":1,"result":{ - "capabilities":{"logging":{},"tools":{"listChanged":true}}, - "protocolVersion":"2024-11-05", - "serverInfo":{"name":"rune-mcp","version":"0.4.0-alpha"}}} -``` - -`serverInfo`는 `cmd/rune-mcp/main.go:33`의 `version` 상수를 그대로 광고. capabilities로 tools와 logging 카테고리를 클라이언트에게 알린다. - -### F3. `tools/list` — 8개 tool 카탈로그 - -Python `mcp/server/server.py`와 bit-identical한 8 tool 이름: - -``` -rune_batch_capture -rune_capture -rune_capture_history -rune_delete_capture -rune_diagnostics -rune_recall -rune_reload_pipelines -rune_vault_status -``` - -각 tool마다: -- `name` — 위 8개 중 하나 -- `description` — Claude가 도구 선택 시 읽는 한 문장 (`internal/mcp/tools.go::Register`에서 정의) -- `inputSchema` — Go input 타입에서 자동 추출 -- `outputSchema` — Go output 타입에서 자동 추출 - -### F4. JSON Schema 자동 추론 - -SDK(`github.com/google/jsonschema-go`)가 Go struct를 보고 다음을 자동 변환: - -| Go 표현 | JSON Schema | +| 영역 | 가능 시점 | |---|---| -| `string` / `int` / `float64` / `bool` | `{"type":"string"}` 등 | -| `*T` 포인터 | `{"type":["null","string"]}` (nullable) | -| `[]T` | `{"type":"array","items":{...}}` | -| nested struct | `{"type":"object","properties":{...}}` | -| `json:"x,omitempty"` | `required` 배열에서 제외 | -| `additionalProperties` | 기본 false (struct 한정) | +| 비즈니스 로직 (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 | -→ **Go 타입 정의 = MCP API 계약**. 별도 IDL 없음. +→ Phase A의 정확한 범위는 **"MCP 프로토콜 표면이 정상 동작한다"** 까지. -예: `rune_recall`의 input은 `domain.RecallArgs` (Go struct)에서 자동으로: -```json -{"type":"object", - "properties":{"query":{"type":"string"},"topk":{"type":"integer"}, - "domain":{"type":["null","string"]}, - "status":{"type":["null","string"]}, - "since":{"type":["null","string"]}}, - "required":["query"]} -``` - -### F5. `tools/call` — stub 응답 (Phase A 한정) +## 3. 8 tool 카탈로그 -8 tool 어느 것을 호출해도 동일한 형태로 응답: - -```json -{"jsonrpc":"2.0","id":3,"result":{ - "isError":true, - "content":[{"type":"text", - "text":" is not yet implemented (skeleton phase A — MCP handshake + tools/list only)."}], - "structuredContent":{... output 타입의 zero value ...}}} ``` - -핵심: -- JSON-RPC 자체는 정상 (오류 처리 valid) -- `isError: true` → Claude UI에서는 빨간 에러로 표시 -- `structuredContent`에 zero value의 output struct가 포함됨 → Phase 5에서 진짜 데이터로 교체될 자리 - -### F6. 정상 종료 처리 - -다음 4가지 종료 사유 모두 **exit 0** + 깔끔한 종료: - -- stdin EOF (Claude 창 닫힘) -- SIGINT (`Ctrl-C`) -- SIGTERM (`kill `) -- 컨텍스트 cancel - -`cmd/rune-mcp/main.go::isNormalShutdown(err)` 함수가 `io.EOF` · `context.Canceled` · `"server is closing"` (jsonrpc2 internal error 메시지) 모두 정상으로 분류. - ---- - -## 2. 동작하지 않는 것 (Phase A 한계) - -| 영역 | 상태 | 가능 시점 | -|---|---|---| -| 실제 capture / recall 비즈니스 로직 | 미구현 | Phase 5 (`service/*` 채워질 때) | -| Vault gRPC 연결 | 미구현 | Phase 4 | -| Envector SDK 연결 | 미구현 | Phase 4 (Q4 PR 머지 후) | -| Embedder gRPC 연결 | 미구현 | Phase 4 (proto stub 필요) | -| 상태 머신 (`lifecycle.Manager`) | 미작동 | Phase 4 (boot loop 시작) | -| `CheckState` 게이트 | 코드는 있으나 호출 안 됨 | Phase 5 | -| `request_id` 로깅 | 미구현 | Phase 4 (`obs/slog.go` 보강) | -| `SensitiveFilter` redaction | 미구현 | 동상 | -| `config.json` 로딩 | 미구현 (빈 Deps) | Phase 4 | -| `capture_log.jsonl` 쓰기/읽기 | 미구현 | Phase 4 (`logio` 본격 구현) | - -→ Phase A의 정확한 범위는 **"MCP 프로토콜 표면이 정상 동작한다"** 까지. 어느 tool을 호출해도 비즈니스 로직 0. - ---- - -## 3. 기능 확인 — 3개 레벨 - -### Level 1 — CLI 직접 (외부 의존성 0) - -가장 빠른 검증. Claude Code도 Inspector도 필요 없음. - -#### 1.1. 빌드 - -```bash -cd /Users/redcourage/cryptolab/rune-project/rune -go build -o bin/rune-mcp ./cmd/rune-mcp -ls -la bin/rune-mcp +rune_batch_capture, rune_capture, rune_capture_history, rune_delete_capture, +rune_diagnostics, rune_recall, rune_reload_pipelines, rune_vault_status ``` -**기대**: `-rwxr-xr-x ... bin/rune-mcp` (~8 MB). 컴파일 에러 없음. +(SDK가 알파벳순 정렬해 광고 — Python 원본과 bit-identical한 8 이름) -#### 1.2. 종료 (stdin EOF) +각 tool의 input/output schema는 `internal/domain/*` · `internal/service/*` 의 Go struct에서 SDK가 자동 추론. **Go 타입 = MCP API 계약**, 별도 IDL 없음. -```bash -./bin/rune-mcp < /dev/null; echo "exit=$?" -``` - -**기대**: `exit=0`, 다른 출력 없음. +## 4. 검증 — 5분 컷 -#### 1.3. `initialize` 응답 +### 4.1. 빌드 & 헬스 체크 ```bash -{ - echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' - sleep 0.3 -} | ./bin/rune-mcp | jq . +cd +go build -o bin/rune-mcp ./cmd/rune-mcp +./bin/rune-mcp < /dev/null; echo "exit=$?" # → exit=0 ``` -**기대**: `serverInfo.name == "rune-mcp"`, `version == "0.4.0-alpha"`, `capabilities.tools` 광고됨. +### 4.2. MCP 시퀀스 — `tools/list` 까지 -#### 1.4. `tools/list` — 이름 8개 +순서: `initialize` → `notifications/initialized` → `tools/list` ```bash { - echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' + 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 - echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' sleep 0.1 - echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + 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' ``` -**기대 출력** (8줄): - -``` -rune_batch_capture -rune_capture -rune_capture_history -rune_delete_capture -rune_diagnostics -rune_recall -rune_reload_pipelines -rune_vault_status -``` - -#### 1.5. 특정 tool의 input schema - -```bash -# 위 1.4 명령에서 마지막 jq만 변경 -| jq 'select(.id==2) | .result.tools[] | select(.name=="rune_recall") | .inputSchema' -``` - -**기대**: `required: ["query"]`, 나머지 4 필드는 nullable optional. +**기대**: 위 §3의 8 이름. -#### 1.6. `tools/call` — stub 응답 +### 4.3. tool 한 개 호출 (stub 응답) ```bash { - echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' + 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 - echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' sleep 0.1 - echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"rune_diagnostics","arguments":{}}}' + 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) | {isError: .result.isError, text: .result.content[0].text}' -``` - -**기대**: - -```json -{"isError": true, - "text": "rune_diagnostics is not yet implemented (skeleton phase A — MCP handshake + tools/list only)."} +} | ./bin/rune-mcp 2>/dev/null | jq 'select(.id==3).result | {isError, text:.content[0].text}' ``` -#### Level 1 합격 기준 - -1.1 ~ 1.6 모두 위 기대 결과대로면 합격. - ---- - -### Level 2 — Claude Code에 등록해서 실전 확인 - -#### 2.1. mcp.json 위치 확인 - -```bash -ls -la ~/.claude/mcp.json 2>/dev/null -``` +**기대**: `isError:true`, text는 `"rune_diagnostics is not yet implemented..."`. -- 파일 있음 → `cp ~/.claude/mcp.json ~/.claude/mcp.json.backup` 으로 백업 -- 파일 없음 → 새로 만든다 +> **MCP framing 핵심 3개** +> ① `initialize` → `notifications/initialized` → `tools/*` **순서 필수** +> ② 각 메시지는 `\n` 종결 (LSP의 Content-Length 미사용) +> ③ 마지막 `sleep 0.3~0.5` 없으면 EOF로 응답 끊김 -#### 2.2. entry 추가 +### 4.4. Claude Code 등록 (선택) -**처음 만드는 경우** — 다음 그대로 저장: +`~/.claude/mcp.json`: ```json { "mcpServers": { "rune-go-dev": { - "command": "/Users/redcourage/cryptolab/rune-project/rune/bin/rune-mcp" + "command": "/bin/rune-mcp" } } } ``` -**기존 파일이 있는 경우** — `mcpServers` 객체 안에 `rune-go-dev` 키만 추가 (기존 entries 보존). - -> ⚠️ 기존 `envector` (Python MCP) entry를 절대 삭제하지 말 것. 두 MCP 공존 가능 — tool 이름 충돌 없음 (`rune_*` 8개는 Go 쪽 전용). - -#### 2.3. Claude Code 재시작 - -- 모든 Claude 창 종료 후 재실행 -- 또는 Cmd+Q → 재실행 - -#### 2.4. tool 인식 확인 (3가지 방법 중 택일) - -**방법 A — `/mcp` 슬래시 명령**: 새 채팅 입력창에 `/mcp` 입력 → 등록된 MCP 서버 목록 표시 → `rune-go-dev` 항목이 "connected" 상태로 보여야 함 - -**방법 B — 도구 아이콘**: 입력창 옆 도구/플러그인 아이콘 → `rune-go-dev` 펼치면 8 tool 리스트 - -**방법 C — 직접 호출**: Claude에게 "rune_diagnostics 호출해서 결과 보여줘" → tool 인식 후 호출 → 빨간 에러 메시지 (`not yet implemented`) 표시되면 정상. "그런 도구 못 찾았어"가 나오면 등록 실패 - -#### 2.5. (선택) Claude Code 로그 확인 - -- macOS Cmd+Shift+P → "Open Output" → MCP 카테고리 -- `rune-go-dev: connecting...` → `connected`. 에러 없어야 함 - -#### Level 2 합격 기준 - -- `/mcp` 또는 도구 목록에 `rune-go-dev` + 8 tool 표시 -- 임의 tool 호출 시 not implemented 응답 (이게 정상) +> `` 는 본인 체크아웃 경로의 **절대 경로**로 치환 (`~`/상대경로 미지원). ---- +Claude 재시작 후 `/mcp` 에서 `rune-go-dev` 가 connected 표시되면 합격. tool 호출하면 빨간 "not implemented" 응답 — **이게 정상**. -### Level 3 — MCP Inspector (시각적, 선택) +> ⚠️ 기존 Python `envector` MCP가 같은 8 이름 광고. namespace는 `mcp__rune-go-dev__*` vs `mcp__envector__*` 로 분리돼 충돌은 없지만 카탈로그가 중복 보임. -#### 3.1. 실행 +### 4.5. MCP Inspector (시각적, 선택) ```bash -cd /Users/redcourage/cryptolab/rune-project/rune npx -y @modelcontextprotocol/inspector ./bin/rune-mcp ``` -브라우저 자동 오픈 (보통 `localhost:6274`). +브라우저(`localhost:6274`)에 8 tool 리스트 + schema + raw JSON-RPC. -#### 3.2. UI에서 확인 +## 5. 8 tool 한 번씩 호출 — `mcp_call` 헬퍼 -- **Server info**: `rune-mcp` 0.4.0-alpha -- **Tools 탭**: 8개 list. 각 클릭 → input/output schema 시각적 표시 -- **Tool 호출**: 클릭 → 폼에 인자 → "Run tool" → response (Phase A는 isError) -- **History**: JSON-RPC 메시지 raw 보기 - -#### Level 3 합격 기준 - -- 8 tool list 표시됨 -- 각 tool schema가 올바른 모양 (required field 표시 등) -- 임의 호출 시 빨간 에러 - ---- - -## 4. 명령어 cookbook (재사용 가능한 패턴) - -§3는 "한 번 합격하기 위한" 시퀀스고, 이 절은 **반복 작업 시 그냥 복사해서 쓰는 명령어 모음**. Phase A뿐 아니라 Phase B/4/5에서도 같은 회로로 재사용된다. - -### 4.1. 서버 실행 — 4가지 변형 - -```bash -cd /Users/redcourage/cryptolab/rune-project/rune -go build -o bin/rune-mcp ./cmd/rune-mcp # 빌드 (Go 1.25+ 필요) -``` - -| 시나리오 | 명령어 | -|---|---| -| **foreground 단순 실행** (stdin 대화 흐름은 안 됨, EOF로 종료) | `./bin/rune-mcp` | -| **stdin EOF 즉시 종료** | `./bin/rune-mcp < /dev/null` | -| **stderr 분리** (디버그 로그 따로 저장) | `./bin/rune-mcp 2>/tmp/rune-stderr.log` | -| **log 두 곳 동시** | `./bin/rune-mcp 2> >(tee /tmp/rune-stderr.log >&2)` | -| **백그라운드 + named pipe로 양방향** | 아래 §4.6 참고 | -| **Inspector(GUI) 띄우기** | `npx -y @modelcontextprotocol/inspector ./bin/rune-mcp` | - -### 4.2. 단발 JSON-RPC 요청 — 가장 짧은 형태 - -`initialize` 응답만 받기: - -```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; } | ./bin/rune-mcp 2>/dev/null | jq . -``` - -`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' -``` - -> **MCP framing 핵심 3개**: -> 1. **순서 필수**: `initialize` → `notifications/initialized` → `tools/*`. 순서 어기면 SDK가 거절 -> 2. **줄바꿈 framing**: 각 메시지는 `\n` 으로 끝나야 함 (LSP의 Content-Length는 미사용) -> 3. **stdin 종료 = 세션 종료**: 마지막 메시지 후 sleep 없으면 응답 받기 전에 EOF로 닫힘. **0.3~0.5초** 정도 마지막 sleep 권장 - -### 4.3. 8 tool 각각 호출 — minimum 인자 - -각 tool의 **input schema에서 필수 필드만 채운** 최소 호출. 응답은 모두 Phase A에서는 `isError=true` + "not yet implemented". - -```bash -# rune_diagnostics — 인자 없음 -mcp_call rune_diagnostics - -# rune_vault_status — 인자 없음 -mcp_call rune_vault_status - -# rune_reload_pipelines — 인자 없음 -mcp_call rune_reload_pipelines - -# rune_capture_history — 모두 optional -mcp_call rune_capture_history - -# rune_recall — query 필수 -mcp_call rune_recall '{"query":"hello"}' - -# rune_capture — text + source + extracted 필수 -mcp_call rune_capture '{"text":"hi","source":"test","extracted":{}}' - -# rune_delete_capture — record_id 필수 -mcp_call rune_delete_capture '{"record_id":"dec_test"}' - -# rune_batch_capture — items (string) 필수 -mcp_call rune_batch_capture '{"items":"[]"}' -``` - -`mcp_call` 헬퍼는 §4.4에 정의. 위 8줄을 그대로 붙여넣으면 8개 모두 동일한 stub 응답이 나오는 것을 확인할 수 있다. - -### 4.4. `mcp_call` bash 헬퍼 함수 - -다음 블록을 `~/.zshrc` / `~/.bashrc` 또는 현재 셸에 그대로 붙여넣으면 `mcp_call` 명령어 사용 가능: +현재 셸에 paste: ```bash mcp_call() { - local tool="$1" - local args="$2" - [ -z "$args" ] && args='{}' + 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 @@ -419,150 +135,48 @@ mcp_call() { } ``` -> ⚠️ **함정 주의**: `local args="${2:-{}}"` 처럼 default value에 `}` 를 쓰면 bash parameter expansion이 깨져서 닫는 brace가 한 개 추가됨 (`{"query":"hello"}}` 가 됨). 위 코드처럼 `[ -z ... ] && args='{}'` 패턴으로 우회하는 게 안전. - -사용 예 (§4.3과 동일): +> ⚠️ default value를 `${2:-{}}` 로 쓰면 bash parameter expansion이 깨짐. 위 `[ -z ... ] && args='{}'` 패턴 필수. ```bash -cd /Users/redcourage/cryptolab/rune-project/rune -mcp_call rune_recall '{"query":"hello world"}' -# {"jsonrpc":"2.0","id":2,"result":{"content":[...],"isError":true,...}} -``` - -### 4.5. 응답 분석 — `jq` 패턴 모음 - -| 목적 | 명령어 (헬퍼 출력 또는 raw 출력에 파이프) | -|---|---| -| 8개 tool 이름만 보기 | `jq -r 'select(.id==2) \| .result.tools[].name'` | -| 특정 tool의 input schema | `jq 'select(.id==2) \| .result.tools[] \| select(.name=="rune_recall") \| .inputSchema'` | -| 특정 tool의 output schema | `jq 'select(.id==2) \| .result.tools[] \| select(.name=="rune_recall") \| .outputSchema'` | -| 모든 tool의 required 필드 매트릭스 | `jq -r 'select(.id==2) \| .result.tools[] \| "\(.name): \(.inputSchema.required \| join(","))"'` | -| `tools/call` 응답에서 텍스트 메시지만 | `jq -r 'select(.id==2) \| .result.content[0].text'` | -| `tools/call` 응답의 isError + structuredContent | `jq 'select(.id==2) \| {isError: .result.isError, structured: .result.structuredContent}'` | -| serverInfo만 | `jq 'select(.id==1) \| .result.serverInfo'` | - -### 4.6. 디버깅 — stderr 분리, raw 메시지 dump - -```bash -# stderr만 따로 보기 (정상이면 비어 있어야 함) -{ - 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.5 -} | ./bin/rune-mcp 2>/tmp/rune-stderr.log >/dev/null -cat /tmp/rune-stderr.log - -# JSON 응답을 한 줄씩 raw로 보기 (jq 없이) -{ ... } | ./bin/rune-mcp 2>/dev/null -# → 줄별 valid JSON. 각 줄을 jq에 따로 파이프해도 됨 - -# 메시지 시퀀스 검증 (input과 output 비교) -{ ... } | tee /tmp/rune-input.log | ./bin/rune-mcp 2>/dev/null | tee /tmp/rune-output.log | jq . -# /tmp/rune-input.log : 보낸 메시지 -# /tmp/rune-output.log: 받은 응답 -``` - -### 4.7. 양방향 stateful 세션 (named pipe / coproc) - -위 모든 명령어는 **단방향** (입력 한꺼번에 보내고 응답 모아서 종료). MCP는 양방향 stateful이라, 진짜 클라이언트처럼 동작하려면 다음이 가능: - -**bash coproc**: -```bash -coproc RUNE { ./bin/rune-mcp 2>/dev/null; } -echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0.0.1"}}}' >&${RUNE[1]} -read -r -u ${RUNE[0]} line; echo "$line" | jq . -echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' >&${RUNE[1]} -echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' >&${RUNE[1]} -read -r -u ${RUNE[0]} line; echo "$line" | jq -r '.result.tools[].name' -exec {RUNE[1]}>&- # stdin 닫음 → server 종료 -wait $COPROC_PID -``` - -대부분의 검증에는 §4.2~4.4의 단방향 패턴으로 충분. coproc은 응답 후 다음 요청을 동적으로 결정해야 할 때만 사용. - -### 4.8. Claude Code 등록 후 검증 - -`~/.claude/mcp.json` 등록(§3 Level 2)이 끝난 뒤: - -```bash -# JSON 문법 검증 (등록 직후 필수) -cat ~/.claude/mcp.json | jq . - -# rune-go-dev entry 존재 확인 -jq '.mcpServers["rune-go-dev"]' ~/.claude/mcp.json - -# 바이너리 권한 확인 -ls -la /Users/redcourage/cryptolab/rune-project/rune/bin/rune-mcp -# → -rwxr-xr-x ... 실행 권한 있어야 함 - -# 바이너리 직접 실행해서 응답 오는지 1회 검증 (§3 1.3과 동일) -cd /Users/redcourage/cryptolab/rune-project/rune && ./bin/rune-mcp < /dev/null; echo "exit=$?" +# 인자 없는 4개 +mcp_call rune_diagnostics +mcp_call rune_vault_status +mcp_call rune_reload_pipelines +mcp_call rune_capture_history -# Claude Code 재시작 후 (수동), 새 세션에서: -# /mcp ← 등록된 서버 목록 표시 -# "rune_diagnostics 호출해" ← Claude가 tool 인식하는지 +# 인자 필수 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":"[]"}' ``` ---- - -## 5. Troubleshooting - -| 증상 | 가능한 원인 | 해결 | -|---|---|---| -| `go build` 실패: `go >= 1.25.0 required` | Go 버전 낮음 | `go install golang.org/dl/go1.25@latest && go1.25 download`, 또는 brew/asdf로 1.25 업그레이드 | -| `go build` 실패: `missing go.sum entry` | `go mod tidy` 안 함 | `go mod tidy` 후 재빌드 | -| Level 1.3 응답 없음 | sleep 너무 짧아 EOF 먼저 옴 | sleep을 0.5 이상으로 | -| Level 2에서 tool 안 보임 | 절대 경로 아님 / JSON 문법 오류 / 권한 부족 | `cat ~/.claude/mcp.json \| jq .`로 JSON 검증 + `chmod +x bin/rune-mcp` | -| Level 2 "Connection failed" | 바이너리 경로 틀림 / 바이너리 stale | 경로 재확인 + `go build` 재실행 | -| Level 2에서 not implemented 안 나오고 다른 에러 | tool 이름 오타 | 8 이름 정확히 입력 | -| Level 3 npx 실패 | Node.js 미설치 | `brew install node` 또는 Level 1·2로 우회 | - ---- - -## 6. 코드 변경 요약 +8개 모두 동일한 stub 응답. -### `cmd/rune-mcp/main.go` (rewrite, 80줄) +## 6. Troubleshooting -스텁 1줄짜리 `log.Println("rune-mcp skeleton — not yet implemented")` 를 다음으로 교체: - -- `context.WithCancel` + signal handler (SIGINT/SIGTERM → cancel) -- 빈 `mcp.Deps{}` (Phase A는 adapter 미주입) -- `sdkmcp.NewServer(&Implementation{Name:"rune-mcp", Version:"0.4.0-alpha"}, nil)` -- `mcp.Register(srv, deps)` — 8 tool 등록 -- `srv.Run(ctx, &sdkmcp.StdioTransport{})` -- `isNormalShutdown(err)` 헬퍼 — io.EOF · ctx Canceled · "server is closing" 모두 정상으로 분류 → exit 0 - -### `internal/mcp/tools.go` (rewrite, 137줄) - -기존 8 tool stub 함수를 SDK handler 패턴으로 재구성: - -- `Register(srv, deps)` — 8 `sdkmcp.AddTool` 호출 -- `stubHandler[In, Out any](toolName)` — generic factory. 어느 tool이든 `IsError=true` + 텍스트 메시지 + zero output 반환 -- `stubResult(toolName)` — 메시지 빌더 -- `Deps` 구조체는 여전히 필드 주석 처리 상태 (Phase 4부터 활성화) - -### `go.mod` / `go.sum` - -- `github.com/modelcontextprotocol/go-sdk v1.5.0` (D2) -- transitive deps: `jsonschema-go`, `uritemplate`, `oauth2`, `segmentio/encoding`, `golang-jwt/jwt`, ... -- Go toolchain `1.24` → `1.25` (SDK 요구) +| 증상 | 해결 | +|---|---| +| `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 사용 | -### `.gitignore` +## 7. 다음 마일스톤 -``` -# Go build artifacts -bin/ -*.test -coverage.out -``` +**가벼운 후속** ---- +- **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. 다음 마일스톤 +**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) -- **Phase B** — `rune_diagnostics`의 environment 섹션을 stdlib만으로 채워서 진짜 응답 받기 (작은 PR, 2-3시간) - - `runtime.GOOS` · `runtime.Version` · `os.Getwd` · `runtime.GOARCH` 만으로 가능 - - 다른 6 섹션은 `null`/zero로 두고 `OK=true` 고정 - - 첫 번째 진짜 응답 → "MCP 표면 + 한 tool 데이터 흐름" 검증 -- **Phase 1 본격** — `go.mod`에 gRPC · protobuf · envector-go SDK · embedder proto stub 추가. 이후 Phase 4 adapter 작업의 전제 조건 +→ 의존성 그래프는 `flow-matrix.md §5-d`. Phase 6은 본 문서가 부분 선행이라 Phase 5에 흡수. diff --git a/go.mod b/go.mod index 6132ac0..499790f 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module github.com/envector/rune-go -go 1.25.0 +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 diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 0079737..60c4c5f 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -17,6 +17,7 @@ package mcp import ( "context" + "fmt" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" @@ -24,83 +25,97 @@ import ( "github.com/envector/rune-go/internal/service" ) -// Deps — injected into all handlers. +// 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. -// Concrete fields stay commented until each adapter has a real Client type; -// the handlers below close over deps already so no signature change later. -type Deps struct { - // Vault vault.Client - // Envector envector.Client - // Embedder embedder.Client - // CaptureLog *logio.CaptureLog - // State *lifecycle.Manager - // Cfg *config.Config -} +// 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{} // emptyArgs — input type for tools that take no arguments. type emptyArgs struct{} // Register binds all 8 MCP tools onto the provided SDK server. // -// Tool naming + ordering are bit-identical to Python `mcp/server/server.py`. -// Descriptions are intentionally short — Claude reads them in tool selection, -// not the user, so they should be a single concrete capability sentence. -func Register(srv *sdkmcp.Server, deps *Deps) { +// Tool names are bit-identical to Python `mcp/server/server.py`. Order in +// this Register call is for readability only; the SDK sorts tools +// alphabetically in `tools/list` output. +// +// AddTool can panic on schema-inference failure (SDK behavior). Register +// recovers so a misconfigured tool surfaces as a startup error instead of +// taking the process down silently after binding. +func Register(srv *sdkmcp.Server, deps *Deps) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("mcp.Register: AddTool panic: %v", r) + } + }() + // Write tools (state gate applies in Phase 5). sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "rune_capture", Description: "Capture a decision record (agent-delegated extraction required).", - }, stubHandler[domain.CaptureRequest, domain.CaptureResponse]("rune_capture")) + }, stubHandler[domain.CaptureRequest, domain.CaptureResponse](deps, "rune_capture")) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "rune_batch_capture", Description: "Capture a batch of decision records (e.g. session-end sweep).", - }, stubHandler[service.BatchCaptureArgs, service.BatchCaptureResult]("rune_batch_capture")) + }, stubHandler[service.BatchCaptureArgs, service.BatchCaptureResult](deps, "rune_batch_capture")) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "rune_recall", Description: "Query organizational memory by natural-language question.", - }, stubHandler[domain.RecallArgs, domain.RecallResult]("rune_recall")) + }, stubHandler[domain.RecallArgs, domain.RecallResult](deps, "rune_recall")) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "rune_delete_capture", Description: "Soft-delete a record by ID (sets status=reverted, re-inserts).", - }, stubHandler[service.DeleteCaptureArgs, service.DeleteCaptureResult]("rune_delete_capture")) + }, stubHandler[service.DeleteCaptureArgs, service.DeleteCaptureResult](deps, "rune_delete_capture")) // Read / diagnostic tools (state gate bypass). sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "rune_capture_history", Description: "List recent captures from local capture_log.jsonl (read-only).", - }, stubHandler[service.CaptureHistoryArgs, service.CaptureHistoryResult]("rune_capture_history")) + }, stubHandler[service.CaptureHistoryArgs, service.CaptureHistoryResult](deps, "rune_capture_history")) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "rune_vault_status", Description: "Probe Vault connectivity and report secure-search mode.", - }, stubHandler[emptyArgs, service.VaultStatusResult]("rune_vault_status")) + }, stubHandler[emptyArgs, service.VaultStatusResult](deps, "rune_vault_status")) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "rune_diagnostics", Description: "Collect a 7-section health snapshot (env / state / vault / keys / pipelines / embedding / envector).", - }, stubHandler[emptyArgs, service.DiagnosticsResult]("rune_diagnostics")) + }, stubHandler[emptyArgs, service.DiagnosticsResult](deps, "rune_diagnostics")) sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "rune_reload_pipelines", Description: "Re-initialize Vault + envector pipelines (BOOT replay) with envector warmup.", - }, stubHandler[emptyArgs, service.ReloadPipelinesResult]("rune_reload_pipelines")) + }, stubHandler[emptyArgs, service.ReloadPipelinesResult](deps, "rune_reload_pipelines")) - _ = deps // Phase A unused; closures will capture this in Phase 5. + return 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. -func stubHandler[In, Out any](toolName string) sdkmcp.ToolHandlerFor[In, Out] { - return func(ctx context.Context, req *sdkmcp.CallToolRequest, in In) (*sdkmcp.CallToolResult, Out, error) { - _ = ctx - _ = req - _ = in +// +// 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 } From a49d4e758c878afa590d712f87d9118b4552b766 Mon Sep 17 00:00:00 2001 From: couragehong Date: Mon, 27 Apr 2026 11:34:36 +0900 Subject: [PATCH 5/6] docs(v04): use printf %s placeholders in mcp_call helper (review feedback) --- docs/v04/progress/phase-a-mcp-boot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/v04/progress/phase-a-mcp-boot.md b/docs/v04/progress/phase-a-mcp-boot.md index 9f9ecdf..6788666 100644 --- a/docs/v04/progress/phase-a-mcp-boot.md +++ b/docs/v04/progress/phase-a-mcp-boot.md @@ -129,7 +129,7 @@ mcp_call() { 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/call\",\"params\":{\"name\":\"$tool\",\"arguments\":$args}}" + 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)' } From 765824683401c00786046ba90a6addf3cfd7c373 Mon Sep 17 00:00:00 2001 From: couragehong Date: Mon, 27 Apr 2026 14:08:20 +0900 Subject: [PATCH 6/6] fix(mcp): panic-guard tool registration via mustAddTool wrapper --- internal/mcp/tools.go | 111 +++++++++++++++++++++++-------------- internal/mcp/tools_test.go | 45 +++++++++++++++ 2 files changed, 113 insertions(+), 43 deletions(-) create mode 100644 internal/mcp/tools_test.go diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 60c4c5f..08c263e 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -48,63 +48,88 @@ type emptyArgs struct{} // Register binds all 8 MCP tools onto the provided SDK server. // -// Tool names are bit-identical to Python `mcp/server/server.py`. Order in -// this Register call is for readability only; the SDK sorts tools -// alphabetically in `tools/list` output. +// 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. // -// AddTool can panic on schema-inference failure (SDK behavior). Register -// recovers so a misconfigured tool surfaces as a startup error instead of -// taking the process down silently after binding. +// 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: AddTool panic: %v", r) + err = fmt.Errorf("mcp.Register: %v", r) } }() // Write tools (state gate applies in Phase 5). - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "rune_capture", - Description: "Capture a decision record (agent-delegated extraction required).", - }, stubHandler[domain.CaptureRequest, domain.CaptureResponse](deps, "rune_capture")) - - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "rune_batch_capture", - Description: "Capture a batch of decision records (e.g. session-end sweep).", - }, stubHandler[service.BatchCaptureArgs, service.BatchCaptureResult](deps, "rune_batch_capture")) - - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "rune_recall", - Description: "Query organizational memory by natural-language question.", - }, stubHandler[domain.RecallArgs, domain.RecallResult](deps, "rune_recall")) - - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "rune_delete_capture", - Description: "Soft-delete a record by ID (sets status=reverted, re-inserts).", - }, stubHandler[service.DeleteCaptureArgs, service.DeleteCaptureResult](deps, "rune_delete_capture")) + 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).") // Read / diagnostic tools (state gate bypass). - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "rune_capture_history", - Description: "List recent captures from local capture_log.jsonl (read-only).", - }, stubHandler[service.CaptureHistoryArgs, service.CaptureHistoryResult](deps, "rune_capture_history")) - - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "rune_vault_status", - Description: "Probe Vault connectivity and report secure-search mode.", - }, stubHandler[emptyArgs, service.VaultStatusResult](deps, "rune_vault_status")) + 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.") - sdkmcp.AddTool(srv, &sdkmcp.Tool{ - Name: "rune_diagnostics", - Description: "Collect a 7-section health snapshot (env / state / vault / keys / pipelines / embedding / envector).", - }, stubHandler[emptyArgs, service.DiagnosticsResult](deps, "rune_diagnostics")) + return 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: "rune_reload_pipelines", - Description: "Re-initialize Vault + envector pipelines (BOOT replay) with envector warmup.", - }, stubHandler[emptyArgs, service.ReloadPipelinesResult](deps, "rune_reload_pipelines")) + Name: name, + Description: description, + }, stubHandler[In, Out](deps, name)) +} - return 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 } // stubHandler returns a SDK ToolHandlerFor that always responds with a 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) + } +}