326fddae
spike: validate channel mechanism, fix meta schema and capability
a73x 2026-04-29 05:41
Commit message
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`. |