7e1e81c1
feat(mcp): emit notifications/claude/channel via direct JSON-RPC
a73x 2026-04-29 05:58
diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 0000000..6ed9ce8 --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,116 @@ package mcp import ( "context" "encoding/json" "io" "os" "sync" ) // ChannelMeta is the meta block of a notifications/claude/channel notification. // All fields are strings: Claude Code's schema is Record<string, string> and // silently drops non-string values. type ChannelMeta struct { Source string `json:"source"` File string `json:"file"` Line string `json:"line"` ReplyTo string `json:"replyTo"` } type Server struct { in io.Reader out io.Writer mu sync.Mutex // serializes writes to out } func New() *Server { return &Server{in: os.Stdin, out: os.Stdout} } // NewWithIO is for tests. func NewWithIO(in io.Reader, out io.Writer) *Server { return &Server{in: in, out: out} } func (s *Server) SendChannel(ctx context.Context, meta ChannelMeta, content string) error { frame := map[string]any{ "jsonrpc": "2.0", "method": "notifications/claude/channel", "params": map[string]any{ "content": content, "meta": map[string]any{ "source": meta.Source, "file": meta.File, "line": meta.Line, "replyTo": meta.ReplyTo, }, }, } s.mu.Lock() defer s.mu.Unlock() return json.NewEncoder(s.out).Encode(frame) } // Run drives the JSON-RPC request loop. Reads requests on s.in; responds to // initialize with the required experimental.claude/channel capability; replies // with method-not-found for any other request. Notifications (no id) are // ignored. Returns nil on EOF or context cancellation. func (s *Server) Run(ctx context.Context) error { dec := json.NewDecoder(s.in) for { if ctx.Err() != nil { return nil } var req map[string]any if err := dec.Decode(&req); err != nil { if err == io.EOF { return nil } return err } method, _ := req["method"].(string) id := req["id"] if id == nil { continue // notification, ignore } switch method { case "initialize": params, _ := req["params"].(map[string]any) protoVer, _ := params["protocolVersion"].(string) if protoVer == "" { protoVer = "2024-11-05" } s.writeResult(id, map[string]any{ "protocolVersion": protoVer, "capabilities": map[string]any{ "experimental": map[string]any{ "claude/channel": map[string]any{}, }, }, "serverInfo": map[string]any{ "name": "filewatch-mcp", "version": "0.1.0", }, }) default: s.writeError(id, -32601, "method not found") } } } func (s *Server) writeResult(id, result any) { s.mu.Lock() defer s.mu.Unlock() _ = json.NewEncoder(s.out).Encode(map[string]any{ "jsonrpc": "2.0", "id": id, "result": result, }) } func (s *Server) writeError(id any, code int, msg string) { s.mu.Lock() defer s.mu.Unlock() _ = json.NewEncoder(s.out).Encode(map[string]any{ "jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": msg}, }) } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go new file mode 100644 index 0000000..bf7e027 --- /dev/null +++ b/internal/mcp/mcp_test.go @@ -0,0 +1,74 @@ package mcp import ( "bytes" "context" "encoding/json" "strings" "testing" ) func TestSendChannelEmitsCorrectFrame(t *testing.T) { var buf bytes.Buffer s := NewWithIO(strings.NewReader(""), &buf) err := s.SendChannel(context.Background(), ChannelMeta{ Source: "filewatch", File: "src/foo.ts", Line: "42", ReplyTo: "fw-a1b2c3d4", }, "@claude write a test for foo") if err != nil { t.Fatal(err) } var frame map[string]any if err := json.NewDecoder(&buf).Decode(&frame); err != nil { t.Fatal(err) } if frame["jsonrpc"] != "2.0" { t.Errorf("jsonrpc = %v, want 2.0", frame["jsonrpc"]) } if frame["method"] != "notifications/claude/channel" { t.Errorf("method = %v, want notifications/claude/channel", frame["method"]) } params := frame["params"].(map[string]any) if params["content"] != "@claude write a test for foo" { t.Errorf("content = %v", params["content"]) } meta := params["meta"].(map[string]any) if meta["source"] != "filewatch" || meta["file"] != "src/foo.ts" || meta["replyTo"] != "fw-a1b2c3d4" { t.Errorf("meta wrong: %#v", meta) } // line MUST be a string — Claude Code's schema is Record<string, string> and // silently rejects non-string values. if s, ok := meta["line"].(string); !ok || s != "42" { t.Errorf("line = %v (%T), want \"42\" (string)", meta["line"], meta["line"]) } } func TestInitializeAdvertisesChannelCapability(t *testing.T) { in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}` + "\n") var out bytes.Buffer s := NewWithIO(in, &out) if err := s.Run(context.Background()); err != nil { t.Fatal(err) } var resp map[string]any if err := json.NewDecoder(&out).Decode(&resp); err != nil { t.Fatalf("decode: %v\nstdout was: %s", err, out.String()) } if resp["id"] != float64(1) { t.Errorf("id = %v", resp["id"]) } result, ok := resp["result"].(map[string]any) if !ok { t.Fatalf("no result: %#v", resp) } if result["protocolVersion"] != "2025-11-25" { t.Errorf("protocolVersion echo wrong: %v", result["protocolVersion"]) } caps, _ := result["capabilities"].(map[string]any) exp, _ := caps["experimental"].(map[string]any) if _, has := exp["claude/channel"]; !has { t.Errorf("missing experimental.claude/channel capability: %#v", caps) } }