diff --git a/cmd/docker-mcp/commands/workingset.go b/cmd/docker-mcp/commands/workingset.go index e3ad417de..2e570c9be 100644 --- a/cmd/docker-mcp/commands/workingset.go +++ b/cmd/docker-mcp/commands/workingset.go @@ -132,10 +132,11 @@ func createWorkingSetCommand(cfg *client.Config) *cobra.Command { Name string Servers []string Connect []string + Format string } cmd := &cobra.Command{ - Use: "create --name [--id ] --server --server ... [--connect --connect ...]", + Use: "create --name [--id ] --server --server ... [--connect --connect ...] [--format ]", Short: "Create a new profile of MCP servers", Long: `Create a new profile that groups multiple MCP servers together. A profile allows you to organize and manage related servers as a single unit. @@ -154,16 +155,23 @@ Profiles are decoupled from catalogs. Servers can be: docker mcp profile create --name my-profile --server http://registry.modelcontextprotocol.io/v0/servers/71de5a2a-6cfb-4250-a196-f93080ecc860 # Connect to clients upon creation - docker mcp profile create --name dev-tools --connect cursor`, + docker mcp profile create --name dev-tools --connect cursor + + # Create a profile with JSON output + docker mcp profile create --name dev-tools --format json`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []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 } registryClient := registryapi.NewClient() ociService := oci.NewService() - return workingset.Create(cmd.Context(), dao, registryClient, ociService, opts.ID, opts.Name, opts.Servers, opts.Connect) + return workingset.Create(cmd.Context(), dao, registryClient, ociService, opts.ID, opts.Name, opts.Servers, opts.Connect, workingset.OutputFormat(opts.Format)) }, } @@ -172,6 +180,7 @@ Profiles are decoupled from catalogs. Servers can be: flags.StringVar(&opts.ID, "id", "", "ID of the profile (defaults to a slugified version of the name)") flags.StringArrayVar(&opts.Servers, "server", []string{}, "Server to include specified with a URI: https:// (MCP Registry reference) or docker:// (Docker Image reference) or catalog:// (Catalog reference) or file:// (Local file path). Can be specified multiple times.") flags.StringArrayVar(&opts.Connect, "connect", []string{}, fmt.Sprintf("Clients to connect to: mcp-client (can be specified multiple times). Supported clients: %s", client.GetSupportedMCPClients(*cfg))) + flags.StringVar(&opts.Format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", "))) _ = cmd.MarkFlagRequired("name") return cmd diff --git a/pkg/workingset/create.go b/pkg/workingset/create.go index a56ab174c..ed68aa96a 100644 --- a/pkg/workingset/create.go +++ b/pkg/workingset/create.go @@ -15,7 +15,7 @@ import ( "github.com/docker/mcp-gateway/pkg/telemetry" ) -func Create(ctx context.Context, dao db.DAO, registryClient registryapi.Client, ociService oci.Service, id string, name string, servers []string, connectClients []string) error { +func Create(ctx context.Context, dao db.DAO, registryClient registryapi.Client, ociService oci.Service, id string, name string, servers []string, connectClients []string, format OutputFormat) error { telemetry.Init() start := time.Now() var success bool @@ -88,9 +88,21 @@ func Create(ctx context.Context, dao db.DAO, registryClient registryapi.Client, } } - fmt.Printf("Created profile %s with %d servers\n", id, len(workingSet.Servers)) - if len(connectClients) > 0 { - fmt.Printf("Connected to clients: %s\n", strings.Join(connectClients, ", ")) + switch format { + case OutputFormatJSON: + // Output JSON with just the profile ID + fmt.Printf("{\"id\":\"%s\"}\n", id) + case OutputFormatYAML: + // Output YAML with just the profile ID + fmt.Printf("id: %s\n", id) + case OutputFormatHumanReadable: + // Output human-readable format (default) + fmt.Printf("Created profile %s with %d servers\n", id, len(workingSet.Servers)) + if len(connectClients) > 0 { + fmt.Printf("Connected to clients: %s\n", strings.Join(connectClients, ", ")) + } + default: + return fmt.Errorf("unsupported output format: %s", format) } success = true diff --git a/pkg/workingset/create_test.go b/pkg/workingset/create_test.go index c6bd42b36..c9ffb7ac8 100644 --- a/pkg/workingset/create_test.go +++ b/pkg/workingset/create_test.go @@ -1,6 +1,7 @@ package workingset import ( + "encoding/json" "testing" v0 "github.com/modelcontextprotocol/registry/pkg/api/v0" @@ -97,7 +98,7 @@ func TestCreateWithDockerImages(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "My Test Set", []string{ "docker://myimage:latest", "docker://anotherimage:v1.0", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Verify the working set was created @@ -123,7 +124,7 @@ func TestCreateWithRegistryServers(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Registry Set", []string{ "https://example.com/v0/servers/server1", "https://example.com/v0/servers/server2", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Verify the working set was created @@ -147,7 +148,7 @@ func TestCreateWithMixedServers(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Mixed Set", []string{ "docker://myimage:latest", "https://example.com/v0/servers/server1", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Verify the working set was created @@ -166,7 +167,7 @@ func TestCreateWithCustomId(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "custom-id", "Test Set", []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Verify the working set was created with custom ID @@ -185,13 +186,13 @@ func TestCreateWithExistingId(t *testing.T) { // Create first working set err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "test-id", "Test Set 1", []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Try to create another with the same ID err = Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "test-id", "Test Set 2", []string{ "docker://anotherimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.Error(t, err) assert.Contains(t, err.Error(), "already exists") } @@ -203,19 +204,19 @@ func TestCreateGeneratesUniqueIds(t *testing.T) { // Create first working set err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Create second with same name err = Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ "docker://anotherimage:v1.0", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Create third with same name err = Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ "docker://anotherimage:v1.0", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // List all working sets @@ -242,7 +243,7 @@ func TestCreateWithInvalidServerFormat(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ "invalid-format", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.Error(t, err) assert.Contains(t, err.Error(), "invalid server value") } @@ -253,7 +254,7 @@ func TestCreateWithEmptyName(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "test-id", "", []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.Error(t, err) assert.Contains(t, err.Error(), "invalid profile") } @@ -262,7 +263,7 @@ func TestCreateWithEmptyServers(t *testing.T) { dao := setupTestDB(t) ctx := t.Context() - err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Empty Set", []string{}, []string{}) + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Empty Set", []string{}, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Verify the working set was created with no servers @@ -279,7 +280,7 @@ func TestCreateAddsDefaultSecrets(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Verify default secrets were added @@ -328,7 +329,7 @@ func TestCreateNameWithSpecialCharacters(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", tt.inputName, []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) // Verify the ID was generated correctly @@ -339,3 +340,177 @@ func TestCreateNameWithSpecialCharacters(t *testing.T) { }) } } + +// TestCreateOutputFormatJSON tests that JSON output format returns valid JSON with the profile ID +func TestCreateOutputFormatJSON(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + // Capture stdout + output := captureStdout(func() { + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ + "docker://myimage:latest", + }, []string{}, OutputFormatJSON) + require.NoError(t, err) + }) + + // Verify JSON structure + var result map[string]string + err := json.Unmarshal([]byte(output), &result) + require.NoError(t, err, "Output should be valid JSON") + + // Verify the ID field exists and has the expected value + assert.Equal(t, "test_set", result["id"]) + assert.Len(t, result, 1, "JSON output should only contain the id field") + + // Verify the profile was created in the database + dbSet, err := dao.GetWorkingSet(ctx, "test_set") + require.NoError(t, err) + assert.Equal(t, "test_set", dbSet.ID) +} + +// TestCreateOutputFormatHuman tests that human-readable format outputs the expected message +func TestCreateOutputFormatHuman(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + // Capture stdout + output := captureStdout(func() { + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ + "docker://myimage:latest", + "docker://anotherimage:v1.0", + }, []string{}, OutputFormatHumanReadable) + require.NoError(t, err) + }) + + // Verify human-readable output + assert.Contains(t, output, "Created profile test_set with 2 servers") + + // Verify the profile was created in the database + dbSet, err := dao.GetWorkingSet(ctx, "test_set") + require.NoError(t, err) + assert.Equal(t, "test_set", dbSet.ID) + assert.Len(t, dbSet.Servers, 2) +} + +// TestCreateJSONWithDuplicateIDPrevention tests that JSON output reflects the actual ID with duplicate suffix +func TestCreateJSONWithDuplicateIDPrevention(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + // Create first profile + output1 := captureStdout(func() { + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test", []string{ + "docker://myimage:latest", + }, []string{}, OutputFormatJSON) + require.NoError(t, err) + }) + + var result1 map[string]string + err := json.Unmarshal([]byte(output1), &result1) + require.NoError(t, err) + assert.Equal(t, "test", result1["id"]) + + // Create second profile with the same name + output2 := captureStdout(func() { + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test", []string{ + "docker://myimage:latest", + }, []string{}, OutputFormatJSON) + require.NoError(t, err) + }) + + var result2 map[string]string + err = json.Unmarshal([]byte(output2), &result2) + require.NoError(t, err) + assert.Equal(t, "test_2", result2["id"], "Second profile should have _2 suffix") + + // Create third profile with the same name + output3 := captureStdout(func() { + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test", []string{ + "docker://myimage:latest", + }, []string{}, OutputFormatJSON) + require.NoError(t, err) + }) + + var result3 map[string]string + err = json.Unmarshal([]byte(output3), &result3) + require.NoError(t, err) + assert.Equal(t, "test_3", result3["id"], "Third profile should have _3 suffix") +} + +// TestCreateDefaultOutputFormat tests that the default format is human-readable +func TestCreateDefaultOutputFormat(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + // Capture stdout with default format (OutputFormatHumanReadable) + output := captureStdout(func() { + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ + "docker://myimage:latest", + }, []string{}, OutputFormatHumanReadable) + require.NoError(t, err) + }) + + // Verify it's human-readable, not JSON + assert.Contains(t, output, "Created profile") + assert.NotContains(t, output, "{\"id\":") +} + +// TestCreateJSONWithNoServers tests that JSON output works correctly with empty server list +func TestCreateJSONWithNoServers(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + // Capture stdout + output := captureStdout(func() { + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Empty Set", []string{}, []string{}, OutputFormatJSON) + require.NoError(t, err) + }) + + // Verify JSON structure + var result map[string]string + err := json.Unmarshal([]byte(output), &result) + require.NoError(t, err) + assert.Equal(t, "empty_set", result["id"]) + + // Verify the profile was created in the database with no servers + dbSet, err := dao.GetWorkingSet(ctx, "empty_set") + require.NoError(t, err) + assert.Equal(t, "empty_set", dbSet.ID) + assert.Empty(t, dbSet.Servers) +} + +// TestCreateOutputFormatYAML tests that YAML output format returns valid YAML with the profile ID +func TestCreateOutputFormatYAML(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + // Capture stdout + output := captureStdout(func() { + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ + "docker://myimage:latest", + }, []string{}, OutputFormatYAML) + require.NoError(t, err) + }) + + // Verify YAML format + assert.Equal(t, "id: test_set\n", output) + + // Verify the profile was created in the database + dbSet, err := dao.GetWorkingSet(ctx, "test_set") + require.NoError(t, err) + assert.Equal(t, "test_set", dbSet.ID) +} + +// TestCreateUnsupportedFormat tests that unsupported formats are rejected +func TestCreateUnsupportedFormat(t *testing.T) { + dao := setupTestDB(t) + ctx := t.Context() + + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "", "Test Set", []string{ + "docker://myimage:latest", + }, []string{}, OutputFormat("unsupported")) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported output format") +} diff --git a/pkg/workingset/server_test.go b/pkg/workingset/server_test.go index 1decc1dae..21d643321 100644 --- a/pkg/workingset/server_test.go +++ b/pkg/workingset/server_test.go @@ -93,7 +93,7 @@ func TestRemoveOneServerFromWorkingSet(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "test-set", "test-set", []string{ serverURI, - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) dbSet, err := dao.GetWorkingSet(ctx, setID) @@ -122,7 +122,7 @@ func TestRemoveMultipleServersFromWorkingSet(t *testing.T) { "docker://anotherimage:v1.0", } - err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), workingSetID, "My Test Set", servers, []string{}) + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), workingSetID, "My Test Set", servers, []string{}, OutputFormatHumanReadable) require.NoError(t, err) dbSet, err := dao.GetWorkingSet(ctx, workingSetID) @@ -148,7 +148,7 @@ func TestRemoveOneOfManyServerFromWorkingSet(t *testing.T) { "docker://anotherimage:v1.0", } - err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), workingSetID, "My Test Set", servers, []string{}) + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), workingSetID, "My Test Set", servers, []string{}, OutputFormatHumanReadable) require.NoError(t, err) dbSet, err := dao.GetWorkingSet(ctx, workingSetID) @@ -174,7 +174,7 @@ func TestRemoveNoServersFromWorkingSet(t *testing.T) { "docker://myimage:latest", } - err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), workingSetID, "My Test Set", servers, []string{}) + err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), workingSetID, "My Test Set", servers, []string{}, OutputFormatHumanReadable) require.NoError(t, err) err = RemoveServers(ctx, dao, workingSetID, []string{}) @@ -858,7 +858,7 @@ func TestListServersNoFilters(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "set-1", "Set 1", []string{ "docker://myimage:latest", "docker://anotherimage:v1.0", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) output := captureStdout(func() { @@ -881,7 +881,7 @@ func TestListServersFilterByName(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "set-1", "Set 1", []string{ "docker://myimage:latest", "docker://anotherimage:v1.0", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) output := captureStdout(func() { @@ -903,7 +903,7 @@ func TestListServersFilterByNameCaseInsensitive(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "set-1", "Set 1", []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) output := captureStdout(func() { @@ -925,12 +925,12 @@ func TestListServersFilterByWorkingSet(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "set-1", "Set 1", []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) err = Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "set-2", "Set 2", []string{ "docker://anotherimage:v1.0", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) output := captureStdout(func() { @@ -953,12 +953,12 @@ func TestListServersFilterByBothNameAndWorkingSet(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "set-1", "Set 1", []string{ "docker://myimage:latest", "docker://anotherimage:v1.0", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) err = Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "set-2", "Set 2", []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) output := captureStdout(func() { @@ -981,7 +981,7 @@ func TestListServersFilterNoMatches(t *testing.T) { err := Create(ctx, dao, getMockRegistryClient(), getMockOciService(), "set-1", "Set 1", []string{ "docker://myimage:latest", - }, []string{}) + }, []string{}, OutputFormatHumanReadable) require.NoError(t, err) output := captureStdout(func() {