Skip to content

Commit 68b377e

Browse files
authored
Merge pull request #242 from docker/refactor-servers-filtering
refactor: servers to server ls
2 parents 55f34cf + 9e42dc0 commit 68b377e

File tree

6 files changed

+361
-1563
lines changed

6 files changed

+361
-1563
lines changed

cmd/docker-mcp/commands/workingset.go

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ func workingSetCommand() *cobra.Command {
2323
cmd.AddCommand(importWorkingSetCommand())
2424
cmd.AddCommand(showWorkingSetCommand())
2525
cmd.AddCommand(listWorkingSetsCommand())
26-
cmd.AddCommand(serversCommand())
2726
cmd.AddCommand(pushWorkingSetCommand())
2827
cmd.AddCommand(pullWorkingSetCommand())
2928
cmd.AddCommand(createWorkingSetCommand())
@@ -299,34 +298,34 @@ func removeWorkingSetCommand() *cobra.Command {
299298
}
300299
}
301300

302-
func serversCommand() *cobra.Command {
301+
func listServersCommand() *cobra.Command {
303302
var opts struct {
304-
WorkingSetID string
305-
Filter string
306-
Format string
303+
Filters []string
304+
Format string
307305
}
308306

309307
cmd := &cobra.Command{
310-
Use: "servers",
311-
Short: "List servers across profiles",
308+
Use: "ls",
309+
Aliases: []string{"list"},
310+
Short: "List servers across profiles",
312311
Long: `List all servers grouped by profile.
313312
314-
Use --filter to search for servers matching a query (case-insensitive substring matching on image names or source URLs).
315-
Use --profile to show servers only from a specific profile.`,
313+
Use --filter to search for servers matching a query (case-insensitive substring matching on server names).
314+
Filters use key=value format (e.g., name=github, profile=my-dev-env).`,
316315
Example: ` # List all servers across all profiles
317-
docker mcp profile servers
316+
docker mcp profile server ls
318317
319318
# Filter servers by name
320-
docker mcp profile servers --filter github
319+
docker mcp profile server ls --filter name=github
321320
322321
# Show servers from a specific profile
323-
docker mcp profile servers --profile dev-tools
322+
docker mcp profile server ls --filter profile=my-dev-env
324323
325-
# Combine filter and profile
326-
docker mcp profile servers --profile dev-tools --filter slack
324+
# Combine multiple filters (using short flag)
325+
docker mcp profile server ls -f name=slack -f profile=my-dev-env
327326
328327
# Output in JSON format
329-
docker mcp profile servers --format json`,
328+
docker mcp profile server ls --format json`,
330329
Args: cobra.NoArgs,
331330
RunE: func(cmd *cobra.Command, _ []string) error {
332331
supported := slices.Contains(workingset.SupportedFormats(), opts.Format)
@@ -339,13 +338,12 @@ Use --profile to show servers only from a specific profile.`,
339338
return err
340339
}
341340

342-
return workingset.Servers(cmd.Context(), dao, opts.Filter, opts.WorkingSetID, workingset.OutputFormat(opts.Format))
341+
return workingset.ListServers(cmd.Context(), dao, opts.Filters, workingset.OutputFormat(opts.Format))
343342
},
344343
}
345344

346345
flags := cmd.Flags()
347-
flags.StringVarP(&opts.WorkingSetID, "profile", "p", "", "Show servers only from specified profile")
348-
flags.StringVar(&opts.Filter, "filter", "", "Filter servers by image name or source URL")
346+
flags.StringArrayVarP(&opts.Filters, "filter", "f", []string{}, "Filter output (e.g., name=github, profile=my-dev-env)")
349347
flags.StringVar(&opts.Format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))
350348

351349
return cmd
@@ -357,6 +355,7 @@ func workingsetServerCommand() *cobra.Command {
357355
Short: "Manage servers in profiles",
358356
}
359357

358+
cmd.AddCommand(listServersCommand())
360359
cmd.AddCommand(addServerCommand())
361360
cmd.AddCommand(removeServerCommand())
362361

cmd/docker-mcp/secret-management/formatting/table.go

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,59 @@ import (
1010
// PrettyPrintTable prints a table (slice of rows, each a slice of string)
1111
// maxWidths is an optional slice of maximum widths for each column.
1212
// Pass nil (or a slice of different length) to skip max-width limitations.
13-
func PrettyPrintTable(rows [][]string, maxWidths []int) {
14-
if len(rows) == 0 {
13+
func PrettyPrintTable(rows [][]string, maxWidths []int, header ...[]string) {
14+
var headerRow []string
15+
if len(header) > 0 {
16+
headerRow = header[0]
17+
}
18+
19+
if len(rows) == 0 && headerRow == nil {
20+
return
21+
}
22+
23+
numColumns := 0
24+
if headerRow != nil {
25+
numColumns = len(headerRow)
26+
} else if len(rows) > 0 {
27+
numColumns = len(rows[0])
28+
}
29+
30+
if numColumns == 0 {
1531
return
1632
}
1733
// Sort rows by the first column.
1834
sort.Slice(rows, func(i, j int) bool {
1935
return strings.ToLower(rows[i][0]) < strings.ToLower(rows[j][0])
2036
})
21-
numColumns := len(rows[0])
2237
colWidths := make([]int, numColumns)
2338

39+
for i, cell := range headerRow {
40+
if w := runeWidth(cell); w > colWidths[i] {
41+
colWidths[i] = w
42+
}
43+
}
44+
2445
for _, row := range rows {
2546
for i, cell := range row {
26-
if w := runeWidth(cell); w > colWidths[i] {
27-
colWidths[i] = w
47+
if i < numColumns {
48+
if w := runeWidth(cell); w > colWidths[i] {
49+
colWidths[i] = w
50+
}
2851
}
2952
}
3053
}
31-
3254
// If provided, limit the width per column.
3355
if maxWidths != nil && len(maxWidths) == numColumns {
3456
for i := range colWidths {
3557
colWidths[i] = intMin(colWidths[i], maxWidths[i])
3658
}
3759
}
3860

39-
// Print each row with proper padding and truncation.
40-
for _, row := range rows {
61+
printRow := func(row []string) {
4162
for i, cell := range row {
63+
if i >= numColumns {
64+
break
65+
}
4266
// Truncate the cell to the allowed width.
4367
s := truncateString(cell, colWidths[i])
4468
// Pad the cell so that each column aligns.
@@ -50,6 +74,12 @@ func PrettyPrintTable(rows [][]string, maxWidths []int) {
5074
}
5175
fmt.Println()
5276
}
77+
if headerRow != nil {
78+
printRow(headerRow)
79+
}
80+
for _, row := range rows {
81+
printRow(row)
82+
}
5383
}
5484

5585
func intMin(a, b int) int {

pkg/workingset/server.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ package workingset
33
import (
44
"context"
55
"database/sql"
6+
"encoding/json"
67
"errors"
78
"fmt"
89
"slices"
10+
"sort"
11+
"strings"
912

1013
"github.com/google/go-containerregistry/pkg/name"
14+
"gopkg.in/yaml.v3"
1115

16+
"github.com/docker/mcp-gateway/cmd/docker-mcp/secret-management/formatting"
1217
"github.com/docker/mcp-gateway/pkg/db"
1318
"github.com/docker/mcp-gateway/pkg/oci"
1419
"github.com/docker/mcp-gateway/pkg/registryapi"
@@ -161,3 +166,141 @@ func mapCatalogServersToWorkingSetServers(dbServers []db.CatalogServer, defaultS
161166
}
162167
return servers
163168
}
169+
170+
type SearchResult struct {
171+
ID string `json:"id" yaml:"id"`
172+
Name string `json:"name" yaml:"name"`
173+
Servers []Server `json:"servers" yaml:"servers"`
174+
}
175+
176+
type serverFilter struct {
177+
key string
178+
value string
179+
}
180+
181+
func ListServers(ctx context.Context, dao db.DAO, filters []string, format OutputFormat) error {
182+
parsedFilters, err := parseFilters(filters)
183+
if err != nil {
184+
return err
185+
}
186+
187+
var nameFilter string
188+
var workingSetFilter string
189+
for _, filter := range parsedFilters {
190+
switch filter.key {
191+
case "name":
192+
nameFilter = filter.value
193+
case "profile":
194+
workingSetFilter = filter.value
195+
default:
196+
return fmt.Errorf("unsupported filter key: %s", filter.key)
197+
}
198+
}
199+
dbSets, err := dao.SearchWorkingSets(ctx, "", workingSetFilter)
200+
if err != nil {
201+
return fmt.Errorf("failed to search profiles: %w", err)
202+
}
203+
results := buildSearchResults(dbSets, nameFilter)
204+
return outputSearchResults(results, format)
205+
}
206+
207+
func parseFilters(filters []string) ([]serverFilter, error) {
208+
parsed := make([]serverFilter, 0, len(filters))
209+
for _, filter := range filters {
210+
parts := strings.SplitN(filter, "=", 2)
211+
if len(parts) != 2 {
212+
return nil, fmt.Errorf("invalid filter format: %s (expected key=value)", filter)
213+
}
214+
parsed = append(parsed, serverFilter{
215+
key: parts[0],
216+
value: parts[1],
217+
})
218+
}
219+
return parsed, nil
220+
}
221+
222+
func buildSearchResults(dbSets []db.WorkingSet, nameFilter string) []SearchResult {
223+
nameLower := strings.ToLower(nameFilter)
224+
results := make([]SearchResult, 0, len(dbSets))
225+
226+
for _, dbSet := range dbSets {
227+
workingSet := NewFromDb(&dbSet)
228+
matchedServers := make([]Server, 0)
229+
230+
for _, server := range workingSet.Servers {
231+
if matchesNameFilter(server, nameLower) {
232+
matchedServers = append(matchedServers, server)
233+
}
234+
}
235+
if len(matchedServers) == 0 {
236+
continue
237+
}
238+
sort.Slice(matchedServers, func(i, j int) bool {
239+
return matchedServers[i].Snapshot.Server.Name < matchedServers[j].Snapshot.Server.Name
240+
})
241+
results = append(results, SearchResult{
242+
ID: workingSet.ID,
243+
Name: workingSet.Name,
244+
Servers: matchedServers,
245+
})
246+
}
247+
return results
248+
}
249+
250+
func matchesNameFilter(server Server, nameLower string) bool {
251+
// TODO: Remove when Snapshot is required
252+
if server.Snapshot == nil {
253+
return false
254+
}
255+
if nameLower == "" {
256+
return true
257+
}
258+
serverName := strings.ToLower(server.Snapshot.Server.Name)
259+
return strings.Contains(serverName, nameLower)
260+
}
261+
262+
func outputSearchResults(results []SearchResult, format OutputFormat) error {
263+
var data []byte
264+
var err error
265+
266+
switch format {
267+
case OutputFormatHumanReadable:
268+
printSearchResultsHuman(results)
269+
return nil
270+
case OutputFormatJSON:
271+
data, err = json.MarshalIndent(results, "", " ")
272+
case OutputFormatYAML:
273+
data, err = yaml.Marshal(results)
274+
default:
275+
return fmt.Errorf("unsupported format: %s", format)
276+
}
277+
278+
if err != nil {
279+
return fmt.Errorf("failed to format search results: %w", err)
280+
}
281+
282+
fmt.Println(string(data))
283+
return nil
284+
}
285+
286+
func printSearchResultsHuman(results []SearchResult) {
287+
if len(results) == 0 {
288+
fmt.Println("No profiles found")
289+
return
290+
}
291+
292+
rows := [][]string{}
293+
294+
for _, result := range results {
295+
for _, server := range result.Servers {
296+
rows = append(rows, []string{
297+
result.ID,
298+
string(server.Type),
299+
server.Snapshot.Server.Name,
300+
})
301+
}
302+
}
303+
304+
header := []string{"PROFILE", "TYPE", "IDENTIFIER"}
305+
formatting.PrettyPrintTable(rows, []int{40, 10, 120}, header)
306+
}

0 commit comments

Comments
 (0)