Skip to content

Commit 43f9ba5

Browse files
committed
add the channel transport wrapper and emitter
The pinned go-sdk exposes no generic notification sender, so channel events are written as ID-less JSON-RPC requests straight to the session connection (ADR 2.1). captureTransport keeps a reference to the connection produced at Connect and hands it to the SDK unwrapped: the SDK type-asserts the connection to an unexported interface for session-state updates, and a proxying wrapper would silently disable that path. channelEmitter implements the notifications.Emitter seam: params carry the llmxml content and the meta map, meta keys the client would silently discard (anything beyond letters, digits, underscores) are dropped on our side with a log naming the key. An emission racing server startup finds no connection and is dropped with a log -- delivery is best-effort by contract, and the client-side outcome of an undeliverable event is the same silence. Refs: #52
1 parent 7af59fc commit 43f9ba5

2 files changed

Lines changed: 343 additions & 0 deletions

File tree

cmd/go-gerrit-mcp/channel.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"log/slog"
7+
"regexp"
8+
"sync"
9+
10+
"dev.gaijin.team/go/golib/e"
11+
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
12+
"github.com/modelcontextprotocol/go-sdk/mcp"
13+
14+
"dev.gaijin.team/go/go-gerrit-mcp/internal/notifications"
15+
)
16+
17+
// channelMethod is the notification method of the Claude Code channels
18+
// contract (ADR 2.1, docs/glossary.md: Channel).
19+
const channelMethod = "notifications/claude/channel"
20+
21+
// metaKeyPattern matches meta keys the client accepts as tag attributes;
22+
// anything else is silently dropped on the client side.
23+
var metaKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
24+
25+
// captureTransport hands the inner transport's connection to the SDK
26+
// unwrapped and keeps a reference for raw notification writes. Unwrapped
27+
// matters: the SDK type-asserts the connection to an unexported interface
28+
// for session-state updates, and a proxying wrapper would silently disable
29+
// that path (pinned by Test_Learning_RawNotificationWrite).
30+
type captureTransport struct {
31+
inner mcp.Transport
32+
33+
mu sync.Mutex `exhaustruct:"optional"`
34+
conn mcp.Connection `exhaustruct:"optional"`
35+
}
36+
37+
// Connect implements [mcp.Transport].
38+
func (t *captureTransport) Connect(ctx context.Context) (mcp.Connection, error) {
39+
conn, err := t.inner.Connect(ctx)
40+
if err != nil {
41+
return nil, e.NewFrom("connect inner transport", err)
42+
}
43+
44+
t.mu.Lock()
45+
defer t.mu.Unlock()
46+
47+
t.conn = conn
48+
49+
return conn, nil
50+
}
51+
52+
// connection reports the captured connection, nil before the session
53+
// connects.
54+
func (t *captureTransport) connection() mcp.Connection {
55+
t.mu.Lock()
56+
defer t.mu.Unlock()
57+
58+
return t.conn
59+
}
60+
61+
// channelParams is the notifications/claude/channel payload: content becomes
62+
// the body of the <channel> block injected into the session; each meta key
63+
// becomes a tag attribute on it.
64+
type channelParams struct {
65+
Content string `json:"content"`
66+
Meta map[string]string `json:"meta,omitempty"`
67+
}
68+
69+
// channelEmitter delivers notifications as ID-less JSON-RPC requests written
70+
// straight to the captured connection — the pinned SDK exposes no generic
71+
// notification sender (ADR 2.1). The seam is deliberately this narrow so an
72+
// SDK-native sender can replace it without touching callers.
73+
type channelEmitter struct {
74+
transport *captureTransport
75+
lgr *slog.Logger
76+
}
77+
78+
var _ notifications.Emitter = (*channelEmitter)(nil)
79+
80+
// Emit implements [notifications.Emitter]. Delivery is best-effort by
81+
// contract: an emission racing server startup finds no connection and is
82+
// dropped with a log — the client-side outcome of an undeliverable event is
83+
// the same silence.
84+
func (ce *channelEmitter) Emit(ctx context.Context, content string, meta map[string]string) error {
85+
conn := ce.transport.connection()
86+
if conn == nil {
87+
ce.lgr.Warn("channel notification dropped: session not connected yet")
88+
89+
return nil
90+
}
91+
92+
raw, err := json.Marshal(channelParams{Content: content, Meta: ce.sanitizeMeta(meta)})
93+
if err != nil {
94+
return e.NewFrom("marshal channel params", err)
95+
}
96+
97+
// A zero ID is what makes this request a JSON-RPC notification.
98+
req := &jsonrpc.Request{ID: jsonrpc.ID{}, Method: channelMethod, Params: raw, Extra: nil}
99+
100+
if err := conn.Write(ctx, req); err != nil {
101+
return e.NewFrom("write channel notification", err)
102+
}
103+
104+
return nil
105+
}
106+
107+
// sanitizeMeta drops meta keys the client would silently discard, naming
108+
// each dropped key on stderr so the loss is visible on our side.
109+
func (ce *channelEmitter) sanitizeMeta(meta map[string]string) map[string]string {
110+
clean := make(map[string]string, len(meta))
111+
112+
for k, v := range meta {
113+
if !metaKeyPattern.MatchString(k) {
114+
ce.lgr.Warn("channel meta key dropped: the client accepts letters, digits, and underscores only",
115+
"key", k)
116+
117+
continue
118+
}
119+
120+
clean[k] = v
121+
}
122+
123+
return clean
124+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"log/slog"
9+
"sync"
10+
"testing"
11+
"time"
12+
13+
"dev.gaijin.team/go/golib/e"
14+
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
15+
"github.com/modelcontextprotocol/go-sdk/mcp"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
const receiveDeadline = 10 * time.Second
21+
22+
// observedTransport wraps the client side of an in-memory pair and copies
23+
// every channel notification the client reads into a buffered channel; the
24+
// SDK client itself drops the method as undispatchable.
25+
type observedTransport struct {
26+
inner mcp.Transport
27+
notifications chan *jsonrpc.Request
28+
}
29+
30+
func (t *observedTransport) Connect(ctx context.Context) (mcp.Connection, error) {
31+
conn, err := t.inner.Connect(ctx)
32+
if err != nil {
33+
return nil, e.NewFrom("connect inner transport", err)
34+
}
35+
36+
return &observedConn{Connection: conn, notifications: t.notifications}, nil
37+
}
38+
39+
type observedConn struct {
40+
mcp.Connection
41+
42+
notifications chan *jsonrpc.Request
43+
}
44+
45+
func (c *observedConn) Read(ctx context.Context) (jsonrpc.Message, error) {
46+
msg, err := c.Connection.Read(ctx)
47+
if err != nil {
48+
// Read errors pass through unwrapped: the SDK detects io.EOF on them.
49+
return msg, err //nolint:wrapcheck
50+
}
51+
52+
if req, ok := msg.(*jsonrpc.Request); ok && req.Method == channelMethod {
53+
c.notifications <- req
54+
}
55+
56+
return msg, nil
57+
}
58+
59+
// channelFixture is a connected server/client pair with the production
60+
// capture transport on the server side and an observing transport on the
61+
// client side.
62+
type channelFixture struct {
63+
emitter *channelEmitter
64+
session *mcp.ClientSession
65+
notifications chan *jsonrpc.Request
66+
logs *bytes.Buffer
67+
}
68+
69+
func newChannelFixture(t *testing.T) *channelFixture {
70+
t.Helper()
71+
72+
serverTransport, clientTransport := mcp.NewInMemoryTransports()
73+
74+
capture := &captureTransport{inner: serverTransport}
75+
observed := &observedTransport{
76+
inner: clientTransport,
77+
notifications: make(chan *jsonrpc.Request, 64),
78+
}
79+
80+
srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil)
81+
82+
type echoArgs struct {
83+
Text string `json:"text"`
84+
}
85+
86+
mcp.AddTool(srv, &mcp.Tool{Name: "echo", Description: "echo the input back"},
87+
func(_ context.Context, _ *mcp.CallToolRequest, args echoArgs) (*mcp.CallToolResult, any, error) {
88+
return &mcp.CallToolResult{
89+
Content: []mcp.Content{&mcp.TextContent{Text: args.Text}},
90+
}, nil, nil
91+
})
92+
93+
_, err := srv.Connect(t.Context(), capture, nil)
94+
require.NoError(t, err)
95+
96+
client := mcp.NewClient(&mcp.Implementation{Name: "probe", Version: "0"}, nil)
97+
98+
session, err := client.Connect(t.Context(), observed, nil)
99+
require.NoError(t, err)
100+
t.Cleanup(func() { _ = session.Close() })
101+
102+
logs := &bytes.Buffer{}
103+
104+
return &channelFixture{
105+
emitter: &channelEmitter{transport: capture, lgr: slog.New(slog.NewTextHandler(logs, nil))},
106+
session: session,
107+
notifications: observed.notifications,
108+
logs: logs,
109+
}
110+
}
111+
112+
func (f *channelFixture) receive(t *testing.T) *jsonrpc.Request {
113+
t.Helper()
114+
115+
select {
116+
case req := <-f.notifications:
117+
return req
118+
case <-time.After(receiveDeadline):
119+
t.Fatal("no channel notification before timeout")
120+
121+
return nil
122+
}
123+
}
124+
125+
func Test_ChannelEmitter_Emit(t *testing.T) {
126+
t.Parallel()
127+
128+
t.Run("notification reaches the client with method and params intact", func(t *testing.T) {
129+
t.Parallel()
130+
131+
f := newChannelFixture(t)
132+
133+
content := `<review_activity change="123" status="NEW"/>`
134+
require.NoError(t, f.emitter.Emit(t.Context(), content, map[string]string{"change": "123"}))
135+
136+
req := f.receive(t)
137+
138+
assert.False(t, req.IsCall(), "channel notification must carry no request ID")
139+
assert.Equal(t, channelMethod, req.Method)
140+
141+
var params channelParams
142+
143+
require.NoError(t, json.Unmarshal(req.Params, &params))
144+
assert.Equal(t, content, params.Content)
145+
assert.Equal(t, map[string]string{"change": "123"}, params.Meta)
146+
})
147+
148+
t.Run("emissions survive concurrent tool traffic", func(t *testing.T) {
149+
t.Parallel()
150+
151+
const emissions = 20
152+
153+
f := newChannelFixture(t)
154+
155+
var wg sync.WaitGroup
156+
157+
wg.Go(func() {
158+
for i := range emissions {
159+
content := fmt.Sprintf("event-%d", i)
160+
if err := f.emitter.Emit(t.Context(), content, map[string]string{"change": "42"}); err != nil {
161+
t.Errorf("emit %d: %v", i, err)
162+
163+
return
164+
}
165+
}
166+
})
167+
168+
wg.Go(func() {
169+
for i := range emissions {
170+
res, err := f.session.CallTool(t.Context(), &mcp.CallToolParams{
171+
Name: "echo",
172+
Arguments: map[string]any{"text": fmt.Sprintf("call-%d", i)},
173+
})
174+
if err != nil || res.IsError {
175+
t.Errorf("tool call %d failed: %v", i, err)
176+
177+
return
178+
}
179+
}
180+
})
181+
182+
wg.Wait()
183+
184+
for range emissions {
185+
req := f.receive(t)
186+
assert.Equal(t, channelMethod, req.Method)
187+
}
188+
})
189+
190+
t.Run("invalid meta keys dropped and named", func(t *testing.T) {
191+
t.Parallel()
192+
193+
f := newChannelFixture(t)
194+
195+
meta := map[string]string{"change": "123", "bad-key": "x", "worse key": "y"}
196+
require.NoError(t, f.emitter.Emit(t.Context(), "content", meta))
197+
198+
var params channelParams
199+
200+
require.NoError(t, json.Unmarshal(f.receive(t).Params, &params))
201+
202+
assert.Equal(t, map[string]string{"change": "123"}, params.Meta)
203+
assert.Contains(t, f.logs.String(), "bad-key")
204+
assert.Contains(t, f.logs.String(), "worse key")
205+
})
206+
207+
t.Run("emission before the session connects is dropped with a log", func(t *testing.T) {
208+
t.Parallel()
209+
210+
logs := &bytes.Buffer{}
211+
emitter := &channelEmitter{
212+
transport: &captureTransport{inner: nil},
213+
lgr: slog.New(slog.NewTextHandler(logs, nil)),
214+
}
215+
216+
require.NoError(t, emitter.Emit(t.Context(), "content", nil))
217+
assert.Contains(t, logs.String(), "session not connected")
218+
})
219+
}

0 commit comments

Comments
 (0)