diff --git a/.dockerignore b/.dockerignore index dceb0b623..890749a41 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,4 @@ !/.git !/server.go !/test/servers +!/test/testdata diff --git a/cmd/docker-mcp/commands/catalog.go b/cmd/docker-mcp/commands/catalog.go index 0f99bc5bc..6cfb2b453 100644 --- a/cmd/docker-mcp/commands/catalog.go +++ b/cmd/docker-mcp/commands/catalog.go @@ -1,11 +1,15 @@ package commands import ( + "context" + "encoding/json" "fmt" "github.com/spf13/cobra" "github.com/docker/mcp-gateway/cmd/docker-mcp/catalog" + catalogTypes "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/catalog" + "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/yq" ) func catalogCommand() *cobra.Command { @@ -31,21 +35,40 @@ func catalogCommand() *cobra.Command { } func importCatalogCommand() *cobra.Command { - return &cobra.Command{ + var mcpRegistry string + var dryRun bool + cmd := &cobra.Command{ Use: "import ", Short: "Import a catalog from URL or file", Long: `Import an MCP server catalog from a URL or local file. The catalog will be downloaded -and stored locally for use with the MCP gateway.`, +and stored locally for use with the MCP gateway. + +When --mcp-registry flag is used, the argument must be an existing catalog name, and the +command will import servers from the MCP registry URL into that catalog.`, Args: cobra.ExactArgs(1), Example: ` # Import from URL docker mcp catalog import https://example.com/my-catalog.yaml # Import from local file - docker mcp catalog import ./shared-catalog.yaml`, + docker mcp catalog import ./shared-catalog.yaml + + # Import from MCP registry URL into existing catalog + docker mcp catalog import my-catalog --mcp-registry https://registry.example.com/server`, RunE: func(cmd *cobra.Command, args []string) error { + // If mcp-registry flag is provided, import to existing catalog + if mcpRegistry != "" { + if dryRun { + return runOfficialregistryImport(cmd.Context(), mcpRegistry, nil) + } + return importMCPRegistryToCatalog(cmd.Context(), args[0], mcpRegistry) + } + // Default behavior: import entire catalog return catalog.Import(cmd.Context(), args[0]) }, } + cmd.Flags().StringVar(&mcpRegistry, "mcp-registry", "", "Import server from MCP registry URL into existing catalog") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show Imported Data but do not update the Catalog") + return cmd } func exportCatalogCommand() *cobra.Command { @@ -237,3 +260,69 @@ func resetCatalogCommand() *cobra.Command { }, } } + +// importMCPRegistryToCatalog imports a server from an MCP registry URL into an existing catalog +func importMCPRegistryToCatalog(ctx context.Context, catalogName, mcpRegistryURL string) error { + // Check if the catalog exists + cfg, err := catalog.ReadConfig() + if err != nil { + return fmt.Errorf("failed to read catalog config: %w", err) + } + + _, exists := cfg.Catalogs[catalogName] + if !exists { + return fmt.Errorf("catalog '%s' does not exist", catalogName) + } + + // Prevent users from modifying the Docker catalog + if catalogName == catalog.DockerCatalogName { + return fmt.Errorf("cannot import servers into catalog '%s' as it is managed by Docker", catalogName) + } + + // Fetch server from MCP registry + var servers []catalogTypes.Server + if err := runOfficialregistryImport(ctx, mcpRegistryURL, &servers); err != nil { + return fmt.Errorf("failed to fetch server from MCP registry: %w", err) + } + + if len(servers) == 0 { + return fmt.Errorf("no servers found at MCP registry URL") + } + + // For now, we'll import the first server (MCP registry URLs typically contain one server) + server := servers[0] + + serverName := server.Name + + // Convert the server to JSON for injection into the catalog + serverJSON, err := json.Marshal(server) + if err != nil { + return fmt.Errorf("failed to marshal server: %w", err) + } + + // Read the current catalog content + catalogContent, err := catalog.ReadCatalogFile(catalogName) + if err != nil { + return fmt.Errorf("failed to read catalog file: %w", err) + } + + // Inject the server into the catalog using the same pattern as the add function + updatedContent, err := injectServerIntoCatalog(catalogContent, serverName, serverJSON) + if err != nil { + return fmt.Errorf("failed to inject server into catalog: %w", err) + } + + // Write the updated catalog back + if err := catalog.WriteCatalogFile(catalogName, updatedContent); err != nil { + return fmt.Errorf("failed to write updated catalog: %w", err) + } + + fmt.Printf("Successfully imported server '%s' from MCP registry into catalog '%s'\n", serverName, catalogName) + return nil +} + +// injectServerIntoCatalog injects a server JSON into a catalog YAML using yq +func injectServerIntoCatalog(yamlData []byte, serverName string, serverJSON []byte) ([]byte, error) { + query := fmt.Sprintf(`.registry."%s" = %s`, serverName, string(serverJSON)) + return yq.Evaluate(query, yamlData, yq.NewYamlDecoder(), yq.NewYamlEncoder()) +} diff --git a/cmd/docker-mcp/commands/feature.go b/cmd/docker-mcp/commands/feature.go index 75a71f290..3f7041073 100644 --- a/cmd/docker-mcp/commands/feature.go +++ b/cmd/docker-mcp/commands/feature.go @@ -37,7 +37,6 @@ func featureEnableCommand(dockerCli command.Cli) *cobra.Command { Long: `Enable an experimental feature. Available features: - configured-catalogs Allow gateway to use user-managed catalogs alongside Docker catalog oauth-interceptor Enable GitHub OAuth flow interception for automatic authentication dynamic-tools Enable internal MCP management tools (mcp-find, mcp-add, mcp-remove)`, Args: cobra.ExactArgs(1), @@ -46,7 +45,7 @@ Available features: // Validate feature name if !isKnownFeature(featureName) { - return fmt.Errorf("unknown feature: %s\n\nAvailable features:\n configured-catalogs Allow gateway to use user-managed catalogs\n oauth-interceptor Enable GitHub OAuth flow interception\n dynamic-tools Enable internal MCP management tools", featureName) + return fmt.Errorf("unknown feature: %s\n\nAvailable features:\n oauth-interceptor Enable GitHub OAuth flow interception\n dynamic-tools Enable internal MCP management tools", featureName) } // Enable the feature @@ -65,12 +64,6 @@ Available features: // Provide usage hints for features switch featureName { - case "configured-catalogs": - fmt.Println("\nTo use configured catalogs with the gateway, run:") - fmt.Println(" docker mcp gateway run --use-configured-catalogs") - fmt.Println("\nTo create and manage catalogs, use:") - fmt.Println(" docker mcp catalog create ") - fmt.Println(" docker mcp catalog add ") case "oauth-interceptor": fmt.Println("\nThis feature enables automatic GitHub OAuth interception when 401 errors occur.") fmt.Println("When enabled, the gateway will automatically provide OAuth URLs for authentication.") @@ -135,7 +128,7 @@ func featureListCommand(dockerCli command.Cli) *cobra.Command { fmt.Println() // Show all known features - knownFeatures := []string{"configured-catalogs", "oauth-interceptor", "dynamic-tools"} + knownFeatures := []string{"oauth-interceptor", "dynamic-tools"} for _, feature := range knownFeatures { status := "disabled" if isFeatureEnabledFromCli(dockerCli, feature) { @@ -146,8 +139,6 @@ func featureListCommand(dockerCli command.Cli) *cobra.Command { // Add description for each feature switch feature { - case "configured-catalogs": - fmt.Printf(" %-20s %s\n", "", "Allow gateway to use user-managed catalogs alongside Docker catalog") case "oauth-interceptor": fmt.Printf(" %-20s %s\n", "", "Enable GitHub OAuth flow interception for automatic authentication") case "dynamic-tools": @@ -212,7 +203,6 @@ func isFeatureEnabledFromConfig(configFile *configfile.ConfigFile, feature strin // isKnownFeature checks if the feature name is valid func isKnownFeature(feature string) bool { knownFeatures := []string{ - "configured-catalogs", "oauth-interceptor", "dynamic-tools", } diff --git a/cmd/docker-mcp/commands/feature_test.go b/cmd/docker-mcp/commands/feature_test.go index 9ee571060..cce94ea5a 100644 --- a/cmd/docker-mcp/commands/feature_test.go +++ b/cmd/docker-mcp/commands/feature_test.go @@ -1,226 +1,79 @@ package commands import ( - "encoding/json" - "os" - "path/filepath" - "strconv" "testing" "github.com/docker/cli/cli/config/configfile" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestIsFeatureEnabledTrue(t *testing.T) { - // Create temporary config directory - tempDir := t.TempDir() - configFile := filepath.Join(tempDir, "config.json") - - // Create config with enabled feature - config := map[string]any{ - "features": map[string]string{ - "configured-catalogs": "enabled", - }, - } - configData, err := json.Marshal(config) - require.NoError(t, err) - err = os.WriteFile(configFile, configData, 0o644) - require.NoError(t, err) - - // Load config file - configFile2 := &configfile.ConfigFile{ - Filename: configFile, - } - _ = configFile2.LoadFromReader(os.Stdin) // This will load from the filename - - // Test directly with Features map - configFile2.Features = map[string]string{ - "configured-catalogs": "enabled", - } - - enabled := isFeatureEnabled(configFile2, "configured-catalogs") - assert.True(t, enabled) -} - -func TestIsFeatureEnabledFalse(t *testing.T) { - configFile := &configfile.ConfigFile{ - Features: map[string]string{ - "configured-catalogs": "disabled", - }, - } - - enabled := isFeatureEnabled(configFile, "configured-catalogs") - assert.False(t, enabled) -} - -func TestIsFeatureEnabledMissing(t *testing.T) { - configFile := &configfile.ConfigFile{ - Features: make(map[string]string), - } - - enabled := isFeatureEnabled(configFile, "configured-catalogs") - assert.False(t, enabled, "missing features should default to disabled") +func TestIsFeatureEnabledOAuthInterceptor(t *testing.T) { + t.Run("enabled", func(t *testing.T) { + configFile := &configfile.ConfigFile{ + Features: map[string]string{ + "oauth-interceptor": "enabled", + }, + } + enabled := isFeatureEnabledFromConfig(configFile, "oauth-interceptor") + assert.True(t, enabled) + }) + + t.Run("disabled", func(t *testing.T) { + configFile := &configfile.ConfigFile{ + Features: map[string]string{ + "oauth-interceptor": "disabled", + }, + } + enabled := isFeatureEnabledFromConfig(configFile, "oauth-interceptor") + assert.False(t, enabled) + }) + + t.Run("missing", func(t *testing.T) { + configFile := &configfile.ConfigFile{ + Features: make(map[string]string), + } + enabled := isFeatureEnabledFromConfig(configFile, "oauth-interceptor") + assert.False(t, enabled, "missing features should default to disabled") + }) } -func TestIsFeatureEnabledCorrupt(t *testing.T) { - configFile := &configfile.ConfigFile{ - Features: map[string]string{ - "configured-catalogs": "invalid-boolean", - }, - } - - enabled := isFeatureEnabled(configFile, "configured-catalogs") - assert.False(t, enabled, "corrupted feature values should default to disabled") +func TestIsFeatureEnabledDynamicTools(t *testing.T) { + t.Run("enabled", func(t *testing.T) { + configFile := &configfile.ConfigFile{ + Features: map[string]string{ + "dynamic-tools": "enabled", + }, + } + enabled := isFeatureEnabledFromConfig(configFile, "dynamic-tools") + assert.True(t, enabled) + }) + + t.Run("disabled", func(t *testing.T) { + configFile := &configfile.ConfigFile{ + Features: map[string]string{ + "dynamic-tools": "disabled", + }, + } + enabled := isFeatureEnabledFromConfig(configFile, "dynamic-tools") + assert.False(t, enabled) + }) + + t.Run("missing", func(t *testing.T) { + configFile := &configfile.ConfigFile{ + Features: make(map[string]string), + } + enabled := isFeatureEnabledFromConfig(configFile, "dynamic-tools") + assert.False(t, enabled, "missing features should default to disabled") + }) } -func TestEnableFeature(t *testing.T) { - // Create temporary config directory - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.json") - - // Create initial config - configFile := &configfile.ConfigFile{ - Filename: configPath, - Features: make(map[string]string), - } - - // Test enabling configured-catalogs feature - err := enableFeature(configFile, "configured-catalogs") - require.NoError(t, err) - - // Verify feature was enabled - enabled := isFeatureEnabled(configFile, "configured-catalogs") - assert.True(t, enabled, "configured-catalogs feature should be enabled") -} - -func TestDisableFeature(t *testing.T) { - // Create temporary config directory - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.json") - - // Create config with feature already enabled - configFile := &configfile.ConfigFile{ - Filename: configPath, - Features: map[string]string{ - "configured-catalogs": "enabled", - }, - } - - // Test disabling configured-catalogs feature - err := disableFeature(configFile, "configured-catalogs") - require.NoError(t, err) - - // Verify feature was disabled - enabled := isFeatureEnabled(configFile, "configured-catalogs") - assert.False(t, enabled, "configured-catalogs feature should be disabled") -} - -func TestListFeatures(t *testing.T) { - // Create config with mixed features - configFile := &configfile.ConfigFile{ - Features: map[string]string{ - "configured-catalogs": "enabled", - "other-feature": "disabled", - }, - } - - // Test listing features - features := listFeatures(configFile) - - // Should contain our feature with correct status - assert.Contains(t, features, "configured-catalogs") - assert.Contains(t, features, "other-feature") - assert.Equal(t, "enabled", features["configured-catalogs"]) - assert.Equal(t, "disabled", features["other-feature"]) -} - -func TestInvalidFeature(t *testing.T) { - configFile := &configfile.ConfigFile{ - Features: make(map[string]string), - } - - // Test enabling invalid feature - err := enableFeature(configFile, "invalid-feature") - require.Error(t, err, "should reject invalid feature names") - assert.Contains(t, err.Error(), "unknown feature") -} - -// Feature management functions that need to be implemented -func enableFeature(configFile *configfile.ConfigFile, feature string) error { - // Validate feature name - if !isKnownFeature(feature) { - return &featureError{feature: feature, message: "unknown feature"} - } - - // Enable the feature - if configFile.Features == nil { - configFile.Features = make(map[string]string) - } - configFile.Features[feature] = "enabled" - - // Save config file - return configFile.Save() -} - -func disableFeature(configFile *configfile.ConfigFile, feature string) error { - // Validate feature name - if !isKnownFeature(feature) { - return &featureError{feature: feature, message: "unknown feature"} - } - - // Disable the feature - if configFile.Features == nil { - configFile.Features = make(map[string]string) - } - configFile.Features[feature] = "disabled" - - // Save config file - return configFile.Save() -} - -func listFeatures(configFile *configfile.ConfigFile) map[string]string { - if configFile.Features == nil { - return make(map[string]string) - } - - // Return copy of features map - result := make(map[string]string) - for k, v := range configFile.Features { - result[k] = v - } - return result -} - -func isFeatureEnabled(configFile *configfile.ConfigFile, _ string) bool { - if configFile.Features == nil { - return false - } - - value, exists := configFile.Features["configured-catalogs"] - if !exists { - return false - } - - // Handle both boolean string values and "enabled"/"disabled" strings - if value == "enabled" { - return true - } - if value == "disabled" { - return false - } - - // Fallback to parsing as boolean - enabled, err := strconv.ParseBool(value) - return err == nil && enabled -} - -// Feature error type -type featureError struct { - feature string - message string -} +func TestIsKnownFeature(t *testing.T) { + // Test valid features + assert.True(t, isKnownFeature("oauth-interceptor")) + assert.True(t, isKnownFeature("dynamic-tools")) -func (e *featureError) Error() string { - return e.message + ": " + e.feature + // Test invalid features + assert.False(t, isKnownFeature("invalid-feature")) + assert.False(t, isKnownFeature("configured-catalogs")) // No longer supported + assert.False(t, isKnownFeature("")) } diff --git a/cmd/docker-mcp/commands/gateway.go b/cmd/docker-mcp/commands/gateway.go index a6e2f6c37..706a94631 100644 --- a/cmd/docker-mcp/commands/gateway.go +++ b/cmd/docker-mcp/commands/gateway.go @@ -4,11 +4,13 @@ import ( "errors" "fmt" "os" + "strings" "github.com/docker/cli/cli/command" "github.com/spf13/cobra" "github.com/docker/mcp-gateway/cmd/docker-mcp/catalog" + catalogTypes "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/catalog" "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/docker" "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/gateway" ) @@ -25,7 +27,8 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command var additionalRegistries []string var additionalConfigs []string var additionalToolsConfig []string - var useConfiguredCatalogs bool + var mcpRegistryUrls []string + var enableAllServers bool if os.Getenv("DOCKER_MCP_IN_CONTAINER") == "1" { // In-container. options = gateway.Config{ @@ -64,10 +67,6 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command Use: "run", Short: "Run the gateway", Args: cobra.NoArgs, - PreRunE: func(_ *cobra.Command, _ []string) error { - // Validate configured catalogs feature flag - return validateConfiguredCatalogsFeatureForCli(dockerCli, useConfiguredCatalogs) - }, RunE: func(cmd *cobra.Command, _ []string) error { // Check if OAuth interceptor feature is enabled options.OAuthInterceptorEnabled = isOAuthInterceptorFeatureEnabled(dockerCli) @@ -92,34 +91,58 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command options.Port = 8811 } - // Build catalog path list with proper precedence order - catalogPaths := options.CatalogPath // Start with existing catalog paths (includes docker-mcp.yaml default) - - // Add configured catalogs if requested - if useConfiguredCatalogs { - configuredPaths := getConfiguredCatalogPaths() - // Insert configured catalogs after docker-mcp.yaml but before CLI-specified catalogs - if len(catalogPaths) > 0 { - // Insert after the first element (docker-mcp.yaml) - catalogPaths = append(catalogPaths[:1], append(configuredPaths, catalogPaths[1:]...)...) - } else { - catalogPaths = append(catalogPaths, configuredPaths...) - } - } + // Build catalog path list with proper precedence order and no duplicates + defaultPaths := convertCatalogNamesToPaths(options.CatalogPath) // Convert any catalog names to paths - // Append additional catalogs (CLI-specified have highest precedence) - catalogPaths = append(catalogPaths, additionalCatalogs...) + // Only add configured catalogs if defaultPaths is not a single Docker catalog entry + var configuredPaths []string + if len(defaultPaths) == 1 && (defaultPaths[0] == catalog.DockerCatalogURL || defaultPaths[0] == catalog.DockerCatalogFilename) { + configuredPaths = getConfiguredCatalogPaths() + } + catalogPaths := buildUniqueCatalogPaths(defaultPaths, configuredPaths, additionalCatalogs) options.CatalogPath = catalogPaths options.RegistryPath = append(options.RegistryPath, additionalRegistries...) options.ConfigPath = append(options.ConfigPath, additionalConfigs...) options.ToolsPath = append(options.ToolsPath, additionalToolsConfig...) + // Process MCP registry URLs if provided + if len(mcpRegistryUrls) > 0 { + var mcpServers []catalogTypes.Server + for _, registryURL := range mcpRegistryUrls { + if err := runOfficialregistryImport(cmd.Context(), registryURL, &mcpServers); err != nil { + return fmt.Errorf("failed to fetch server from MCP registry %s: %w", registryURL, err) + } + } + options.MCPRegistryServers = mcpServers + } + + // Handle --enable-all-servers flag + if enableAllServers { + if len(options.ServerNames) > 0 { + return fmt.Errorf("cannot use --enable-all-servers with --servers flag") + } + + // Read all catalogs to get server names + mcpCatalog, err := catalogTypes.ReadFrom(cmd.Context(), catalogPaths) + if err != nil { + return fmt.Errorf("failed to read catalogs for --enable-all-servers: %w", err) + } + + // Extract all server names from the catalog + var allServerNames []string + for serverName := range mcpCatalog.Servers { + allServerNames = append(allServerNames, serverName) + } + options.ServerNames = allServerNames + } + return gateway.NewGateway(options, docker).Run(cmd.Context()) }, } runCmd.Flags().StringSliceVar(&options.ServerNames, "servers", nil, "Names of the servers to enable (if non empty, ignore --registry flag)") + runCmd.Flags().BoolVar(&enableAllServers, "enable-all-servers", false, "Enable all servers in the catalog (instead of using individual --servers options)") runCmd.Flags().StringSliceVar(&options.CatalogPath, "catalog", options.CatalogPath, "Paths to docker catalogs (absolute or relative to ~/.docker/mcp/catalogs/)") runCmd.Flags().StringSliceVar(&additionalCatalogs, "additional-catalog", nil, "Additional catalog paths to append to the default catalogs") runCmd.Flags().StringSliceVar(&options.RegistryPath, "registry", options.RegistryPath, "Paths to the registry files (absolute or relative to ~/.docker/mcp/)") @@ -132,6 +155,7 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command runCmd.Flags().StringSliceVar(&options.ToolNames, "tools", options.ToolNames, "List of tools to enable") runCmd.Flags().StringArrayVar(&options.Interceptors, "interceptor", options.Interceptors, "List of interceptors to use (format: when:type:path, e.g. 'before:exec:/bin/path')") runCmd.Flags().StringArrayVar(&options.OciRef, "oci-ref", options.OciRef, "OCI image references to use") + runCmd.Flags().StringSliceVar(&mcpRegistryUrls, "mcp-registry", nil, "MCP registry URLs to fetch servers from (can be repeated)") runCmd.Flags().IntVar(&options.Port, "port", options.Port, "TCP port to listen on (default is to listen on stdio)") runCmd.Flags().StringVar(&options.Transport, "transport", options.Transport, "stdio, sse or streaming (default is stdio)") runCmd.Flags().BoolVar(&options.LogCalls, "log-calls", options.LogCalls, "Log calls to the tools") @@ -147,9 +171,6 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command runCmd.Flags().StringVar(&options.Memory, "memory", options.Memory, "Memory allocated to each MCP Server (default is 2Gb)") runCmd.Flags().BoolVar(&options.Static, "static", options.Static, "Enable static mode (aka pre-started servers)") - // Configured catalogs feature - runCmd.Flags().BoolVar(&useConfiguredCatalogs, "use-configured-catalogs", false, "Include user-managed catalogs (requires 'configured-catalogs' feature to be enabled)") - // Very experimental features runCmd.Flags().BoolVar(&options.Central, "central", options.Central, "In central mode, clients tell us which servers to enable") _ = runCmd.Flags().MarkHidden("central") @@ -159,43 +180,6 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command return cmd } -// validateConfiguredCatalogsFeatureForCli validates that the configured-catalogs feature is enabled when requested -func validateConfiguredCatalogsFeatureForCli(dockerCli command.Cli, useConfigured bool) error { - if !useConfigured { - return nil // No validation needed when feature not requested - } - - // Check if config is accessible (container mode check) - configFile := dockerCli.ConfigFile() - if configFile == nil { - return fmt.Errorf(`docker configuration not accessible. - -If running in container, mount Docker config: - -v ~/.docker:/root/.docker - -Or mount just the config file: - -v ~/.docker/config.json:/root/.docker/config.json`) - } - - // Check if feature is enabled - if configFile.Features != nil { - if value, exists := configFile.Features["configured-catalogs"]; exists { - if value == "enabled" { - return nil // Feature is enabled - } - } - } - - // Feature not enabled - return fmt.Errorf(`configured catalogs feature is not enabled - -To enable this experimental feature, run: - docker mcp feature enable configured-catalogs - -This feature allows the gateway to automatically include user-managed catalogs -alongside the default Docker catalog`) -} - // getConfiguredCatalogPaths returns the file paths of all configured catalogs func getConfiguredCatalogPaths() []string { cfg, err := catalog.ReadConfig() @@ -216,6 +200,61 @@ func getConfiguredCatalogPaths() []string { return catalogPaths } +// convertCatalogNamesToPaths converts catalog names to their corresponding file paths. +// If a path entry is already a file path (contains .yaml or is an absolute/relative path), +// it's returned as-is. If it's a catalog name from the configuration, it's converted to catalogName.yaml. +func convertCatalogNamesToPaths(catalogPaths []string) []string { + cfg, err := catalog.ReadConfig() + if err != nil { + // If config doesn't exist or can't be read, return paths as-is + return catalogPaths + } + + var result []string + for _, path := range catalogPaths { + // If it's already a file path (contains .yaml, starts with /, ./, or ../), keep as-is + if strings.Contains(path, ".yaml") || strings.HasPrefix(path, "/") || + strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../") { + result = append(result, path) + } else if _, exists := cfg.Catalogs[path]; exists { + // It's a catalog name from config, convert to file path + result = append(result, path+".yaml") + } else { + // Not a known catalog name and not a clear file path, keep as-is + result = append(result, path) + } + } + + return result +} + +// buildUniqueCatalogPaths builds a unique list of catalog paths with proper precedence order: +// 1. Default catalogs (e.g., docker-mcp.yaml) +// 2. Configured catalogs (from catalog management system) +// 3. Additional catalogs (CLI-specified, highest precedence) +// Duplicates are removed while preserving order and precedence. +func buildUniqueCatalogPaths(defaultPaths, configuredPaths, additionalPaths []string) []string { + seen := make(map[string]bool) + var result []string + + // Helper function to add unique paths + addUnique := func(paths []string) { + for _, path := range paths { + if !seen[path] { + seen[path] = true + result = append(result, path) + } + } + } + + // Add paths in precedence order + addUnique(defaultPaths) + addUnique(configuredPaths) + addUnique(additionalPaths) + + return result +} + // isOAuthInterceptorFeatureEnabled checks if the oauth-interceptor feature is enabled func isOAuthInterceptorFeatureEnabled(dockerCli command.Cli) bool { configFile := dockerCli.ConfigFile() diff --git a/cmd/docker-mcp/commands/gateway_test.go b/cmd/docker-mcp/commands/gateway_test.go index 4da697d87..bb8d44b4e 100644 --- a/cmd/docker-mcp/commands/gateway_test.go +++ b/cmd/docker-mcp/commands/gateway_test.go @@ -1,122 +1,12 @@ package commands import ( - "path/filepath" "testing" "github.com/docker/cli/cli/config/configfile" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestGatewayUseConfiguredCatalogsEnabled(t *testing.T) { - // Create temporary config directory - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.json") - - // Create config with feature enabled - configFile := &configfile.ConfigFile{ - Filename: configPath, - Features: map[string]string{ - "configured-catalogs": "enabled", - }, - } - - // Test validation passes when feature enabled - err := validateConfiguredCatalogsFeature(configFile, true) - assert.NoError(t, err, "should allow --use-configured-catalogs when feature enabled") -} - -func TestGatewayUseConfiguredCatalogsDisabled(t *testing.T) { - // Create config with feature disabled - configFile := &configfile.ConfigFile{ - Features: map[string]string{ - "configured-catalogs": "disabled", - }, - } - - // Test validation fails when feature disabled - err := validateConfiguredCatalogsFeature(configFile, true) - require.Error(t, err, "should reject --use-configured-catalogs when feature disabled") - assert.Contains(t, err.Error(), "configured catalogs feature is not enabled") -} - -func TestGatewayFeatureFlagErrorMessage(t *testing.T) { - // Create config with no features - configFile := &configfile.ConfigFile{ - Features: make(map[string]string), - } - - // Test validation fails with helpful error message - err := validateConfiguredCatalogsFeature(configFile, true) - require.Error(t, err) - assert.Contains(t, err.Error(), "configured catalogs feature is not enabled") - assert.Contains(t, err.Error(), "docker mcp feature enable configured-catalogs") -} - -func TestGatewayContainerModeDetection(t *testing.T) { - // Test with nil config file (simulating container mode without volume mount) - err := validateConfiguredCatalogsFeature(nil, true) - require.Error(t, err) - assert.Contains(t, err.Error(), "Docker configuration not accessible") - assert.Contains(t, err.Error(), "running in container") -} - -func TestGatewayNoValidationWhenFlagNotUsed(t *testing.T) { - // Test that validation is skipped when flag not used, even if config is nil - err := validateConfiguredCatalogsFeature(nil, false) - assert.NoError(t, err, "should skip validation when --use-configured-catalogs not used") -} - -// Feature validation function that needs to be implemented -func validateConfiguredCatalogsFeature(configFile *configfile.ConfigFile, useConfigured bool) error { - if !useConfigured { - return nil // No validation needed when feature not requested - } - - // Check if config is accessible (container mode check) - if configFile == nil { - return &configError{ - message: `Docker configuration not accessible. - -If running in container, mount Docker config: - -v ~/.docker:/root/.docker - -Or mount just the config file: - -v ~/.docker/config.json:/root/.docker/config.json`, - } - } - - // Check if feature is enabled - if configFile.Features != nil { - if value, exists := configFile.Features["configured-catalogs"]; exists { - if value == "enabled" { - return nil // Feature is enabled - } - } - } - - // Feature not enabled - return &configError{ - message: `configured catalogs feature is not enabled. - -To enable this experimental feature, run: - docker mcp feature enable configured-catalogs - -This feature allows the gateway to automatically include user-managed catalogs -alongside the default Docker catalog.`, - } -} - -// Config error type -type configError struct { - message string -} - -func (e *configError) Error() string { - return e.message -} - func TestIsOAuthInterceptorFeatureEnabled(t *testing.T) { t.Run("enabled", func(t *testing.T) { configFile := &configfile.ConfigFile{ @@ -226,3 +116,150 @@ func isDynamicToolsFeatureEnabledFromConfig(configFile *configfile.ConfigFile) b } return value == "enabled" } + +func TestConvertCatalogNamesToPaths(t *testing.T) { + t.Run("keeps existing file paths unchanged", func(t *testing.T) { + paths := []string{ + "docker-mcp.yaml", + "./my-catalog.yaml", + "../other-catalog.yaml", + "/absolute/path/catalog.yaml", + } + + result := convertCatalogNamesToPaths(paths) + + assert.Equal(t, paths, result) + }) + + t.Run("keeps unknown names unchanged", func(t *testing.T) { + paths := []string{ + "unknown-name", + "another-unknown", + } + + result := convertCatalogNamesToPaths(paths) + + assert.Equal(t, paths, result) + }) + + t.Run("handles empty slice", func(t *testing.T) { + paths := []string{} + + result := convertCatalogNamesToPaths(paths) + + assert.Empty(t, result) + }) + + t.Run("mixed paths and names", func(t *testing.T) { + paths := []string{ + "docker-mcp.yaml", // file path - keep as-is + "unknown-name", // unknown name - keep as-is + "./relative.yaml", // file path - keep as-is + } + + result := convertCatalogNamesToPaths(paths) + + expected := []string{ + "docker-mcp.yaml", + "unknown-name", + "./relative.yaml", + } + assert.Equal(t, expected, result) + }) +} + +func TestBuildUniqueCatalogPaths(t *testing.T) { + t.Run("no duplicates", func(t *testing.T) { + defaults := []string{"docker-mcp.yaml"} + configured := []string{"my-catalog.yaml", "other-catalog.yaml"} + additional := []string{"cli-catalog.yaml"} + + result := buildUniqueCatalogPaths(defaults, configured, additional) + + expected := []string{"docker-mcp.yaml", "my-catalog.yaml", "other-catalog.yaml", "cli-catalog.yaml"} + assert.Equal(t, expected, result) + }) + + t.Run("removes duplicates preserving precedence", func(t *testing.T) { + defaults := []string{"docker-mcp.yaml", "common.yaml"} + configured := []string{"my-catalog.yaml", "common.yaml"} // duplicate + additional := []string{"cli-catalog.yaml", "docker-mcp.yaml"} // duplicate + + result := buildUniqueCatalogPaths(defaults, configured, additional) + + // Should keep first occurrence (maintaining precedence) + expected := []string{"docker-mcp.yaml", "common.yaml", "my-catalog.yaml", "cli-catalog.yaml"} + assert.Equal(t, expected, result) + }) + + t.Run("handles empty slices", func(t *testing.T) { + defaults := []string{"docker-mcp.yaml"} + configured := []string{} + additional := []string{} + + result := buildUniqueCatalogPaths(defaults, configured, additional) + + expected := []string{"docker-mcp.yaml"} + assert.Equal(t, expected, result) + }) + + t.Run("handles all empty slices", func(t *testing.T) { + defaults := []string{} + configured := []string{} + additional := []string{} + + result := buildUniqueCatalogPaths(defaults, configured, additional) + + assert.Empty(t, result) + }) + + t.Run("maintains precedence order", func(t *testing.T) { + // Test that later sources can't override earlier ones (first occurrence wins) + defaults := []string{"first.yaml"} + configured := []string{"first.yaml"} // duplicate - should be ignored + additional := []string{"first.yaml"} // duplicate - should be ignored + + result := buildUniqueCatalogPaths(defaults, configured, additional) + + expected := []string{"first.yaml"} // Only one occurrence + assert.Equal(t, expected, result) + }) +} + +func TestConditionalConfiguredCatalogPaths(t *testing.T) { + t.Run("excludes configured catalogs when single Docker catalog URL", func(t *testing.T) { + // Test the logic for when defaultPaths contains only DockerCatalogURL + defaultPaths := []string{"https://desktop.docker.com/mcp/catalog/v2/catalog.yaml"} + + // This should match the condition and return empty configuredPaths + shouldExclude := len(defaultPaths) == 1 && (defaultPaths[0] == "https://desktop.docker.com/mcp/catalog/v2/catalog.yaml" || defaultPaths[0] == "docker-mcp.yaml") + assert.True(t, shouldExclude, "should exclude configured catalogs when single Docker catalog URL") + }) + + t.Run("excludes configured catalogs when single Docker catalog filename", func(t *testing.T) { + // Test the logic for when defaultPaths contains only DockerCatalogFilename + defaultPaths := []string{"docker-mcp.yaml"} + + // This should match the condition and return empty configuredPaths + shouldExclude := len(defaultPaths) == 1 && (defaultPaths[0] == "https://desktop.docker.com/mcp/catalog/v2/catalog.yaml" || defaultPaths[0] == "docker-mcp.yaml") + assert.True(t, shouldExclude, "should exclude configured catalogs when single Docker catalog filename") + }) + + t.Run("includes configured catalogs when multiple paths", func(t *testing.T) { + // Test the logic for when defaultPaths contains multiple entries + defaultPaths := []string{"docker-mcp.yaml", "other-catalog.yaml"} + + // This should NOT match the condition and allow configuredPaths + shouldExclude := len(defaultPaths) == 1 && (defaultPaths[0] == "https://desktop.docker.com/mcp/catalog/v2/catalog.yaml" || defaultPaths[0] == "docker-mcp.yaml") + assert.False(t, shouldExclude, "should include configured catalogs when multiple paths") + }) + + t.Run("includes configured catalogs when single non-Docker catalog", func(t *testing.T) { + // Test the logic for when defaultPaths contains a single non-Docker catalog + defaultPaths := []string{"custom-catalog.yaml"} + + // This should NOT match the condition and allow configuredPaths + shouldExclude := len(defaultPaths) == 1 && (defaultPaths[0] == "https://desktop.docker.com/mcp/catalog/v2/catalog.yaml" || defaultPaths[0] == "docker-mcp.yaml") + assert.False(t, shouldExclude, "should include configured catalogs when single non-Docker catalog") + }) +} diff --git a/cmd/docker-mcp/commands/import.go b/cmd/docker-mcp/commands/import.go new file mode 100644 index 000000000..22c963a08 --- /dev/null +++ b/cmd/docker-mcp/commands/import.go @@ -0,0 +1,106 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/catalog" + "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/oci" +) + +func runOfficialregistryImport(ctx context.Context, serverURL string, servers *[]catalog.Server) error { + // Validate URL + parsedURL, err := url.Parse(serverURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return fmt.Errorf("URL must use http or https protocol") + } + + // Fetch the server definition + fmt.Printf("Fetching server definition from: %s\n\n", serverURL) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch server definition: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to fetch server definition: HTTP %d %s", resp.StatusCode, resp.Status) + } + + // Parse the JSON response + var serverDetail oci.ServerDetail + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&serverDetail); err != nil { + return fmt.Errorf("failed to parse server definition: %w", err) + } + + // Convert to catalog server + catalogServer := serverDetail.ToCatalogServer() + + // Add to servers slice if provided (for gateway use) + if servers != nil { + *servers = append(*servers, catalogServer) + } else { + // Pretty print the results (for import command use) + fmt.Println("=== Server Detail (Original) ===") + serverDetailJSON, err := json.MarshalIndent(serverDetail, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal server detail: %w", err) + } + fmt.Println(string(serverDetailJSON)) + + fmt.Println("\n=== Catalog Server (Converted) ===") + catalogServerJSON, err := json.MarshalIndent(catalogServer, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal catalog server: %w", err) + } + fmt.Println(string(catalogServerJSON)) + + // Print summary information + fmt.Println("\n=== Summary ===") + fmt.Printf("Server Name: %s\n", serverDetail.Name) + fmt.Printf("Description: %s\n", serverDetail.Description) + fmt.Printf("Status: %s\n", serverDetail.Status) + + if serverDetail.VersionDetail != nil { + fmt.Printf("Version: %s\n", serverDetail.VersionDetail.Version) + } + + if len(serverDetail.Packages) > 0 { + pkg := serverDetail.Packages[0] + fmt.Printf("Registry Type: %s\n", pkg.RegistryType) + fmt.Printf("Image: %s:%s\n", pkg.Identifier, pkg.Version) + fmt.Printf("Environment Variables: %d\n", len(pkg.Env)) + } + + fmt.Printf("Secrets Required: %d\n", len(catalogServer.Secrets)) + if len(catalogServer.Secrets) > 0 { + fmt.Printf("Secret Names: ") + for i, secret := range catalogServer.Secrets { + if i > 0 { + fmt.Printf(", ") + } + fmt.Printf("%s", secret.Name) + } + fmt.Println() + } + + fmt.Printf("Config Schemas: %d\n", len(catalogServer.Config)) + } + + return nil +} diff --git a/cmd/docker-mcp/commands/officialregistry.go b/cmd/docker-mcp/commands/officialregistry.go deleted file mode 100644 index 70045b23a..000000000 --- a/cmd/docker-mcp/commands/officialregistry.go +++ /dev/null @@ -1,135 +0,0 @@ -package commands - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - - "github.com/spf13/cobra" - - "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/oci" -) - -func officialregistryCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "officialregistry", - Short: "Manage official MCP registry operations", - Long: "Commands for importing and managing servers from official MCP registries", - Hidden: true, - } - - cmd.AddCommand(officialregistryImportCommand()) - - return cmd -} - -func officialregistryImportCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "import ", - Short: "Import a server from an official registry URL", - Long: `Import and parse a server definition from an official MCP registry URL. - -This command fetches the server definition from the provided URL, parses it as a ServerDetail, -converts it to the internal Server format, and displays the results. - -Example: - docker mcp officialregistry import https://registry.example.com/servers/my-server`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runOfficialregistryImport(cmd.Context(), args[0]) - }, - } - - return cmd -} - -func runOfficialregistryImport(ctx context.Context, serverURL string) error { - // Validate URL - parsedURL, err := url.Parse(serverURL) - if err != nil { - return fmt.Errorf("invalid URL: %w", err) - } - - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return fmt.Errorf("URL must use http or https protocol") - } - - // Fetch the server definition - fmt.Printf("Fetching server definition from: %s\n\n", serverURL) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to fetch server definition: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to fetch server definition: HTTP %d %s", resp.StatusCode, resp.Status) - } - - // Parse the JSON response - var serverDetail oci.ServerDetail - decoder := json.NewDecoder(resp.Body) - if err := decoder.Decode(&serverDetail); err != nil { - return fmt.Errorf("failed to parse server definition: %w", err) - } - - // Convert to catalog server - catalogServer := serverDetail.ToCatalogServer() - - // Pretty print the results - fmt.Println("=== Server Detail (Original) ===") - serverDetailJSON, err := json.MarshalIndent(serverDetail, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal server detail: %w", err) - } - fmt.Println(string(serverDetailJSON)) - - fmt.Println("\n=== Catalog Server (Converted) ===") - catalogServerJSON, err := json.MarshalIndent(catalogServer, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal catalog server: %w", err) - } - fmt.Println(string(catalogServerJSON)) - - // Print summary information - fmt.Println("\n=== Summary ===") - fmt.Printf("Server Name: %s\n", serverDetail.Name) - fmt.Printf("Description: %s\n", serverDetail.Description) - fmt.Printf("Status: %s\n", serverDetail.Status) - - if serverDetail.VersionDetail != nil { - fmt.Printf("Version: %s\n", serverDetail.VersionDetail.Version) - } - - if len(serverDetail.Packages) > 0 { - pkg := serverDetail.Packages[0] - fmt.Printf("Registry Type: %s\n", pkg.RegistryType) - fmt.Printf("Image: %s:%s\n", pkg.Identifier, pkg.Version) - fmt.Printf("Environment Variables: %d\n", len(pkg.Env)) - } - - fmt.Printf("Secrets Required: %d\n", len(catalogServer.Secrets)) - if len(catalogServer.Secrets) > 0 { - fmt.Printf("Secret Names: ") - for i, secret := range catalogServer.Secrets { - if i > 0 { - fmt.Printf(", ") - } - fmt.Printf("%s", secret.Name) - } - fmt.Println() - } - - fmt.Printf("Config Schemas: %d\n", len(catalogServer.Config)) - - return nil -} diff --git a/cmd/docker-mcp/commands/officialregistry_test.go b/cmd/docker-mcp/commands/officialregistry_test.go index db17cb125..2cf12fbb8 100644 --- a/cmd/docker-mcp/commands/officialregistry_test.go +++ b/cmd/docker-mcp/commands/officialregistry_test.go @@ -69,7 +69,7 @@ func TestOfficialregistryImportCommand(t *testing.T) { // Test the import function ctx := context.Background() - err := runOfficialregistryImport(ctx, testServer.URL) + err := runOfficialregistryImport(ctx, testServer.URL, nil) if err != nil { t.Errorf("Expected no error, got: %v", err) } @@ -79,13 +79,13 @@ func TestOfficialregistryImportCommand_InvalidURL(t *testing.T) { ctx := context.Background() // Test invalid URL - err := runOfficialregistryImport(ctx, "not-a-url") + err := runOfficialregistryImport(ctx, "not-a-url", nil) if err == nil { t.Error("Expected error for invalid URL, got none") } // Test unsupported scheme - err = runOfficialregistryImport(ctx, "ftp://example.com") + err = runOfficialregistryImport(ctx, "ftp://example.com", nil) if err == nil { t.Error("Expected error for unsupported scheme, got none") } @@ -99,7 +99,7 @@ func TestOfficialregistryImportCommand_HTTPError(t *testing.T) { defer testServer.Close() ctx := context.Background() - err := runOfficialregistryImport(ctx, testServer.URL) + err := runOfficialregistryImport(ctx, testServer.URL, nil) if err == nil { t.Error("Expected error for 404 response, got none") } @@ -118,7 +118,7 @@ func TestOfficialregistryImportCommand_InvalidJSON(t *testing.T) { defer testServer.Close() ctx := context.Background() - err := runOfficialregistryImport(ctx, testServer.URL) + err := runOfficialregistryImport(ctx, testServer.URL, nil) if err == nil { t.Error("Expected error for invalid JSON, got none") } diff --git a/cmd/docker-mcp/commands/registry.go b/cmd/docker-mcp/commands/registry.go new file mode 100644 index 000000000..3a20e71c6 --- /dev/null +++ b/cmd/docker-mcp/commands/registry.go @@ -0,0 +1,71 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/oci" +) + +func registryCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "registry", + Short: "Registry operations", + Hidden: true, // Hidden command + } + + cmd.AddCommand(registryConvertCommand()) + + return cmd +} + +func registryConvertCommand() *cobra.Command { + var filePath string + + cmd := &cobra.Command{ + Use: "convert", + Short: "Convert OCI registry server definition to catalog server format", + RunE: func(_ *cobra.Command, _ []string) error { + if filePath == "" { + return fmt.Errorf("--file flag is required") + } + + // Read the file contents + fileContents, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", filePath, err) + } + + // Parse into ServerDetail + var serverDetail oci.ServerDetail + err = json.Unmarshal(fileContents, &serverDetail) + if err != nil { + return fmt.Errorf("failed to parse JSON from file %s: %w", filePath, err) + } + + // Convert to catalog server + catalogServer := serverDetail.ToCatalogServer() + + // Marshal to YAML and print to stdout + outputYAML, err := yaml.Marshal(catalogServer) + if err != nil { + return fmt.Errorf("failed to marshal catalog server to YAML: %w", err) + } + + fmt.Print(string(outputYAML)) + return nil + }, + } + + cmd.Flags().StringVar(&filePath, "file", "", "Path to the OCI registry server definition JSON file") + if err := cmd.MarkFlagRequired("file"); err != nil { + // This should not happen in practice, but we need to handle the error for linting + panic(fmt.Sprintf("failed to mark flag as required: %v", err)) + } + + return cmd +} diff --git a/cmd/docker-mcp/commands/root.go b/cmd/docker-mcp/commands/root.go index e1ce0b117..ef6a235d6 100644 --- a/cmd/docker-mcp/commands/root.go +++ b/cmd/docker-mcp/commands/root.go @@ -76,8 +76,8 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command cmd.AddCommand(featureCommand(dockerCli)) cmd.AddCommand(gatewayCommand(dockerClient, dockerCli)) cmd.AddCommand(oauthCommand()) - cmd.AddCommand(officialregistryCommand()) cmd.AddCommand(policyCommand()) + cmd.AddCommand(registryCommand()) cmd.AddCommand(secretCommand(dockerClient)) cmd.AddCommand(serverCommand(dockerClient)) cmd.AddCommand(toolsCommand(dockerClient)) diff --git a/cmd/docker-mcp/commands/server.go b/cmd/docker-mcp/commands/server.go index a24fa5128..8085db5d6 100644 --- a/cmd/docker-mcp/commands/server.go +++ b/cmd/docker-mcp/commands/server.go @@ -69,19 +69,6 @@ func serverCommand(docker docker.Client) *cobra.Command { }, }) - var pushFlag bool - importCommand := &cobra.Command{ - Use: "import", - Short: "Import a server", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - push, _ := cmd.Flags().GetBool("push") - return server.Import(args[0], args[1], push) - }, - } - importCommand.Flags().BoolVar(&pushFlag, "push", false, "push the new server artifact") - cmd.AddCommand(importCommand) - cmd.AddCommand(&cobra.Command{ Use: "inspect", Short: "Get information about a server or inspect an OCI artifact", diff --git a/cmd/docker-mcp/integration_test.go b/cmd/docker-mcp/integration_test.go index 12f4fbd71..f5eefcbe6 100644 --- a/cmd/docker-mcp/integration_test.go +++ b/cmd/docker-mcp/integration_test.go @@ -39,6 +39,87 @@ func writeFile(t *testing.T, parent, name string, content string) { require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) } +// createClickhouseCatalogFile creates a temporary catalog file containing only the clickhouse server entry +func createClickhouseCatalogFile(t *testing.T, tempDir string) string { + t.Helper() + + // Create a minimal catalog using raw YAML content + catalogYAML := `registry: + clickhouse: + description: Official ClickHouse MCP Server. + title: Official ClickHouse MCP Server + type: server + dateAdded: "2025-06-12T18:00:16Z" + image: mcp/clickhouse@sha256:3a18fb4687c2f08364fd27be4bb3a7f33e2c77b22d3bca2760d22dcb73e47108 + ref: "" + readme: http://desktop.docker.com/mcp/catalog/v2/readme/clickhouse.md + toolsUrl: http://desktop.docker.com/mcp/catalog/v2/tools/clickhouse.json + source: https://github.com/ClickHouse/mcp-clickhouse/tree/main + upstream: https://github.com/ClickHouse/mcp-clickhouse + icon: https://avatars.githubusercontent.com/u/54801242?v=4 + tools: + - name: list_databases + - name: list_tables + - name: run_select_query + secrets: + - name: clickhouse.password + env: CLICKHOUSE_PASSWORD + example: + env: + - name: CLICKHOUSE_HOST + value: '{{clickhouse.host}}' + - name: CLICKHOUSE_PORT + value: '{{clickhouse.port}}' + - name: CLICKHOUSE_USER + value: '{{clickhouse.user}}' + - name: CLICKHOUSE_SECURE + value: '{{clickhouse.secure}}' + - name: CLICKHOUSE_VERIFY + value: '{{clickhouse.verify}}' + - name: CLICKHOUSE_CONNECT_TIMEOUT + value: '{{clickhouse.connect_timeout}}' + - name: CLICKHOUSE_SEND_RECEIVE_TIMEOUT + value: '{{clickhouse.send_receive_timeout}}' + prompts: 0 + resources: {} + config: + - name: clickhouse + description: Configure the connection to ClickHouse + type: object + properties: + host: + type: string + port: + type: string + user: + type: string + secure: + type: string + verify: + type: string + connect_timeout: + type: string + send_receive_timeout: + type: string + metadata: + pulls: 10413 + stars: 2 + githubStars: 519 + category: database + tags: + - database + - clickhouse + license: Apache License 2.0 + owner: ClickHouse +` + + // Write to temporary file + catalogFile := filepath.Join(tempDir, "clickhouse-catalog.yaml") + require.NoError(t, os.WriteFile(catalogFile, []byte(catalogYAML), 0o644)) + + return catalogFile +} + func TestIntegrationVersion(t *testing.T) { thisIsAnIntegrationTest(t) out := runDockerMCP(t, "version") @@ -76,11 +157,15 @@ func TestIntegrationCallToolClickhouse(t *testing.T) { writeFile(t, tmp, ".env", "clickhouse.password=") writeFile(t, tmp, "config.yaml", "clickhouse:\n host: sql-clickhouse.clickhouse.com\n user: demo\n") + // Create temporary catalog file with only the clickhouse entry + catalogFile := createClickhouseCatalogFile(t, tmp) + gatewayArgs := []string{ "--servers=clickhouse", + "--catalog=" + catalogFile, "--secrets=" + filepath.Join(tmp, ".env"), "--config=" + filepath.Join(tmp, "config.yaml"), - "--catalog=" + catalog.DockerCatalogURL, + "--verbose", } out := runDockerMCP(t, "tools", "call", "--gateway-arg="+strings.Join(gatewayArgs, ","), "list_databases") diff --git a/cmd/docker-mcp/internal/catalog/catalog.go b/cmd/docker-mcp/internal/catalog/catalog.go index c2e542fce..cebc04cc5 100644 --- a/cmd/docker-mcp/internal/catalog/catalog.go +++ b/cmd/docker-mcp/internal/catalog/catalog.go @@ -140,9 +140,27 @@ func GetWithOptions(ctx context.Context, useConfigured bool, additionalCatalogs catalogPaths = append(catalogPaths, additionalCatalogs...) } + // Remove duplicates while preserving order + catalogPaths = removeDuplicates(catalogPaths) + return ReadFrom(ctx, catalogPaths) } +// removeDuplicates removes duplicate strings while preserving order (first occurrence wins) +func removeDuplicates(slice []string) []string { + keys := make(map[string]bool) + result := []string{} + + for _, item := range slice { + if !keys[item] { + keys[item] = true + result = append(result, item) + } + } + + return result +} + // getConfiguredCatalogs reads the catalog registry and returns the list of configured catalog files func getConfiguredCatalogs() ([]string, error) { homeDir, err := user.HomeDir() diff --git a/cmd/docker-mcp/internal/catalog/types.go b/cmd/docker-mcp/internal/catalog/types.go index 5b491dce3..11b110912 100644 --- a/cmd/docker-mcp/internal/catalog/types.go +++ b/cmd/docker-mcp/internal/catalog/types.go @@ -13,6 +13,7 @@ type topLevel struct { // MCP Servers type Server struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` Image string `yaml:"image" json:"image"` Description string `yaml:"description,omitempty" json:"description,omitempty"` LongLived bool `yaml:"longLived,omitempty" json:"longLived,omitempty"` @@ -40,7 +41,7 @@ type Env struct { } type Remote struct { - URL string `yaml:"url" json:"url"` + URL string `yaml:"url,omitempty" json:"url,omitempty"` Transport string `yaml:"transport_type,omitempty" json:"transport_type,omitempty"` Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` } diff --git a/cmd/docker-mcp/internal/gateway/config.go b/cmd/docker-mcp/internal/gateway/config.go index a9e2e882e..d9ce377d0 100644 --- a/cmd/docker-mcp/internal/gateway/config.go +++ b/cmd/docker-mcp/internal/gateway/config.go @@ -1,13 +1,16 @@ package gateway +import "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/catalog" + type Config struct { Options - ServerNames []string - CatalogPath []string - ConfigPath []string - RegistryPath []string - ToolsPath []string - SecretsPath string + ServerNames []string + CatalogPath []string + ConfigPath []string + RegistryPath []string + ToolsPath []string + SecretsPath string + MCPRegistryServers []catalog.Server // catalog.Server objects from MCP registries } type Options struct { diff --git a/cmd/docker-mcp/internal/gateway/configuration.go b/cmd/docker-mcp/internal/gateway/configuration.go index bef68b118..b6444577a 100644 --- a/cmd/docker-mcp/internal/gateway/configuration.go +++ b/cmd/docker-mcp/internal/gateway/configuration.go @@ -75,7 +75,7 @@ func (c *Configuration) Find(serverName string) (*catalog.ServerConfig, *map[str Name: serverName, Spec: server, Config: map[string]any{ - serverName: c.config[serverName], + oci.CanonicalizeServerName(serverName): c.config[oci.CanonicalizeServerName(serverName)], }, Secrets: c.secrets, // TODO: we could keep just the secrets for this server }, nil, true @@ -90,15 +90,16 @@ func (c *Configuration) Find(serverName string) (*catalog.ServerConfig, *map[str } type FileBasedConfiguration struct { - CatalogPath []string - ServerNames []string // Takes precedence over the RegistryPath - RegistryPath []string - ConfigPath []string - ToolsPath []string - SecretsPath string // Optional, if not set, use Docker Desktop's secrets API - OciRef []string // OCI references to fetch server definitions from - Watch bool - Central bool + CatalogPath []string + ServerNames []string // Takes precedence over the RegistryPath + RegistryPath []string + ConfigPath []string + ToolsPath []string + SecretsPath string // Optional, if not set, use Docker Desktop's secrets API + OciRef []string // OCI references to fetch server definitions from + MCPRegistryServers []catalog.Server // Servers fetched from MCP registries + Watch bool + Central bool docker docker.Client } @@ -259,6 +260,48 @@ func (c *FileBasedConfiguration) readOnce(ctx context.Context) (Configuration, e } } + // Add MCP registry servers if any are provided + if len(c.MCPRegistryServers) > 0 { + for i, mcpServer := range c.MCPRegistryServers { + // Generate a unique name for the MCP registry server based on its image + serverName := fmt.Sprintf("mcp-registry-%d", i) + if mcpServer.Image != "" { + // Use image name as server name if available, cleaned up + parts := strings.Split(mcpServer.Image, "/") + imageName := parts[len(parts)-1] // Get the last part (image:tag) + if colonIdx := strings.Index(imageName, ":"); colonIdx != -1 { + imageName = imageName[:colonIdx] // Remove tag + } + serverName = fmt.Sprintf("mcp-registry-%s", imageName) + } + + // Ensure unique server name + originalName := serverName + counter := 1 + for _, exists := servers[serverName]; exists; _, exists = servers[serverName] { + serverName = fmt.Sprintf("%s-%d", originalName, counter) + counter++ + } + + // Add the MCP registry server directly + servers[serverName] = mcpServer + + // Add to serverNames list if not already present + found := false + for _, existing := range serverNames { + if existing == serverName { + found = true + break + } + } + if !found { + serverNames = append(serverNames, serverName) + } + + log(fmt.Sprintf("Added MCP registry server: %s (image: %s)", serverName, mcpServer.Image)) + } + } + // TODO(dga): Do we expect every server to have a config, in Central mode? serversConfig, err := c.readConfig(ctx) if err != nil { diff --git a/cmd/docker-mcp/internal/gateway/run.go b/cmd/docker-mcp/internal/gateway/run.go index 6511f8ccf..faf52ac14 100644 --- a/cmd/docker-mcp/internal/gateway/run.go +++ b/cmd/docker-mcp/internal/gateway/run.go @@ -60,16 +60,17 @@ func NewGateway(config Config, docker docker.Client) *Gateway { Options: config.Options, docker: docker, configurator: &FileBasedConfiguration{ - ServerNames: config.ServerNames, - CatalogPath: config.CatalogPath, - RegistryPath: config.RegistryPath, - ConfigPath: config.ConfigPath, - SecretsPath: config.SecretsPath, - ToolsPath: config.ToolsPath, - OciRef: config.OciRef, - Watch: config.Watch, - Central: config.Central, - docker: docker, + ServerNames: config.ServerNames, + CatalogPath: config.CatalogPath, + RegistryPath: config.RegistryPath, + ConfigPath: config.ConfigPath, + SecretsPath: config.SecretsPath, + ToolsPath: config.ToolsPath, + OciRef: config.OciRef, + MCPRegistryServers: config.MCPRegistryServers, + Watch: config.Watch, + Central: config.Central, + docker: docker, }, sessionCache: make(map[*mcp.ServerSession]*ServerSessionCache), } @@ -177,22 +178,6 @@ func (g *Gateway) Run(ctx context.Context) error { g.mcpServer.AddReceivingMiddleware(middlewares...) } - if err := g.reloadConfiguration(ctx, configuration, nil, nil); err != nil { - return fmt.Errorf("loading configuration: %w", err) - } - - // Central mode. - if g.Central { - log("> Initialized (in central mode) in", time.Since(start)) - if g.DryRun { - log("Dry run mode enabled, not starting the server.") - return nil - } - - log("> Start streaming server on port", g.Port) - return g.startCentralStreamingServer(ctx, ln, configuration) - } - // Which docker images are used? // Pull them and verify them if possible. if !g.Static { @@ -210,6 +195,22 @@ func (g *Gateway) Run(ctx context.Context) error { } } + if err := g.reloadConfiguration(ctx, configuration, nil, nil); err != nil { + return fmt.Errorf("loading configuration: %w", err) + } + + // Central mode. + if g.Central { + log("> Initialized (in central mode) in", time.Since(start)) + if g.DryRun { + log("Dry run mode enabled, not starting the server.") + return nil + } + + log("> Start streaming server on port", g.Port) + return g.startCentralStreamingServer(ctx, ln, configuration) + } + // Optionally watch for configuration updates. if configurationUpdates != nil { log("- Watching for configuration updates...") diff --git a/cmd/docker-mcp/server/import.go b/cmd/docker-mcp/internal/oci/import.go similarity index 74% rename from cmd/docker-mcp/server/import.go rename to cmd/docker-mcp/internal/oci/import.go index 41e7d17c8..629bd17a0 100644 --- a/cmd/docker-mcp/server/import.go +++ b/cmd/docker-mcp/internal/oci/import.go @@ -1,4 +1,4 @@ -package server +package oci import ( "context" @@ -12,9 +12,48 @@ import ( "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/oci" + "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/catalog" ) +func ImportToServer(registryURL string) (catalog.Server, error) { + if registryURL == "" { + return catalog.Server{}, fmt.Errorf("registry URL is required") + } + + // Fetch JSON document from registryUrl + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, registryURL, nil) + if err != nil { + return catalog.Server{}, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return catalog.Server{}, fmt.Errorf("failed to fetch JSON from %s: %w", registryURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return catalog.Server{}, fmt.Errorf("failed to fetch JSON: HTTP %d", resp.StatusCode) + } + + jsonContent, err := io.ReadAll(resp.Body) + if err != nil { + return catalog.Server{}, fmt.Errorf("failed to read JSON content: %w", err) + } + + // Parse the JSON content into a ServerDetail (the new structure is the server data directly) + var serverDetail ServerDetail + if err := json.Unmarshal(jsonContent, &serverDetail); err != nil { + return catalog.Server{}, fmt.Errorf("failed to parse JSON content as ServerDetail: %w", err) + } + + return serverDetail.ToCatalogServer(), nil +} + func Import(registryURL string, ociRepository string, push bool) error { if registryURL == "" { return fmt.Errorf("registry URL is required") @@ -136,24 +175,24 @@ func Import(registryURL string, ociRepository string, push bool) error { } // Parse the JSON content into a ServerDetail (the new structure is the server data directly) - var serverDetail oci.ServerDetail + var serverDetail ServerDetail if err := json.Unmarshal(jsonContent, &serverDetail); err != nil { return fmt.Errorf("failed to parse JSON content as ServerDetail: %w", err) } // Wrap it in an oci.Server structure for the OCI catalog - server := oci.Server{ + server := Server{ Server: serverDetail, Registry: json.RawMessage(`{}`), // Empty registry metadata } // Create an OCI Catalog with the server entry - ociCatalog := oci.Catalog{ - Registry: []oci.Server{server}, + ociCatalog := Catalog{ + Registry: []Server{server}, } // Create the OCI artifact with the subject - manifest, err := oci.CreateArtifactWithSubjectAndPush(artifactRef, ociCatalog, subjectDescriptor.Digest, subjectDescriptor.Size, subjectDescriptor.MediaType, push) + manifest, err := CreateArtifactWithSubjectAndPush(artifactRef, ociCatalog, subjectDescriptor.Digest, subjectDescriptor.Size, subjectDescriptor.MediaType, push) if err != nil { return fmt.Errorf("failed to create OCI artifact: %w", err) } diff --git a/cmd/docker-mcp/internal/oci/types.go b/cmd/docker-mcp/internal/oci/types.go index ec12b9aaa..492e51c72 100644 --- a/cmd/docker-mcp/internal/oci/types.go +++ b/cmd/docker-mcp/internal/oci/types.go @@ -2,6 +2,7 @@ package oci import ( "fmt" + "regexp" "strings" "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/catalog" @@ -11,9 +12,10 @@ import ( type ServerDetail struct { Name string `json:"name"` Description string `json:"description"` + Version string `json:"version"` VersionDetail *VersionDetail `json:"version_detail,omitempty"` Status string `json:"status,omitempty"` // "active", "deprecated", or "deleted" - Repository *Repository `json:"repository,omitempty"` + Repository Repository `json:"repository,omitempty"` Packages []Package `json:"packages,omitempty"` Remotes []Remote `json:"remotes,omitempty"` Meta map[string]any `json:"_meta,omitempty"` @@ -33,20 +35,34 @@ type VersionDetail struct { // Package represents a package definition type Package struct { - RegistryType string `json:"registry_type"` - Identifier string `json:"identifier"` - Version string `json:"version,omitempty"` - Args []string `json:"args,omitempty"` - Env []KeyValueInput `json:"environment_variables,omitempty"` - RuntimeOptions *RuntimeOptions `json:"runtime_arguments,omitempty"` - TransportOptions *TransportOptions `json:"transport,omitempty"` - Inputs []Input `json:"inputs,omitempty"` + RegistryType string `json:"registry_type"` // npm, pypi, oci, nuget, mcpb + Identifier string `json:"identifier"` // registry name + Version string `json:"version,omitempty"` // tag or digest + RegistryBaseURL string `json:"registry_base_url,omitempty"` + Env []KeyValueInput `json:"environment_variables,omitempty"` + RuntimeOptions []Argument `json:"runtime_arguments,omitempty"` + PackageArguments []Argument `json:"package_arguments,omitempty"` } -type KeyValueInput struct { +type InputWithVariables struct { Input - Name string `json:"name"` - Variables []Input `json:"variable,omitempty"` + + Variables map[string]Input `json:"variables,omitempty"` +} + +type Argument struct { + InputWithVariables + + Type string `json:"type"` // named, positional + ValueHint string `json:"value_hint,omitempty"` + IsRepeated bool `json:"is_repeated,omitempty"` + Name string `json:"name,omitempty"` // required if named +} + +type KeyValueInput struct { + InputWithVariables + + Name string `json:"name"` } // RuntimeOptions contains runtime configuration @@ -57,38 +73,22 @@ type RuntimeOptions struct { WorkDir string `json:"work_dir,omitempty"` } -// TransportOptions contains transport configuration -type TransportOptions struct { - Type string `json:"type,omitempty"` // "stdio", "sse", etc. - URL string `json:"url,omitempty"` - Headers map[string]string `json:"headers,omitempty"` -} - // Input represents input configuration type Input struct { - Name string `json:"name"` - Type string `json:"type"` // "positional", "named", "secret", "configurable" Description string `json:"description,omitempty"` - Value string `json:"string,omitempty"` + Value string `json:"value,omitempty"` Required bool `json:"is_required,omitempty"` Secret bool `json:"is_secret,omitempty"` - DefaultValue any `json:"default,omitempty"` + DefaultValue string `json:"default,omitempty"` Choices []string `json:"choices,omitempty"` -} - -// InputOption represents an option for input validation -type InputOption struct { - Value any `json:"value"` - Label string `json:"label,omitempty"` - Description string `json:"description,omitempty"` + Format string `json:"format,omitempty"` // "string", "number", "boolean", "filepath" } // Remote represents a remote server configuration type RemoteServer struct { - URL string `json:"url"` - TransportType string `json:"transport_type,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - Inputs []Input `json:"inputs,omitempty"` + URL string `json:"url,omitempty"` + TransportType string `json:"type,omitempty"` // "streamable-http", "sse" + Headers []KeyValueInput `json:"headers,omitempty"` } // Remote alias for consistency with existing code @@ -98,6 +98,7 @@ type Remote = RemoteServer func (sd *ServerDetail) ToCatalogServer() catalog.Server { server := catalog.Server{ Description: sd.Description, + Name: sd.Name, } // Extract image from the first package if available @@ -105,121 +106,358 @@ func (sd *ServerDetail) ToCatalogServer() catalog.Server { pkg := sd.Packages[0] server.Image = fmt.Sprintf("%s:%s", pkg.Identifier, pkg.Version) - // Set command and environment from runtime options - if pkg.RuntimeOptions != nil { - server.Command = pkg.RuntimeOptions.Command - - // Convert env map to env slice - if pkg.RuntimeOptions.Env != nil { - for key, value := range pkg.RuntimeOptions.Env { - if strVal, ok := value.(string); ok { - server.Env = append(server.Env, catalog.Env{ - Name: key, - Value: strVal, - }) - } - } + // Convert environment variables to secrets, env vars, and config schemas + for _, envVar := range pkg.Env { + value, secrets, config := getKeyValueInput(envVar, CanonicalizeServerName(sd.Name), false) + if len(secrets) > 0 { + // don't need explicit env because secret adds it + server.Secrets = append(server.Secrets, secrets...) + } else { + server.Env = append(server.Env, catalog.Env{ + Name: envVar.Name, + Value: value, + }) + server.Config = mergeConfig(server.Config, CanonicalizeServerName(sd.Name), config) } } - // Set transport options - if pkg.TransportOptions != nil { - server.Remote = catalog.Remote{ - Transport: pkg.TransportOptions.Type, - Headers: pkg.TransportOptions.Headers, - } - } + // Process package arguments and append positional ones to command + for _, arg := range pkg.PackageArguments { + switch arg.Type { + case "positional": + value, secrets, configSchema := getInput(arg.InputWithVariables, CanonicalizeServerName(sd.Name)) + server.Command = append(server.Command, value) - // Convert environment variables to secrets and config schemas - for _, envVar := range pkg.Env { - if envVar.Secret { - server.Secrets = append(server.Secrets, catalog.Secret{ - Name: envVar.Name, - Env: envVar.Name, // Use actual name instead of uppercase conversion - }) - } else if envVar.Type == "configurable" { - // Convert configurable input to JSON schema object - schema := map[string]any{ - "type": "object", - "properties": map[string]any{ - envVar.Name: map[string]any{ - "type": "string", // Default to string, could be enhanced based on input validation - "description": envVar.Description, - }, - }, - "required": []string{}, + // Add any secrets from the argument + if len(secrets) > 0 { + server.Secrets = append(server.Secrets, secrets...) } - if envVar.Required { - schema["required"] = []string{envVar.Name} + + // Add any config schema from the argument + if configSchema != nil { + server.Config = mergeConfig(server.Config, CanonicalizeServerName(sd.Name), configSchema) + } + case "named": + value, secrets, configSchema := getInput(arg.InputWithVariables, CanonicalizeServerName(sd.Name)) + server.Command = append(server.Command, fmt.Sprintf("--%s", arg.Name), value) + + // Add any secrets from the argument + if len(secrets) > 0 { + server.Secrets = append(server.Secrets, secrets...) + } + + // Add any config schema from the argument + if configSchema != nil { + server.Config = mergeConfig(server.Config, CanonicalizeServerName(sd.Name), configSchema) } - server.Config = append(server.Config, schema) } } - // Convert inputs to secrets for credential-type inputs and config schemas for configurable inputs - for _, input := range pkg.Inputs { - switch input.Type { - case "secret": - server.Secrets = append(server.Secrets, catalog.Secret{ - Name: input.Name, - Env: strings.ToUpper(input.Name), - }) - case "configurable": - // Convert configurable input to JSON schema object - schema := map[string]any{ - "type": "object", - "properties": map[string]any{ - input.Name: map[string]any{ - "type": "string", // Default to string, could be enhanced based on input validation - "description": input.Description, - }, - }, - "required": []string{}, + // Process runtime arguments + for _, arg := range pkg.RuntimeOptions { + // volume arguments have special meaning + if arg.Type == "named" && (arg.Name == "-v" || arg.Name == "--mount") { + config, volume := createVolume(arg, CanonicalizeServerName(sd.Name)) + if volume != "" { + server.Volumes = append(server.Volumes, volume) } - if input.Required { - schema["required"] = []string{input.Name} + if config != nil { + server.Config = mergeConfig(server.Config, CanonicalizeServerName(sd.Name), config) } - server.Config = append(server.Config, schema) } + // TODO support User args explicitly } } // Handle remote configuration if available if len(sd.Remotes) > 0 { remote := sd.Remotes[0] + + // Convert KeyValueInput headers to string headers + headers := make(map[string]string) + headerSecrets := []catalog.Secret{} + for _, header := range remote.Headers { + value, secrets, _ := getKeyValueInput(header, CanonicalizeServerName(sd.Name), true) + headers[header.Name] = value + headerSecrets = append(headerSecrets, secrets...) + } + server.Remote = catalog.Remote{ URL: remote.URL, Transport: remote.TransportType, - Headers: remote.Headers, + Headers: headers, } + if len(headerSecrets) > 0 { + server.Secrets = headerSecrets + } + } - // Convert remote inputs to secrets and config schemas - for _, input := range remote.Inputs { - switch input.Type { - case "secret": - server.Secrets = append(server.Secrets, catalog.Secret{ - Name: input.Name, - Env: strings.ToUpper(input.Name), - }) - case "configurable": - // Convert configurable input to JSON schema object - schema := map[string]any{ - "type": "object", - "properties": map[string]any{ - input.Name: map[string]any{ - "type": "string", // Default to string, could be enhanced based on input validation - "description": input.Description, - }, - }, - "required": []string{}, + return server +} + +func getKeyValueInput(kvi KeyValueInput, serverName string, useEnvForm bool) (string, []catalog.Secret, map[string]any) { + if len(kvi.Variables) == 0 { + if kvi.Secret { + var templateValue string + if useEnvForm { + templateValue = fmt.Sprintf("${%s}", canonicalizeEnvName(kvi.Name)) + } else { + templateValue = fmt.Sprintf("{{%s}}", kvi.Name) + } + secret := catalog.Secret{ + Name: fmt.Sprintf("%s.%s", serverName, kvi.Name), + Env: canonicalizeEnvName(kvi.Name), + } + return templateValue, []catalog.Secret{secret}, map[string]any{} + } else if kvi.Value != "" { + return kvi.Value, []catalog.Secret{}, map[string]any{} + } else if kvi.DefaultValue != "" { + // Use default value when no explicit value is provided + return fmt.Sprintf("%v", kvi.DefaultValue), []catalog.Secret{}, map[string]any{} + } + config := map[string]any{ + "type": "object", + "properties": map[string]any{ + kvi.Name: map[string]any{ + "type": "string", + }, + }, + } + return fmt.Sprintf("{{%s}}", fmt.Sprintf("%s.%s", serverName, kvi.Name)), []catalog.Secret{}, config + } + value, secrets, config := getInput(kvi.InputWithVariables, serverName) + return value, secrets, config +} + +func getInput(arg InputWithVariables, serverName string) (string, []catalog.Secret, map[string]any) { + var secrets []catalog.Secret + var configSchema map[string]any + + // Process the main input + value := arg.Value + if arg.Secret { + // TODO - this is bad practice (visible to docker inspect) + secret := catalog.Secret{ + Name: fmt.Sprintf("%s.%s", serverName, arg.Description), + Env: canonicalizeEnvName(arg.Description), + } + secrets = append(secrets, secret) + + // Replace with template reference + value = fmt.Sprintf("{{%s}}", arg.Description) + } else if len(arg.Variables) > 0 { + // This input has variables, create config schema + properties := make(map[string]any) + required := []string{} + + for varName, variable := range arg.Variables { + prop := map[string]any{ + "type": "string", // Default to string + "description": variable.Description, + } + + // Add default value if present + if variable.DefaultValue != "" { + prop["default"] = variable.DefaultValue + } + + // Add format if specified + if variable.Format != "" { + prop["format"] = variable.Format + } + + // Add choices as enum if present + if len(variable.Choices) > 0 { + prop["enum"] = variable.Choices + } + + properties[varName] = prop + + // Add to required if the variable is required + if variable.Required { + required = append(required, varName) + } + + // Handle secret variables + if variable.Secret { + secret := catalog.Secret{ + Name: fmt.Sprintf("%s.%s", serverName, varName), + Env: canonicalizeEnvName(varName), } - if input.Required { - schema["required"] = []string{input.Name} + secrets = append(secrets, secret) + + // Replace in value string using secret reference + value = replaceVariables(value, serverName) + } + } + + // Create the config schema + configSchema = map[string]any{ + "type": "object", + "properties": properties, + } + + if len(required) > 0 { + configSchema["required"] = required + } + + // Replace variables in the value string + value = replaceVariables(value, serverName) + } + + // If we have a default value and no explicit value, use the default + if value == "" && arg.DefaultValue != "" { + value = fmt.Sprintf("%v", arg.DefaultValue) + } + + return value, secrets, configSchema +} + +func canonicalizeEnvName(s string) string { + return strings.ReplaceAll(s, "-", "_") +} + +// createVolume creates both a config schema and volume string from a runtime argument +func createVolume(arg Argument, serverName string) (map[string]any, string) { + var config map[string]any + var volume string + + if arg.Name == "--mount" || arg.Name == "-v" { + volume = replaceVariables(parseVolumeMount(arg.Value), serverName) + + // Create config schema from the argument's variables + if len(arg.Variables) > 0 { + properties := make(map[string]any) + required := []string{} + + for varName, variable := range arg.Variables { + prop := map[string]any{ + "type": "string", // Default to string + "description": variable.Description, + } + + if variable.DefaultValue != "" { + prop["default"] = variable.DefaultValue + } + + if variable.Format != "" { + prop["format"] = variable.Format + } + + properties[varName] = prop + + if variable.Required { + required = append(required, varName) } - server.Config = append(server.Config, schema) + } + + config = map[string]any{ + "type": "object", + "properties": properties, + "required": required, } } } - return server + return config, volume +} + +// mergeConfig merges a new config into the existing config slice, creating a top-level +// schema object with name, type "object", and properties fields +func mergeConfig(existingConfig []any, serverName string, newConfig map[string]any) []any { + // If there's no existing config, create a new schema object + if len(existingConfig) == 0 { + schemaObject := map[string]any{ + "name": serverName, + "type": "object", + "properties": map[string]any{}, + } + // Merge new properties if they exist + if newProperties, hasProps := newConfig["properties"].(map[string]any); hasProps { + schemaObject["properties"] = newProperties + } + return []any{schemaObject} + } + + // Find existing server config object by name + for _, configItem := range existingConfig { + if configMap, ok := configItem.(map[string]any); ok { + if name, hasName := configMap["name"].(string); hasName && name == serverName { + // Merge properties into the existing server config + if properties, hasProps := configMap["properties"].(map[string]any); hasProps { + if newProperties, newHasProps := newConfig["properties"].(map[string]any); newHasProps { + // Merge new properties into existing properties + for key, value := range newProperties { + properties[key] = value + } + } + } else { + // Initialize properties if they don't exist + if newProperties, newHasProps := newConfig["properties"].(map[string]any); newHasProps { + configMap["properties"] = newProperties + } + } + return existingConfig + } + } + } + + // If no existing config for this server, append a new schema object + schemaObject := map[string]any{ + "name": serverName, + "type": "object", + "properties": map[string]any{}, + } + if newProperties, hasProps := newConfig["properties"].(map[string]any); hasProps { + schemaObject["properties"] = newProperties + } + return append(existingConfig, schemaObject) +} + +// parseVolumeMount parses a volume mount string and converts it to src:dst format if it's a bind mount +func parseVolumeMount(value string) string { + // If the string doesn't contain type=bind, return as-is + if !strings.Contains(value, "type=bind") { + return value + } + + // Parse comma-separated values + parts := strings.Split(value, ",") + mountOptions := make(map[string]string) + + for _, part := range parts { + // Parse each part as "X=Y" + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) == 2 { + mountOptions[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + + // Check for required src and dst entries + src, hasSrc := mountOptions["src"] + dst, hasDst := mountOptions["dst"] + + if !hasSrc || !hasDst { + // Return original value if both src and dst are not present + return value + } + + // Return in src:dst format + return fmt.Sprintf("%s:%s", src, dst) +} + +// replaceVariables replaces {X} patterns with {{serverName.X}} in the input string +func replaceVariables(input, serverName string) string { + // Use regex to find all {X} patterns and replace with {{serverName.X}} + re := regexp.MustCompile(`\{([^}]+)\}`) + return re.ReplaceAllStringFunc(input, func(match string) string { + // Extract the variable name (remove the braces) + varName := match[1 : len(match)-1] + return fmt.Sprintf("{{%s.%s}}", serverName, varName) + }) +} + +// canonicalizeServerName replaces all dots in a string with underscores +func CanonicalizeServerName(serverName string) string { + return strings.ReplaceAll(serverName, ".", "_") } diff --git a/cmd/docker-mcp/internal/oci/types_test.go b/cmd/docker-mcp/internal/oci/types_test.go index 2992beba1..feadd091d 100644 --- a/cmd/docker-mcp/internal/oci/types_test.go +++ b/cmd/docker-mcp/internal/oci/types_test.go @@ -2,58 +2,23 @@ package oci import ( "encoding/json" + "os" + "path/filepath" "testing" "github.com/docker/mcp-gateway/cmd/docker-mcp/internal/catalog" ) func TestServerDetailParsing(t *testing.T) { - // Test data matching the provided JSON structure - jsonData := `{ - "name": "io.github.slimslenderslacks/garmin_mcp", - "description": "exposes your fitness and health data to Claude and other MCP-compatible clients.", - "status": "active", - "repository": { - "url": "https://github.com/slimslenderslacks/poci", - "source": "github" - }, - "version_detail": { - "version": "0.1.1" - }, - "packages": [ - { - "registry_type": "oci", - "identifier": "jimclark106/gramin_mcp", - "version": "latest", - "environment_variables": [ - { - "description": "Garmin Connect email address", - "is_required": true, - "is_secret": true, - "name": "GARMIN_EMAIL" - }, - { - "description": "Garmin Connect password", - "is_required": true, - "is_secret": true, - "name": "GARMIN_PASSWORD" - } - ] - } - ], - "_meta": { - "io.modelcontextprotocol.registry": { - "id": "a2cda0d4-8160-4734-880f-1c8de2b484a1", - "published_at": "2025-09-07T04:40:26.882157132Z", - "updated_at": "2025-09-07T04:40:26.882157132Z", - "is_latest": true, - "release_date": "2025-09-07T04:40:26Z" - } - } -}` + // Read test data from external JSON file + testDataPath := filepath.Join("..", "..", "..", "..", "test", "testdata", "officialregistry", "server_garmin_mcp.json") + jsonData, err := os.ReadFile(testDataPath) + if err != nil { + t.Fatalf("Failed to read test data file %s: %v", testDataPath, err) + } var serverDetail ServerDetail - err := json.Unmarshal([]byte(jsonData), &serverDetail) + err = json.Unmarshal(jsonData, &serverDetail) if err != nil { t.Fatalf("Failed to parse JSON: %v", err) } @@ -71,16 +36,14 @@ func TestServerDetailParsing(t *testing.T) { t.Errorf("Expected status 'active', got '%s'", serverDetail.Status) } - // Verify version detail - if serverDetail.VersionDetail == nil { - t.Error("Expected VersionDetail to be non-nil") - } else if serverDetail.VersionDetail.Version != "0.1.1" { - t.Errorf("Expected version '0.1.1', got '%s'", serverDetail.VersionDetail.Version) + // Verify version (direct field, not VersionDetail struct in this test data) + if serverDetail.Version != "0.1.1" { + t.Errorf("Expected version '0.1.1', got '%s'", serverDetail.Version) } // Verify repository - if serverDetail.Repository == nil { - t.Error("Expected Repository to be non-nil") + if serverDetail.Repository.URL == "" { + t.Error("Expected Repository URL to be non-empty") } else { if serverDetail.Repository.URL != "https://github.com/slimslenderslacks/poci" { t.Errorf("Expected repository URL 'https://github.com/slimslenderslacks/poci', got '%s'", serverDetail.Repository.URL) @@ -141,59 +104,19 @@ func TestServerDetailParsing(t *testing.T) { } } - // Verify meta field exists - if serverDetail.Meta == nil { - t.Error("Expected Meta to be non-nil") - } + // Note: Meta field is not present in this test data file, which is expected } func TestServerDetailToCatalogServer(t *testing.T) { - // Use the same JSON data as the parsing test - jsonData := `{ - "name": "io.github.slimslenderslacks/garmin_mcp", - "description": "exposes your fitness and health data to Claude and other MCP-compatible clients.", - "status": "active", - "repository": { - "url": "https://github.com/slimslenderslacks/poci", - "source": "github" - }, - "version_detail": { - "version": "0.1.1" - }, - "packages": [ - { - "registry_type": "oci", - "identifier": "jimclark106/gramin_mcp", - "version": "latest", - "environment_variables": [ - { - "description": "Garmin Connect email address", - "is_required": true, - "is_secret": true, - "name": "GARMIN_EMAIL" - }, - { - "description": "Garmin Connect password", - "is_required": true, - "is_secret": true, - "name": "GARMIN_PASSWORD" - } - ] - } - ], - "_meta": { - "io.modelcontextprotocol.registry": { - "id": "a2cda0d4-8160-4734-880f-1c8de2b484a1", - "published_at": "2025-09-07T04:40:26.882157132Z", - "updated_at": "2025-09-07T04:40:26.882157132Z", - "is_latest": true, - "release_date": "2025-09-07T04:40:26Z" - } - } -}` + // Read test data from external JSON file + testDataPath := filepath.Join("..", "..", "..", "..", "test", "testdata", "officialregistry", "server_garmin_mcp.json") + jsonData, err := os.ReadFile(testDataPath) + if err != nil { + t.Fatalf("Failed to read test data file %s: %v", testDataPath, err) + } var serverDetail ServerDetail - err := json.Unmarshal([]byte(jsonData), &serverDetail) + err = json.Unmarshal(jsonData, &serverDetail) if err != nil { t.Fatalf("Failed to parse JSON: %v", err) } @@ -212,8 +135,8 @@ func TestServerDetailToCatalogServer(t *testing.T) { // Verify secrets conversion (both GARMIN_EMAIL and GARMIN_PASSWORD should be secrets) expectedSecrets := []catalog.Secret{ - {Name: "GARMIN_EMAIL", Env: "GARMIN_EMAIL"}, - {Name: "GARMIN_PASSWORD", Env: "GARMIN_PASSWORD"}, + {Name: "io_github_slimslenderslacks/garmin_mcp.GARMIN_EMAIL", Env: "GARMIN_EMAIL"}, + {Name: "io_github_slimslenderslacks/garmin_mcp.GARMIN_PASSWORD", Env: "GARMIN_PASSWORD"}, } if len(catalogServer.Secrets) != len(expectedSecrets) { t.Errorf("Expected %d secrets, got %d", len(expectedSecrets), len(catalogServer.Secrets)) @@ -233,3 +156,415 @@ func TestServerDetailToCatalogServer(t *testing.T) { t.Errorf("Expected 0 config schemas, got %d", len(catalogServer.Config)) } } + +func TestConversionForFileSystem(t *testing.T) { + // Read test data from external JSON file + testDataPath := filepath.Join("..", "..", "..", "..", "test", "testdata", "officialregistry", "server_filesystem.json") + jsonData, err := os.ReadFile(testDataPath) + if err != nil { + t.Fatalf("Failed to read test data file %s: %v", testDataPath, err) + } + + var serverDetail ServerDetail + err = json.Unmarshal(jsonData, &serverDetail) + if err != nil { + t.Fatalf("Failed to parse filesystem JSON: %v", err) + } + + // Verify basic filesystem server fields + if serverDetail.Name != "io.github.slimslenderslacks/filesystem" { + t.Errorf("Expected name 'io.github.slimslenderslacks/filesystem', got '%s'", serverDetail.Name) + } + + if serverDetail.Description != "Node.js server implementing Model Context Protocol (MCP) for filesystem operations." { + t.Errorf("Expected filesystem description to match, got '%s'", serverDetail.Description) + } + + if serverDetail.Version != "1.0.2" { + t.Errorf("Expected version '1.0.2', got '%s'", serverDetail.Version) + } + + // Verify repository + if serverDetail.Repository.URL != "https://github.com/modelcontextprotocol/servers" { + t.Errorf("Expected repository URL 'https://github.com/modelcontextprotocol/servers', got '%s'", serverDetail.Repository.URL) + } + if serverDetail.Repository.Source != "github" { + t.Errorf("Expected repository source 'github', got '%s'", serverDetail.Repository.Source) + } + + // Verify packages + if len(serverDetail.Packages) != 1 { + t.Errorf("Expected 1 package, got %d", len(serverDetail.Packages)) + return + } + + pkg := serverDetail.Packages[0] + if pkg.RegistryType != "oci" { + t.Errorf("Expected registry type 'oci', got '%s'", pkg.RegistryType) + } + if pkg.Identifier != "mcp/filesystem" { + t.Errorf("Expected identifier 'mcp/filesystem', got '%s'", pkg.Identifier) + } + if pkg.Version != "1.0.2" { + t.Errorf("Expected version '1.0.2', got '%s'", pkg.Version) + } + + // Verify runtime arguments + if len(pkg.RuntimeOptions) != 1 { + t.Errorf("Expected 1 runtime argument, got %d", len(pkg.RuntimeOptions)) + return + } + + runtimeArg := pkg.RuntimeOptions[0] + if runtimeArg.Type != "named" { + t.Errorf("Expected runtime argument type 'named', got '%s'", runtimeArg.Type) + } + if runtimeArg.Name != "--mount" { + t.Errorf("Expected runtime argument name '--mount', got '%s'", runtimeArg.Name) + } + if runtimeArg.Value != "type=bind,src={source_path},dst={target_path}" { + t.Errorf("Expected runtime argument value 'type=bind,src={source_path},dst={target_path}', got '%s'", runtimeArg.Value) + } + if !runtimeArg.Required { + t.Error("Expected runtime argument to be required") + } + if !runtimeArg.IsRepeated { + t.Error("Expected runtime argument to be repeatable") + } + + // Verify runtime argument variables + if len(runtimeArg.Variables) != 2 { + t.Errorf("Expected 2 runtime argument variables, got %d", len(runtimeArg.Variables)) + return + } + + if sourcePath, exists := runtimeArg.Variables["source_path"]; !exists { + t.Error("Expected 'source_path' variable to exist") + } else { + if sourcePath.Description != "Source path on host" { + t.Errorf("Expected source_path description 'Source path on host', got '%s'", sourcePath.Description) + } + if sourcePath.Format != "filepath" { + t.Errorf("Expected source_path format 'filepath', got '%s'", sourcePath.Format) + } + if !sourcePath.Required { + t.Error("Expected source_path to be required") + } + } + + if targetPath, exists := runtimeArg.Variables["target_path"]; !exists { + t.Error("Expected 'target_path' variable to exist") + } else { + if targetPath.Description != "Path to mount in the container. It should be rooted in `/project` directory." { + t.Errorf("Expected target_path description to match, got '%s'", targetPath.Description) + } + if targetPath.DefaultValue != "/project" { + t.Errorf("Expected target_path default '/project', got '%v'", targetPath.DefaultValue) + } + if !targetPath.Required { + t.Error("Expected target_path to be required") + } + } + + // Verify package arguments + if len(pkg.PackageArguments) != 1 { + t.Errorf("Expected 1 package argument, got %d", len(pkg.PackageArguments)) + return + } + + packageArg := pkg.PackageArguments[0] + if packageArg.Type != "positional" { + t.Errorf("Expected package argument type 'positional', got '%s'", packageArg.Type) + } + if packageArg.Value != "/project" { + t.Errorf("Expected package argument value '/project', got '%s'", packageArg.Value) + } + if packageArg.ValueHint != "target_dir" { + t.Errorf("Expected package argument value hint 'target_dir', got '%s'", packageArg.ValueHint) + } + + // Verify environment variables + if len(pkg.Env) != 1 { + t.Errorf("Expected 1 environment variable, got %d", len(pkg.Env)) + return + } + + env := pkg.Env[0] + if env.Name != "LOG_LEVEL" { + t.Errorf("Expected env var name 'LOG_LEVEL', got '%s'", env.Name) + } + if env.Description != "Logging level (debug, info, warn, error)" { + t.Errorf("Expected env var description 'Logging level (debug, info, warn, error)', got '%s'", env.Description) + } + if env.DefaultValue != "info" { + t.Errorf("Expected env var default 'info', got '%v'", env.DefaultValue) + } + if env.Required { + t.Error("Expected env var to not be required (has default)") + } + if env.Secret { + t.Error("Expected env var to not be secret") + } + + // Test conversion to catalog server + catalogServer := serverDetail.ToCatalogServer() + + // Verify basic conversion + if catalogServer.Description != "Node.js server implementing Model Context Protocol (MCP) for filesystem operations." { + t.Errorf("Expected catalog server description to match, got '%s'", catalogServer.Description) + } + + if catalogServer.Image != "mcp/filesystem:1.0.2" { + t.Errorf("Expected catalog server image 'mcp/filesystem:1.0.2', got '%s'", catalogServer.Image) + } + + // Verify no secrets (LOG_LEVEL is not secret) + if len(catalogServer.Secrets) != 0 { + t.Errorf("Expected 0 secrets, got %d", len(catalogServer.Secrets)) + } + + // Verify environment variables conversion (non-secret env vars should be preserved) + expectedEnvVars := []catalog.Env{ + {Name: "LOG_LEVEL", Value: "info"}, // Default value should be used + } + if len(catalogServer.Env) != len(expectedEnvVars) { + t.Errorf("Expected %d environment variables, got %d", len(expectedEnvVars), len(catalogServer.Env)) + } else { + for i, expected := range expectedEnvVars { + if catalogServer.Env[i].Name != expected.Name { + t.Errorf("Expected env var name '%s', got '%s'", expected.Name, catalogServer.Env[i].Name) + } + if catalogServer.Env[i].Value != expected.Value { + t.Errorf("Expected env var value '%s', got '%s'", expected.Value, catalogServer.Env[i].Value) + } + } + } +} + +func TestBasicServerConversion(t *testing.T) { + // Read test data from the basic test JSON file + testDataPath := filepath.Join("..", "..", "..", "..", "test", "testdata", "officialregistry", "server.test.json") + jsonData, err := os.ReadFile(testDataPath) + if err != nil { + t.Fatalf("Failed to read test data file %s: %v", testDataPath, err) + } + + var serverDetail ServerDetail + err = json.Unmarshal(jsonData, &serverDetail) + if err != nil { + t.Fatalf("Failed to parse basic server JSON: %v", err) + } + + // Verify basic fields + if serverDetail.Name != "io.github.slimslenderslacks/poci" { + t.Errorf("Expected name 'io.github.slimslenderslacks/poci', got '%s'", serverDetail.Name) + } + + if serverDetail.Description != "construct new tools out of existing images" { + t.Errorf("Expected description 'construct new tools out of existing images', got '%s'", serverDetail.Description) + } + + if serverDetail.Status != "active" { + t.Errorf("Expected status 'active', got '%s'", serverDetail.Status) + } + + if serverDetail.Version != "1.0.12" { + t.Errorf("Expected version '1.0.12', got '%s'", serverDetail.Version) + } + + // Verify repository + if serverDetail.Repository.URL != "https://github.com/slimslenderslacks/poci" { + t.Errorf("Expected repository URL 'https://github.com/slimslenderslacks/poci', got '%s'", serverDetail.Repository.URL) + } + if serverDetail.Repository.Source != "github" { + t.Errorf("Expected repository source 'github', got '%s'", serverDetail.Repository.Source) + } + + // Verify packages + if len(serverDetail.Packages) != 1 { + t.Errorf("Expected 1 package, got %d", len(serverDetail.Packages)) + return + } + + pkg := serverDetail.Packages[0] + if pkg.RegistryType != "oci" { + t.Errorf("Expected registry type 'oci', got '%s'", pkg.RegistryType) + } + if pkg.Identifier != "jimclark106/poci" { + t.Errorf("Expected identifier 'jimclark106/poci', got '%s'", pkg.Identifier) + } + if pkg.Version != "latest" { + t.Errorf("Expected version 'latest', got '%s'", pkg.Version) + } + + // This basic server has no environment variables or runtime arguments + if len(pkg.Env) != 0 { + t.Errorf("Expected 0 environment variables, got %d", len(pkg.Env)) + } + if len(pkg.RuntimeOptions) != 0 { + t.Errorf("Expected 0 runtime arguments, got %d", len(pkg.RuntimeOptions)) + } + + // Test conversion to catalog server + catalogServer := serverDetail.ToCatalogServer() + + // Verify basic conversion + if catalogServer.Description != "construct new tools out of existing images" { + t.Errorf("Expected catalog server description to match, got '%s'", catalogServer.Description) + } + + if catalogServer.Image != "jimclark106/poci:latest" { + t.Errorf("Expected catalog server image 'jimclark106/poci:latest', got '%s'", catalogServer.Image) + } + + // Verify no secrets, environment variables, or configuration + if len(catalogServer.Secrets) != 0 { + t.Errorf("Expected 0 secrets, got %d", len(catalogServer.Secrets)) + } + if len(catalogServer.Env) != 0 { + t.Errorf("Expected 0 environment variables, got %d", len(catalogServer.Env)) + } + if len(catalogServer.Config) != 0 { + t.Errorf("Expected 0 config schemas, got %d", len(catalogServer.Config)) + } +} + +func TestRemoteServerConversion(t *testing.T) { + // Read test data from remote server JSON file + testDataPath := filepath.Join("..", "..", "..", "..", "test", "testdata", "officialregistry", "server.remote.json") + jsonData, err := os.ReadFile(testDataPath) + if err != nil { + t.Fatalf("Failed to read test data file %s: %v", testDataPath, err) + } + + var serverDetail ServerDetail + err = json.Unmarshal(jsonData, &serverDetail) + if err != nil { + t.Fatalf("Failed to parse remote server JSON: %v", err) + } + + // Verify basic fields + if serverDetail.Name != "io.github.slimslenderslacks/remote" { + t.Errorf("Expected name 'io.github.slimslenderslacks/remote', got '%s'", serverDetail.Name) + } + + if serverDetail.Description != "remote example" { + t.Errorf("Expected description 'remote example', got '%s'", serverDetail.Description) + } + + if serverDetail.Status != "active" { + t.Errorf("Expected status 'active', got '%s'", serverDetail.Status) + } + + if serverDetail.Version != "0.1.0" { + t.Errorf("Expected version '0.1.0', got '%s'", serverDetail.Version) + } + + // Verify repository + if serverDetail.Repository.URL != "https://github.com/slimslenderslacks/poci" { + t.Errorf("Expected repository URL 'https://github.com/slimslenderslacks/poci', got '%s'", serverDetail.Repository.URL) + } + if serverDetail.Repository.Source != "github" { + t.Errorf("Expected repository source 'github', got '%s'", serverDetail.Repository.Source) + } + + // Verify remotes configuration + if len(serverDetail.Remotes) != 1 { + t.Fatalf("Expected 1 remote configuration, got %d", len(serverDetail.Remotes)) + } + + remote := serverDetail.Remotes[0] + if remote.TransportType != "sse" { + t.Errorf("Expected transport type 'sse', got '%s'", remote.TransportType) + } + if remote.URL != "http://mcp-fs.anonymous.modelcontextprotocol.io/sse" { + t.Errorf("Expected remote URL 'http://mcp-fs.anonymous.modelcontextprotocol.io/sse', got '%s'", remote.URL) + } + + // Verify headers + if len(remote.Headers) != 2 { + t.Fatalf("Expected 2 headers, got %d", len(remote.Headers)) + } + + // Check secret header (X-API-Key) + apiKeyHeader := remote.Headers[0] + if apiKeyHeader.Name != "X-API-Key" { + t.Errorf("Expected first header name 'X-API-Key', got '%s'", apiKeyHeader.Name) + } + if apiKeyHeader.Description != "API key for authentication" { + t.Errorf("Expected first header description 'API key for authentication', got '%s'", apiKeyHeader.Description) + } + if !apiKeyHeader.Required { + t.Error("Expected X-API-Key header to be required") + } + if !apiKeyHeader.Secret { + t.Error("Expected X-API-Key header to be secret") + } + + // Check non-secret header (X-Region) with choices + regionHeader := remote.Headers[1] + if regionHeader.Name != "X-Region" { + t.Errorf("Expected second header name 'X-Region', got '%s'", regionHeader.Name) + } + if regionHeader.Description != "Service region" { + t.Errorf("Expected second header description 'Service region', got '%s'", regionHeader.Description) + } + if regionHeader.DefaultValue != "us-east-1" { + t.Errorf("Expected X-Region header default 'us-east-1', got '%v'", regionHeader.DefaultValue) + } + expectedChoices := []string{"us-east-1", "eu-west-1", "ap-southeast-1"} + if len(regionHeader.Choices) != len(expectedChoices) { + t.Errorf("Expected %d choices, got %d", len(expectedChoices), len(regionHeader.Choices)) + } else { + for i, expected := range expectedChoices { + if regionHeader.Choices[i] != expected { + t.Errorf("Expected choice '%s' at index %d, got '%s'", expected, i, regionHeader.Choices[i]) + } + } + } + + // Test conversion to catalog server + catalogServer := serverDetail.ToCatalogServer() + + // Verify basic conversion + if catalogServer.Description != "remote example" { + t.Errorf("Expected catalog server description 'remote example', got '%s'", catalogServer.Description) + } + + // Verify remote configuration + if catalogServer.Remote.URL != "http://mcp-fs.anonymous.modelcontextprotocol.io/sse" { + t.Errorf("Expected catalog remote URL 'http://mcp-fs.anonymous.modelcontextprotocol.io/sse', got '%s'", catalogServer.Remote.URL) + } + if catalogServer.Remote.Transport != "sse" { + t.Errorf("Expected catalog remote transport 'sse', got '%s'", catalogServer.Remote.Transport) + } + + // Verify headers conversion + expectedHeaders := map[string]string{ + "X-API-Key": "${X_API_Key}", // Secret header should become template (canonicalized) + "X-Region": "us-east-1", // Non-secret header should use default value + } + if len(catalogServer.Remote.Headers) != len(expectedHeaders) { + t.Errorf("Expected %d headers, got %d", len(expectedHeaders), len(catalogServer.Remote.Headers)) + } + for key, expectedValue := range expectedHeaders { + if actualValue, exists := catalogServer.Remote.Headers[key]; !exists { + t.Errorf("Expected header '%s' to exist", key) + } else if actualValue != expectedValue { + t.Errorf("Expected header '%s' value '%s', got '%s'", key, expectedValue, actualValue) + } + } + + // Verify secrets (only X-API-Key should become a secret) + if len(catalogServer.Secrets) != 1 { + t.Errorf("Expected 1 secret, got %d", len(catalogServer.Secrets)) + } else { + secret := catalogServer.Secrets[0] + if secret.Name != "io_github_slimslenderslacks/remote.X-API-Key" { + t.Errorf("Expected secret name 'io_github_slimslenderslacks/remote.X-API-Key', got '%s'", secret.Name) + } + if secret.Env != "X_API_Key" { + t.Errorf("Expected secret env 'X_API_Key', got '%s'", secret.Env) + } + } +} diff --git a/cmd/docker-mcp/server/enable.go b/cmd/docker-mcp/server/enable.go index 00f30e1ae..f96d6ef2e 100644 --- a/cmd/docker-mcp/server/enable.go +++ b/cmd/docker-mcp/server/enable.go @@ -32,7 +32,7 @@ func update(ctx context.Context, docker docker.Client, add []string, remove []st return fmt.Errorf("parsing registry config: %w", err) } - catalog, err := catalog.Get(ctx) + catalog, err := catalog.GetWithOptions(ctx, true, nil) if err != nil { return err } diff --git a/cmd/docker-mcp/server/server_test.go b/cmd/docker-mcp/server/server_test.go index 0b93246da..3c042d355 100644 --- a/cmd/docker-mcp/server/server_test.go +++ b/cmd/docker-mcp/server/server_test.go @@ -229,6 +229,18 @@ func withCatalog(yaml string) option { return func(t *testing.T, home string, _ *fakeDocker) { t.Helper() writeFile(t, filepath.Join(home, ".docker/mcp/catalogs/docker-mcp.yaml"), []byte(yaml)) + + // Create catalog.json registry file to register the docker-mcp catalog + catalogRegistry := `{ + "catalogs": { + "docker-mcp": { + "displayName": "Docker MCP Default Catalog", + "url": "docker-mcp.yaml", + "lastUpdate": "2024-01-01T00:00:00Z" + } + } +}` + writeFile(t, filepath.Join(home, ".docker/mcp/catalog.json"), []byte(catalogRegistry)) } } diff --git a/docs/generator/reference/docker_mcp_catalog_import.yaml b/docs/generator/reference/docker_mcp_catalog_import.yaml index 886c058e0..1ad165c3a 100644 --- a/docs/generator/reference/docker_mcp_catalog_import.yaml +++ b/docs/generator/reference/docker_mcp_catalog_import.yaml @@ -1,10 +1,30 @@ command: docker mcp catalog import short: Import a catalog from URL or file -long: "Import an MCP server catalog from a URL or local file. The catalog will be downloaded \nand stored locally for use with the MCP gateway." +long: "Import an MCP server catalog from a URL or local file. The catalog will be downloaded \nand stored locally for use with the MCP gateway.\n\nWhen --mcp-registry flag is used, the argument must be an existing catalog name, and the\ncommand will import servers from the MCP registry URL into that catalog." usage: docker mcp catalog import pname: docker mcp catalog plink: docker_mcp_catalog.yaml -examples: " # Import from URL\n docker mcp catalog import https://example.com/my-catalog.yaml\n \n # Import from local file\n docker mcp catalog import ./shared-catalog.yaml" +options: + - option: dry-run + value_type: bool + default_value: "false" + description: Show Imported Data but do not update the Catalog + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: mcp-registry + value_type: string + description: Import server from MCP registry URL into existing catalog + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +examples: " # Import from URL\n docker mcp catalog import https://example.com/my-catalog.yaml\n \n # Import from local file\n docker mcp catalog import ./shared-catalog.yaml\n \n # Import from MCP registry URL into existing catalog\n docker mcp catalog import my-catalog --mcp-registry https://registry.example.com/server" deprecated: false hidden: false experimental: false diff --git a/docs/generator/reference/docker_mcp_feature_enable.yaml b/docs/generator/reference/docker_mcp_feature_enable.yaml index 0e8b0ccee..7f5ad4e4b 100644 --- a/docs/generator/reference/docker_mcp_feature_enable.yaml +++ b/docs/generator/reference/docker_mcp_feature_enable.yaml @@ -4,7 +4,6 @@ long: |- Enable an experimental feature. Available features: - configured-catalogs Allow gateway to use user-managed catalogs alongside Docker catalog oauth-interceptor Enable GitHub OAuth flow interception for automatic authentication dynamic-tools Enable internal MCP management tools (mcp-find, mcp-add, mcp-remove) usage: docker mcp feature enable diff --git a/docs/generator/reference/docker_mcp_gateway_run.yaml b/docs/generator/reference/docker_mcp_gateway_run.yaml index cd2127042..534f66d9c 100644 --- a/docs/generator/reference/docker_mcp_gateway_run.yaml +++ b/docs/generator/reference/docker_mcp_gateway_run.yaml @@ -127,6 +127,17 @@ options: experimentalcli: false kubernetes: false swarm: false + - option: enable-all-servers + value_type: bool + default_value: "false" + description: | + Enable all servers in the catalog (instead of using individual --servers options) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: interceptor value_type: stringArray default_value: '[]' @@ -159,6 +170,16 @@ options: experimentalcli: false kubernetes: false swarm: false + - option: mcp-registry + value_type: stringSlice + default_value: '[]' + description: MCP registry URLs to fetch servers from (can be repeated) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: memory value_type: string default_value: 2Gb @@ -262,17 +283,6 @@ options: experimentalcli: false kubernetes: false swarm: false - - option: use-configured-catalogs - value_type: bool - default_value: "false" - description: | - Include user-managed catalogs (requires 'configured-catalogs' feature to be enabled) - deprecated: false - hidden: false - experimental: false - experimentalcli: false - kubernetes: false - swarm: false - option: verbose value_type: bool default_value: "false" diff --git a/docs/generator/reference/docker_mcp_import.yaml b/docs/generator/reference/docker_mcp_import.yaml new file mode 100644 index 000000000..6bff46f46 --- /dev/null +++ b/docs/generator/reference/docker_mcp_import.yaml @@ -0,0 +1,49 @@ +command: docker mcp import +short: Import a server +long: |- + Import and parse a server definition from an official MCP registry URL. + + This command fetches the server definition from the provided URL, parses it as a ServerDetail, + converts it to the internal Server format, and displays the results. + + Example: + docker mcp officialregistry import https://registry.example.com/servers/my-server +usage: docker mcp import +pname: docker mcp +plink: docker_mcp.yaml +options: + - option: catalog + value_type: string + description: import to local catalog + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: mcp-registry + value_type: string + description: import from MCP registry format + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: push + value_type: bool + default_value: "false" + description: push the new server artifact + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/docs/generator/reference/docker_mcp_registry.yaml b/docs/generator/reference/docker_mcp_registry.yaml new file mode 100644 index 000000000..285def738 --- /dev/null +++ b/docs/generator/reference/docker_mcp_registry.yaml @@ -0,0 +1,16 @@ +command: docker mcp registry +short: Registry operations +long: Registry operations +pname: docker mcp +plink: docker_mcp.yaml +cname: + - docker mcp registry convert +clink: + - docker_mcp_registry_convert.yaml +deprecated: false +hidden: true +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/docs/generator/reference/docker_mcp_registry_convert.yaml b/docs/generator/reference/docker_mcp_registry_convert.yaml new file mode 100644 index 000000000..5affc6bcd --- /dev/null +++ b/docs/generator/reference/docker_mcp_registry_convert.yaml @@ -0,0 +1,23 @@ +command: docker mcp registry convert +short: Convert OCI registry server definition to catalog server format +long: Convert OCI registry server definition to catalog server format +usage: docker mcp registry convert +pname: docker mcp registry +plink: docker_mcp_registry.yaml +options: + - option: file + value_type: string + description: Path to the OCI registry server definition JSON file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: true +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/docs/generator/reference/docker_mcp_server.yaml b/docs/generator/reference/docker_mcp_server.yaml index e7a3c1331..075d75349 100644 --- a/docs/generator/reference/docker_mcp_server.yaml +++ b/docs/generator/reference/docker_mcp_server.yaml @@ -6,14 +6,12 @@ plink: docker_mcp.yaml cname: - docker mcp server disable - docker mcp server enable - - docker mcp server import - docker mcp server inspect - docker mcp server list - docker mcp server reset clink: - docker_mcp_server_disable.yaml - docker_mcp_server_enable.yaml - - docker_mcp_server_import.yaml - docker_mcp_server_inspect.yaml - docker_mcp_server_list.yaml - docker_mcp_server_reset.yaml diff --git a/docs/generator/reference/mcp_catalog_import.md b/docs/generator/reference/mcp_catalog_import.md index 7e01fcc8d..8de338288 100644 --- a/docs/generator/reference/mcp_catalog_import.md +++ b/docs/generator/reference/mcp_catalog_import.md @@ -4,6 +4,16 @@ Import an MCP server catalog from a URL or local file. The catalog will be downloaded and stored locally for use with the MCP gateway. +When --mcp-registry flag is used, the argument must be an existing catalog name, and the +command will import servers from the MCP registry URL into that catalog. + +### Options + +| Name | Type | Default | Description | +|:-----------------|:---------|:--------|:----------------------------------------------------------| +| `--dry-run` | `bool` | | Show Imported Data but do not update the Catalog | +| `--mcp-registry` | `string` | | Import server from MCP registry URL into existing catalog | + diff --git a/docs/generator/reference/mcp_feature_enable.md b/docs/generator/reference/mcp_feature_enable.md index 5aa9ca9d6..0beccf9b0 100644 --- a/docs/generator/reference/mcp_feature_enable.md +++ b/docs/generator/reference/mcp_feature_enable.md @@ -4,7 +4,6 @@ Enable an experimental feature. Available features: - configured-catalogs Allow gateway to use user-managed catalogs alongside Docker catalog oauth-interceptor Enable GitHub OAuth flow interception for automatic authentication dynamic-tools Enable internal MCP management tools (mcp-find, mcp-add, mcp-remove) diff --git a/docs/generator/reference/mcp_gateway_run.md b/docs/generator/reference/mcp_gateway_run.md index d30d6c4fd..218b2ef54 100644 --- a/docs/generator/reference/mcp_gateway_run.md +++ b/docs/generator/reference/mcp_gateway_run.md @@ -18,9 +18,11 @@ Run the gateway | `--cpus` | `int` | `1` | CPUs allocated to each MCP Server (default is 1) | | `--debug-dns` | `bool` | | Debug DNS resolution | | `--dry-run` | `bool` | | Start the gateway but do not listen for connections (useful for testing the configuration) | +| `--enable-all-servers` | `bool` | | Enable all servers in the catalog (instead of using individual --servers options) | | `--interceptor` | `stringArray` | | List of interceptors to use (format: when:type:path, e.g. 'before:exec:/bin/path') | | `--log-calls` | `bool` | `true` | Log calls to the tools | | `--long-lived` | `bool` | | Containers are long-lived and will not be removed until the gateway is stopped, useful for stateful servers | +| `--mcp-registry` | `stringSlice` | | MCP registry URLs to fetch servers from (can be repeated) | | `--memory` | `string` | `2Gb` | Memory allocated to each MCP Server (default is 2Gb) | | `--oci-ref` | `stringArray` | | OCI image references to use | | `--port` | `int` | `0` | TCP port to listen on (default is to listen on stdio) | @@ -31,7 +33,6 @@ Run the gateway | `--tools` | `stringSlice` | | List of tools to enable | | `--tools-config` | `stringSlice` | `[tools.yaml]` | Paths to the tools files (absolute or relative to ~/.docker/mcp/) | | `--transport` | `string` | `stdio` | stdio, sse or streaming (default is stdio) | -| `--use-configured-catalogs` | `bool` | | Include user-managed catalogs (requires 'configured-catalogs' feature to be enabled) | | `--verbose` | `bool` | | Verbose output | | `--verify-signatures` | `bool` | | Verify signatures of the server images | | `--watch` | `bool` | `true` | Watch for changes and reconfigure the gateway | diff --git a/docs/generator/reference/mcp_import.md b/docs/generator/reference/mcp_import.md new file mode 100644 index 000000000..2c763bdcb --- /dev/null +++ b/docs/generator/reference/mcp_import.md @@ -0,0 +1,22 @@ +# docker mcp import + + +Import and parse a server definition from an official MCP registry URL. + +This command fetches the server definition from the provided URL, parses it as a ServerDetail, +converts it to the internal Server format, and displays the results. + +Example: + docker mcp officialregistry import https://registry.example.com/servers/my-server + +### Options + +| Name | Type | Default | Description | +|:-----------------|:---------|:--------|:--------------------------------| +| `--catalog` | `string` | | import to local catalog | +| `--mcp-registry` | `string` | | import from MCP registry format | +| `--push` | `bool` | | push the new server artifact | + + + + diff --git a/docs/generator/reference/mcp_server.md b/docs/generator/reference/mcp_server.md index 6ee305592..650a995c8 100644 --- a/docs/generator/reference/mcp_server.md +++ b/docs/generator/reference/mcp_server.md @@ -9,7 +9,6 @@ Manage servers |:-----------------------------------|:----------------------------------------------------------| | [`disable`](mcp_server_disable.md) | Disable a server or multiple servers | | [`enable`](mcp_server_enable.md) | Enable a server or multiple servers | -| [`import`](mcp_server_import.md) | Import a server | | [`inspect`](mcp_server_inspect.md) | Get information about a server or inspect an OCI artifact | | [`list`](mcp_server_list.md) | List enabled servers | | [`reset`](mcp_server_reset.md) | Disable all the servers | diff --git a/shell.nix b/shell.nix new file mode 100644 index 000000000..77a34bd8a --- /dev/null +++ b/shell.nix @@ -0,0 +1,59 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = with pkgs; [ + # Go toolchain + go + + # Task runner (go-task) + go-task + + # Additional useful Go development tools + gopls # Go Language Server + golangci-lint # Go linter + delve # Go debugger + gotools # Additional Go tools (goimports, etc.) + gofumpt + + # Git for version control + git + + # Common development utilities + curl + jq + wget + ]; + + shellHook = '' + echo "🚀 Go development environment loaded!" + echo "" + echo "Available tools:" + echo " • Go $(go version | cut -d' ' -f3)" + echo " • Task $(task --version)" + echo " • gopls (Go Language Server)" + echo " • golangci-lint" + echo " • delve (Go debugger)" + echo "" + echo "Getting started:" + echo " • Initialize a new Go module: go mod init " + echo " • Create a Taskfile.yml for task automation" + echo " • Run 'task --list' to see available tasks" + echo "" + + # Set up Go environment variables + export GOPATH="$HOME/go" + export GOBIN="$GOPATH/bin" + export PATH="$GOBIN:$PATH" + + # Create GOPATH directories if they don't exist + mkdir -p "$GOPATH"/{bin,src,pkg} + + echo "Environment variables set:" + echo " • GOPATH=$GOPATH" + echo " • GOBIN=$GOBIN" + echo "" + ''; + + # Set environment variables + GOROOT = "${pkgs.go}/share/go"; +} diff --git a/test/testdata/officialregistry/server.remote.json b/test/testdata/officialregistry/server.remote.json new file mode 100644 index 000000000..d9843c841 --- /dev/null +++ b/test/testdata/officialregistry/server.remote.json @@ -0,0 +1,34 @@ +{ + "name": "io.github.slimslenderslacks/remote", + "description": "remote example", + "status": "active", + "repository": { + "url": "https://github.com/slimslenderslacks/poci", + "source": "github" + }, + "version": "0.1.0", + "remotes": [ + { + "type": "sse", + "url": "http://mcp-fs.anonymous.modelcontextprotocol.io/sse", + "headers": [ + { + "name": "X-API-Key", + "description": "API key for authentication", + "is_required": true, + "is_secret": true + }, + { + "name": "X-Region", + "description": "Service region", + "default": "us-east-1", + "choices": [ + "us-east-1", + "eu-west-1", + "ap-southeast-1" + ] + } + ] + } + ] +} diff --git a/test/testdata/officialregistry/server.test.json b/test/testdata/officialregistry/server.test.json new file mode 100644 index 000000000..0ce4c7a21 --- /dev/null +++ b/test/testdata/officialregistry/server.test.json @@ -0,0 +1,21 @@ +{ + "name": "io.github.slimslenderslacks/poci", + "description": "construct new tools out of existing images", + "status": "active", + "repository": { + "url": "https://github.com/slimslenderslacks/poci", + "source": "github" + }, + "version": "1.0.12", + "packages": [ + { + "transport": { + "type": "stdio" + }, + "registry_type": "oci", + "registry_base_url": "https://docker.io", + "identifier": "jimclark106/poci", + "version": "latest" + } + ] +} diff --git a/test/testdata/officialregistry/server_cockroach.json b/test/testdata/officialregistry/server_cockroach.json new file mode 100644 index 000000000..c6b288cfe --- /dev/null +++ b/test/testdata/officialregistry/server_cockroach.json @@ -0,0 +1,35 @@ + +{ + "name": "io.github.slimslenderslacks/cockroach", + "description": "cockroach", + "status": "active", + "repository": { + "url": "https://github.com/slimslenderslacks/cockroach", + "source": "github" + }, + "version": "0.1.1", + "packages": [ + { + "registry_type": "oci", + "identifier": "mcp/cockroach", + "version": "latest", + "transport": { + "type": "stdio" + }, + "environment_variables": [ + { + "name": "CRDB_HOST", + "description": "host", + "is_required": true, + "is_secret": false + }, + { + "name": "CRDB_PWD", + "description": "pwd", + "is_required": true, + "is_secret": true + } + ] + } + ] +} diff --git a/test/testdata/officialregistry/server_filesystem.json b/test/testdata/officialregistry/server_filesystem.json new file mode 100644 index 000000000..c1e664ae3 --- /dev/null +++ b/test/testdata/officialregistry/server_filesystem.json @@ -0,0 +1,58 @@ +{ + "name": "io.github.slimslenderslacks/filesystem", + "description": "Node.js server implementing Model Context Protocol (MCP) for filesystem operations.", + "status": "active", + "repository": { + "url": "https://github.com/modelcontextprotocol/servers", + "source": "github", + "id": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9" + }, + "version": "1.0.2", + "packages": [ + { + "registry_type": "oci", + "registry_base_url": "https://docker.io", + "identifier": "mcp/filesystem", + "version": "1.0.2", + "transport": { + "type": "stdio" + }, + "runtime_arguments": [ + { + "type": "named", + "description": "Mount a volume into the container", + "name": "--mount", + "value": "type=bind,src={source_path},dst={target_path}", + "is_required": true, + "is_repeated": true, + "variables": { + "source_path": { + "description": "Source path on host", + "format": "filepath", + "is_required": true + }, + "target_path": { + "description": "Path to mount in the container. It should be rooted in `/project` directory.", + "is_required": true, + "default": "/project" + } + } + } + ], + "package_arguments": [ + { + "type": "positional", + "value_hint": "target_dir", + "value": "/project" + } + ], + "environment_variables": [ + { + "name": "LOG_LEVEL", + "description": "Logging level (debug, info, warn, error)", + "default": "info" + } + ] + } + ] +} diff --git a/test/testdata/officialregistry/server_gadget.json b/test/testdata/officialregistry/server_gadget.json new file mode 100644 index 000000000..4f081274c --- /dev/null +++ b/test/testdata/officialregistry/server_gadget.json @@ -0,0 +1,49 @@ +{ + "name": "io.github.slimslenderslacks/gadget", + "description": "gadget", + "status": "active", + "repository": { + "url": "https://github.com/slimslenderslacks/gadget", + "source": "github" + }, + "version": "0.1.1", + "packages": [ + { + "registry_type": "oci", + "identifier": "mcp/gadget", + "version": "latest", + "transport": { + "type": "stdio" + }, + "runtime_arguments": [ + { + "type": "named", + "name": "-v", + "value": "{kubeconfig}:/kubeconfig", + "variables": { + "kubeconfig": { + "is_required": true, + "description": "path to kube file" + } + } + } + ], + "package_arguments": [ + { + "type": "positional", + "value": "-gadget-discoverer=artifacthub" + }, + { + "type": "positional", + "value": "-gadget-images={gadget-images}", + "variables": { + "gadget-images": { + "is_required": true, + "description": "comma separated list of stuff" + } + } + } + ] + } + ] +} diff --git a/test/testdata/officialregistry/server_garmin_mcp.json b/test/testdata/officialregistry/server_garmin_mcp.json new file mode 100644 index 000000000..ae9addcb9 --- /dev/null +++ b/test/testdata/officialregistry/server_garmin_mcp.json @@ -0,0 +1,35 @@ +{ + "name": "io.github.slimslenderslacks/garmin_mcp", + "description": "exposes your fitness and health data to Claude and other MCP-compatible clients.", + "status": "active", + "repository": { + "url": "https://github.com/slimslenderslacks/poci", + "source": "github" + }, + "version": "0.1.1", + "packages": [ + { + "registry_type": "oci", + "registry_base_url": "https://docker.io", + "identifier": "jimclark106/gramin_mcp", + "version": "latest", + "transport": { + "type": "stdio" + }, + "environment_variables": [ + { + "name": "GARMIN_EMAIL", + "description": "Garmin Connect email address", + "is_required": true, + "is_secret": true + }, + { + "name": "GARMIN_PASSWORD", + "description": "Garmin Connect password", + "is_required": true, + "is_secret": true + } + ] + } + ] +}