Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions cmd/docker-mcp/commands/workingset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [--id <id>] --server <ref1> --server <ref2> ... [--connect <client1> --connect <client2> ...]",
Use: "create --name <name> [--id <id>] --server <ref1> --server <ref2> ... [--connect <client1> --connect <client2> ...] [--format <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.
Expand All @@ -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))
},
}

Expand All @@ -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
Expand Down
20 changes: 16 additions & 4 deletions pkg/workingset/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +92 to +97
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: We don't allow quotes in ids, so this seems safe. But if we wanted to future proof it, we could do something like:

var o struct {
  ID string `yaml:"id" json:"id"`
}
o.ID = id
data, err := json.Marshal(o)
// err check...
fmt.Printf("%s\n", string(data))

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
Expand Down
203 changes: 189 additions & 14 deletions pkg/workingset/create_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workingset

import (
"encoding/json"
"testing"

v0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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")
}
Expand All @@ -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
Expand All @@ -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")
}
Expand All @@ -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")
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chore: We already test ID duplication I think, so we can remove this test.

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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chore: This might be overkill since this behavior is already tested I think, and we're really just worried about testing output format.

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")
}
Loading
Loading