Skip to content

Commit 12cf517

Browse files
committed
pin SDK contracts for channel notification delivery
Review notifications ride the Claude Code channels contract (ADR 2.1) through two go-sdk behaviors research could not verify from docs alone: a Capabilities override carrying only an Experimental entry, and raw ID-less writes on the session connection. Learning tests prove against the pinned SDK that the inferred tools capability survives the override while the logging default is dropped (the conditional wiring must declare Logging explicitly), and that a raw notifications/claude/channel request written to the connection captured at Connect arrives intact as a notification while concurrent tool calls share the connection. The capture transport hands the inner connection to the SDK unwrapped: a proxying wrapper would defeat the SDK's unexported session-state type assert. Refs: #49
1 parent c1fe1d8 commit 12cf517

1 file changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
package main_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"sync"
8+
"testing"
9+
"time"
10+
11+
"dev.gaijin.team/go/golib/e"
12+
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
13+
"github.com/modelcontextprotocol/go-sdk/mcp"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
const channelMethod = "notifications/claude/channel"
19+
20+
// Test_Learning_CapabilitiesOverride pins the go-sdk contract the channel
21+
// capability declaration is built on: setting ServerOptions.Capabilities to a
22+
// non-nil value with only an Experimental entry does NOT lose the inferred
23+
// tools capability — the SDK still augments Tools when tools are registered —
24+
// but it DOES drop the {"logging":{}} default that a nil Capabilities carries.
25+
// If an upgrade breaks the tools half, the conditional capability wiring must
26+
// set Tools explicitly; the logging half means the wiring must always set
27+
// Logging alongside Experimental to keep capability parity with the disabled
28+
// path.
29+
func Test_Learning_CapabilitiesOverride(t *testing.T) {
30+
t.Parallel()
31+
32+
serverTransport, clientTransport := mcp.NewInMemoryTransports()
33+
34+
srv := mcp.NewServer(
35+
&mcp.Implementation{Name: "learning", Version: "0"},
36+
&mcp.ServerOptions{
37+
Capabilities: &mcp.ServerCapabilities{
38+
Experimental: map[string]any{"claude/channel": map[string]any{}},
39+
},
40+
},
41+
)
42+
registerEchoTool(srv)
43+
44+
_, err := srv.Connect(t.Context(), serverTransport, nil)
45+
require.NoError(t, err)
46+
47+
client := mcp.NewClient(&mcp.Implementation{Name: "probe", Version: "0"}, nil)
48+
49+
session, err := client.Connect(t.Context(), clientTransport, nil)
50+
require.NoError(t, err)
51+
t.Cleanup(func() { _ = session.Close() })
52+
53+
caps := session.InitializeResult().Capabilities
54+
require.NotNil(t, caps)
55+
56+
assert.NotNil(t, caps.Tools, "tools capability must survive the Experimental override")
57+
assert.Contains(t, caps.Experimental, "claude/channel")
58+
assert.Nil(t, caps.Logging, "overriding Capabilities drops the logging default — declare it explicitly")
59+
}
60+
61+
// Test_Learning_RawNotificationWrite pins the transport contract the channel
62+
// emitter is built on: an ID-less jsonrpc.Request written directly to the
63+
// mcp.Connection captured at Connect is framed as a JSON-RPC notification and
64+
// received intact by the peer, even when the writes race SDK-originated tool
65+
// responses on the same connection (Connection.Write is documented
66+
// concurrency-safe). The capturing transport returns the inner connection
67+
// unwrapped: the SDK type-asserts it to an unexported interface for session
68+
// state updates, so a proxying wrapper would silently change behavior. A torn
69+
// or misframed message would kill the session and fail the concurrent tool
70+
// calls below.
71+
func Test_Learning_RawNotificationWrite(t *testing.T) {
72+
t.Parallel()
73+
74+
const (
75+
writers = 2
76+
notificationsPerSide = 25
77+
callers = 4
78+
callsPerCaller = 10
79+
)
80+
81+
serverTransport, clientTransport := mcp.NewInMemoryTransports()
82+
83+
capture := &captureTransport{inner: serverTransport}
84+
tee := &teeTransport{
85+
inner: clientTransport,
86+
notifications: make(chan *jsonrpc.Request, writers*notificationsPerSide),
87+
}
88+
89+
srv := mcp.NewServer(&mcp.Implementation{Name: "learning", Version: "0"}, nil)
90+
registerEchoTool(srv)
91+
92+
_, err := srv.Connect(t.Context(), capture, nil)
93+
require.NoError(t, err)
94+
require.NotNil(t, capture.conn)
95+
96+
client := mcp.NewClient(&mcp.Implementation{Name: "probe", Version: "0"}, nil)
97+
98+
session, err := client.Connect(t.Context(), tee, nil)
99+
require.NoError(t, err)
100+
t.Cleanup(func() { _ = session.Close() })
101+
102+
var wg sync.WaitGroup
103+
104+
for w := range writers {
105+
wg.Go(func() { emitChannelNotifications(t, capture.conn, w, notificationsPerSide) })
106+
}
107+
108+
for range callers {
109+
wg.Go(func() { callEchoRepeatedly(t, session, callsPerCaller) })
110+
}
111+
112+
wg.Wait()
113+
114+
received := drainNotifications(t, tee.notifications, writers*notificationsPerSide)
115+
116+
for w := range writers {
117+
for i := range notificationsPerSide {
118+
assert.True(t, received[fmt.Sprintf("event-%d-%d", w, i)], "notification %d-%d lost", w, i)
119+
}
120+
}
121+
}
122+
123+
// emitChannelNotifications writes count ID-less channel notifications through
124+
// the captured server connection, each with a content marker unique to the
125+
// writer.
126+
func emitChannelNotifications(t *testing.T, conn mcp.Connection, writer, count int) {
127+
t.Helper()
128+
129+
for i := range count {
130+
params, err := json.Marshal(map[string]any{
131+
"content": fmt.Sprintf("event-%d-%d", writer, i),
132+
"meta": map[string]string{"change": "42"},
133+
})
134+
if err != nil {
135+
t.Errorf("marshal params: %v", err)
136+
137+
return
138+
}
139+
140+
// A zero ID is what makes this request a JSON-RPC notification.
141+
req := &jsonrpc.Request{ID: jsonrpc.ID{}, Method: channelMethod, Params: params, Extra: nil}
142+
if err := conn.Write(t.Context(), req); err != nil {
143+
t.Errorf("raw write %d-%d: %v", writer, i, err)
144+
145+
return
146+
}
147+
}
148+
}
149+
150+
// callEchoRepeatedly keeps SDK-originated traffic in flight on the same
151+
// connection the raw writes target.
152+
func callEchoRepeatedly(t *testing.T, session *mcp.ClientSession, calls int) {
153+
t.Helper()
154+
155+
for i := range calls {
156+
res, err := session.CallTool(t.Context(), &mcp.CallToolParams{
157+
Name: "echo",
158+
Arguments: map[string]any{"text": fmt.Sprintf("call-%d", i)},
159+
})
160+
if err != nil {
161+
t.Errorf("tool call %d: %v", i, err)
162+
163+
return
164+
}
165+
166+
if res.IsError {
167+
t.Errorf("tool call %d returned an error result", i)
168+
169+
return
170+
}
171+
}
172+
}
173+
174+
// drainNotifications receives want channel notifications from the tee,
175+
// asserts each is framed as a notification with intact params, and returns
176+
// the set of content markers seen.
177+
func drainNotifications(t *testing.T, notifications <-chan *jsonrpc.Request, want int) map[string]bool {
178+
t.Helper()
179+
180+
received := make(map[string]bool, want)
181+
182+
for range want {
183+
select {
184+
case req := <-notifications:
185+
assert.False(t, req.IsCall(), "channel notification must carry no request ID")
186+
187+
var params struct {
188+
Content string `json:"content"`
189+
Meta map[string]string `json:"meta"`
190+
}
191+
192+
require.NoError(t, json.Unmarshal(req.Params, &params))
193+
assert.Equal(t, map[string]string{"change": "42"}, params.Meta)
194+
195+
received[params.Content] = true
196+
197+
case <-time.After(10 * time.Second):
198+
t.Fatalf("received %d of %d notifications before timeout", len(received), want)
199+
}
200+
}
201+
202+
return received
203+
}
204+
205+
func registerEchoTool(srv *mcp.Server) {
206+
type echoArgs struct {
207+
Text string `json:"text"`
208+
}
209+
210+
mcp.AddTool(srv, &mcp.Tool{Name: "echo", Description: "echo the input back"},
211+
func(_ context.Context, _ *mcp.CallToolRequest, args echoArgs) (*mcp.CallToolResult, any, error) {
212+
return &mcp.CallToolResult{
213+
Content: []mcp.Content{&mcp.TextContent{Text: args.Text}},
214+
}, nil, nil
215+
})
216+
}
217+
218+
// captureTransport keeps a reference to the connection produced by the inner
219+
// transport and hands the connection to the SDK unwrapped. This is the seam
220+
// the production channel emitter uses for raw notification writes.
221+
type captureTransport struct {
222+
inner mcp.Transport
223+
224+
conn mcp.Connection `exhaustruct:"optional"`
225+
}
226+
227+
func (t *captureTransport) Connect(ctx context.Context) (mcp.Connection, error) {
228+
conn, err := t.inner.Connect(ctx)
229+
if err != nil {
230+
return nil, e.NewFrom("connect inner transport", err)
231+
}
232+
233+
t.conn = conn
234+
235+
return conn, nil
236+
}
237+
238+
// teeTransport wraps the client side of the pair and copies every channel
239+
// notification the client reads into a buffered channel, exposing raw frames
240+
// an SDK client would otherwise drop (the method is not in its dispatch
241+
// table).
242+
type teeTransport struct {
243+
inner mcp.Transport
244+
notifications chan *jsonrpc.Request
245+
}
246+
247+
func (t *teeTransport) Connect(ctx context.Context) (mcp.Connection, error) {
248+
conn, err := t.inner.Connect(ctx)
249+
if err != nil {
250+
return nil, e.NewFrom("connect inner transport", err)
251+
}
252+
253+
return &teeConn{Connection: conn, notifications: t.notifications}, nil
254+
}
255+
256+
type teeConn struct {
257+
mcp.Connection
258+
259+
notifications chan *jsonrpc.Request
260+
}
261+
262+
func (c *teeConn) Read(ctx context.Context) (jsonrpc.Message, error) {
263+
msg, err := c.Connection.Read(ctx)
264+
if err != nil {
265+
// Read errors pass through unwrapped: the SDK detects io.EOF on them.
266+
return msg, err //nolint:wrapcheck
267+
}
268+
269+
if req, ok := msg.(*jsonrpc.Request); ok && req.Method == channelMethod {
270+
c.notifications <- req
271+
}
272+
273+
return msg, nil
274+
}

0 commit comments

Comments
 (0)