Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
13a1243
docs(spec): read-only cloud-context MCP (GCP and AWS) design
sourcehawk May 30, 2026
74e86cf
docs(plan): cloud-context MCP implementation plan and orchestration s…
sourcehawk May 30, 2026
c32b8fe
chore(state): enter developing, dispatch #45 scaffold wave
sourcehawk May 30, 2026
6a15a36
docs(claude): require testify assertions in tests; log #45 env + test…
sourcehawk May 30, 2026
2a0d6d4
feat(cloud): scaffold the read-only cloud-context MCP package and saf…
sourcehawk May 30, 2026
98f7d40
chore(state): #45 self-merged (#48), contracts locked, Phase 2 unblocked
sourcehawk May 30, 2026
189362d
chore(state): dispatch Wave 2 (#43/#46/#47); log serve.go resource co…
sourcehawk May 30, 2026
7a36b2a
chore(state): re-sequence #47 to Wave 2b (depends on #43+#46); add pr…
sourcehawk May 30, 2026
3b7dcc2
feat(cloud/gcp): GCP provider for the cloud-context MCP (#49)
sourcehawk May 30, 2026
cffa192
chore(state): #43 self-merged (#49); log probe-env, expected-identity…
sourcehawk May 30, 2026
09c0946
feat(cloud/aws): AWS provider for the cloud-context MCP (#50)
sourcehawk May 30, 2026
5aa093d
chore(state): #46 self-merged (#50); Wave 2a complete
sourcehawk May 30, 2026
72c9afa
chore(state): Wave 2a checkpoint clean; dispatch Wave 2b (#47 + probe…
sourcehawk May 30, 2026
16aca72
fix(cloud): probe with a minimal subprocess env instead of inheriting…
sourcehawk May 30, 2026
60185f3
chore(state): probe-env remediation self-merged (#51)
sourcehawk May 30, 2026
9f8c72f
feat(cloud): launcher integration for the cloud-context MCP (#52)
sourcehawk May 30, 2026
c921b13
chore(state): #47 self-merged (#52); all sub-PRs merged, status=review
sourcehawk May 30, 2026
3ffad0f
chore(state): integration PR #53 opened
sourcehawk May 30, 2026
c714e40
chore: remove cloud-context-mcp scratch plan and state (shipped)
sourcehawk May 30, 2026
eddc127
docs(cloud): document configuring the GCP and AWS cloud providers (#54)
sourcehawk May 30, 2026
a31ff0c
refactor(cloud): thread probe identity explicitly instead of pinning …
sourcehawk May 30, 2026
25986bd
fix(cloud): enforce cloud-source degrade and carry the source alias (…
sourcehawk May 30, 2026
435219e
fix(cloud): address review findings in the harness, allowlist, and pr…
sourcehawk May 30, 2026
ff3d568
fix(cloud): second review pass — GCP probe regression, profile valida…
sourcehawk May 30, 2026
9f59281
docs(cloud): recommend a minimal read-only IAM grant for the pinned i…
sourcehawk May 30, 2026
de57731
fix(cloud): third review pass — deny-floor nested exfil paths, file-f…
sourcehawk May 30, 2026
ad8b311
fix(cloud): close deny-floor prefix bypass and bound the identity pro…
sourcehawk May 31, 2026
a32040f
docs(cloud): clarify scope is a guardrail and read-only rests on the …
sourcehawk May 31, 2026
453a5ac
fix(cloud): review round 2 — bounded output, degraded-identity report…
sourcehawk May 31, 2026
5117d4c
docs(spec): bounded target selection (set_active_target) for the clou…
sourcehawk May 31, 2026
b6c4c5a
docs(spec): keep the base cloud-context ADR as-built; switching desig…
sourcehawk May 31, 2026
267a752
docs(spec): cloud active-target-selection ADR (extends the cloud-cont…
sourcehawk May 31, 2026
6ab5072
docs(plan): implementation plan for cloud active-target selection
sourcehawk May 31, 2026
ab11261
feat(cloud): active-target selection core (set_active_target) (#67)
sourcehawk May 31, 2026
2c01ab1
feat(cloud): AWS multi-account active-target wiring and docs (#68)
sourcehawk May 31, 2026
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ These skills live in the `feature-dev-workflow` plugin (`github.com/sourcehawk/f
## Operational rules

- **TDD is the standard.** Failing test → watch it fail for the right reason → implement. One commit per task.
- **Tests assert with `testify`.** Use `github.com/stretchr/testify/assert` for checks the test should keep running past, and `require` for preconditions a failure must stop at (a non-nil error before a dereference, setup that must succeed). Bare `t.Fatal` / `t.Errorf` is the rare exception, not the default.
- **Before claiming done: `make test` + `make lint`; if `frontend/` touched, also `cd frontend && npm run typecheck`.** CI gates all three; local is the cheapest place to catch failures. Race-clean is non-negotiable.
- **Commit conventions:** `feat(<area>): ...`, `fix(<area>): ...`, `refactor(<area>): ...`, `test(<area>): ...`, `chore(<area>): ...`. Area mirrors the module path.
- **Never `--no-verify`, never `git add -A` / `git add .`.** Stage by name; pre-commit hooks exist for a reason.
Expand Down
145 changes: 120 additions & 25 deletions cmd/triagent-mcp/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ package main

import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"strings"
"syscall"

"github.com/charmbracelet/log"
"github.com/sourcehawk/triagent/pkg/mcp/agentoperator"
"github.com/sourcehawk/triagent/pkg/mcp/cloud"
"github.com/sourcehawk/triagent/pkg/mcp/cloud/providers"
"github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws"
"github.com/sourcehawk/triagent/pkg/mcp/git"
"github.com/sourcehawk/triagent/pkg/mcp/incidentio"
"github.com/sourcehawk/triagent/pkg/mcp/k8s"
Expand All @@ -21,28 +26,27 @@ import (
"github.com/sourcehawk/triagent/pkg/mcp/strategies"
"github.com/sourcehawk/triagent/pkg/mcp/teleport"
"github.com/sourcehawk/triagent/pkg/mcp/wiki"
"github.com/charmbracelet/log"
"github.com/spf13/cobra"
)

// Environment variable names. Flags override env when both are set.
const (
envKubeconfig = "TRIAGENT_MCP_KUBECONFIG"
envCRDsFile = "TRIAGENT_MCP_CRDS_FILE"
envCrossplaneGroups = "TRIAGENT_MCP_CROSSPLANE_GROUPS"
envSessionDir = "TRIAGENT_MCP_SESSION_DIR"
envUserPlaybooksDir = "TRIAGENT_MCP_USER_PLAYBOOKS_DIR"
envPluginPlaybooksDir = "TRIAGENT_MCP_PLUGIN_PLAYBOOKS_DIR"
envSystemPlaybooksDir = "TRIAGENT_MCP_SYSTEM_PLAYBOOKS_DIR"
envStrategiesSubagentModel = "TRIAGENT_MCP_STRATEGIES_SUBAGENT_MODEL"
envMCPConfigPath = "TRIAGENT_MCP_CONFIG_PATH"
envGitRepo = "TRIAGENT_MCP_GIT_REPO"
envGitCacheDir = "TRIAGENT_MCP_GIT_CACHE_DIR"
envGitClaudeBinary = "TRIAGENT_MCP_GIT_CLAUDE_BINARY"
envGitFilterPrereleases = "TRIAGENT_MCP_GIT_FILTER_PRERELEASES"
envWikiServePath = "TRIAGENT_MCP_WIKI_PATH"
envWikiServeProposalsPath = "TRIAGENT_MCP_WIKI_PROPOSALS_PATH"
envWikiServeClaudeBinary = "TRIAGENT_MCP_WIKI_CLAUDE_BINARY"
envKubeconfig = "TRIAGENT_MCP_KUBECONFIG"
envCRDsFile = "TRIAGENT_MCP_CRDS_FILE"
envCrossplaneGroups = "TRIAGENT_MCP_CROSSPLANE_GROUPS"
envSessionDir = "TRIAGENT_MCP_SESSION_DIR"
envUserPlaybooksDir = "TRIAGENT_MCP_USER_PLAYBOOKS_DIR"
envPluginPlaybooksDir = "TRIAGENT_MCP_PLUGIN_PLAYBOOKS_DIR"
envSystemPlaybooksDir = "TRIAGENT_MCP_SYSTEM_PLAYBOOKS_DIR"
envStrategiesSubagentModel = "TRIAGENT_MCP_STRATEGIES_SUBAGENT_MODEL"
envMCPConfigPath = "TRIAGENT_MCP_CONFIG_PATH"
envGitRepo = "TRIAGENT_MCP_GIT_REPO"
envGitCacheDir = "TRIAGENT_MCP_GIT_CACHE_DIR"
envGitClaudeBinary = "TRIAGENT_MCP_GIT_CLAUDE_BINARY"
envGitFilterPrereleases = "TRIAGENT_MCP_GIT_FILTER_PRERELEASES"
envWikiServePath = "TRIAGENT_MCP_WIKI_PATH"
envWikiServeProposalsPath = "TRIAGENT_MCP_WIKI_PROPOSALS_PATH"
envWikiServeClaudeBinary = "TRIAGENT_MCP_WIKI_CLAUDE_BINARY"

envSessionsProposalsPath = "TRIAGENT_MCP_SESSIONS_PROPOSALS_PATH"
envSessionsClaudeBinary = "TRIAGENT_MCP_SESSIONS_CLAUDE_BINARY"
Expand Down Expand Up @@ -71,10 +75,10 @@ type serveFlags struct {
systemPlaybooksDir string

// git flags
gitRepo string
gitCacheDir string
gitClaudeBinary string
gitFilterPrereleases bool
gitRepo string
gitCacheDir string
gitClaudeBinary string
gitFilterPrereleases bool

// wiki flags
wikiPath string
Expand All @@ -95,6 +99,9 @@ type serveFlags struct {
promURL string
promBearer string
promBasic string

// cloud flags
cloudProvider string
}

func serveCmd() *cobra.Command {
Expand All @@ -104,14 +111,14 @@ func serveCmd() *cobra.Command {
Short: "Run one of the triagent-mcp MCP servers over stdio",
Long: "Run one of the triagent-mcp MCP servers over stdio.\n\n" +
"Select the server via --kind. Supported kinds:\n" +
" k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom",
" k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, cloud",
Hidden: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runServe(cmd.Context(), resolveFlags(f))
},
}
cmd.Flags().StringVar(&f.kind, "kind", "", "which MCP server to run: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, or prom (required)")
cmd.Flags().StringVar(&f.kind, "kind", "", "which MCP server to run: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, or cloud (required)")

// k8s flags
cmd.Flags().StringVar(&f.kubeconfig, "kubeconfig", "", "path to kubeconfig (defaults to $"+envKubeconfig+", then $KUBECONFIG, then ~/.kube/config) [kind=k8s]")
Expand Down Expand Up @@ -150,6 +157,9 @@ func serveCmd() *cobra.Command {
cmd.Flags().StringVar(&f.promBearer, "prom-bearer", "", "Authorization: Bearer token for Prometheus (defaults to $"+envPromBearer+") [kind=prom]")
cmd.Flags().StringVar(&f.promBasic, "prom-basic", "", "Basic auth credentials user:pass for Prometheus (defaults to $"+envPromBasic+") [kind=prom]")

// cloud flags
cmd.Flags().StringVar(&f.cloudProvider, "provider", "", "cloud provider to serve: gcp or aws; required (defaults to $"+cloud.EnvProvider+") [kind=cloud]")

return cmd
}

Expand Down Expand Up @@ -215,6 +225,9 @@ func resolveFlags(f *serveFlags) serveFlags {
if out.promBasic == "" {
out.promBasic = os.Getenv(envPromBasic)
}
if out.cloudProvider == "" {
out.cloudProvider = os.Getenv(cloud.EnvProvider)
}
// Bool env override: only consider when the operator hasn't passed
// the flag explicitly. Cobra preserves the flag default (true) when
// unset, so we can't distinguish "operator passed --filter-prereleases=true"
Expand Down Expand Up @@ -263,10 +276,12 @@ func runServe(ctx context.Context, f serveFlags) error {
return runParallel(ctx, f)
case "prom":
return runProm(ctx, f)
case "cloud":
return runCloud(ctx, f)
case "":
return fmt.Errorf("--kind is required (one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom)")
return fmt.Errorf("--kind is required (one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, cloud)")
default:
return fmt.Errorf("unknown --kind %q (want one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom)", f.kind)
return fmt.Errorf("unknown --kind %q (want one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, cloud)", f.kind)
}
}

Expand Down Expand Up @@ -423,6 +438,86 @@ func runProm(ctx context.Context, f serveFlags) error {
return srv.Run(ctx)
}

// runCloud wires the read-only cloud-context MCP. --provider selects the
// concrete backend; New plugs it in behind cloud.Provider. The launcher passes
// the allowlist override path, target scope, and pinned identity through the
// subprocess env (cloud.EnvAllowlistPath, cloud.EnvScope,
// cloud.EnvExpectedIdentity), never argv. A multi-account aws source additionally
// carries its accounts, source_profile, and alias (cloud.EnvAWSAccounts,
// _SOURCE_PROFILE, _ALIAS); New generates the per-account assume-role profiles
// and surfaces the accounts as the agent's selectable targets.
func runCloud(ctx context.Context, f serveFlags) error {
if f.cloudProvider == "" {
return fmt.Errorf("--provider is required (gcp or aws) (set --provider or $%s)", cloud.EnvProvider)
}
scope, err := parseCloudScope(os.Getenv(cloud.EnvScope))
if err != nil {
return fmt.Errorf("build cloud mcp server: %w", err)
}
accounts, err := parseAWSAccounts(os.Getenv(cloud.EnvAWSAccounts))
if err != nil {
return fmt.Errorf("build cloud mcp server: %w", err)
}
provider, err := providers.New(f.cloudProvider, providers.Options{
AWSAlias: os.Getenv(cloud.EnvAWSAlias),
AWSSourceProfile: os.Getenv(cloud.EnvAWSSourceProfile),
AWSAccounts: accounts,
})
if err != nil {
return err
}
srv, err := cloud.New(cloud.Options{
Provider: provider,
AllowlistPath: os.Getenv(cloud.EnvAllowlistPath),
Scope: scope,
ExpectedIdentity: os.Getenv(cloud.EnvExpectedIdentity),
})
if err != nil {
return fmt.Errorf("build cloud mcp server: %w", err)
}
log.Info("mcp serve --kind=cloud starting", "provider", f.cloudProvider)
return srv.Run(ctx)
}

// parseCloudScope decodes the JSON-encoded target scope the launcher froze into
// a cloud.ScopeAllowlist. An empty value yields an empty scope, which leaves the
// target axes unconstrained. A malformed value is an error that aborts startup:
// failing closed, since a misconfigured scope must never silently widen run_cli
// by dropping the deployment's restrictions.
func parseCloudScope(raw string) (cloud.ScopeAllowlist, error) {
var scope cloud.ScopeAllowlist
if raw == "" {
return scope, nil
}
if err := json.Unmarshal([]byte(raw), &scope); err != nil {
return cloud.ScopeAllowlist{}, fmt.Errorf("malformed cloud scope in $%s: %w", cloud.EnvScope, err)
}
return scope, nil
}

// parseAWSAccounts decodes the JSON-encoded aws multi-account set the launcher
// froze into the aws provider's account list. An empty value yields nil, the
// single-account / single-identity form. A malformed value is an error that
// aborts startup: failing closed, since a misconfigured accounts list must never
// silently drop accounts the agent should be able to select.
func parseAWSAccounts(raw string) ([]aws.Account, error) {
if raw == "" {
return nil, nil
}
var wire []struct {
AccountID string `json:"account_id"`
RoleARN string `json:"role_arn"`
}
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
return nil, fmt.Errorf("malformed cloud aws accounts in $%s: %w", cloud.EnvAWSAccounts, err)
}
accounts := make([]aws.Account, 0, len(wire))
for _, w := range wire {
accounts = append(accounts, aws.Account{ID: w.AccountID, RoleARN: w.RoleARN})
}
return accounts, nil
}

func runGit(ctx context.Context, f serveFlags) error {
if f.gitRepo == "" {
return fmt.Errorf("--repo is required (owner/name) (set --repo or $%s)", envGitRepo)
Expand Down
97 changes: 97 additions & 0 deletions cmd/triagent-mcp/serve_cloud_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRunServe_CloudKindRequiresProvider(t *testing.T) {
t.Parallel()
err := runServe(context.Background(), serveFlags{kind: "cloud"})
require.Error(t, err, "expected error when --provider is missing")
assert.Contains(t, err.Error(), "provider", "error should mention --provider")
}

func TestRunServe_CloudKindRejectsUnknownProvider(t *testing.T) {
t.Parallel()
err := runServe(context.Background(), serveFlags{kind: "cloud", cloudProvider: "azure"})
require.Error(t, err, "expected error for an unknown provider")
assert.Contains(t, err.Error(), "azure", "error should name the rejected provider")
}

func TestRunServe_UnknownKindErrorListsCloud(t *testing.T) {
t.Parallel()
err := runServe(context.Background(), serveFlags{kind: "bogus"})
require.Error(t, err, "expected error for unknown kind")
assert.Contains(t, err.Error(), "cloud", "kind list should include cloud")
}

func TestServeCmd_KnowsCloudKind(t *testing.T) {
t.Parallel()
cmd := serveCmd()
assert.Contains(t, cmd.Long, "cloud", "serve --help should list cloud")
}

func TestParseCloudScope_EmptyYieldsUnconstrained(t *testing.T) {
t.Parallel()
scope, err := parseCloudScope("")
require.NoError(t, err)
assert.Empty(t, scope.Projects)
assert.Empty(t, scope.Regions)
assert.Empty(t, scope.Accounts)
}

func TestParseCloudScope_ValidJSON(t *testing.T) {
t.Parallel()
scope, err := parseCloudScope(`{"projects":["prod"],"regions":["us-central1"]}`)
require.NoError(t, err)
assert.Equal(t, []string{"prod"}, scope.Projects)
assert.Equal(t, []string{"us-central1"}, scope.Regions)
}

func TestParseCloudScope_MalformedFailsClosed(t *testing.T) {
t.Parallel()
_, err := parseCloudScope(`{"projects":`)
require.Error(t, err, "a malformed scope must fail closed, not silently drop restrictions")
}

func TestRunCloud_MalformedScopeAborts(t *testing.T) {
t.Setenv("TRIAGENT_CLOUD_PROVIDER", "gcp")
t.Setenv("TRIAGENT_CLOUD_SCOPE", `{"projects":`)
err := runCloud(context.Background(), serveFlags{kind: "cloud", cloudProvider: "gcp"})
require.Error(t, err, "a malformed scope must abort cloud-server startup")
assert.Contains(t, err.Error(), "scope", "the error should name the scope")
}

func TestParseAWSAccounts_EmptyYieldsNil(t *testing.T) {
t.Parallel()
accs, err := parseAWSAccounts("")
require.NoError(t, err)
assert.Nil(t, accs)
}

func TestParseAWSAccounts_DecodesJSON(t *testing.T) {
t.Parallel()
accs, err := parseAWSAccounts(`[{"account_id":"111111111111","role_arn":"arn:aws:iam::111111111111:role/r"},{"account_id":"222222222222","role_arn":"arn:aws:iam::222222222222:role/r"}]`)
require.NoError(t, err)
require.Len(t, accs, 2)
assert.Equal(t, "111111111111", accs[0].ID)
assert.Equal(t, "arn:aws:iam::222222222222:role/r", accs[1].RoleARN)
}

func TestParseAWSAccounts_MalformedFailsClosed(t *testing.T) {
t.Parallel()
_, err := parseAWSAccounts(`[{"account_id":`)
require.Error(t, err, "a malformed accounts list must fail closed, not silently drop accounts")
}

func TestRunCloud_MalformedAWSAccountsAborts(t *testing.T) {
t.Setenv("TRIAGENT_CLOUD_PROVIDER", "aws")
t.Setenv("TRIAGENT_CLOUD_AWS_ACCOUNTS", `[{"account_id":`)
err := runCloud(context.Background(), serveFlags{kind: "cloud", cloudProvider: "aws"})
require.Error(t, err, "a malformed accounts list must abort cloud-server startup")
assert.Contains(t, err.Error(), "accounts", "the error should name the accounts")
}
Loading
Loading