Skip to content

Commit 7a8e2db

Browse files
committed
Add capabilities conformance test
1 parent 001a665 commit 7a8e2db

File tree

3 files changed

+502
-14
lines changed

3 files changed

+502
-14
lines changed

conformance/conformance_test.go

+362
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
//go:build conformance
2+
3+
package conformance_test
4+
5+
import (
6+
"bufio"
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"os"
12+
"reflect"
13+
"strings"
14+
"testing"
15+
16+
"github.com/docker/docker/api/types/container"
17+
"github.com/docker/docker/api/types/network"
18+
"github.com/docker/docker/client"
19+
"github.com/docker/docker/pkg/stdcopy"
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
type maintainer string
24+
25+
const (
26+
anthropic maintainer = "anthropic"
27+
github maintainer = "github"
28+
)
29+
30+
type testLogWriter struct {
31+
t *testing.T
32+
}
33+
34+
func (w testLogWriter) Write(p []byte) (n int, err error) {
35+
w.t.Log(string(p))
36+
return len(p), nil
37+
}
38+
39+
func start(t *testing.T, m maintainer) server {
40+
var image string
41+
if m == github {
42+
image = "github/github-mcp-server"
43+
} else {
44+
image = "mcp/github"
45+
}
46+
47+
ctx := context.Background()
48+
dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
49+
require.NoError(t, err)
50+
51+
containerCfg := &container.Config{
52+
OpenStdin: true,
53+
AttachStdin: true,
54+
AttachStdout: true,
55+
AttachStderr: true,
56+
Env: []string{
57+
fmt.Sprintf("GITHUB_PERSONAL_ACCESS_TOKEN=%s", os.Getenv("GITHUB_PERSONAL_ACCESS_TOKEN")),
58+
},
59+
Image: image,
60+
}
61+
62+
resp, err := dockerClient.ContainerCreate(
63+
ctx,
64+
containerCfg,
65+
&container.HostConfig{},
66+
&network.NetworkingConfig{},
67+
nil,
68+
"")
69+
require.NoError(t, err)
70+
71+
t.Cleanup(func() {
72+
require.NoError(t, dockerClient.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true}))
73+
})
74+
75+
hijackedResponse, err := dockerClient.ContainerAttach(ctx, resp.ID, container.AttachOptions{
76+
Stream: true,
77+
Stdin: true,
78+
Stdout: true,
79+
Stderr: true,
80+
})
81+
require.NoError(t, err)
82+
t.Cleanup(func() { hijackedResponse.Close() })
83+
84+
require.NoError(t, dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{}))
85+
86+
serverStart := make(chan serverStartResult)
87+
go func() {
88+
prOut, pwOut := io.Pipe()
89+
prErr, pwErr := io.Pipe()
90+
91+
go func() {
92+
// Ignore error, we should be done?
93+
// TODO: maybe check for use of closed network connection specifically
94+
_, _ = stdcopy.StdCopy(pwOut, pwErr, hijackedResponse.Reader)
95+
pwOut.Close()
96+
pwErr.Close()
97+
}()
98+
99+
bufferedStderr := bufio.NewReader(prErr)
100+
line, err := bufferedStderr.ReadString('\n')
101+
if err != nil {
102+
serverStart <- serverStartResult{err: err}
103+
}
104+
105+
if strings.TrimSpace(line) != "GitHub MCP Server running on stdio" {
106+
serverStart <- serverStartResult{
107+
err: fmt.Errorf("unexpected server output: %s", line),
108+
}
109+
return
110+
}
111+
112+
serverStart <- serverStartResult{
113+
server: server{
114+
m: m,
115+
log: testLogWriter{t},
116+
stdin: hijackedResponse.Conn,
117+
stdout: bufio.NewReader(prOut),
118+
},
119+
}
120+
}()
121+
122+
t.Logf("waiting for %s server to start...", m)
123+
serveResult := <-serverStart
124+
require.NoError(t, serveResult.err, "expected the server to start successfully")
125+
126+
return serveResult.server
127+
}
128+
129+
func TestCapabilities(t *testing.T) {
130+
anthropicServer := start(t, anthropic)
131+
githubServer := start(t, github)
132+
133+
req := newInitializeRequest(
134+
initializeRequestParams{
135+
ProtocolVersion: "2025-03-26",
136+
Capabilities: clientCapabilities{},
137+
ClientInfo: clientInfo{
138+
Name: "ConformanceTest",
139+
Version: "0.0.1",
140+
},
141+
},
142+
)
143+
144+
require.NoError(t, anthropicServer.send(req))
145+
146+
var anthropicInitializeResponse initializeResponse
147+
require.NoError(t, anthropicServer.receive(&anthropicInitializeResponse))
148+
149+
require.NoError(t, githubServer.send(req))
150+
151+
var ghInitializeResponse initializeResponse
152+
require.NoError(t, githubServer.receive(&ghInitializeResponse))
153+
154+
// Any capabilities in the anthropic response should be present in the github response
155+
// (though the github response may have additional capabilities)
156+
if diff := diffNonNilFields(anthropicInitializeResponse.Result.Capabilities, ghInitializeResponse.Result.Capabilities, ""); diff != "" {
157+
t.Errorf("capabilities mismatch:\n%s", diff)
158+
}
159+
}
160+
161+
func diffNonNilFields(a, b interface{}, path string) string {
162+
var sb strings.Builder
163+
164+
va := reflect.ValueOf(a)
165+
vb := reflect.ValueOf(b)
166+
167+
if !va.IsValid() {
168+
return ""
169+
}
170+
171+
if va.Kind() == reflect.Ptr {
172+
if va.IsNil() {
173+
return ""
174+
}
175+
if !vb.IsValid() || vb.IsNil() {
176+
sb.WriteString(path + "\n")
177+
return sb.String()
178+
}
179+
va = va.Elem()
180+
vb = vb.Elem()
181+
}
182+
183+
if va.Kind() != reflect.Struct || vb.Kind() != reflect.Struct {
184+
return ""
185+
}
186+
187+
t := va.Type()
188+
for i := range va.NumField() {
189+
field := t.Field(i)
190+
if !field.IsExported() {
191+
continue
192+
}
193+
194+
subPath := field.Name
195+
if path != "" {
196+
subPath = fmt.Sprintf("%s.%s", path, field.Name)
197+
}
198+
199+
fieldA := va.Field(i)
200+
fieldB := vb.Field(i)
201+
202+
switch fieldA.Kind() {
203+
case reflect.Ptr:
204+
if fieldA.IsNil() {
205+
continue // not required
206+
}
207+
if fieldB.IsNil() {
208+
sb.WriteString(subPath + "\n")
209+
continue
210+
}
211+
sb.WriteString(diffNonNilFields(fieldA.Interface(), fieldB.Interface(), subPath))
212+
213+
case reflect.Struct:
214+
sb.WriteString(diffNonNilFields(fieldA.Interface(), fieldB.Interface(), subPath))
215+
216+
default:
217+
zero := reflect.Zero(fieldA.Type())
218+
if !reflect.DeepEqual(fieldA.Interface(), zero.Interface()) {
219+
// fieldA is non-zero; now check that fieldB matches
220+
if !reflect.DeepEqual(fieldA.Interface(), fieldB.Interface()) {
221+
sb.WriteString(subPath + "\n")
222+
}
223+
}
224+
}
225+
}
226+
227+
return sb.String()
228+
}
229+
230+
type serverStartResult struct {
231+
server server
232+
err error
233+
}
234+
235+
type server struct {
236+
m maintainer
237+
log io.Writer
238+
239+
stdin io.Writer
240+
stdout *bufio.Reader
241+
}
242+
243+
func (s server) send(req request) error {
244+
b, err := req.marshal()
245+
if err != nil {
246+
return err
247+
}
248+
249+
fmt.Fprintf(s.log, "sending %s: %s\n", s.m, string(b))
250+
251+
n, err := s.stdin.Write(append(b, '\n'))
252+
if err != nil {
253+
return err
254+
}
255+
256+
if n != len(b)+1 {
257+
return fmt.Errorf("wrote %d bytes, expected %d", n, len(b)+1)
258+
}
259+
260+
return nil
261+
}
262+
263+
func (s server) receive(res response) error {
264+
line, err := s.stdout.ReadBytes('\n')
265+
if err != nil {
266+
if err == io.EOF {
267+
return fmt.Errorf("EOF after reading %s", string(line))
268+
}
269+
return err
270+
}
271+
272+
fmt.Fprintf(s.log, "received from %s: %s\n", s.m, string(line))
273+
274+
return res.unmarshal(line)
275+
}
276+
277+
type jsonRPRCRequest[params any] struct {
278+
JSONRPC string `json:"jsonrpc"`
279+
ID int `json:"id"`
280+
Method string `json:"method"`
281+
Params params `json:"params"`
282+
}
283+
284+
type jsonRPRCResponse[result any] struct {
285+
JSONRPC string `json:"jsonrpc"`
286+
ID int `json:"id"`
287+
Method string `json:"method"`
288+
Result result `json:"result"`
289+
}
290+
291+
type request interface {
292+
marshal() ([]byte, error)
293+
}
294+
295+
type response interface {
296+
unmarshal([]byte) error
297+
}
298+
299+
func newInitializeRequest(params initializeRequestParams) initializeRequest {
300+
return initializeRequest{
301+
jsonRPRCRequest: jsonRPRCRequest[initializeRequestParams]{
302+
JSONRPC: "2.0",
303+
ID: 1,
304+
Method: "initialize",
305+
Params: params,
306+
},
307+
}
308+
}
309+
310+
type initializeRequest struct {
311+
jsonRPRCRequest[initializeRequestParams]
312+
}
313+
314+
func (r initializeRequest) marshal() ([]byte, error) {
315+
return json.Marshal(r)
316+
}
317+
318+
type initializeRequestParams struct {
319+
ProtocolVersion string `json:"protocolVersion"`
320+
Capabilities clientCapabilities `json:"capabilities"`
321+
ClientInfo clientInfo `json:"clientInfo"`
322+
}
323+
324+
type clientCapabilities struct{} // don't actually care about any of these right now
325+
326+
type clientInfo struct {
327+
Name string `json:"name"`
328+
Version string `json:"version"`
329+
}
330+
331+
type initializeResponse struct {
332+
jsonRPRCResponse[initializeResult]
333+
}
334+
335+
func (r *initializeResponse) unmarshal(b []byte) error {
336+
return json.Unmarshal(b, r)
337+
}
338+
339+
type initializeResult struct {
340+
ProtocolVersion string `json:"protocolVersion"`
341+
Capabilities serverCapabilities `json:"capabilities"`
342+
ServerInfo serverInfo `json:"serverInfo"`
343+
}
344+
345+
type serverCapabilities struct {
346+
Logging *struct{} `json:"logging,omitempty"`
347+
Prompts *struct {
348+
ListChanged bool `json:"listChanged,omitempty"`
349+
} `json:"prompts,omitempty"`
350+
Resources *struct {
351+
Subscribe bool `json:"subscribe,omitempty"`
352+
ListChanged bool `json:"listChanged,omitempty"`
353+
} `json:"resources,omitempty"`
354+
Tools *struct {
355+
ListChanged bool `json:"listChanged,omitempty"`
356+
} `json:"tools,omitempty"`
357+
}
358+
359+
type serverInfo struct {
360+
Name string `json:"name"`
361+
Version string `json:"version"`
362+
}

0 commit comments

Comments
 (0)