From 7f078b46a847b5400a3d2feeaa1ec057ee0030f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 04:52:07 +0200 Subject: [PATCH 1/6] feat(cloud): Target type and active-target provider methods (#47-followup) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/fake_test.go | 5 +++++ pkg/mcp/cloud/probe_test.go | 2 ++ pkg/mcp/cloud/provider.go | 15 +++++++++++++++ pkg/mcp/cloud/server_test.go | 7 +++++++ 4 files changed, 29 insertions(+) diff --git a/pkg/mcp/cloud/fake_test.go b/pkg/mcp/cloud/fake_test.go index bdc7207..364b091 100644 --- a/pkg/mcp/cloud/fake_test.go +++ b/pkg/mcp/cloud/fake_test.go @@ -15,6 +15,7 @@ type fakeProvider struct { identity IdentityStatus identityErr error envPassthrough []string + targets []Target } func (f *fakeProvider) Name() string { @@ -49,3 +50,7 @@ func (f *fakeProvider) Inventory(context.Context, RunFunc) (Inventory, error) { func (f *fakeProvider) Identity(context.Context, RunFunc, string) (IdentityStatus, error) { return f.identity, f.identityErr } + +func (f *fakeProvider) ConfiguredTargets() []Target { return f.targets } + +func (f *fakeProvider) ActiveTargetEnv(id string) []string { return []string{"FAKE_TARGET=" + id} } diff --git a/pkg/mcp/cloud/probe_test.go b/pkg/mcp/cloud/probe_test.go index 4925233..8ecaf68 100644 --- a/pkg/mcp/cloud/probe_test.go +++ b/pkg/mcp/cloud/probe_test.go @@ -30,6 +30,8 @@ func (p *envProbeProvider) Inventory(context.Context, RunFunc) (Inventory, error return Inventory{}, nil } +func (p *envProbeProvider) ConfiguredTargets() []Target { return nil } +func (p *envProbeProvider) ActiveTargetEnv(id string) []string { return []string{"FAKE_TARGET=" + id} } func (p *envProbeProvider) Identity(ctx context.Context, run RunFunc, _ string) (IdentityStatus, error) { res, err := run(ctx, nil) if err != nil { diff --git a/pkg/mcp/cloud/provider.go b/pkg/mcp/cloud/provider.go index 32d1bee..c3219a8 100644 --- a/pkg/mcp/cloud/provider.go +++ b/pkg/mcp/cloud/provider.go @@ -41,6 +41,21 @@ type Provider interface { // empty when none is pinned); the provider validates the resolved identity // against it. It execs only through run, never directly. Identity(ctx context.Context, run RunFunc, expected string) (IdentityStatus, error) + // ConfiguredTargets is the deployment-configured selectable set the provider + // itself knows (aws: its accounts list). Empty when the set comes from the + // server's scope/inventory instead (gcp). + ConfiguredTargets() []Target + // ActiveTargetEnv returns the env var(s) that pin the CLI to targetID for the + // next invocation: gcp CLOUDSDK_CORE_PROJECT, aws AWS_PROFILE. The agent never + // supplies these; the server sets them per-exec. + ActiveTargetEnv(targetID string) []string +} + +// Target is one selectable project (gcp) or account (aws) the agent may make +// active via set_active_target. +type Target struct { + ID string `json:"id"` + Name string `json:"name"` } // RunFunc is the harness exec core, injected into providers so they never exec diff --git a/pkg/mcp/cloud/server_test.go b/pkg/mcp/cloud/server_test.go index 002cfb0..991dad8 100644 --- a/pkg/mcp/cloud/server_test.go +++ b/pkg/mcp/cloud/server_test.go @@ -7,6 +7,13 @@ import ( "github.com/stretchr/testify/require" ) +func TestFakeProviderSatisfiesActiveTargetContract(t *testing.T) { + t.Parallel() + var p Provider = &fakeProvider{} + require.NotNil(t, p) + // compile-time: the interface now includes ActiveTargetEnv + ConfiguredTargets +} + func TestNewRequiresProvider(t *testing.T) { t.Parallel() _, err := New(Options{}) From 4cbf3bc7917ba9873a29a7f35f87eafd84455032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 04:53:29 +0200 Subject: [PATCH 2/6] feat(cloud): server active-target state, selectable set, and env application Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/server.go | 55 +++++++++++++++++++++++++++++++++++- pkg/mcp/cloud/server_test.go | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index d03ca44..fcacb61 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -42,6 +42,11 @@ type Server struct { allowlist *CommandAllowlist scope ScopeAllowlist expectedIdentity string + // activeTarget is the project (gcp) or account (aws) subsequent run_cli + // commands run against, chosen via set_active_target from selectableTargets. + // Empty means none chosen yet; subprocessEnv injects the provider's target + // env only when set. + activeTarget string } // New constructs a cloud-context MCP server. Provider is required. The command @@ -67,10 +72,54 @@ func New(opts Options) (*Server, error) { scope: opts.Scope, expectedIdentity: opts.ExpectedIdentity, } + // A single selectable target is the active target from session start + // (today's behavior); with several, the agent must choose via + // set_active_target before run_cli will run. + if sel := s.selectableTargets(context.Background()); len(sel) == 1 { + s.activeTarget = sel[0].ID + } s.registerOn(impl) return s, nil } +// selectableTargets returns the set the agent may choose from: the provider's +// configured targets (aws accounts) when present, else the scope projects, else +// (unconstrained) the live inventory scopes. +func (s *Server) selectableTargets(ctx context.Context) []Target { + if t := s.provider.ConfiguredTargets(); len(t) > 0 { + return t + } + if len(s.scope.Projects) > 0 { + out := make([]Target, 0, len(s.scope.Projects)) + for _, p := range s.scope.Projects { + out = append(out, Target{ID: p, Name: p}) + } + return out + } + inv, err := s.provider.Inventory(ctx, s.run) + if err != nil { + return nil + } + out := make([]Target, 0, len(inv.Scopes)) + for _, sc := range inv.Scopes { + out = append(out, Target{ID: sc.ID, Name: sc.Name}) + } + return out +} + +// setActive validates id against the selectable set and pins it as the active +// target. An id outside the set is rejected, so the agent can never name a +// target the deployment did not configure. +func (s *Server) setActive(id string) error { + for _, t := range s.selectableTargets(context.Background()) { + if t.ID == id { + s.activeTarget = id + return nil + } + } + return fmt.Errorf("target %q is not in the configured set", id) +} + // loadAllowlist resolves the command allowlist for a provider: the override path // when given, else the provider's embedded default, always filtered through the // base deny floor plus the provider's deny-floor additions. @@ -104,7 +153,11 @@ func (s *Server) run(ctx context.Context, argv []string) (CLIResult, error) { // names, read from the launcher-controlled process env. Everything else is // dropped, so the launcher's ambient secrets never reach the CLI. func (s *Server) subprocessEnv() []string { - return minimalEnv(s.provider.EnvPassthrough()) + env := minimalEnv(s.provider.EnvPassthrough()) + if s.activeTarget != "" { + env = append(env, s.provider.ActiveTargetEnv(s.activeTarget)...) + } + return env } // minimalEnv returns the subprocess environment built from os.Environ() filtered diff --git a/pkg/mcp/cloud/server_test.go b/pkg/mcp/cloud/server_test.go index 991dad8..2311f9f 100644 --- a/pkg/mcp/cloud/server_test.go +++ b/pkg/mcp/cloud/server_test.go @@ -1,6 +1,7 @@ package cloud import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -22,6 +23,58 @@ func TestNewRequiresProvider(t *testing.T) { require.NoError(t, err) } +func TestSelectableTargetsPrefersConfigured(t *testing.T) { + t.Parallel() + p := &fakeProvider{targets: []Target{{ID: "acct-1", Name: "one"}}} + s := newTestServer(t, p) + got := s.selectableTargets(context.Background()) + assert.Equal(t, []Target{{ID: "acct-1", Name: "one"}}, got) +} + +func TestSelectableTargetsFallsBackToScopeProjects(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{}, func(o *Options) { + o.Scope = ScopeAllowlist{Projects: []string{"prod", "staging"}} + }) + got := s.selectableTargets(context.Background()) + assert.Equal(t, []Target{{ID: "prod", Name: "prod"}, {ID: "staging", Name: "staging"}}, got) +} + +func TestSelectableTargetsFallsBackToInventory(t *testing.T) { + t.Parallel() + p := &fakeProvider{inventory: Inventory{Scopes: []Scope{{ID: "p1", Name: "Project One"}}}} + s := newTestServer(t, p) + got := s.selectableTargets(context.Background()) + assert.Equal(t, []Target{{ID: "p1", Name: "Project One"}}, got) +} + +func TestSetActiveTargetRejectsOutOfSet(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}}) + require.Error(t, s.setActive("acct-9")) + require.NoError(t, s.setActive("acct-1")) + assert.Equal(t, "acct-1", s.activeTarget) +} + +func TestSubprocessEnvIncludesActiveTarget(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}}) + require.NoError(t, s.setActive("acct-1")) + assert.Contains(t, s.subprocessEnv(), "FAKE_TARGET=acct-1") +} + +func TestSingleTargetIsDefaultActive(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "only"}}}) + assert.Equal(t, "only", s.activeTarget) +} + +func TestMultiTargetHasNoDefault(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "a"}, {ID: "b"}}}) + assert.Equal(t, "", s.activeTarget) +} + // TestSubprocessEnvDropsParentSecretsKeepsPassthrough exercises the env the // server actually builds for run_cli — the path the real harness takes, which // the isolated execCLI test cannot cover. A parent-env canary must be dropped From 8b6643f2710c6426bb8fc67d6388171cc57193ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 04:55:15 +0200 Subject: [PATCH 3/6] feat(cloud): set_active_target tool and spec Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/server.go | 4 ++++ pkg/mcp/cloud/specs.go | 6 ++++++ pkg/mcp/cloud/tools_target.go | 30 ++++++++++++++++++++++++++++++ pkg/mcp/cloud/tools_test.go | 12 ++++++++++++ pkg/mcp/cloud/tools_wire_test.go | 4 ++-- 5 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 pkg/mcp/cloud/tools_target.go diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index fcacb61..1202a35 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -194,6 +194,10 @@ func (s *Server) registerOn(impl *mcp.Server) { Name: "session_status", Description: descSessionStatus, }, telemetry.Wrap("session_status", s.sessionStatus)) + mcp.AddTool(impl, &mcp.Tool{ + Name: "set_active_target", + Description: descSetActiveTarget, + }, telemetry.Wrap("set_active_target", s.setActiveTarget)) mcp.AddTool(impl, &mcp.Tool{ Name: "run_cli", Description: descRunCLI, diff --git a/pkg/mcp/cloud/specs.go b/pkg/mcp/cloud/specs.go index 75e699b..c567ec3 100644 --- a/pkg/mcp/cloud/specs.go +++ b/pkg/mcp/cloud/specs.go @@ -20,6 +20,12 @@ func ToolSpecs() []toolspec.ToolSpec { Description: descSessionStatus, Inputs: toolspec.FromStruct(SessionStatusInput{}), }, + { + Server: "triagent-cloud", + Name: "set_active_target", + Description: descSetActiveTarget, + Inputs: toolspec.FromStruct(SetActiveTargetInput{}), + }, { Server: "triagent-cloud", Name: "run_cli", diff --git a/pkg/mcp/cloud/tools_target.go b/pkg/mcp/cloud/tools_target.go new file mode 100644 index 0000000..aa4d8c2 --- /dev/null +++ b/pkg/mcp/cloud/tools_target.go @@ -0,0 +1,30 @@ +package cloud + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const descSetActiveTarget = "Choose which project (GCP) or account (AWS) subsequent run_cli commands run against, from the configured set shown by list_inventory. You cannot choose a target outside that set. Read-only." + +// SetActiveTargetInput is the input schema for set_active_target. +type SetActiveTargetInput struct { + Target string `json:"target" jsonschema:"The project id (GCP) or account id (AWS) to activate, from list_inventory."` +} + +// SetActiveTargetOutput is the response schema for set_active_target: the new +// target's session_status, so the agent immediately sees whether it is valid. +type SetActiveTargetOutput = IdentityStatus + +// setActiveTarget pins the active target after validating it against the +// selectable set, then re-probes so the returned status reflects the new +// target. A target outside the set is rejected before anything changes. +func (s *Server) setActiveTarget(ctx context.Context, _ *mcp.CallToolRequest, in SetActiveTargetInput) (*mcp.CallToolResult, SetActiveTargetOutput, error) { + if err := s.setActive(in.Target); err != nil { + return errorResult(fmt.Sprintf("set_active_target rejected: %v", err)), SetActiveTargetOutput{}, nil + } + st, _ := Probe(ctx, s.provider, s.expectedIdentity, s.subprocessEnv()) + return nil, st, nil +} diff --git a/pkg/mcp/cloud/tools_test.go b/pkg/mcp/cloud/tools_test.go index 9a70571..1da6d4f 100644 --- a/pkg/mcp/cloud/tools_test.go +++ b/pkg/mcp/cloud/tools_test.go @@ -18,6 +18,18 @@ func newTestServer(t *testing.T, p Provider, opts ...func(*Options)) *Server { return srv } +func TestSetActiveTargetTool(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", AssumedIdentity: "ro@acct-1", Valid: true}}) + _, out, err := s.setActiveTarget(context.Background(), nil, SetActiveTargetInput{Target: "acct-1"}) + require.NoError(t, err) + require.True(t, out.Valid) + require.Equal(t, "acct-1", s.activeTarget) + + res, _, _ := s.setActiveTarget(context.Background(), nil, SetActiveTargetInput{Target: "nope"}) + require.True(t, res.IsError) +} + func TestListInventoryReturnsProviderScopes(t *testing.T) { t.Parallel() p := &fakeProvider{inventory: Inventory{Scopes: []Scope{{ID: "prod", Name: "Production"}}}} diff --git a/pkg/mcp/cloud/tools_wire_test.go b/pkg/mcp/cloud/tools_wire_test.go index 9cf246c..4042c67 100644 --- a/pkg/mcp/cloud/tools_wire_test.go +++ b/pkg/mcp/cloud/tools_wire_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestTools_Registered confirms the four cloud tools are exposed and that the +// TestTools_Registered confirms the cloud tools are exposed and that the // set registered on the server matches the ToolSpecs() catalog exactly — the // wire test fails if registration drifts from the catalog. func TestTools_Registered(t *testing.T) { @@ -45,7 +45,7 @@ func TestTools_Registered(t *testing.T) { assert.True(t, cataloged[name], "tool %q registered but absent from ToolSpecs()", name) } - for _, want := range []string{"list_inventory", "session_status", "run_cli", "list_allowed_commands"} { + for _, want := range []string{"list_inventory", "session_status", "set_active_target", "run_cli", "list_allowed_commands"} { assert.True(t, registered[want], "%s not registered", want) } } From 72945bb22dd13ea87732066fbcca5de5a7b8f499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 04:56:42 +0200 Subject: [PATCH 4/6] feat(cloud): run_cli requires an active target when several are configured Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/server.go | 9 +++++++++ pkg/mcp/cloud/tools_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index 1202a35..e934b7d 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -2,6 +2,7 @@ package cloud import ( "context" + "errors" "fmt" "os" "strings" @@ -142,12 +143,20 @@ func (s *Server) Run(ctx context.Context) error { // and allowlist. Providers and tools exec only through this RunFunc, never // directly: it validates argv before handing it to the no-shell exec core. func (s *Server) run(ctx context.Context, argv []string) (CLIResult, error) { + if s.activeTarget == "" && len(s.selectableTargets(ctx)) > 1 { + return CLIResult{}, errNoActiveTarget + } if err := validateArgv(argv, s.allowlist, s.scope); err != nil { return CLIResult{}, err } return execCLI(ctx, s.provider.Binary(), argv, s.subprocessEnv(), defaultOutputLimit) } +// errNoActiveTarget is returned by run when several targets are selectable but +// none is active, so a command never runs against an unintended default. It is +// surfaced to the agent as an actionable run_cli tool error. +var errNoActiveTarget = errors.New("no active target; call set_active_target to choose one") + // subprocessEnv builds the explicit, minimal environment for a provider CLI // invocation: only the base names plus the provider's declared passthrough // names, read from the launcher-controlled process env. Everything else is diff --git a/pkg/mcp/cloud/tools_test.go b/pkg/mcp/cloud/tools_test.go index 1da6d4f..fa4b3e0 100644 --- a/pkg/mcp/cloud/tools_test.go +++ b/pkg/mcp/cloud/tools_test.go @@ -2,8 +2,10 @@ package cloud import ( "context" + "strings" "testing" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -18,6 +20,32 @@ func newTestServer(t *testing.T, p Provider, opts ...func(*Options)) *Server { return srv } +// errText reads the text content of a tool error result. +func errText(res *mcp.CallToolResult) string { + var b strings.Builder + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + b.WriteString(tc.Text) + } + } + return b.String() +} + +func TestRunCLIRequiresActiveTargetWhenMultiple(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "a"}, {ID: "b"}}, binary: "/bin/echo", + allowlist: &CommandAllowlist{Commands: []Command{{Path: "echo"}}}}) + res, _, _ := s.runCLI(context.Background(), nil, RunCLIInput{Argv: []string{"echo", "x"}}) + require.True(t, res.IsError) + require.Contains(t, errText(res), "set_active_target") + + require.NoError(t, s.setActive("a")) + res2, out2, err2 := s.runCLI(context.Background(), nil, RunCLIInput{Argv: []string{"echo", "x"}}) + require.NoError(t, err2) + require.Nil(t, res2, "with an active target the command runs (no error result)") + require.Contains(t, out2.Stdout, "x") +} + func TestSetActiveTargetTool(t *testing.T) { t.Parallel() s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", AssumedIdentity: "ro@acct-1", Valid: true}}) From 0de60fe1added7e4b0c91e81d4ec1dabf9049b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 04:57:40 +0200 Subject: [PATCH 5/6] feat(cloud): session_status reports the active target Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/provider.go | 5 +++++ pkg/mcp/cloud/tools_status.go | 1 + pkg/mcp/cloud/tools_target.go | 1 + pkg/mcp/cloud/tools_test.go | 8 ++++++++ 4 files changed, 15 insertions(+) diff --git a/pkg/mcp/cloud/provider.go b/pkg/mcp/cloud/provider.go index c3219a8..c354af9 100644 --- a/pkg/mcp/cloud/provider.go +++ b/pkg/mcp/cloud/provider.go @@ -82,6 +82,11 @@ type IdentityStatus struct { AssumedIdentity string `json:"assumed_identity"` Valid bool `json:"valid"` Hint string `json:"hint,omitempty"` + // ActiveTarget is the project (gcp) or account (aws) run_cli currently runs + // against. The probe leaves it empty; the server fills it from its + // active-target state so session_status reports the identity and the target + // together. + ActiveTarget string `json:"active_target,omitempty"` } // CLIResult is the result of one run_cli invocation. It carries the provider diff --git a/pkg/mcp/cloud/tools_status.go b/pkg/mcp/cloud/tools_status.go index 02ecaca..60d600c 100644 --- a/pkg/mcp/cloud/tools_status.go +++ b/pkg/mcp/cloud/tools_status.go @@ -24,5 +24,6 @@ func (s *Server) sessionStatus(ctx context.Context, _ *mcp.CallToolRequest, _ Se if err != nil { return errorResult(err.Error()), SessionStatusOutput{}, nil } + st.ActiveTarget = s.activeTarget return nil, st, nil } diff --git a/pkg/mcp/cloud/tools_target.go b/pkg/mcp/cloud/tools_target.go index aa4d8c2..87d477b 100644 --- a/pkg/mcp/cloud/tools_target.go +++ b/pkg/mcp/cloud/tools_target.go @@ -26,5 +26,6 @@ func (s *Server) setActiveTarget(ctx context.Context, _ *mcp.CallToolRequest, in return errorResult(fmt.Sprintf("set_active_target rejected: %v", err)), SetActiveTargetOutput{}, nil } st, _ := Probe(ctx, s.provider, s.expectedIdentity, s.subprocessEnv()) + st.ActiveTarget = s.activeTarget return nil, st, nil } diff --git a/pkg/mcp/cloud/tools_test.go b/pkg/mcp/cloud/tools_test.go index fa4b3e0..3ca7030 100644 --- a/pkg/mcp/cloud/tools_test.go +++ b/pkg/mcp/cloud/tools_test.go @@ -46,6 +46,14 @@ func TestRunCLIRequiresActiveTargetWhenMultiple(t *testing.T) { require.Contains(t, out2.Stdout, "x") } +func TestSessionStatusReportsActiveTarget(t *testing.T) { + t.Parallel() + s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", AssumedIdentity: "ro@acct-1", Valid: true}}) + require.NoError(t, s.setActive("acct-1")) + _, out, _ := s.sessionStatus(context.Background(), nil, SessionStatusInput{}) + require.Equal(t, "acct-1", out.ActiveTarget) +} + func TestSetActiveTargetTool(t *testing.T) { t.Parallel() s := newTestServer(t, &fakeProvider{targets: []Target{{ID: "acct-1"}}, identity: IdentityStatus{Provider: "fake", AssumedIdentity: "ro@acct-1", Valid: true}}) From f08da1ef30cba30f562742af7609a3ad95f8c206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Sun, 31 May 2026 05:02:14 +0200 Subject: [PATCH 6/6] feat(cloud): real providers satisfy the active-target interface The Provider interface gained ConfiguredTargets and ActiveTargetEnv, so the gcp and aws realizations and the providers-package probe double must implement them to keep the tree compiling. gcp pins CLOUDSDK_CORE_PROJECT and returns no configured set (its set is scope/inventory); aws pins AWS_PROFILE. The deployment-configured aws accounts list arrives with the AWS accounts config. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/mcp/cloud/providers/aws/provider.go | 13 +++++++++++++ pkg/mcp/cloud/providers/gcp/provider.go | 12 ++++++++++++ pkg/mcp/cloud/providers/probe_test.go | 12 +++++++----- pkg/mcp/cloud/server.go | 2 +- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/pkg/mcp/cloud/providers/aws/provider.go b/pkg/mcp/cloud/providers/aws/provider.go index 85da7af..d4527b7 100644 --- a/pkg/mcp/cloud/providers/aws/provider.go +++ b/pkg/mcp/cloud/providers/aws/provider.go @@ -119,6 +119,19 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { } } +// ConfiguredTargets is the deployment-configured account set. The single-account +// deployment carries no accounts list, so the selectable set comes from the +// server's inventory; the multi-account accounts list arrives with the AWS +// accounts config. +func (p *Provider) ConfiguredTargets() []cloud.Target { return nil } + +// ActiveTargetEnv pins the aws CLI to the active account via AWS_PROFILE, the +// generated assume-role profile for that account. The value is a profile name, +// not a credential: the CLI performs the assume-role from the operator's base. +func (p *Provider) ActiveTargetEnv(id string) []string { + return []string{EnvProfile + "=" + id} +} + // EnvPassthrough lists the env var NAMES the aws subprocess needs forwarded: // AWS_PROFILE pins the assume-role identity; the region and config-file names // let the launcher point the CLI at the right account/config without the agent diff --git a/pkg/mcp/cloud/providers/gcp/provider.go b/pkg/mcp/cloud/providers/gcp/provider.go index 3b819ea..23d7875 100644 --- a/pkg/mcp/cloud/providers/gcp/provider.go +++ b/pkg/mcp/cloud/providers/gcp/provider.go @@ -97,6 +97,18 @@ func (p *Provider) DenyFloorAdditions() cloud.DenyFloor { } } +// ConfiguredTargets is empty for gcp: the selectable set comes from the +// server's scope projects or live inventory, not the provider's own config. +func (p *Provider) ConfiguredTargets() []cloud.Target { return nil } + +// ActiveTargetEnv pins gcloud to the active project via CLOUDSDK_CORE_PROJECT, +// the default project for every command that takes one. One impersonated +// identity spans the allowlisted projects, so switching changes only the +// project, never the identity. +func (p *Provider) ActiveTargetEnv(id string) []string { + return []string{"CLOUDSDK_CORE_PROJECT=" + id} +} + // EnvPassthrough names the gcloud env vars the subprocess needs: the pinned // impersonation target plus the config and active-project locations. PATH and // HOME are forwarded by the harness base set, so they are absent here. diff --git a/pkg/mcp/cloud/providers/probe_test.go b/pkg/mcp/cloud/providers/probe_test.go index 8d54a8b..3b4a316 100644 --- a/pkg/mcp/cloud/providers/probe_test.go +++ b/pkg/mcp/cloud/providers/probe_test.go @@ -18,14 +18,16 @@ import ( // killed by the deadline. It lets the timeout be observed without a real sleep. type blockingProvider struct{} -func (blockingProvider) Name() string { return "gcp" } -func (blockingProvider) Binary() string { return "/bin/true" } -func (blockingProvider) DefaultAllowlist() *cloud.CommandAllowlist { return &cloud.CommandAllowlist{} } -func (blockingProvider) DenyFloorAdditions() cloud.DenyFloor { return cloud.DenyFloor{} } -func (blockingProvider) EnvPassthrough() []string { return nil } +func (blockingProvider) Name() string { return "gcp" } +func (blockingProvider) Binary() string { return "/bin/true" } +func (blockingProvider) DefaultAllowlist() *cloud.CommandAllowlist { return &cloud.CommandAllowlist{} } +func (blockingProvider) DenyFloorAdditions() cloud.DenyFloor { return cloud.DenyFloor{} } +func (blockingProvider) EnvPassthrough() []string { return nil } func (blockingProvider) Inventory(context.Context, cloud.RunFunc) (cloud.Inventory, error) { return cloud.Inventory{}, nil } +func (blockingProvider) ConfiguredTargets() []cloud.Target { return nil } +func (blockingProvider) ActiveTargetEnv(string) []string { return nil } func (blockingProvider) Identity(ctx context.Context, _ cloud.RunFunc, _ string) (cloud.IdentityStatus, error) { <-ctx.Done() diff --git a/pkg/mcp/cloud/server.go b/pkg/mcp/cloud/server.go index e934b7d..6b6e452 100644 --- a/pkg/mcp/cloud/server.go +++ b/pkg/mcp/cloud/server.go @@ -103,7 +103,7 @@ func (s *Server) selectableTargets(ctx context.Context) []Target { } out := make([]Target, 0, len(inv.Scopes)) for _, sc := range inv.Scopes { - out = append(out, Target{ID: sc.ID, Name: sc.Name}) + out = append(out, Target(sc)) } return out }