Skip to content
Merged
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
3 changes: 2 additions & 1 deletion cmd/docker-mcp/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
118 changes: 118 additions & 0 deletions cmd/docker-mcp/commands/template.go
Original file line number Diff line number Diff line change
@@ -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 <template-id>",
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 <template-id>

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
}
40 changes: 40 additions & 0 deletions cmd/docker-mcp/commands/template_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
74 changes: 62 additions & 12 deletions cmd/docker-mcp/commands/workingset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -128,35 +130,83 @@ To view enabled tools, use: docker mcp profile show <profile-id>`,

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 <name> [--id <id>] --server <ref1> --server <ref2> ... [--connect <client1> --connect <client2> ...]",
Use: "create [--name <name>] [--id <id>] [--server <ref> ...] [--from-template <template-id>] [--connect <client> ...]",
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.
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
Expand All @@ -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
}
Expand Down
32 changes: 32 additions & 0 deletions pkg/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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),
))
}
33 changes: 33 additions & 0 deletions pkg/template/ensure_catalog.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading