Skip to content

Commit 75d71ad

Browse files
Add capabilities conformance test (#48)
1 parent 6c0cb46 commit 75d71ad

File tree

3 files changed

+502
-14
lines changed

3 files changed

+502
-14
lines changed

conformance/conformance_test.go

Lines changed: 362 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy