a73x

7e1e81c1

feat(mcp): emit notifications/claude/channel via direct JSON-RPC

a73x   2026-04-29 05:58

Commit message
feat(mcp): emit notifications/claude/channel via direct JSON-RPC

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 }