a73x

326fddae

spike: validate channel mechanism, fix meta schema and capability

a73x   2026-04-29 05:41

Commit message
spike: validate channel mechanism, fix meta schema and capability

Two corrections from end-to-end smoke against Claude Code 2.1.121:

1. MCP server must declare experimental.claude/channel capability in
   the initialize response, or Claude Code silently skips channel
   registration ("server did not declare claude/channel capability").

2. notifications/claude/channel meta is Record<string, string>; non-
   string values (e.g. line as number) are silently rejected by Zod
   validation. Line is now stringified.

The original spec and Task 10/11 plan code had line as number, citing
"don't use string-typed integers." Reverted: schema requires string.

docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md
Old New
@@ -45,10 +45,11 @@ func (w *Watcher) Start(ctx context.Context) error
45 func (w *Watcher) Close() error 45 func (w *Watcher) Close() error
46 46
47 // internal/mcp 47 // internal/mcp
48 // All fields are strings: schema is Record<string, string>.
48 type ChannelMeta struct { 49 type ChannelMeta struct {
49 Source string // always "filewatch" 50 Source string // always "filewatch"
50 File string 51 File string
51 Line int 52 Line string // stringified line number
52 ReplyTo string // "fw-XXXXXXXX" 53 ReplyTo string // "fw-XXXXXXXX"
53 } 54 }
54 type Server struct{ /* unexported */ } 55 type Server struct{ /* unexported */ }
@@ -1662,8 +1663,8 @@ func TestSendChannelEmitsCorrectFrame(t *testing.T) {
1662 t.Errorf("meta wrong: %#v", meta) 1663 t.Errorf("meta wrong: %#v", meta)
1663 } 1664 }
1664 // line should be a number, not a string 1665 // line should be a number, not a string
1665 if l, ok := meta["line"].(float64); !ok || l != 42 { 1666 if s, ok := meta["line"].(string); !ok || s != "42" {
1666 t.Errorf("line = %v (%T), want 42 (number)", meta["line"], meta["line"]) 1667 t.Errorf("line = %v (%T), want \"42\" (string)", meta["line"], meta["line"])
1667 } 1668 }
1668 } 1669 }
1669 ``` 1670 ```
@@ -1688,10 +1689,12 @@ import (
1688 "sync" 1689 "sync"
1689 ) 1690 )
1690 1691
1692 // Note: all meta fields are strings — Claude Code's schema is
1693 // Record<string, string> and silently drops non-string values.
1691 type ChannelMeta struct { 1694 type ChannelMeta struct {
1692 Source string `json:"source"` 1695 Source string `json:"source"`
1693 File string `json:"file"` 1696 File string `json:"file"`
1694 Line int `json:"line"` 1697 Line string `json:"line"`
1695 ReplyTo string `json:"replyTo"` 1698 ReplyTo string `json:"replyTo"`
1696 } 1699 }
1697 1700
@@ -1761,9 +1764,21 @@ func (s *Server) Run(ctx context.Context) error {
1761 var result any 1764 var result any
1762 switch method { 1765 switch method {
1763 case "initialize": 1766 case "initialize":
1767 // Echo the client's protocolVersion so we accept whatever they speak.
1768 params, _ := req["params"].(map[string]any)
1769 protoVer, _ := params["protocolVersion"].(string)
1770 if protoVer == "" {
1771 protoVer = "2024-11-05"
1772 }
1773 // experimental.claude/channel capability is REQUIRED — Claude Code
1774 // silently skips channel registration without it.
1764 result = map[string]any{ 1775 result = map[string]any{
1765 "protocolVersion": "2024-11-05", 1776 "protocolVersion": protoVer,
1766 "capabilities": map[string]any{}, 1777 "capabilities": map[string]any{
1778 "experimental": map[string]any{
1779 "claude/channel": map[string]any{},
1780 },
1781 },
1767 "serverInfo": map[string]any{ 1782 "serverInfo": map[string]any{
1768 "name": "filewatch-mcp", 1783 "name": "filewatch-mcp",
1769 "version": "0.1.0", 1784 "version": "0.1.0",
@@ -1827,6 +1842,7 @@ import (
1827 "log" 1842 "log"
1828 "os" 1843 "os"
1829 "os/signal" 1844 "os/signal"
1845 "strconv"
1830 "syscall" 1846 "syscall"
1831 "time" 1847 "time"
1832 1848
@@ -1903,7 +1919,7 @@ func handle(ctx context.Context, path string, srv *mcp.Server) {
1903 if err := srv.SendChannel(ctx, mcp.ChannelMeta{ 1919 if err := srv.SendChannel(ctx, mcp.ChannelMeta{
1904 Source: "filewatch", 1920 Source: "filewatch",
1905 File: path, 1921 File: path,
1906 Line: m.Line, 1922 Line: strconv.Itoa(m.Line),
1907 ReplyTo: id, 1923 ReplyTo: id,
1908 }, m.Text); err != nil { 1924 }, m.Text); err != nil {
1909 log.Printf("send: %v", err) 1925 log.Printf("send: %v", err)
docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md
Old New
@@ -82,7 +82,24 @@ Consequences:
82 82
83 ### `internal/mcp` 83 ### `internal/mcp`
84 - MCP stdio server using `github.com/modelcontextprotocol/go-sdk`. 84 - MCP stdio server using `github.com/modelcontextprotocol/go-sdk`.
85 - For each fired (untagged) marker, emits one outbound notification: 85 - **Init handshake must declare the channel capability**, or Claude Code silently skips registration:
86
87 ```json
88 {
89 "jsonrpc": "2.0", "id": 0,
90 "result": {
91 "protocolVersion": "<echo client's>",
92 "capabilities": {
93 "experimental": {
94 "claude/channel": {}
95 }
96 },
97 "serverInfo": { "name": "filewatch-mcp", "version": "..." }
98 }
99 }
100 ```
101
102 - For each fired (untagged) marker, emits one outbound notification. **All `meta` values must be strings** — Claude Code's schema is `Record<string, string>`; non-string values are silently rejected by validation.
86 103
87 ```json 104 ```json
88 { 105 {
@@ -93,7 +110,7 @@ Consequences:
93 "meta": { 110 "meta": {
94 "source": "filewatch", 111 "source": "filewatch",
95 "file": "src/foo.ts", 112 "file": "src/foo.ts",
96 "line": 42, 113 "line": "42",
97 "replyTo": "fw-a1b2c3d4" 114 "replyTo": "fw-a1b2c3d4"
98 } 115 }
99 } 116 }
@@ -102,6 +119,7 @@ Consequences:
102 119
103 Claude renders this as `<channel source="filewatch" file="src/foo.ts" line="42" replyTo="fw-a1b2c3d4">@claude can you write a test for foo</channel>`. The `replyTo` UUID matches the bracketed tag stamped into the file. 120 Claude renders this as `<channel source="filewatch" file="src/foo.ts" line="42" replyTo="fw-a1b2c3d4">@claude can you write a test for foo</channel>`. The `replyTo` UUID matches the bracketed tag stamped into the file.
104 - No reply tool exposed in v1. Claude's `Edit` to remove the marker is the implicit ack. 121 - No reply tool exposed in v1. Claude's `Edit` to remove the marker is the implicit ack.
122 - Confirmed via spike on Claude Code v2.1.121 with `--dangerously-load-development-channels server:<name>`.
105 123
106 ### `internal/ignore` 124 ### `internal/ignore`
107 - `.gitignore` parsing via `github.com/sabhiram/go-gitignore`. 125 - `.gitignore` parsing via `github.com/sabhiram/go-gitignore`.