From ee42c1f78a25a118f48523dd57b66d50370dbade Mon Sep 17 00:00:00 2001 From: Bobby House Date: Tue, 2 Dec 2025 01:48:20 -0800 Subject: [PATCH 1/2] feat: add --no-tools option and server ls To address issue where too much data was being printed when showing an entire catalog, introduce a option to specify --no-tools to exclude tools from the response. Additionally, add a `docker mcp catalog-next server ls` command so that we can get individual server information from a catalog that does include the tools. --- cmd/docker-mcp/commands/catalog_next.go | 64 ++- pkg/catalog_next/server.go | 172 +++++++ pkg/catalog_next/server_test.go | 643 ++++++++++++++++++++++++ pkg/catalog_next/show.go | 24 +- pkg/catalog_next/show_test.go | 73 ++- 5 files changed, 967 insertions(+), 9 deletions(-) create mode 100644 pkg/catalog_next/server.go create mode 100644 pkg/catalog_next/server_test.go diff --git a/cmd/docker-mcp/commands/catalog_next.go b/cmd/docker-mcp/commands/catalog_next.go index 23162a6f..6d246b17 100644 --- a/cmd/docker-mcp/commands/catalog_next.go +++ b/cmd/docker-mcp/commands/catalog_next.go @@ -26,6 +26,7 @@ func catalogNextCommand() *cobra.Command { cmd.AddCommand(pushCatalogNextCommand()) cmd.AddCommand(pullCatalogNextCommand()) cmd.AddCommand(tagCatalogNextCommand()) + cmd.AddCommand(catalogNextServerCommand()) return cmd } @@ -83,6 +84,7 @@ func tagCatalogNextCommand() *cobra.Command { func showCatalogNextCommand() *cobra.Command { format := string(workingset.OutputFormatHumanReadable) pullOption := string(catalognext.PullOptionNever) + var noTools bool cmd := &cobra.Command{ Use: "show [--pull ]", @@ -98,13 +100,14 @@ func showCatalogNextCommand() *cobra.Command { return err } ociService := oci.NewService() - return catalognext.Show(cmd.Context(), dao, ociService, args[0], workingset.OutputFormat(format), pullOption) + return catalognext.Show(cmd.Context(), dao, ociService, args[0], workingset.OutputFormat(format), pullOption, noTools) }, } flags := cmd.Flags() flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", "))) flags.StringVar(&pullOption, "pull", string(catalognext.PullOptionNever), fmt.Sprintf("Supported: %s, or duration (e.g. '1h', '1d'). Duration represents time since last update.", strings.Join(catalognext.SupportedPullOptions(), ", "))) + flags.BoolVar(&noTools, "no-tools", false, "Exclude tools from output") return cmd } @@ -181,3 +184,62 @@ func pullCatalogNextCommand() *cobra.Command { }, } } + +func catalogNextServerCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "server", + Short: "Manage servers in catalogs", + } + + cmd.AddCommand(listCatalogNextServersCommand()) + + return cmd +} + +func listCatalogNextServersCommand() *cobra.Command { + var opts struct { + Filters []string + Format string + } + + cmd := &cobra.Command{ + Use: "ls ", + Aliases: []string{"list"}, + Short: "List servers in a catalog", + Long: `List all servers in a catalog. + +Use --filter to search for servers matching a query (case-insensitive substring matching on server names). +Filters use key=value format (e.g., name=github).`, + Example: ` # List all servers in a catalog + docker mcp catalog-next server ls mcp/docker-mcp-catalog:latest + + # Filter servers by name + docker mcp catalog-next server ls mcp/docker-mcp-catalog:latest --filter name=github + + # Combine multiple filters (using short flag) + docker mcp catalog-next server ls mcp/docker-mcp-catalog:latest -f name=slack -f name=github + + # Output in JSON format + docker mcp catalog-next server ls mcp/docker-mcp-catalog:latest --format json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + supported := slices.Contains(workingset.SupportedFormats(), opts.Format) + if !supported { + return fmt.Errorf("unsupported format: %s", opts.Format) + } + + dao, err := db.New() + if err != nil { + return err + } + + return catalognext.ListServers(cmd.Context(), dao, args[0], opts.Filters, workingset.OutputFormat(opts.Format)) + }, + } + + flags := cmd.Flags() + flags.StringArrayVarP(&opts.Filters, "filter", "f", []string{}, "Filter output (e.g., name=github)") + flags.StringVar(&opts.Format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", "))) + + return cmd +} diff --git a/pkg/catalog_next/server.go b/pkg/catalog_next/server.go new file mode 100644 index 00000000..b3cf2c30 --- /dev/null +++ b/pkg/catalog_next/server.go @@ -0,0 +1,172 @@ +package catalognext + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/goccy/go-yaml" + + "github.com/docker/mcp-gateway/pkg/db" + "github.com/docker/mcp-gateway/pkg/workingset" +) + +type serverFilter struct { + key string + value string +} + +// ListServers lists servers in a catalog with optional filtering +func ListServers(ctx context.Context, dao db.DAO, catalogRef string, filters []string, format workingset.OutputFormat) error { + parsedFilters, err := parseFilters(filters) + if err != nil { + return err + } + + // Get the catalog + dbCatalog, err := dao.GetCatalog(ctx, catalogRef) + if err != nil { + return fmt.Errorf("failed to get catalog %s: %w", catalogRef, err) + } + + catalog := NewFromDb(dbCatalog) + + // Apply name filter + var nameFilter string + for _, filter := range parsedFilters { + switch filter.key { + case "name": + nameFilter = filter.value + default: + return fmt.Errorf("unsupported filter key: %s", filter.key) + } + } + + // Filter servers + servers := filterServers(catalog.Servers, nameFilter) + + // Output results + return outputServers(catalog.Ref, catalog.Title, servers, format) +} + +func parseFilters(filters []string) ([]serverFilter, error) { + parsed := make([]serverFilter, 0, len(filters)) + for _, filter := range filters { + parts := strings.SplitN(filter, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid filter format: %s (expected key=value)", filter) + } + parsed = append(parsed, serverFilter{ + key: parts[0], + value: parts[1], + }) + } + return parsed, nil +} + +func filterServers(servers []Server, nameFilter string) []Server { + if nameFilter == "" { + return servers + } + + nameLower := strings.ToLower(nameFilter) + filtered := make([]Server, 0) + + for _, server := range servers { + if matchesNameFilter(server, nameLower) { + filtered = append(filtered, server) + } + } + + return filtered +} + +func matchesNameFilter(server Server, nameLower string) bool { + if server.Snapshot == nil { + return false + } + serverName := strings.ToLower(server.Snapshot.Server.Name) + return strings.Contains(serverName, nameLower) +} + +func outputServers(catalogRef, catalogTitle string, servers []Server, format workingset.OutputFormat) error { + // Sort servers by name + sort.Slice(servers, func(i, j int) bool { + if servers[i].Snapshot == nil || servers[j].Snapshot == nil { + return false + } + return servers[i].Snapshot.Server.Name < servers[j].Snapshot.Server.Name + }) + + var data []byte + var err error + + switch format { + case workingset.OutputFormatHumanReadable: + printServersHuman(catalogRef, catalogTitle, servers) + return nil + case workingset.OutputFormatJSON: + output := map[string]any{ + "catalog": catalogRef, + "title": catalogTitle, + "servers": servers, + } + data, err = json.MarshalIndent(output, "", " ") + case workingset.OutputFormatYAML: + output := map[string]any{ + "catalog": catalogRef, + "title": catalogTitle, + "servers": servers, + } + data, err = yaml.Marshal(output) + default: + return fmt.Errorf("unsupported format: %s", format) + } + + if err != nil { + return fmt.Errorf("failed to format servers: %w", err) + } + + fmt.Println(string(data)) + return nil +} + +func printServersHuman(catalogRef, catalogTitle string, servers []Server) { + if len(servers) == 0 { + fmt.Println("No servers found") + return + } + + fmt.Printf("Catalog: %s\n", catalogRef) + fmt.Printf("Title: %s\n", catalogTitle) + fmt.Printf("Servers (%d):\n\n", len(servers)) + + for _, server := range servers { + if server.Snapshot == nil { + continue + } + srv := server.Snapshot.Server + fmt.Printf(" %s\n", srv.Name) + if srv.Title != "" { + fmt.Printf(" Title: %s\n", srv.Title) + } + if srv.Description != "" { + fmt.Printf(" Description: %s\n", srv.Description) + } + fmt.Printf(" Type: %s\n", server.Type) + switch server.Type { + case workingset.ServerTypeImage: + fmt.Printf(" Image: %s\n", server.Image) + case workingset.ServerTypeRegistry: + fmt.Printf(" Source: %s\n", server.Source) + case workingset.ServerTypeRemote: + fmt.Printf(" Endpoint: %s\n", server.Endpoint) + } + if len(srv.Tools) > 0 { + fmt.Printf(" Tools: %d\n", len(srv.Tools)) + } + fmt.Println() + } +} diff --git a/pkg/catalog_next/server_test.go b/pkg/catalog_next/server_test.go new file mode 100644 index 00000000..1f8b00a1 --- /dev/null +++ b/pkg/catalog_next/server_test.go @@ -0,0 +1,643 @@ +package catalognext + +import ( + "encoding/json" + "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/mcp-gateway/pkg/catalog" + "github.com/docker/mcp-gateway/pkg/workingset" +) + +func TestListServersNoFilters(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + // Create a catalog with multiple servers + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "server-one", + Description: "First server", + }, + }, + }, + { + Type: workingset.ServerTypeImage, + Image: "docker/server2:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "server-two", + Description: "Second server", + }, + }, + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{}, workingset.OutputFormatJSON) + require.NoError(t, err) + }) + + var result map[string]any + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + assert.Equal(t, catalogObj.Ref, result["catalog"]) + assert.Equal(t, catalogObj.Title, result["title"]) + servers := result["servers"].([]any) + assert.Len(t, servers, 2) +} + +func TestListServersFilterByName(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "my-server", + Description: "My server", + }, + }, + }, + { + Type: workingset.ServerTypeImage, + Image: "docker/server2:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "other-server", + Description: "Other server", + }, + }, + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{"name=my"}, workingset.OutputFormatJSON) + require.NoError(t, err) + }) + + var result map[string]any + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + servers := result["servers"].([]any) + assert.Len(t, servers, 1) +} + +func TestListServersFilterByNameCaseInsensitive(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "MyServer", + Description: "Test server", + }, + }, + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{"name=myserver"}, workingset.OutputFormatJSON) + require.NoError(t, err) + }) + + var result map[string]any + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + servers := result["servers"].([]any) + assert.Len(t, servers, 1) +} + +func TestListServersFilterByNamePartialMatch(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "my-awesome-server", + Description: "Test server", + }, + }, + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{"name=awesome"}, workingset.OutputFormatJSON) + require.NoError(t, err) + }) + + var result map[string]any + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + servers := result["servers"].([]any) + assert.Len(t, servers, 1) +} + +func TestListServersFilterNoMatches(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "my-server", + Description: "Test server", + }, + }, + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{"name=nonexistent"}, workingset.OutputFormatJSON) + require.NoError(t, err) + }) + + var result map[string]any + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + servers := result["servers"].([]any) + assert.Empty(t, servers) +} + +func TestListServersWithoutSnapshot(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: nil, // No snapshot + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{"name=test"}, workingset.OutputFormatJSON) + require.NoError(t, err) + }) + + var result map[string]any + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + // Server without snapshot should not match any name filter + servers := result["servers"].([]any) + assert.Empty(t, servers) +} + +func TestListServersInvalidFilter(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + err = ListServers(ctx, dao, catalogObj.Ref, []string{"invalid"}, workingset.OutputFormatJSON) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid filter format") +} + +func TestListServersUnsupportedFilterKey(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + err = ListServers(ctx, dao, catalogObj.Ref, []string{"unsupported=value"}, workingset.OutputFormatJSON) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported filter key") +} + +func TestListServersCatalogNotFound(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + err := ListServers(ctx, dao, "test/nonexistent:latest", []string{}, workingset.OutputFormatJSON) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get catalog") +} + +func TestListServersYAMLFormat(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "server-one", + }, + }, + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{}, workingset.OutputFormatYAML) + require.NoError(t, err) + }) + + var result map[string]any + err = yaml.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + assert.Equal(t, catalogObj.Ref, result["catalog"]) + assert.Equal(t, catalogObj.Title, result["title"]) +} + +func TestListServersHumanReadableFormat(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "server-one", + Title: "Server One", + Description: "First server", + Tools: []catalog.Tool{ + {Name: "tool1"}, + {Name: "tool2"}, + }, + }, + }, + }, + { + Type: workingset.ServerTypeRegistry, + Source: "https://example.com/api", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "server-two", + }, + }, + }, + { + Type: workingset.ServerTypeRemote, + Endpoint: "https://remote.example.com", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "server-three", + }, + }, + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{}, workingset.OutputFormatHumanReadable) + require.NoError(t, err) + }) + + // Verify human-readable format contains expected elements + assert.Contains(t, output, "Catalog: "+catalogObj.Ref) + assert.Contains(t, output, "Title: Test Catalog") + assert.Contains(t, output, "Servers (3)") + assert.Contains(t, output, "server-one") + assert.Contains(t, output, "Title: Server One") + assert.Contains(t, output, "Description: First server") + assert.Contains(t, output, "Type: image") + assert.Contains(t, output, "Image: docker/server1:v1") + assert.Contains(t, output, "Tools: 2") + assert.Contains(t, output, "server-two") + assert.Contains(t, output, "Type: registry") + assert.Contains(t, output, "Source: https://example.com/api") + assert.Contains(t, output, "server-three") + assert.Contains(t, output, "Type: remote") + assert.Contains(t, output, "Endpoint: https://remote.example.com") +} + +func TestListServersHumanReadableNoServers(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Empty Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: nil, // No snapshot + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{"name=nonexistent"}, workingset.OutputFormatHumanReadable) + require.NoError(t, err) + }) + + assert.Contains(t, output, "No servers found") +} + +func TestListServersUnsupportedFormat(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + err = ListServers(ctx, dao, catalogObj.Ref, []string{}, "unsupported") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} + +func TestListServersServersSortedByName(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + catalogObj := Catalog{ + Ref: "test/catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "Test Catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/server1:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "zebra-server", + }, + }, + }, + { + Type: workingset.ServerTypeImage, + Image: "docker/server2:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "alpha-server", + }, + }, + }, + { + Type: workingset.ServerTypeImage, + Image: "docker/server3:v1", + Snapshot: &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "beta-server", + }, + }, + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := ListServers(ctx, dao, catalogObj.Ref, []string{}, workingset.OutputFormatJSON) + require.NoError(t, err) + }) + + var result map[string]any + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + servers := result["servers"].([]any) + require.Len(t, servers, 3) + + // Verify servers are sorted alphabetically by name + firstServer := servers[0].(map[string]any) + snapshot := firstServer["snapshot"].(map[string]any) + server := snapshot["server"].(map[string]any) + assert.Equal(t, "alpha-server", server["name"]) + + secondServer := servers[1].(map[string]any) + snapshot = secondServer["snapshot"].(map[string]any) + server = snapshot["server"].(map[string]any) + assert.Equal(t, "beta-server", server["name"]) + + thirdServer := servers[2].(map[string]any) + snapshot = thirdServer["snapshot"].(map[string]any) + server = snapshot["server"].(map[string]any) + assert.Equal(t, "zebra-server", server["name"]) +} + +func TestParseFilters(t *testing.T) { + tests := []struct { + name string + filters []string + expected []serverFilter + expectError bool + errorMsg string + }{ + { + name: "single filter", + filters: []string{"name=test"}, + expected: []serverFilter{{key: "name", value: "test"}}, + }, + { + name: "multiple filters", + filters: []string{"name=test", "type=image"}, + expected: []serverFilter{{key: "name", value: "test"}, {key: "type", value: "image"}}, + }, + { + name: "empty filters", + filters: []string{}, + expected: []serverFilter{}, + }, + { + name: "invalid filter format - no equals", + filters: []string{"invalid"}, + expectError: true, + errorMsg: "invalid filter format", + }, + { + name: "invalid filter format - multiple equals", + filters: []string{"key=value=extra"}, + expected: []serverFilter{{key: "key", value: "value=extra"}}, // SplitN allows this + expectError: false, + }, + { + name: "filter with empty value", + filters: []string{"name="}, + expected: []serverFilter{{key: "name", value: ""}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseFilters(tt.filters) + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} diff --git a/pkg/catalog_next/show.go b/pkg/catalog_next/show.go index a54fd352..a168b278 100644 --- a/pkg/catalog_next/show.go +++ b/pkg/catalog_next/show.go @@ -19,7 +19,7 @@ import ( "github.com/docker/mcp-gateway/pkg/workingset" ) -func Show(ctx context.Context, dao db.DAO, ociService oci.Service, refStr string, format workingset.OutputFormat, pullOptionParam string) error { +func Show(ctx context.Context, dao db.DAO, ociService oci.Service, refStr string, format workingset.OutputFormat, pullOptionParam string, noTools bool) error { pullOption, pullInterval, err := parsePullOption(pullOptionParam) if err != nil { return err @@ -77,6 +77,10 @@ func Show(ctx context.Context, dao db.DAO, ociService oci.Service, refStr string catalog := NewFromDb(dbCatalog) + if noTools { + catalog = filterCatalogTools(catalog) + } + var data []byte switch format { case workingset.OutputFormatHumanReadable: @@ -114,6 +118,24 @@ func printHumanReadable(catalog CatalogWithDigest) string { return fmt.Sprintf("Reference: %s\nTitle: %s\nSource: %s\nServers:\n%s", catalog.Ref, catalog.Title, catalog.Source, servers) } +func filterCatalogTools(catalog CatalogWithDigest) CatalogWithDigest { + filteredServers := make([]Server, len(catalog.Servers)) + for i, server := range catalog.Servers { + filteredServer := server + filteredServer.Tools = nil + if filteredServer.Snapshot != nil { + snapshotCopy := *filteredServer.Snapshot + serverCopy := snapshotCopy.Server + serverCopy.Tools = nil + snapshotCopy.Server = serverCopy + filteredServer.Snapshot = &snapshotCopy + } + filteredServers[i] = filteredServer + } + catalog.Servers = filteredServers + return catalog +} + func parsePullOption(pullOptionParam string) (PullOption, time.Duration, error) { if pullOptionParam == "" { return PullOptionNever, 0, nil diff --git a/pkg/catalog_next/show_test.go b/pkg/catalog_next/show_test.go index dc198a9b..2191fb2f 100644 --- a/pkg/catalog_next/show_test.go +++ b/pkg/catalog_next/show_test.go @@ -16,7 +16,7 @@ func TestShowNotFound(t *testing.T) { dao := setupTestDB(t) ctx := t.Context() - err := Show(ctx, dao, getMockOciService(), "test/nonexistent:latest", workingset.OutputFormatJSON, PullOptionNever) + err := Show(ctx, dao, getMockOciService(), "test/nonexistent:latest", workingset.OutputFormatJSON, PullOptionNever, false) require.Error(t, err) assert.Contains(t, err.Error(), "catalog test/nonexistent:latest not found") } @@ -51,7 +51,7 @@ func TestShowHumanReadable(t *testing.T) { require.NoError(t, err) output := captureStdout(t, func() { - err := Show(ctx, dao, getMockOciService(), catalog.Ref, workingset.OutputFormatHumanReadable, PullOptionNever) + err := Show(ctx, dao, getMockOciService(), catalog.Ref, workingset.OutputFormatHumanReadable, PullOptionNever, false) require.NoError(t, err) }) @@ -91,7 +91,7 @@ func TestShowJSON(t *testing.T) { require.NoError(t, err) output := captureStdout(t, func() { - err := Show(ctx, dao, getMockOciService(), catalog.Ref, workingset.OutputFormatJSON, PullOptionNever) + err := Show(ctx, dao, getMockOciService(), catalog.Ref, workingset.OutputFormatJSON, PullOptionNever, false) require.NoError(t, err) }) @@ -136,7 +136,7 @@ func TestShowYAML(t *testing.T) { require.NoError(t, err) output := captureStdout(t, func() { - err := Show(ctx, dao, getMockOciService(), catalog.Ref, workingset.OutputFormatYAML, PullOptionNever) + err := Show(ctx, dao, getMockOciService(), catalog.Ref, workingset.OutputFormatYAML, PullOptionNever, false) require.NoError(t, err) }) @@ -187,7 +187,7 @@ func TestShowWithSnapshot(t *testing.T) { require.NoError(t, err) output := captureStdout(t, func() { - err := Show(ctx, dao, getMockOciService(), catalogObj.Ref, workingset.OutputFormatJSON, PullOptionNever) + err := Show(ctx, dao, getMockOciService(), catalogObj.Ref, workingset.OutputFormatJSON, PullOptionNever, false) require.NoError(t, err) }) @@ -204,17 +204,76 @@ func TestShowInvalidReferenceWithDigest(t *testing.T) { dao := setupTestDB(t) ctx := t.Context() - err := Show(ctx, dao, getMockOciService(), "test/invalid-reference@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1", workingset.OutputFormatJSON, PullOptionNever) + err := Show(ctx, dao, getMockOciService(), "test/invalid-reference@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1", workingset.OutputFormatJSON, PullOptionNever, false) require.Error(t, err) assert.Contains(t, err.Error(), "reference test/invalid-reference@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 must be a valid OCI reference without a digest") } +func TestShowWithNoTools(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + snapshot := &workingset.ServerSnapshot{ + Server: catalog.Server{ + Name: "snapshot-server", + Description: "A server with snapshot", + Tools: []catalog.Tool{ + {Name: "snapshot-tool1", Description: "First snapshot tool"}, + {Name: "snapshot-tool2", Description: "Second snapshot tool"}, + }, + }, + } + + catalogObj := Catalog{ + Ref: "test/no-tools-catalog:latest", + CatalogArtifact: CatalogArtifact{ + Title: "no-tools-catalog", + Servers: []Server{ + { + Type: workingset.ServerTypeImage, + Image: "docker/test:v1", + Tools: []string{"tool1", "tool2", "tool3"}, + Snapshot: snapshot, + }, + { + Type: workingset.ServerTypeRegistry, + Source: "https://example.com/api", + Tools: []string{"tool4", "tool5"}, + }, + }, + }, + } + + dbCat, err := catalogObj.ToDb() + require.NoError(t, err) + err = dao.UpsertCatalog(ctx, dbCat) + require.NoError(t, err) + + output := captureStdout(t, func() { + err := Show(ctx, dao, getMockOciService(), catalogObj.Ref, workingset.OutputFormatJSON, PullOptionNever, true) + require.NoError(t, err) + }) + + var result CatalogWithDigest + err = json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + + // Verify tools are filtered out + assert.Len(t, result.Servers, 2) + assert.Nil(t, result.Servers[0].Tools) + assert.Nil(t, result.Servers[1].Tools) + + // Verify snapshot tools are also filtered out + require.NotNil(t, result.Servers[0].Snapshot) + assert.Nil(t, result.Servers[0].Snapshot.Server.Tools) +} + // TODO(cody): Add tests for pull once we have proper mocks in place func TestInvalidPullOption(t *testing.T) { dao := setupTestDB(t) ctx := t.Context() - err := Show(ctx, dao, getMockOciService(), "test/catalog:latest", workingset.OutputFormatJSON, "invalid") + err := Show(ctx, dao, getMockOciService(), "test/catalog:latest", workingset.OutputFormatJSON, "invalid", false) require.Error(t, err) assert.Contains(t, err.Error(), "failed to parse pull option invalid: should be missing, never, always, or duration (e.g. '1h', '1d')") } From 3e01128ab906a5cfca93e38f4e8e52caedcde723 Mon Sep 17 00:00:00 2001 From: Bobby House Date: Tue, 2 Dec 2025 01:50:14 -0800 Subject: [PATCH 2/2] fix: return catalog list data Using --format json or yaml would return the entire contents of all the catalogs. Instead, return the data that is returned for human readable output. --- pkg/catalog_next/catalog.go | 6 ++++ pkg/catalog_next/list.go | 16 +++++---- pkg/catalog_next/list_test.go | 61 ++++++++++++++--------------------- 3 files changed, 40 insertions(+), 43 deletions(-) diff --git a/pkg/catalog_next/catalog.go b/pkg/catalog_next/catalog.go index 41ee4bcd..8f846700 100644 --- a/pkg/catalog_next/catalog.go +++ b/pkg/catalog_next/catalog.go @@ -25,6 +25,12 @@ type CatalogWithDigest struct { Digest string `yaml:"digest" json:"digest"` } +type CatalogSummary struct { + Ref string `yaml:"ref" json:"ref"` + Digest string `yaml:"digest" json:"digest"` + Title string `yaml:"title" json:"title"` +} + // Source prefixes must be of the form ":" const ( SourcePrefixWorkingSet = "profile:" diff --git a/pkg/catalog_next/list.go b/pkg/catalog_next/list.go index ceaba323..2ea723cd 100644 --- a/pkg/catalog_next/list.go +++ b/pkg/catalog_next/list.go @@ -23,19 +23,23 @@ func List(ctx context.Context, dao db.DAO, format workingset.OutputFormat) error return nil } - catalogs := make([]CatalogWithDigest, len(dbCatalogs)) + summaries := make([]CatalogSummary, len(dbCatalogs)) for i, dbCatalog := range dbCatalogs { - catalogs[i] = NewFromDb(&dbCatalog) + summaries[i] = CatalogSummary{ + Ref: dbCatalog.Ref, + Digest: dbCatalog.Digest, + Title: dbCatalog.Title, + } } var data []byte switch format { case workingset.OutputFormatHumanReadable: - data = []byte(printListHumanReadable(catalogs)) + data = []byte(printListHumanReadable(summaries)) case workingset.OutputFormatJSON: - data, err = json.MarshalIndent(catalogs, "", " ") + data, err = json.MarshalIndent(summaries, "", " ") case workingset.OutputFormatYAML: - data, err = yaml.Marshal(catalogs) + data, err = yaml.Marshal(summaries) } if err != nil { return fmt.Errorf("failed to marshal catalogs: %w", err) @@ -46,7 +50,7 @@ func List(ctx context.Context, dao db.DAO, format workingset.OutputFormat) error return nil } -func printListHumanReadable(catalogs []CatalogWithDigest) string { +func printListHumanReadable(catalogs []CatalogSummary) string { lines := "" for _, catalog := range catalogs { lines += fmt.Sprintf("%s\t| %s\t| %s\n", catalog.Ref, catalog.Digest, catalog.Title) diff --git a/pkg/catalog_next/list_test.go b/pkg/catalog_next/list_test.go index 935d8bbc..54c44b84 100644 --- a/pkg/catalog_next/list_test.go +++ b/pkg/catalog_next/list_test.go @@ -138,26 +138,20 @@ func TestListJSON(t *testing.T) { }) // Parse JSON output - var catalogs []CatalogWithDigest + var catalogs []CatalogSummary err = json.Unmarshal([]byte(output), &catalogs) require.NoError(t, err) assert.Len(t, catalogs, 2) - // Verify first catalog + // Verify first catalog (summary only) + assert.Equal(t, "test/catalog4:latest", catalogs[0].Ref) assert.Equal(t, "catalog-one", catalogs[0].Title) - assert.Equal(t, "source-1", catalogs[0].Source) - assert.Len(t, catalogs[0].Servers, 1) - assert.Equal(t, workingset.ServerTypeImage, catalogs[0].Servers[0].Type) - assert.Equal(t, "test:v1", catalogs[0].Servers[0].Image) - assert.Equal(t, []string{"tool1"}, catalogs[0].Servers[0].Tools) + assert.NotEmpty(t, catalogs[0].Digest) - // Verify second catalog + // Verify second catalog (summary only) + assert.Equal(t, "test/catalog5:latest", catalogs[1].Ref) assert.Equal(t, "catalog-two", catalogs[1].Title) - assert.Equal(t, "source-2", catalogs[1].Source) - assert.Len(t, catalogs[1].Servers, 1) - assert.Equal(t, workingset.ServerTypeRegistry, catalogs[1].Servers[0].Type) - assert.Equal(t, "https://example.com", catalogs[1].Servers[0].Source) - assert.Equal(t, []string{"tool2", "tool3"}, catalogs[1].Servers[0].Tools) + assert.NotEmpty(t, catalogs[1].Digest) } func TestListJSONEmpty(t *testing.T) { @@ -170,7 +164,7 @@ func TestListJSONEmpty(t *testing.T) { }) // Parse JSON output - var catalogs []CatalogWithDigest + var catalogs []CatalogSummary err := json.Unmarshal([]byte(output), &catalogs) require.NoError(t, err) assert.Empty(t, catalogs) @@ -206,18 +200,15 @@ func TestListYAML(t *testing.T) { }) // Parse YAML output - var catalogs []CatalogWithDigest + var catalogs []CatalogSummary err = yaml.Unmarshal([]byte(output), &catalogs) require.NoError(t, err) assert.Len(t, catalogs, 1) - // Verify catalog + // Verify catalog (summary only) + assert.Equal(t, "test/catalog6:latest", catalogs[0].Ref) assert.Equal(t, "catalog-yaml", catalogs[0].Title) - assert.Equal(t, "yaml-source", catalogs[0].Source) - assert.Len(t, catalogs[0].Servers, 1) - assert.Equal(t, workingset.ServerTypeImage, catalogs[0].Servers[0].Type) - assert.Equal(t, "test:yaml", catalogs[0].Servers[0].Image) - assert.Equal(t, []string{"tool1", "tool2"}, catalogs[0].Servers[0].Tools) + assert.NotEmpty(t, catalogs[0].Digest) } func TestListYAMLEmpty(t *testing.T) { @@ -230,7 +221,7 @@ func TestListYAMLEmpty(t *testing.T) { }) // Parse YAML output - var catalogs []CatalogWithDigest + var catalogs []CatalogSummary err := yaml.Unmarshal([]byte(output), &catalogs) require.NoError(t, err) assert.Empty(t, catalogs) @@ -271,15 +262,15 @@ func TestListWithSnapshot(t *testing.T) { require.NoError(t, err) }) - var result []CatalogWithDigest + var result []CatalogSummary err = json.Unmarshal([]byte(output), &result) require.NoError(t, err) assert.Len(t, result, 1) - // Verify snapshot is included - require.NotNil(t, result[0].Servers[0].Snapshot) - assert.Equal(t, "test-server", result[0].Servers[0].Snapshot.Server.Name) - assert.Equal(t, "Test description", result[0].Servers[0].Snapshot.Server.Description) + // Verify only summary fields are present + assert.Equal(t, "test/catalog7:latest", result[0].Ref) + assert.Equal(t, "snapshot-catalog", result[0].Title) + assert.NotEmpty(t, result[0].Digest) } func TestListWithMultipleServers(t *testing.T) { @@ -319,19 +310,15 @@ func TestListWithMultipleServers(t *testing.T) { require.NoError(t, err) }) - var result []CatalogWithDigest + var result []CatalogSummary err = json.Unmarshal([]byte(output), &result) require.NoError(t, err) assert.Len(t, result, 1) - assert.Len(t, result[0].Servers, 3) - // Just verify that all three server types are present, order may vary - types := make(map[workingset.ServerType]int) - for _, s := range result[0].Servers { - types[s.Type]++ - } - assert.Equal(t, 2, types[workingset.ServerTypeImage]) - assert.Equal(t, 1, types[workingset.ServerTypeRegistry]) + // Verify only summary fields are present + assert.Equal(t, "test/catalog8:latest", result[0].Ref) + assert.Equal(t, "multi-server-catalog", result[0].Title) + assert.NotEmpty(t, result[0].Digest) } func TestListHumanReadableEmptyDoesNotShowInJSON(t *testing.T) { @@ -344,7 +331,7 @@ func TestListHumanReadableEmptyDoesNotShowInJSON(t *testing.T) { require.NoError(t, err) }) - var catalogs []CatalogWithDigest + var catalogs []CatalogSummary err := json.Unmarshal([]byte(outputJSON), &catalogs) require.NoError(t, err) assert.Empty(t, catalogs)