diff --git a/pkg/config/gather.go b/pkg/config/gather.go index 79704c772..95013e729 100644 --- a/pkg/config/gather.go +++ b/pkg/config/gather.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "maps" - "os" "slices" "strconv" "strings" @@ -327,10 +326,11 @@ func GatherEnvVarsForTools(ctx context.Context, cfg *latest.Config) ([]string, e if !ok { requiredEnv[secret.Env] = true } else { - os.Expand(value, func(name string) string { + // Use the same normalization the runtime applies, so `${env.X}` + // discovers X rather than the literal name "env.X". + for _, name := range environment.Refs(value) { requiredEnv[name] = true - return "" - }) + } } } } diff --git a/pkg/config/gather_mcp_env_test.go b/pkg/config/gather_mcp_env_test.go new file mode 100644 index 000000000..01376cc78 --- /dev/null +++ b/pkg/config/gather_mcp_env_test.go @@ -0,0 +1,67 @@ +package config + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/gateway" +) + +// mcpCatalogContext serves a static MCP catalog so the tool env preflight does +// not fetch the live Docker catalog. +func mcpCatalogContext(t *testing.T) context.Context { + t.Helper() + catalog := gateway.Catalog{ + "github-official": { + Type: "server", + Secrets: []gateway.Secret{{Name: "github.personal_access_token", Env: "GITHUB_PERSONAL_ACCESS_TOKEN"}}, + }, + } + return gateway.WithLoader(t.Context(), gateway.NewStaticLoader(catalog)) +} + +func mcpToolsetConfig(envValue string) *latest.Config { + return &latest.Config{ + Agents: latest.Agents{{ + Name: "root", + Toolsets: []latest.Toolset{{ + Type: "mcp", + Ref: "docker:github-official", + Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": envValue}, + }}, + }}, + } +} + +// TestGatherEnvVarsForTools_ExpandsToolsetEnvLikeRuntime checks that the +// up-front preflight discovers the same variables the runtime expands into +// an MCP toolset's env. The canonical ${env.X} form (as used by +// examples/mcp-definitions.yaml) must identify X, not "env.X"; the preflight +// used to report the literal "env.GITHUB_PERSONAL_ACCESS_TOKEN" and abort the +// run even though the variable was set. +func TestGatherEnvVarsForTools_ExpandsToolsetEnvLikeRuntime(t *testing.T) { + for _, tc := range []struct { + name string + envValue string + }{ + {name: "canonical JS form", envValue: "${env.GITHUB_PERSONAL_ACCESS_TOKEN}"}, + {name: "shell form", envValue: "${GITHUB_PERSONAL_ACCESS_TOKEN}"}, + {name: "bare form", envValue: "$GITHUB_PERSONAL_ACCESS_TOKEN"}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := mcpCatalogContext(t) + cfg := mcpToolsetConfig(tc.envValue) + + env := environment.NewMapEnvProvider(map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_test"}) + require.NoError(t, CheckRequiredEnvVars(ctx, cfg, "", env)) + + names, err := GatherEnvVarsForTools(ctx, cfg) + require.NoError(t, err) + require.Equal(t, []string{"GITHUB_PERSONAL_ACCESS_TOKEN"}, names) + }) + } +}