7e1e81c1
feat(mcp): emit notifications/claude/channel via direct JSON-RPC
a73x 2026-04-29 05:58
Commit message
internal/mcp/mcp.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,116 @@ | |||
| 1 | package mcp | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "encoding/json" | ||
| 6 | "io" | ||
| 7 | "os" | ||
| 8 | "sync" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // ChannelMeta is the meta block of a notifications/claude/channel notification. | ||
| 12 | // All fields are strings: Claude Code's schema is Record<string, string> and | ||
| 13 | // silently drops non-string values. | ||
| 14 | type ChannelMeta struct { | ||
| 15 | Source string `json:"source"` | ||
| 16 | File string `json:"file"` | ||
| 17 | Line string `json:"line"` | ||
| 18 | ReplyTo string `json:"replyTo"` | ||
| 19 | } | ||
| 20 | |||
| 21 | type Server struct { | ||
| 22 | in io.Reader | ||
| 23 | out io.Writer | ||
| 24 | mu sync.Mutex // serializes writes to out | ||
| 25 | } | ||
| 26 | |||
| 27 | func New() *Server { | ||
| 28 | return &Server{in: os.Stdin, out: os.Stdout} | ||
| 29 | } | ||
| 30 | |||
| 31 | // NewWithIO is for tests. | ||
| 32 | func NewWithIO(in io.Reader, out io.Writer) *Server { | ||
| 33 | return &Server{in: in, out: out} | ||
| 34 | } | ||
| 35 | |||
| 36 | func (s *Server) SendChannel(ctx context.Context, meta ChannelMeta, content string) error { | ||
| 37 | frame := map[string]any{ | ||
| 38 | "jsonrpc": "2.0", | ||
| 39 | "method": "notifications/claude/channel", | ||
| 40 | "params": map[string]any{ | ||
| 41 | "content": content, | ||
| 42 | "meta": map[string]any{ | ||
| 43 | "source": meta.Source, | ||
| 44 | "file": meta.File, | ||
| 45 | "line": meta.Line, | ||
| 46 | "replyTo": meta.ReplyTo, | ||
| 47 | }, | ||
| 48 | }, | ||
| 49 | } | ||
| 50 | s.mu.Lock() | ||
| 51 | defer s.mu.Unlock() | ||
| 52 | return json.NewEncoder(s.out).Encode(frame) | ||
| 53 | } | ||
| 54 | |||
| 55 | // Run drives the JSON-RPC request loop. Reads requests on s.in; responds to | ||
| 56 | // initialize with the required experimental.claude/channel capability; replies | ||
| 57 | // with method-not-found for any other request. Notifications (no id) are | ||
| 58 | // ignored. Returns nil on EOF or context cancellation. | ||
| 59 | func (s *Server) Run(ctx context.Context) error { | ||
| 60 | dec := json.NewDecoder(s.in) | ||
| 61 | for { | ||
| 62 | if ctx.Err() != nil { | ||
| 63 | return nil | ||
| 64 | } | ||
| 65 | var req map[string]any | ||
| 66 | if err := dec.Decode(&req); err != nil { | ||
| 67 | if err == io.EOF { | ||
| 68 | return nil | ||
| 69 | } | ||
| 70 | return err | ||
| 71 | } | ||
| 72 | method, _ := req["method"].(string) | ||
| 73 | id := req["id"] | ||
| 74 | if id == nil { | ||
| 75 | continue // notification, ignore | ||
| 76 | } | ||
| 77 | switch method { | ||
| 78 | case "initialize": | ||
| 79 | params, _ := req["params"].(map[string]any) | ||
| 80 | protoVer, _ := params["protocolVersion"].(string) | ||
| 81 | if protoVer == "" { | ||
| 82 | protoVer = "2024-11-05" | ||
| 83 | } | ||
| 84 | s.writeResult(id, map[string]any{ | ||
| 85 | "protocolVersion": protoVer, | ||
| 86 | "capabilities": map[string]any{ | ||
| 87 | "experimental": map[string]any{ | ||
| 88 | "claude/channel": map[string]any{}, | ||
| 89 | }, | ||
| 90 | }, | ||
| 91 | "serverInfo": map[string]any{ | ||
| 92 | "name": "filewatch-mcp", | ||
| 93 | "version": "0.1.0", | ||
| 94 | }, | ||
| 95 | }) | ||
| 96 | default: | ||
| 97 | s.writeError(id, -32601, "method not found") | ||
| 98 | } | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | func (s *Server) writeResult(id, result any) { | ||
| 103 | s.mu.Lock() | ||
| 104 | defer s.mu.Unlock() | ||
| 105 | _ = json.NewEncoder(s.out).Encode(map[string]any{ | ||
| 106 | "jsonrpc": "2.0", "id": id, "result": result, | ||
| 107 | }) | ||
| 108 | } | ||
| 109 | |||
| 110 | func (s *Server) writeError(id any, code int, msg string) { | ||
| 111 | s.mu.Lock() | ||
| 112 | defer s.mu.Unlock() | ||
| 113 | _ = json.NewEncoder(s.out).Encode(map[string]any{ | ||
| 114 | "jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": msg}, | ||
| 115 | }) | ||
| 116 | } | ||
internal/mcp/mcp_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,74 @@ | |||
| 1 | package mcp | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "context" | ||
| 6 | "encoding/json" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func TestSendChannelEmitsCorrectFrame(t *testing.T) { | ||
| 12 | var buf bytes.Buffer | ||
| 13 | s := NewWithIO(strings.NewReader(""), &buf) | ||
| 14 | err := s.SendChannel(context.Background(), ChannelMeta{ | ||
| 15 | Source: "filewatch", | ||
| 16 | File: "src/foo.ts", | ||
| 17 | Line: "42", | ||
| 18 | ReplyTo: "fw-a1b2c3d4", | ||
| 19 | }, "@claude write a test for foo") | ||
| 20 | if err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | var frame map[string]any | ||
| 24 | if err := json.NewDecoder(&buf).Decode(&frame); err != nil { | ||
| 25 | t.Fatal(err) | ||
| 26 | } | ||
| 27 | if frame["jsonrpc"] != "2.0" { | ||
| 28 | t.Errorf("jsonrpc = %v, want 2.0", frame["jsonrpc"]) | ||
| 29 | } | ||
| 30 | if frame["method"] != "notifications/claude/channel" { | ||
| 31 | t.Errorf("method = %v, want notifications/claude/channel", frame["method"]) | ||
| 32 | } | ||
| 33 | params := frame["params"].(map[string]any) | ||
| 34 | if params["content"] != "@claude write a test for foo" { | ||
| 35 | t.Errorf("content = %v", params["content"]) | ||
| 36 | } | ||
| 37 | meta := params["meta"].(map[string]any) | ||
| 38 | if meta["source"] != "filewatch" || meta["file"] != "src/foo.ts" || meta["replyTo"] != "fw-a1b2c3d4" { | ||
| 39 | t.Errorf("meta wrong: %#v", meta) | ||
| 40 | } | ||
| 41 | // line MUST be a string — Claude Code's schema is Record<string, string> and | ||
| 42 | // silently rejects non-string values. | ||
| 43 | if s, ok := meta["line"].(string); !ok || s != "42" { | ||
| 44 | t.Errorf("line = %v (%T), want \"42\" (string)", meta["line"], meta["line"]) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | func TestInitializeAdvertisesChannelCapability(t *testing.T) { | ||
| 49 | in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}` + "\n") | ||
| 50 | var out bytes.Buffer | ||
| 51 | s := NewWithIO(in, &out) | ||
| 52 | if err := s.Run(context.Background()); err != nil { | ||
| 53 | t.Fatal(err) | ||
| 54 | } | ||
| 55 | var resp map[string]any | ||
| 56 | if err := json.NewDecoder(&out).Decode(&resp); err != nil { | ||
| 57 | t.Fatalf("decode: %v\nstdout was: %s", err, out.String()) | ||
| 58 | } | ||
| 59 | if resp["id"] != float64(1) { | ||
| 60 | t.Errorf("id = %v", resp["id"]) | ||
| 61 | } | ||
| 62 | result, ok := resp["result"].(map[string]any) | ||
| 63 | if !ok { | ||
| 64 | t.Fatalf("no result: %#v", resp) | ||
| 65 | } | ||
| 66 | if result["protocolVersion"] != "2025-11-25" { | ||
| 67 | t.Errorf("protocolVersion echo wrong: %v", result["protocolVersion"]) | ||
| 68 | } | ||
| 69 | caps, _ := result["capabilities"].(map[string]any) | ||
| 70 | exp, _ := caps["experimental"].(map[string]any) | ||
| 71 | if _, has := exp["claude/channel"]; !has { | ||
| 72 | t.Errorf("missing experimental.claude/channel capability: %#v", caps) | ||
| 73 | } | ||
| 74 | } | ||