Skip to content

Commit 19b7bf6

Browse files
redcourageclaude
andcommitted
feat(go): first MCP boot — handshake + tools/list (Phase A)
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) <noreply@anthropic.com>
1 parent dbfdea7 commit 19b7bf6

5 files changed

Lines changed: 184 additions & 96 deletions

File tree

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,9 @@ __pycache__/
5454
.worktrees/
5555

5656
# Internal Planning
57-
docs/planning/
57+
docs/planning/
58+
59+
# Go build artifacts
60+
bin/
61+
*.test
62+
coverage.out

cmd/rune-mcp/main.go

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,76 @@
66
// Tools: 8 MCP tools (capture, recall, batch_capture, capture_history,
77
// delete_capture, vault_status, diagnostics, reload_pipelines).
88
//
9+
// Phase A (current): MCP handshake + tools/list only. All 8 handlers return
10+
// "not yet implemented" CallToolResult. RunBootLoop · Vault · envector ·
11+
// embedder are not wired. Phase 4-5 brings real adapters + service logic.
12+
//
913
// Python reference: mcp/server/server.py (2002 LoC)
1014
package main
1115

1216
import (
1317
"context"
18+
"errors"
19+
"io"
1420
"log"
1521
"os"
1622
"os/signal"
23+
"strings"
1724
"syscall"
25+
26+
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
27+
28+
"github.com/envector/rune-go/internal/mcp"
1829
)
1930

31+
// version is the rune-mcp protocol version surfaced in MCP `initialize`.
32+
// Phase A is "0.4.0-alpha" until adapters are wired.
33+
const version = "0.4.0-alpha"
34+
2035
func main() {
2136
ctx, cancel := context.WithCancel(context.Background())
2237
defer cancel()
2338

24-
// TODO Phase 1: config.Load("~/.rune/config.json") — 3-section schema
25-
// TODO Phase 2: lifecycle.RunBootLoop(ctx, deps) — Vault.GetPublicKey retry
26-
// TODO Phase 3: mcp.NewServer + RegisterTools + Serve(stdio)
27-
// TODO Phase 4: graceful shutdown (30s) on stdin EOF / SIGTERM
28-
//
29-
// Python reference: server.py:main() + RunMCPServer lifecycle.
30-
39+
// SIGINT / SIGTERM → cancel ctx → srv.Run unblocks.
40+
// stdin EOF (Claude window closed) also unblocks Run via the StdioTransport.
3141
sigCh := make(chan os.Signal, 1)
3242
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
43+
go func() {
44+
select {
45+
case <-sigCh:
46+
cancel()
47+
case <-ctx.Done():
48+
}
49+
}()
50+
51+
// Phase A: empty Deps. RunBootLoop / config.Load / adapter wiring deferred.
52+
deps := &mcp.Deps{}
3353

34-
log.Println("rune-mcp skeleton — not yet implemented")
54+
srv := sdkmcp.NewServer(&sdkmcp.Implementation{
55+
Name: "rune-mcp",
56+
Version: version,
57+
}, nil)
3558

36-
select {
37-
case <-ctx.Done():
38-
case <-sigCh:
39-
cancel()
59+
mcp.Register(srv, deps)
60+
61+
if err := srv.Run(ctx, &sdkmcp.StdioTransport{}); err != nil && !isNormalShutdown(err) {
62+
log.Printf("rune-mcp serve error: %v", err)
63+
os.Exit(1)
64+
}
65+
}
66+
67+
// isNormalShutdown reports whether err corresponds to expected stdio teardown
68+
// (stdin EOF, ctx cancel from SIGINT/SIGTERM, or the SDK's internal
69+
// jsonrpc2.ErrServerClosing surfacing as "server is closing"). Those are not
70+
// failures and must not produce exit code 1.
71+
func isNormalShutdown(err error) bool {
72+
if err == nil {
73+
return true
74+
}
75+
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
76+
return true
4077
}
78+
// jsonrpc2.ErrServerClosing lives in an internal package, so we can't use
79+
// errors.Is. The message is stable per the SDK source.
80+
return strings.Contains(err.Error(), "server is closing")
4181
}

go.mod

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/envector/rune-go
22

3-
go 1.24
3+
go 1.25.0
44

55
// External dependencies to be added as implementation progresses:
66
//
@@ -10,3 +10,14 @@ go 1.24
1010
// github.com/CryptoLabInc/envector-go-sdk — envector FHE client (Q4 PR pending)
1111
//
1212
// Skeleton stage: stdlib only. No external imports yet.
13+
14+
require github.com/modelcontextprotocol/go-sdk v1.5.0
15+
16+
require (
17+
github.com/google/jsonschema-go v0.4.2 // indirect
18+
github.com/segmentio/asm v1.1.3 // indirect
19+
github.com/segmentio/encoding v0.5.4 // indirect
20+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
21+
golang.org/x/oauth2 v0.35.0 // indirect
22+
golang.org/x/sys v0.41.0 // indirect
23+
)

go.sum

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
2+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
3+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5+
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
6+
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
7+
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
8+
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
9+
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
10+
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
11+
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
12+
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
13+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
14+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
15+
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
16+
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
17+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
18+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
19+
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
20+
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=

internal/mcp/tools.go

Lines changed: 94 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,119 @@
1-
// Package mcp holds the 8 MCP tool handlers + stdio server wiring.
1+
// Package mcp wires the 8 MCP tool handlers onto the official Go SDK and
2+
// owns Deps injection + state-aware response shaping.
3+
//
24
// Spec:
3-
// docs/v04/spec/flows/capture.md (7-phase capture)
4-
// docs/v04/spec/flows/recall.md (7-phase recall)
5-
// docs/v04/spec/flows/lifecycle.md (6 other tools)
6-
// Python: mcp/server/server.py (2002 LoC).
5+
// docs/v04/spec/components/rune-mcp.md (MCP server 구현)
6+
// docs/v04/spec/flows/{capture,recall,lifecycle}.md
7+
//
8+
// SDK: github.com/modelcontextprotocol/go-sdk v1.5.0+ (D2). Stdio transport.
9+
// Input schema is auto-inferred from the Go input struct (jsonschema tags
10+
// optional but recommended; will be tightened in Phase 5).
711
//
8-
// MCP SDK: github.com/modelcontextprotocol/go-sdk v1.5.0+ (D2).
9-
// Stdio transport, tools/call dispatch. Input schema auto-generated from Go
10-
// structs with jsonschema tags.
12+
// Phase A (current): handshake + tools/list only. Every handler returns a
13+
// stubResult ("not yet implemented") so Claude Code can discover the catalog
14+
// without any adapter being wired. Phase 5 replaces each stub with a
15+
// service-layer call (CheckState → service.X.Handle → response wrap).
1116
package mcp
1217

1318
import (
1419
"context"
1520

21+
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
22+
1623
"github.com/envector/rune-go/internal/domain"
24+
"github.com/envector/rune-go/internal/service"
1725
)
1826

19-
// Deps — injected into all handlers. TODO: fill as adapters stabilize.
27+
// Deps — injected into all handlers.
28+
//
29+
// Phase A: empty struct. Adapter clients · state machine · config will be
30+
// added as Phase 4 (adapters) and Phase 5 (service orchestration) land.
31+
// Concrete fields stay commented until each adapter has a real Client type;
32+
// the handlers below close over deps already so no signature change later.
2033
type Deps struct {
21-
// Vault vault.Client
22-
// Envector envector.Client
23-
// Embedder embedder.Client
34+
// Vault vault.Client
35+
// Envector envector.Client
36+
// Embedder embedder.Client
2437
// CaptureLog *logio.CaptureLog
25-
// State *lifecycle.Manager
26-
// Cfg *config.Config
38+
// State *lifecycle.Manager
39+
// Cfg *config.Config
2740
}
2841

29-
// ─────────────────────────────────────────────────────────────────────────────
30-
// 8 MCP tools — Python bit-identical names/shapes
31-
// ─────────────────────────────────────────────────────────────────────────────
32-
33-
// ToolCapture — rune_capture. Python: server.py:L698-806 + L1208-1407 _capture_single.
34-
// 7-phase flow (spec/flows/capture.md).
35-
func ToolCapture(ctx context.Context, deps *Deps, req *domain.CaptureRequest) (*domain.CaptureResponse, error) {
36-
// TODO Phase 1: state gate
37-
// TODO Phase 2: validate + tier2 check + text_to_embed pick
38-
// TODO Phase 3: embedder.EmbedSingle(text)
39-
// TODO Phase 4: envector.Score + Vault.DecryptScores → novelty (D11 thresholds)
40-
// TODO Phase 5: record_builder.BuildPhases + embedder.EmbedBatch + AES Seal
41-
// TODO Phase 6: envector.Insert (batch)
42-
// TODO Phase 7: capture_log append + respond
43-
return nil, nil
44-
}
42+
// emptyArgs — input type for tools that take no arguments.
43+
type emptyArgs struct{}
4544

46-
// ToolRecall — rune_recall. Python: server.py:L910-1034. 7-phase.
47-
func ToolRecall(ctx context.Context, deps *Deps, args *domain.RecallArgs) (*domain.RecallResult, error) {
48-
// TODO Phase 1: state gate + topk validation (max 10)
49-
// TODO Phase 2: policy.Parse (English only — D21)
50-
// TODO Phase 3: embedder.EmbedBatch(expansions[:3]) (D22/D23)
51-
// TODO Phase 4: sequential 4-RPC per expansion (D25): Score → DecryptScores
52-
// → GetMetadata → DecryptMetadata (service layer calls Vault directly)
53-
// TODO Phase 5: metadata 3-way classify (AES/plain/legacy base64 per D26)
54-
// TODO Phase 6: phase_chain expansion (D27) + group assemble + filter
55-
// + recency weighting (half-life 90d, status mul)
56-
// TODO Phase 7: build response (synthesized=false per D28)
57-
return nil, nil
58-
}
45+
// Register binds all 8 MCP tools onto the provided SDK server.
46+
//
47+
// Tool naming + ordering are bit-identical to Python `mcp/server/server.py`.
48+
// Descriptions are intentionally short — Claude reads them in tool selection,
49+
// not the user, so they should be a single concrete capability sentence.
50+
func Register(srv *sdkmcp.Server, deps *Deps) {
51+
// Write tools (state gate applies in Phase 5).
52+
sdkmcp.AddTool(srv, &sdkmcp.Tool{
53+
Name: "rune_capture",
54+
Description: "Capture a decision record (agent-delegated extraction required).",
55+
}, stubHandler[domain.CaptureRequest, domain.CaptureResponse]("rune_capture"))
5956

60-
// ToolBatchCapture — rune_batch_capture. Python: server.py:L810-896.
61-
// Per-item independent processing (skipped/captured/near_duplicate/error).
62-
func ToolBatchCapture(ctx context.Context, deps *Deps, items []domain.CaptureRequest) (any, error) {
63-
// TODO: per-item _capture_single call + summary (captured/skipped/errors)
64-
return nil, nil
65-
}
57+
sdkmcp.AddTool(srv, &sdkmcp.Tool{
58+
Name: "rune_batch_capture",
59+
Description: "Capture a batch of decision records (e.g. session-end sweep).",
60+
}, stubHandler[service.BatchCaptureArgs, service.BatchCaptureResult]("rune_batch_capture"))
6661

67-
// ToolCaptureHistory — rune_capture_history. Python: server.py:L1092-1111.
68-
// Read ~/.rune/capture_log.jsonl reverse, filter by domain/since, limit (default 20, max 100).
69-
func ToolCaptureHistory(ctx context.Context, deps *Deps, limit int, domainFilter, since *string) (any, error) {
70-
// TODO: logio.Tail
71-
return nil, nil
72-
}
62+
sdkmcp.AddTool(srv, &sdkmcp.Tool{
63+
Name: "rune_recall",
64+
Description: "Query organizational memory by natural-language question.",
65+
}, stubHandler[domain.RecallArgs, domain.RecallResult]("rune_recall"))
7366

74-
// ToolDeleteCapture — rune_delete_capture. Python: server.py:L1123-1206.
75-
// Soft-delete: search_by_id → set status=reverted → re-embed → re-insert → log.
76-
func ToolDeleteCapture(ctx context.Context, deps *Deps, recordID string) (any, error) {
77-
// TODO: soft-delete flow
78-
return nil, nil
79-
}
67+
sdkmcp.AddTool(srv, &sdkmcp.Tool{
68+
Name: "rune_delete_capture",
69+
Description: "Soft-delete a record by ID (sets status=reverted, re-inserts).",
70+
}, stubHandler[service.DeleteCaptureArgs, service.DeleteCaptureResult]("rune_delete_capture"))
8071

81-
// ToolVaultStatus — rune_vault_status. Python: server.py:L496-528. Read-only.
82-
func ToolVaultStatus(ctx context.Context, deps *Deps) (any, error) {
83-
// TODO: vault.HealthCheck + mode/endpoint response
84-
return nil, nil
85-
}
72+
// Read / diagnostic tools (state gate bypass).
73+
sdkmcp.AddTool(srv, &sdkmcp.Tool{
74+
Name: "rune_capture_history",
75+
Description: "List recent captures from local capture_log.jsonl (read-only).",
76+
}, stubHandler[service.CaptureHistoryArgs, service.CaptureHistoryResult]("rune_capture_history"))
77+
78+
sdkmcp.AddTool(srv, &sdkmcp.Tool{
79+
Name: "rune_vault_status",
80+
Description: "Probe Vault connectivity and report secure-search mode.",
81+
}, stubHandler[emptyArgs, service.VaultStatusResult]("rune_vault_status"))
82+
83+
sdkmcp.AddTool(srv, &sdkmcp.Tool{
84+
Name: "rune_diagnostics",
85+
Description: "Collect a 7-section health snapshot (env / state / vault / keys / pipelines / embedding / envector).",
86+
}, stubHandler[emptyArgs, service.DiagnosticsResult]("rune_diagnostics"))
87+
88+
sdkmcp.AddTool(srv, &sdkmcp.Tool{
89+
Name: "rune_reload_pipelines",
90+
Description: "Re-initialize Vault + envector pipelines (BOOT replay) with envector warmup.",
91+
}, stubHandler[emptyArgs, service.ReloadPipelinesResult]("rune_reload_pipelines"))
8692

87-
// ToolDiagnostics — rune_diagnostics. Python: server.py:L540-684.
88-
// 7 sections: environment / state / vault / keys / pipelines / embedding / envector.
89-
func ToolDiagnostics(ctx context.Context, deps *Deps) (any, error) {
90-
// TODO: collect 7 sections + 5s envector timeout
91-
return nil, nil
93+
_ = deps // Phase A unused; closures will capture this in Phase 5.
9294
}
9395

94-
// ToolReloadPipelines — rune_reload_pipelines. Python: server.py:L1046-1089.
95-
// Re-init scribe/retriever pipelines + envector GetIndexList warmup (60s timeout).
96-
func ToolReloadPipelines(ctx context.Context, deps *Deps) (any, error) {
97-
// TODO: AwaitInitDone + ReinitPipelines + 60s warmup
98-
return nil, nil
96+
// stubHandler returns a SDK ToolHandlerFor that always responds with a
97+
// not-yet-implemented isError result. Output type is preserved so tools/list
98+
// can still publish the inferred output schema.
99+
func stubHandler[In, Out any](toolName string) sdkmcp.ToolHandlerFor[In, Out] {
100+
return func(ctx context.Context, req *sdkmcp.CallToolRequest, in In) (*sdkmcp.CallToolResult, Out, error) {
101+
_ = ctx
102+
_ = req
103+
_ = in
104+
var zero Out
105+
return stubResult(toolName), zero, nil
106+
}
99107
}
100108

101-
// Register — called from main() to bind all 8 tools to the MCP SDK server.
102-
// TODO: uses github.com/modelcontextprotocol/go-sdk/mcp.AddTool for each.
103-
func Register(/* srv *mcp.Server, */ deps *Deps) {
104-
// TODO:
105-
// mcp.AddTool(srv, &mcp.Tool{Name: "rune_capture", ...}, ToolCapture adapter)
106-
// ... 8 tools
109+
// stubResult composes the Phase-A "not implemented" response.
110+
func stubResult(toolName string) *sdkmcp.CallToolResult {
111+
return &sdkmcp.CallToolResult{
112+
IsError: true,
113+
Content: []sdkmcp.Content{
114+
&sdkmcp.TextContent{
115+
Text: toolName + " is not yet implemented (skeleton phase A — MCP handshake + tools/list only).",
116+
},
117+
},
118+
}
107119
}

0 commit comments

Comments
 (0)