diff --git a/cmd/docker-mcp/commands/root.go b/cmd/docker-mcp/commands/root.go index bfac10f78..a98e8d365 100644 --- a/cmd/docker-mcp/commands/root.go +++ b/cmd/docker-mcp/commands/root.go @@ -66,7 +66,7 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli, features featu if os.Getenv("DOCKER_MCP_IN_CONTAINER") != "1" { if features.IsProfilesFeatureEnabled() { - if isSubcommandOf(cmd, []string{"catalog-next", "catalog", "catalogs", "profile"}) { + if isSubcommandOf(cmd, []string{"catalog-next", "catalog", "catalogs", "profile", "template"}) { dao, err := db.New() if err != nil { return err @@ -100,6 +100,7 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli, features featu if features.IsProfilesFeatureEnabled() { cmd.AddCommand(workingSetCommand(cwd)) + cmd.AddCommand(templateCommand()) cmd.AddCommand(catalogNextCommand()) cmd.AddCommand(obsoleteCommand("config", "See `docker mcp profile config --help` instead.")) } else { diff --git a/cmd/docker-mcp/commands/template.go b/cmd/docker-mcp/commands/template.go new file mode 100644 index 000000000..eb4b9289b --- /dev/null +++ b/cmd/docker-mcp/commands/template.go @@ -0,0 +1,118 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/docker/mcp-gateway/pkg/client" + "github.com/docker/mcp-gateway/pkg/db" + "github.com/docker/mcp-gateway/pkg/oci" + "github.com/docker/mcp-gateway/pkg/registryapi" + "github.com/docker/mcp-gateway/pkg/telemetry" + "github.com/docker/mcp-gateway/pkg/template" + "github.com/docker/mcp-gateway/pkg/workingset" +) + +func templateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "template", + Short: "Manage starter profile templates", + } + + cmd.AddCommand(listTemplatesCommand()) + cmd.AddCommand(useTemplateCommand()) + + return cmd +} + +func listTemplatesCommand() *cobra.Command { + format := string(workingset.OutputFormatHumanReadable) + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List available starter templates", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return template.List(workingset.OutputFormat(format)) + }, + } + + flags := cmd.Flags() + flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), + fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", "))) + + return cmd +} + +func useTemplateCommand() *cobra.Command { + cfg := client.ReadConfig() + + var opts struct { + Name string + Connect []string + } + + cmd := &cobra.Command{ + Use: "use ", + Short: "Create a profile from a starter template", + Long: `Create a new profile from a starter template. + +This is equivalent to: docker mcp profile create --from-template + +Use 'docker mcp template list' to see available templates.`, + Example: ` # Create a profile from the ai-coding template + docker mcp template use ai-coding + + # Override the profile name + docker mcp template use ai-coding --name "My AI Tools" + + # Create and connect to a client in one shot + docker mcp template use ai-coding --connect cursor`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + templateID := args[0] + tmpl := template.FindByID(templateID) + if tmpl == nil { + return fmt.Errorf("unknown template: %s. Use `docker mcp template list` to see available templates", templateID) + } + + name := opts.Name + if name == "" { + name = tmpl.Title + } + + dao, err := db.New() + if err != nil { + return err + } + + ociService := oci.NewService() + + if err := template.EnsureCatalogExists(cmd.Context(), dao, ociService); err != nil { + return err + } + + registryClient := registryapi.NewClient() + servers := []string{tmpl.CatalogServerRef()} + + if err := workingset.Create(cmd.Context(), dao, registryClient, ociService, "", name, servers, opts.Connect); err != nil { + return err + } + + telemetry.Init() + telemetry.RecordTemplateUsage(cmd.Context(), templateID, "template-use") + return nil + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.Name, "name", "", "Override the profile name (defaults to template title)") + flags.StringArrayVar(&opts.Connect, "connect", []string{}, + fmt.Sprintf("Clients to connect to (can be specified multiple times). Supported clients: %s", + client.GetSupportedMCPClients(*cfg))) + + return cmd +} diff --git a/cmd/docker-mcp/commands/template_test.go b/cmd/docker-mcp/commands/template_test.go new file mode 100644 index 000000000..6e36a567a --- /dev/null +++ b/cmd/docker-mcp/commands/template_test.go @@ -0,0 +1,40 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTemplateCommandStructure(t *testing.T) { + cmd := templateCommand() + + assert.Equal(t, "template", cmd.Use) + + // Verify subcommands exist + subcommands := make(map[string]bool) + for _, sub := range cmd.Commands() { + subcommands[sub.Name()] = true + } + assert.True(t, subcommands["list"], "template list subcommand should exist") + assert.True(t, subcommands["use"], "template use subcommand should exist") +} + +func TestListTemplatesCommand(t *testing.T) { + cmd := listTemplatesCommand() + assert.Equal(t, "list", cmd.Use) + assert.Contains(t, cmd.Aliases, "ls") +} + +func TestUseTemplateCommandRequiresArgs(t *testing.T) { + cmd := useTemplateCommand() + require.NotNil(t, cmd) + + // Verify the command requires exactly 1 argument + err := cmd.Args(cmd, []string{}) + require.Error(t, err, "use command should require exactly 1 argument") + + err = cmd.Args(cmd, []string{"ai-coding"}) + assert.NoError(t, err, "use command should accept 1 argument") +} diff --git a/cmd/docker-mcp/commands/workingset.go b/cmd/docker-mcp/commands/workingset.go index a059a7e32..952e0cf57 100644 --- a/cmd/docker-mcp/commands/workingset.go +++ b/cmd/docker-mcp/commands/workingset.go @@ -11,6 +11,8 @@ import ( "github.com/docker/mcp-gateway/pkg/db" "github.com/docker/mcp-gateway/pkg/oci" "github.com/docker/mcp-gateway/pkg/registryapi" + "github.com/docker/mcp-gateway/pkg/telemetry" + "github.com/docker/mcp-gateway/pkg/template" "github.com/docker/mcp-gateway/pkg/workingset" ) @@ -128,14 +130,15 @@ To view enabled tools, use: docker mcp profile show `, func createWorkingSetCommand(cfg *client.Config) *cobra.Command { var opts struct { - ID string - Name string - Servers []string - Connect []string + ID string + Name string + Servers []string + Connect []string + FromTemplate string } cmd := &cobra.Command{ - Use: "create --name [--id ] --server --server ... [--connect --connect ...]", + Use: "create [--name ] [--id ] [--server ...] [--from-template ] [--connect ...]", 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. @@ -143,20 +146,67 @@ Profiles are decoupled from catalogs. Servers can be: - MCP Registry references (e.g. http://registry.modelcontextprotocol.io/v0/servers/312e45a4-2216-4b21-b9a8-0f1a51425073) - OCI image references with docker:// prefix (e.g., "docker://my-server:latest"). Images must be self-describing. - Catalog references with catalog:// prefix (e.g., "catalog://mcp/docker-mcp-catalog/github+obsidian"). - - Local file references with file:// prefix (e.g., "file://./server.yaml").`, - Example: ` # Create a profile with servers from a catalog + - Local file references with file:// prefix (e.g., "file://./server.yaml"). + +Alternatively, use --from-template to create a profile from a starter template. +Use 'docker mcp template list' to see available templates.`, + Example: ` # Create a profile from a starter template + docker mcp profile create --from-template ai-coding + + # Create from a template and connect to a client + docker mcp profile create --from-template ai-coding --connect cursor + + # Create from a template with a custom name + docker mcp profile create --from-template ai-coding --name "My AI Tools" + + # Create a profile with servers from a catalog docker mcp profile create --name dev-tools --server catalog://mcp/docker-mcp-catalog/github+obsidian # Create a profile with multiple servers (OCI references) docker mcp profile create --name my-profile --server docker://my-server:latest --server docker://my-other-server:latest - # Create a profile with MCP Registry references - 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`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + if opts.FromTemplate != "" && len(opts.Servers) > 0 { + return fmt.Errorf("--from-template and --server are mutually exclusive") + } + + if opts.FromTemplate != "" { + tmpl := template.FindByID(opts.FromTemplate) + if tmpl == nil { + return fmt.Errorf("unknown template: %s. Use `docker mcp template list` to see available templates", opts.FromTemplate) + } + if opts.Name == "" { + opts.Name = tmpl.Title + } + opts.Servers = []string{tmpl.CatalogServerRef()} + + ociService := oci.NewService() + dao, err := db.New() + if err != nil { + return err + } + + if err := template.EnsureCatalogExists(cmd.Context(), dao, ociService); err != nil { + return err + } + + registryClient := registryapi.NewClient() + if err := workingset.Create(cmd.Context(), dao, registryClient, ociService, opts.ID, opts.Name, opts.Servers, opts.Connect); err != nil { + return err + } + + telemetry.Init() + telemetry.RecordTemplateUsage(cmd.Context(), opts.FromTemplate, "profile-create-flag") + return nil + } + + if opts.Name == "" { + return fmt.Errorf("--name is required (or use --from-template)") + } + dao, err := db.New() if err != nil { return err @@ -168,11 +218,11 @@ Profiles are decoupled from catalogs. Servers can be: } flags := cmd.Flags() - flags.StringVar(&opts.Name, "name", "", "Name of the profile (required)") + flags.StringVar(&opts.Name, "name", "", "Name of the profile (required unless --from-template is used)") 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))) - _ = cmd.MarkFlagRequired("name") + flags.StringVar(&opts.FromTemplate, "from-template", "", "Create profile from a starter template (use `docker mcp template list` to see options)") return cmd } diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 9cb2c8dd9..9e153a781 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -80,6 +80,9 @@ var ( ResourceTemplateErrorCounter metric.Int64Counter ResourceTemplatesDiscovered metric.Int64Gauge ListResourceTemplatesCounter metric.Int64Counter + + // Profile template usage metrics + TemplateUsageCounter metric.Int64Counter ) // Init initializes the telemetry package with global providers @@ -376,6 +379,17 @@ func Init() { } } + // Initialize profile template metrics + TemplateUsageCounter, err = meter.Int64Counter("mcp.template.usage", + metric.WithDescription("Number of profile template usages"), + metric.WithUnit("1")) + if err != nil { + // Log error but don't fail + if os.Getenv("DOCKER_MCP_TELEMETRY_DEBUG") != "" { + fmt.Fprintf(os.Stderr, "[MCP-TELEMETRY] Error creating template usage counter: %v\n", err) + } + } + if os.Getenv("DOCKER_MCP_TELEMETRY_DEBUG") != "" { fmt.Fprintf(os.Stderr, "[MCP-TELEMETRY] Metrics created successfully\n") } @@ -908,3 +922,21 @@ func RecordResourceTemplateList(ctx context.Context, serverName string, template attribute.String("mcp.server.origin", serverName), )) } + +// RecordTemplateUsage records a profile template usage event. +// source indicates the entry point: "profile-create-flag" or "template-use". +func RecordTemplateUsage(ctx context.Context, templateID string, source string) { + if TemplateUsageCounter == nil { + return // Telemetry not initialized + } + + if os.Getenv("DOCKER_MCP_TELEMETRY_DEBUG") != "" { + fmt.Fprintf(os.Stderr, "[MCP-TELEMETRY] Template used: %s via %s\n", templateID, source) + } + + TemplateUsageCounter.Add(ctx, 1, + metric.WithAttributes( + attribute.String("mcp.template.id", templateID), + attribute.String("mcp.template.source", source), + )) +} diff --git a/pkg/template/ensure_catalog.go b/pkg/template/ensure_catalog.go new file mode 100644 index 000000000..3ed132479 --- /dev/null +++ b/pkg/template/ensure_catalog.go @@ -0,0 +1,33 @@ +package template + +import ( + "context" + "database/sql" + "errors" + "fmt" + + catalognext "github.com/docker/mcp-gateway/pkg/catalog_next" + "github.com/docker/mcp-gateway/pkg/db" + "github.com/docker/mcp-gateway/pkg/oci" +) + +// EnsureCatalogExists checks whether the Docker MCP catalog is available +// locally and pulls it if it is not. This allows template commands to work +// out of the box without requiring the user to manually pull the catalog +// first. +func EnsureCatalogExists(ctx context.Context, dao db.DAO, ociService oci.Service) error { + _, err := dao.GetCatalog(ctx, DefaultCatalogRef) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to check for catalog: %w", err) + } + + fmt.Println("Pulling Docker MCP catalog...") + if err := catalognext.Pull(ctx, dao, ociService, DefaultCatalogRef); err != nil { + return fmt.Errorf("failed to pull catalog %s: %w", DefaultCatalogRef, err) + } + + return nil +} diff --git a/pkg/template/list.go b/pkg/template/list.go new file mode 100644 index 000000000..cdcecfff4 --- /dev/null +++ b/pkg/template/list.go @@ -0,0 +1,46 @@ +package template + +import ( + "encoding/json" + "fmt" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/docker/mcp-gateway/pkg/workingset" +) + +// List prints the available templates in the specified output format. +func List(format workingset.OutputFormat) error { + var data []byte + var err error + + switch format { + case workingset.OutputFormatHumanReadable: + fmt.Println(printHumanReadable()) + return nil + case workingset.OutputFormatJSON: + data, err = json.MarshalIndent(Templates, "", " ") + case workingset.OutputFormatYAML: + data, err = yaml.Marshal(Templates) + default: + return fmt.Errorf("unsupported format: %s", format) + } + if err != nil { + return fmt.Errorf("failed to marshal templates: %w", err) + } + + fmt.Println(string(data)) + return nil +} + +func printHumanReadable() string { + var sb strings.Builder + sb.WriteString("ID\tTitle\tServers\tDescription\n") + sb.WriteString("----\t----\t----\t----\n") + for _, t := range Templates { + sb.WriteString(fmt.Sprintf("%s\t%s\t%s\t%s\n", + t.ID, t.Title, strings.Join(t.ServerNames, ", "), t.Description)) + } + return strings.TrimSuffix(sb.String(), "\n") +} diff --git a/pkg/template/template.go b/pkg/template/template.go new file mode 100644 index 000000000..79ec70e28 --- /dev/null +++ b/pkg/template/template.go @@ -0,0 +1,53 @@ +package template + +import "strings" + +// DefaultCatalogRef is the OCI reference for Docker's default MCP catalog. +const DefaultCatalogRef = "mcp/docker-mcp-catalog" + +// Template defines a starter profile template that bundles a curated set of +// MCP servers from the Docker catalog. +type Template struct { + ID string `json:"id" yaml:"id"` + Title string `json:"title" yaml:"title"` + Description string `json:"description" yaml:"description"` + ServerNames []string `json:"server_names" yaml:"server_names"` +} + +// Templates is the list of built-in starter templates. +var Templates = []Template{ + { + ID: "ai-coding", + Title: "AI coding", + Description: "Write code faster with Context7 for codebase awareness and Sequential Thinking for structured problem-solving.", + ServerNames: []string{"context7", "sequentialthinking"}, + }, + { + ID: "dev-workflow", + Title: "Dev workflow", + Description: "Automate your development cycle: open issues, write code, and update tickets with GitHub and Atlassian.", + ServerNames: []string{"github-official", "atlassian-remote"}, + }, + { + ID: "terminal-control", + Title: "Terminal control", + Description: "Run commands and scripts, manage files, and control your system directly from your AI client.", + ServerNames: []string{"desktop-commander", "filesystem"}, + }, +} + +// FindByID returns the template with the given ID, or nil if not found. +func FindByID(id string) *Template { + for i := range Templates { + if Templates[i].ID == id { + return &Templates[i] + } + } + return nil +} + +// CatalogServerRef returns the catalog:// URI for use with profile creation. +// For example: "catalog://mcp/docker-mcp-catalog/context7+sequentialthinking" +func (t *Template) CatalogServerRef() string { + return "catalog://" + DefaultCatalogRef + "/" + strings.Join(t.ServerNames, "+") +} diff --git a/pkg/template/template_test.go b/pkg/template/template_test.go new file mode 100644 index 000000000..bec3bf7d8 --- /dev/null +++ b/pkg/template/template_test.go @@ -0,0 +1,152 @@ +package template + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/mcp-gateway/pkg/workingset" +) + +func TestFindByID(t *testing.T) { + tests := []struct { + id string + wantNil bool + wantID string + wantName string + }{ + {id: "ai-coding", wantNil: false, wantID: "ai-coding", wantName: "AI coding"}, + {id: "dev-workflow", wantNil: false, wantID: "dev-workflow", wantName: "Dev workflow"}, + {id: "terminal-control", wantNil: false, wantID: "terminal-control", wantName: "Terminal control"}, + {id: "nonexistent", wantNil: true}, + {id: "", wantNil: true}, + } + + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + tmpl := FindByID(tt.id) + if tt.wantNil { + assert.Nil(t, tmpl) + } else { + require.NotNil(t, tmpl) + assert.Equal(t, tt.wantID, tmpl.ID) + assert.Equal(t, tt.wantName, tmpl.Title) + assert.NotEmpty(t, tmpl.Description) + assert.NotEmpty(t, tmpl.ServerNames) + } + }) + } +} + +func TestCatalogServerRef(t *testing.T) { + tests := []struct { + id string + wantRef string + }{ + { + id: "ai-coding", + wantRef: "catalog://mcp/docker-mcp-catalog/context7+sequentialthinking", + }, + { + id: "dev-workflow", + wantRef: "catalog://mcp/docker-mcp-catalog/github-official+atlassian-remote", + }, + { + id: "terminal-control", + wantRef: "catalog://mcp/docker-mcp-catalog/desktop-commander+filesystem", + }, + } + + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + tmpl := FindByID(tt.id) + require.NotNil(t, tmpl) + assert.Equal(t, tt.wantRef, tmpl.CatalogServerRef()) + }) + } +} + +func TestTemplatesHaveUniqueIDs(t *testing.T) { + seen := make(map[string]bool) + for _, tmpl := range Templates { + assert.False(t, seen[tmpl.ID], "duplicate template ID: %s", tmpl.ID) + seen[tmpl.ID] = true + } +} + +func TestTemplatesHaveRequiredFields(t *testing.T) { + for _, tmpl := range Templates { + t.Run(tmpl.ID, func(t *testing.T) { + assert.NotEmpty(t, tmpl.ID) + assert.NotEmpty(t, tmpl.Title) + assert.NotEmpty(t, tmpl.Description) + assert.GreaterOrEqual(t, len(tmpl.ServerNames), 1) + for _, name := range tmpl.ServerNames { + assert.NotEmpty(t, name) + } + }) + } +} + +// captureStdout captures stdout during function execution. +func captureStdout(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func TestListHumanReadable(t *testing.T) { + output := captureStdout(func() { + err := List(workingset.OutputFormatHumanReadable) + require.NoError(t, err) + }) + + assert.Contains(t, output, "ai-coding") + assert.Contains(t, output, "dev-workflow") + assert.Contains(t, output, "terminal-control") + assert.Contains(t, output, "AI coding") + assert.Contains(t, output, "context7") +} + +func TestListJSON(t *testing.T) { + output := captureStdout(func() { + err := List(workingset.OutputFormatJSON) + require.NoError(t, err) + }) + + var templates []Template + err := json.Unmarshal([]byte(output), &templates) + require.NoError(t, err) + assert.Len(t, templates, len(Templates)) + assert.Equal(t, "ai-coding", templates[0].ID) +} + +func TestListYAML(t *testing.T) { + output := captureStdout(func() { + err := List(workingset.OutputFormatYAML) + require.NoError(t, err) + }) + + assert.Contains(t, output, "ai-coding") + assert.Contains(t, output, "context7") +} + +func TestListUnsupportedFormat(t *testing.T) { + err := List(workingset.OutputFormat("invalid")) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} diff --git a/pkg/workingset/list.go b/pkg/workingset/list.go index 8e4665caf..19fde92ad 100644 --- a/pkg/workingset/list.go +++ b/pkg/workingset/list.go @@ -20,6 +20,10 @@ func List(ctx context.Context, dao db.DAO, format OutputFormat) error { if len(dbSets) == 0 && format == OutputFormatHumanReadable { fmt.Println("No profiles found. Use `docker mcp profile create --name ` to create a profile.") + fmt.Println("") + fmt.Println("Tip: Get started quickly with a starter template:") + fmt.Println(" docker mcp template list View available templates") + fmt.Println(" docker mcp template use Create a profile from a template") return nil }