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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/docker-mcp/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command
HiddenDefaultCmd: true,
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
cmd.SetContext(ctx)
if err := plugin.PersistentPreRunE(cmd, args); err != nil {
return err
}
Expand All @@ -60,6 +59,7 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command
},
Version: version.Version,
}
cmd.SetContext(ctx)
cmd.SetVersionTemplate("{{.Version}}\n")
cmd.Flags().BoolP("version", "v", false, "Print version information and quit")
cmd.SetHelpTemplate(helpTemplate)
Expand Down
110 changes: 69 additions & 41 deletions cmd/docker-mcp/commands/secret.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
package commands

import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"

"github.com/spf13/cobra"

"github.com/docker/mcp-gateway/cmd/docker-mcp/secret-management/formatting"
"github.com/docker/mcp-gateway/cmd/docker-mcp/secret-management/secret"
"github.com/docker/mcp-gateway/pkg/desktop"
"github.com/docker/mcp-gateway/pkg/docker"
)

const setSecretExample = `
### Use secrets for postgres password with default policy

> docker mcp secret set POSTGRES_PASSWORD=my-secret-password
> docker run -d -l x-secret:POSTGRES_PASSWORD=/pwd.txt -e POSTGRES_PASSWORD_FILE=/pwd.txt -p 5432 postgres
> docker mcp secret set postgres_password=my-secret-password

Inject the secret by querying by ID
> docker run -d -e POSTGRES_PASSWORD=se://docker/mcp/generic/postgres_password -p 5432 postgres

Another way to inject secrets would be to use a pattern.
> docker run -d -e POSTGRES_PASSWORD=se://**/postgres_password -p 5432 postgres

### Pass the secret via STDIN

Expand All @@ -26,66 +35,105 @@ const setSecretExample = `
func secretCommand(docker docker.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "secret",
Short: "Manage secrets",
Short: "Manage secrets in the local OS Keychain",
Example: strings.Trim(setSecretExample, "\n"),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
err := desktop.CheckHasDockerPass(cmd.Context())
if err != nil {
return err
}
return nil
},
}
cmd.AddCommand(rmSecretCommand())
cmd.AddCommand(listSecretCommand())
cmd.AddCommand(setSecretCommand())
cmd.AddCommand(exportSecretCommand(docker))
return cmd
}

func rmSecretCommand() *cobra.Command {
var opts secret.RmOpts
var all bool
cmd := &cobra.Command{
Use: "rm name1 name2 ...",
Short: "Remove secrets from Docker Desktop's secret store",
Short: "Remove secrets from the OS Keychain",
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRmArgs(args, opts); err != nil {
if err := validateRmArgs(args, all); err != nil {
return err
}
return secret.Remove(cmd.Context(), args, opts)

ids := slices.Clone(args)
if all {
var err error
ids, err = secret.List(cmd.Context())
if err != nil {
return err
}
}

var errs []error
for _, s := range ids {
errs = append(errs, secret.DeleteSecret(cmd.Context(), s))
}
return errors.Join(errs...)
},
}
flags := cmd.Flags()
flags.BoolVar(&opts.All, "all", false, "Remove all secrets")
flags.BoolVar(&all, "all", false, "Remove all secrets")
return cmd
}

func validateRmArgs(args []string, opts secret.RmOpts) error {
if len(args) == 0 && !opts.All {
func validateRmArgs(args []string, all bool) error {
if len(args) == 0 && !all {
return errors.New("either provide a secret name or use --all to remove all secrets")
}
return nil
}

func listSecretCommand() *cobra.Command {
var opts secret.ListOptions
var outJSON bool
cmd := &cobra.Command{
Use: "ls",
Short: "List all secret names in Docker Desktop's secret store",
Short: "List all secrets from the local OS Keychain as well as any active Secrets Engine provider",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return secret.List(cmd.Context(), opts)
// query the Secrets Engine instead to get all the secrets from
// all active providers.
l, err := secret.GetSecrets(cmd.Context())
if err != nil {
return err
}
if outJSON {
if len(l) == 0 {
l = []secret.Envelope{} // Guarantee empty list (instead of displaying null)
}
jsonData, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
fmt.Println(string(jsonData))
return nil
}
var rows [][]string
for _, v := range l {
rows = append(rows, []string{v.Id, v.Provider})
}
formatting.PrettyPrintTable(rows, []int{40, 120})
return nil
},
}
flags := cmd.Flags()
flags.BoolVar(&opts.JSON, "json", false, "Print as JSON.")
flags.BoolVar(&outJSON, "json", false, "Print as JSON.")
return cmd
}

func setSecretCommand() *cobra.Command {
opts := &secret.SetOpts{}
cmd := &cobra.Command{
Use: "set key[=value]",
Short: "Set a secret in Docker Desktop's secret store",
Short: "Set a secret in the local OS Keychain",
Example: strings.Trim(setSecretExample, "\n"),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !secret.IsValidProvider(opts.Provider) {
return fmt.Errorf("invalid provider: %s", opts.Provider)
}
var s secret.Secret
if isNotImplicitReadFromStdinSyntax(args, *opts) {
va, err := secret.ParseArg(args[0], *opts)
Expand All @@ -105,30 +153,10 @@ func setSecretCommand() *cobra.Command {
}
flags := cmd.Flags()
flags.StringVar(&opts.Provider, "provider", "", "Supported: credstore, oauth/<provider>")
flags.MarkDeprecated("provider", "option will be ignored")
return cmd
}

func isNotImplicitReadFromStdinSyntax(args []string, opts secret.SetOpts) bool {
return strings.Contains(args[0], "=") || len(args) > 1 || opts.Provider != ""
}

func exportSecretCommand(docker docker.Client) *cobra.Command {
return &cobra.Command{
Use: "export [server1] [server2] ...",
Short: "Export secrets for the specified servers",
Hidden: true,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
secrets, err := secret.Export(cmd.Context(), docker, args)
if err != nil {
return err
}

for name, secret := range secrets {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s=%s\n", name, secret)
}

return nil
},
}
return strings.Contains(args[0], "=") || len(args) > 1
}
154 changes: 52 additions & 102 deletions cmd/docker-mcp/secret-management/secret/credstore.go
Original file line number Diff line number Diff line change
@@ -1,125 +1,75 @@
// This package stores secrets in the local OS Keychain.
package secret

import (
"bufio"
"bytes"
"context"
"io"
"fmt"
"os/exec"
"path"
"strings"

"github.com/docker/docker-credential-helpers/client"
"github.com/docker/docker-credential-helpers/credentials"

"github.com/docker/mcp-gateway/pkg/desktop"
)

type CredStoreProvider struct {
credentialHelper credentials.Helper
}

func NewCredStoreProvider() *CredStoreProvider {
return &CredStoreProvider{credentialHelper: GetHelper()}
}

type CredStoreProvider struct{}

func cmd(ctx context.Context, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "docker", append([]string{"pass"}, args...)...)
}

// getSecretKey prefixes the secrets with the docker/mcp/generic namespace.
// Additional namespaces can be added when defining the secretName.
//
// Example:
//
// secretName = "mysecret/application/id"
// return "docker/mcp/generic/mysecret/application/id"
//
// This can later then be queried by the Secrets Engine using a pattern or direct
// ID match.
//
// Example:
//
// # anything under mcp/generic
// pattern = "docker/mcp/generic/**"
// # specific to mysecret
// pattern = "docker/mcp/generic/mysecret/application/**"
func getSecretKey(secretName string) string {
return "sm_" + secretName
return path.Join("docker/mcp/generic/", secretName)
}

func (store *CredStoreProvider) GetSecret(id string) (string, error) {
_, val, err := store.credentialHelper.Get(getSecretKey(id))
func List(ctx context.Context) ([]string, error) {
c := cmd(ctx, "ls")
out, err := c.Output()
if err != nil {
return "", err
return nil, fmt.Errorf("could not list secrets: %s\n%s", bytes.TrimSpace(out), err)
}
return val, nil
}

func (store *CredStoreProvider) SetSecret(id string, value string) error {
return store.credentialHelper.Add(&credentials.Credentials{
ServerURL: getSecretKey(id),
Username: "mcp",
Secret: value,
})
}

func (store *CredStoreProvider) DeleteSecret(id string) error {
return store.credentialHelper.Delete(getSecretKey(id))
}

func GetHelper() credentials.Helper {
credentialHelperPath := desktop.Paths().CredentialHelperPath()
return Helper{
program: newShellProgramFunc(credentialHelperPath),
}
}

// newShellProgramFunc creates programs that are executed in a Shell.
func newShellProgramFunc(name string) client.ProgramFunc {
return func(args ...string) client.Program {
return &shell{cmd: exec.CommandContext(context.Background(), name, args...)}
scanner := bufio.NewScanner(bytes.NewReader(out))
var secrets []string
for scanner.Scan() {
secret := scanner.Text()
if len(secret) == 0 {
continue
}
secrets = append(secrets, secret)
}
return secrets, nil
}

// shell invokes shell commands to talk with a remote credentials-helper.
type shell struct {
cmd *exec.Cmd
}

// Output returns responses from the remote credentials-helper.
func (s *shell) Output() ([]byte, error) {
return s.cmd.Output()
}

// Input sets the input to send to a remote credentials-helper.
func (s *shell) Input(in io.Reader) {
s.cmd.Stdin = in
}

// Helper wraps credential helper program.
type Helper struct {
// name string
program client.ProgramFunc
}

func (h Helper) List() (map[string]string, error) {
return map[string]string{}, nil
}

// Add stores new credentials.
func (h Helper) Add(creds *credentials.Credentials) error {
username, secret, err := h.Get(creds.ServerURL)
if err != nil && !credentials.IsErrCredentialsNotFound(err) && !isErrDecryption(err) {
return err
}
if username == creds.Username && secret == creds.Secret {
return nil
}
if err := client.Store(h.program, creds); err != nil {
return err
func setSecret(ctx context.Context, id string, value string) error {
c := cmd(ctx, "set", getSecretKey(id))
c.Stdin = strings.NewReader(value)
out, err := c.CombinedOutput()
if err != nil {
return fmt.Errorf("could not store secret: %s\n%s", bytes.TrimSpace(out), err)
}
return nil
}

// Delete removes credentials.
func (h Helper) Delete(serverURL string) error {
if _, _, err := h.Get(serverURL); err != nil {
if credentials.IsErrCredentialsNotFound(err) {
return nil
}
return err
}
return client.Erase(h.program, serverURL)
}

// Get returns the username and secret to use for a given registry server URL.
func (h Helper) Get(serverURL string) (string, string, error) {
creds, err := client.Get(h.program, serverURL)
func DeleteSecret(ctx context.Context, id string) error {
out, err := cmd(ctx, "rm", getSecretKey(id)).CombinedOutput()
if err != nil {
return "", "", err
return fmt.Errorf("could not delete secret: %s\n%s\n%s", id, bytes.TrimSpace(out), err)
}
return creds.Username, creds.Secret, nil
}

func isErrDecryption(err error) bool {
return err != nil && strings.Contains(err.Error(), "gpg: decryption failed: No secret key")
return nil
}

var _ credentials.Helper = Helper{}
Loading
Loading