Skip to content

Commit 7a7b59e

Browse files
authored
Validate remote mcp server tools (github#223)
* Validate remote mcp server tools. * Improve CI validation script. * Deduplicate server handling.
1 parent 0e74961 commit 7a7b59e

6 files changed

Lines changed: 284 additions & 18 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,4 @@ jobs:
2929
3030
- name: Build and catalog changed servers
3131
shell: bash
32-
run: |
33-
set -eo pipefail
34-
while IFS= read -r file; do
35-
dir=$(dirname "$file")
36-
name=$(basename "$dir")
37-
task validate -- --name $name
38-
task build -- --tools --pull-community $name
39-
echo "--------------------------------"
40-
task catalog -- $name
41-
echo "--------------------------------"
42-
cat catalogs/$name/catalog.yaml
43-
echo "--------------------------------"
44-
done < changed-servers.txt
32+
run: ./scripts/ci-validation.sh

cmd/validate/main.go

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"flag"
67
"fmt"
78
"image"
@@ -11,11 +12,13 @@ import (
1112
"path/filepath"
1213
"regexp"
1314
"strings"
15+
"time"
1416

1517
_ "image/jpeg"
1618
_ "image/png"
1719

1820
"github.com/docker/mcp-registry/internal/licenses"
21+
"github.com/docker/mcp-registry/internal/mcp"
1922
"github.com/docker/mcp-registry/pkg/github"
2023
"github.com/docker/mcp-registry/pkg/servers"
2124
"gopkg.in/yaml.v3"
@@ -56,7 +59,7 @@ func run(name string) error {
5659
if err := isRemoteValid(name); err != nil {
5760
return err
5861
}
59-
62+
6063
if err := isOAuthDynamicValid(name); err != nil {
6164
return err
6265
}
@@ -147,13 +150,13 @@ func IsLicenseValid(name string) error {
147150
if err != nil {
148151
return err
149152
}
150-
153+
151154
// Skip license validation for remote servers without source
152155
if server.Source.Project == "" {
153156
fmt.Println("✅ License validation skipped (remote server)")
154157
return nil
155158
}
156-
159+
157160
repository, err := client.GetProjectRepository(ctx, server.Source.Project)
158161
if err != nil {
159162
return err
@@ -193,14 +196,14 @@ func isIconValid(name string) error {
193196
fmt.Println("🛑 Icon is too large. It must be less than 2MB")
194197
return nil
195198
}
196-
199+
197200
// Check content type for SVG support
198201
contentType := resp.Header.Get("Content-Type")
199202
if contentType == "image/svg+xml" {
200203
fmt.Println("✅ Icon is valid (SVG)")
201204
return nil
202205
}
203-
206+
204207
img, format, err := image.DecodeConfig(resp.Body)
205208
if err != nil {
206209
return err
@@ -250,10 +253,52 @@ func isRemoteValid(name string) error {
250253
return fmt.Errorf("remote server transport_type must be one of: stdio, sse, streamable-http (got: %s)", server.Remote.TransportType)
251254
}
252255

256+
if err := hasValidTools(server); err != nil {
257+
return err
258+
}
259+
253260
fmt.Println("✅ Remote is valid")
254261
return nil
255262
}
256263

264+
// Check that there is either a tools.json, dynamic tools, or can fetch remote tools
265+
func hasValidTools(server servers.Server) error {
266+
defaultErr := fmt.Errorf("server must have either a tools.json, dynamic tools, or can fetch remote tools")
267+
268+
// Dynamic tools are valid
269+
if server.Dynamic != nil && server.Dynamic.Tools {
270+
fmt.Println("✅ Dynamic tools are valid")
271+
return nil
272+
}
273+
274+
// Tools.json is valid
275+
tools, err := readToolsJson(server.Name)
276+
if err == nil {
277+
toolCount := len(tools)
278+
fmt.Printf("✅ tools.json is valid. Found %d tools.\n", toolCount)
279+
return nil
280+
}
281+
if !os.IsNotExist(err) {
282+
fmt.Printf("🛑 Tools.json could not be read: %v\n", err)
283+
return defaultErr
284+
}
285+
286+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
287+
defer cancel()
288+
289+
// Remote tools are valid
290+
remoteTools, err := mcp.RemoteTools(ctx, server)
291+
if err != nil {
292+
fmt.Printf("🛑 Remote tools could not be fetched (if auth is required, specify \ndynamic:\n tools: true\n): %v\n", err)
293+
return defaultErr
294+
}
295+
296+
toolCount := len(remoteTools)
297+
298+
fmt.Printf("✅ Remote tools are valid. Found %d tools.\n", toolCount)
299+
return nil
300+
}
301+
257302
// check if servers with OAuth have dynamic tools enabled
258303
func isOAuthDynamicValid(name string) error {
259304
server, err := readServerYaml(name)
@@ -284,3 +329,18 @@ func readServerYaml(name string) (servers.Server, error) {
284329
}
285330
return server, nil
286331
}
332+
333+
func readToolsJson(name string) ([]mcp.Tool, error) {
334+
path := filepath.Join("servers", name, "tools.json")
335+
buf, err := os.ReadFile(path)
336+
if err != nil {
337+
return nil, err
338+
}
339+
340+
var tools []mcp.Tool
341+
if err := json.Unmarshal(buf, &tools); err != nil {
342+
return nil, err
343+
}
344+
345+
return tools, nil
346+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ require (
2424
github.com/dustin/go-humanize v1.0.1 // indirect
2525
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
2626
github.com/google/go-querystring v1.1.0 // indirect
27+
github.com/google/uuid v1.6.0 // indirect
2728
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
2829
github.com/mattn/go-isatty v0.0.20 // indirect
2930
github.com/mattn/go-localereader v0.0.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHS
5353
github.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY=
5454
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
5555
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
56+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
57+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
5658
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
5759
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
5860
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

internal/mcp/helper.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@ import (
2828
"slices"
2929
"sort"
3030
"strings"
31+
"time"
3132

3233
"github.com/mark3labs/mcp-go/mcp"
3334

3435
"github.com/docker/mcp-registry/pkg/servers"
36+
mcpclient "github.com/mark3labs/mcp-go/client"
37+
"github.com/mark3labs/mcp-go/client/transport"
3538
)
3639

3740
func Tools(ctx context.Context, server servers.Server, pull, cleanup, debug bool) ([]Tool, error) {
@@ -167,6 +170,144 @@ func Tools(ctx context.Context, server servers.Server, pull, cleanup, debug bool
167170
return list, nil
168171
}
169172

173+
func RemoteTools(ctx context.Context, server servers.Server) ([]Tool, error) {
174+
var c *mcpclient.Client
175+
var err error
176+
177+
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
178+
defer cancel()
179+
180+
switch server.Remote.TransportType {
181+
case "sse":
182+
sseTransport, err := transport.NewSSE(server.Remote.URL)
183+
if err != nil {
184+
return nil, err
185+
}
186+
c = mcpclient.NewClient(sseTransport)
187+
188+
case "http":
189+
case "streamable-http":
190+
httpTransport, err := transport.NewStreamableHTTP(server.Remote.URL)
191+
if err != nil {
192+
return nil, err
193+
}
194+
c = mcpclient.NewClient(httpTransport)
195+
196+
default:
197+
return nil, fmt.Errorf("invalid transport type: %s", server.Remote.TransportType)
198+
}
199+
200+
err = c.Start(ctx)
201+
if err != nil {
202+
return nil, err
203+
}
204+
defer c.Close()
205+
206+
initRequest := mcp.InitializeRequest{}
207+
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
208+
initRequest.Params.ClientInfo = mcp.Implementation{
209+
Name: "docker",
210+
Version: "1.0.0",
211+
}
212+
213+
serverInfo, err := c.Initialize(ctx, initRequest)
214+
if err != nil {
215+
return nil, err
216+
}
217+
218+
if serverInfo.Capabilities.Tools == nil {
219+
return nil, fmt.Errorf("tools not supported")
220+
}
221+
222+
toolsRequest := mcp.ListToolsRequest{}
223+
toolsResult, err := c.ListTools(ctx, toolsRequest)
224+
if err != nil {
225+
return nil, err
226+
}
227+
228+
var list []Tool
229+
for _, tool := range toolsResult.Tools {
230+
var arguments []ToolArgument
231+
var requiredPropertyNames []string
232+
var optionalPropertyNames []string
233+
for name := range tool.InputSchema.Properties {
234+
if slices.Contains(tool.InputSchema.Required, name) {
235+
requiredPropertyNames = append(requiredPropertyNames, name)
236+
} else {
237+
optionalPropertyNames = append(optionalPropertyNames, name)
238+
}
239+
}
240+
sort.Strings(requiredPropertyNames)
241+
sort.Strings(optionalPropertyNames)
242+
243+
propertyNames := append(requiredPropertyNames, optionalPropertyNames...)
244+
245+
for _, name := range propertyNames {
246+
v := tool.InputSchema.Properties[name]
247+
248+
// Type
249+
argumentType := "string"
250+
rawType := v.(map[string]any)["type"]
251+
if rawType != "" && rawType != nil {
252+
if str, ok := rawType.(string); ok {
253+
argumentType = str
254+
}
255+
}
256+
257+
// Item types
258+
var items *Items
259+
if argumentType == "array" {
260+
itemsType := "string"
261+
if rawItems, found := v.(map[string]any)["items"]; found {
262+
if kv, ok := rawItems.(map[string]any); ok {
263+
if rawItemsType, found := kv["type"]; found {
264+
if str, ok := rawItemsType.(string); ok {
265+
itemsType = str
266+
}
267+
}
268+
}
269+
}
270+
items = &Items{
271+
Type: itemsType,
272+
}
273+
}
274+
275+
// Description
276+
desc := v.(map[string]any)["description"]
277+
278+
// Properties
279+
arguments = append(arguments, ToolArgument{
280+
Name: name,
281+
Type: argumentType,
282+
Items: items,
283+
Optional: !slices.Contains(tool.InputSchema.Required, name),
284+
Description: argumentDescription(name, desc, tool.Description),
285+
})
286+
}
287+
288+
// Annotations
289+
var annotations *ToolAnnotations
290+
if tool.Annotations != (mcp.ToolAnnotation{}) {
291+
annotations = &ToolAnnotations{
292+
Title: tool.Annotations.Title,
293+
ReadOnlyHint: tool.Annotations.ReadOnlyHint,
294+
DestructiveHint: tool.Annotations.DestructiveHint,
295+
IdempotentHint: tool.Annotations.IdempotentHint,
296+
OpenWorldHint: tool.Annotations.OpenWorldHint,
297+
}
298+
}
299+
300+
list = append(list, Tool{
301+
Name: tool.Name,
302+
Description: removeArgs(tool.Description),
303+
Arguments: arguments,
304+
Annotations: annotations,
305+
})
306+
}
307+
308+
return list, nil
309+
}
310+
170311
func removeArgs(input string) string {
171312
var result []string
172313

0 commit comments

Comments
 (0)