a73x

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)
	}
}