diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index 601a871..0e715aa 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -599,6 +599,10 @@ func (r *Runner) Run(ctx context.Context) error { // and persisted in the registration record. mcpRegisteredOAuthHosts // applies the store-path override and reads those hosts back. egressDomains = append(egressDomains, mcpRegisteredOAuthHosts(r.cfg.Config.MCP)...) + // §19: the platform token resolver must be reachable for + // auth.type=platform servers — merge its host (env-expanded; the + // endpoint may be materialized as ${VAR}). + egressDomains = append(egressDomains, platformResolverHost(r.cfg.Config.Platform)...) // Phase 6 (#107 / #108) — same for the OTel collector. Without // this, dev runs with `observability.tracing.enabled: true` and // `egress.mode: allowlist` would silently drop spans on shutdown. diff --git a/forge-cli/runtime/runner_mcp.go b/forge-cli/runtime/runner_mcp.go index c8e31d3..3c25ef1 100644 --- a/forge-cli/runtime/runner_mcp.go +++ b/forge-cli/runtime/runner_mcp.go @@ -3,6 +3,7 @@ package runtime import ( "context" "net/http" + "net/url" "os" "github.com/initializ/forge/forge-core/llm/oauth" @@ -117,6 +118,7 @@ func (r *Runner) startMCPManager( Logger: r.logger, Audit: auditLogger, OAuth: flow, + Platform: r.cfg.Config.Platform, }) if err != nil { return nil, err @@ -134,3 +136,17 @@ func (r *Runner) startMCPManager( }) return mgr, nil } + +// platformResolverHost returns the platform token-resolver host for the +// egress allowlist (auth.type=platform servers fetch tokens from it). +// Empty when no platform block is configured. +func platformResolverHost(p *types.PlatformConfig) []string { + if p == nil || p.TokenEndpoint == "" { + return nil + } + u, err := url.Parse(os.Expand(p.TokenEndpoint, os.Getenv)) + if err != nil || u.Hostname() == "" { + return nil + } + return []string{u.Hostname()} +} diff --git a/forge-core/mcp/manager.go b/forge-core/mcp/manager.go index a03641b..870ee64 100644 --- a/forge-core/mcp/manager.go +++ b/forge-core/mcp/manager.go @@ -55,6 +55,11 @@ type ManagerDeps struct { // auth.type=oauth. Required when at least one such server exists // in cfg; otherwise may be nil. OAuth *OAuthFlow + + // Platform is the managed token-resolver wiring (ForgeConfig.Platform), + // required by servers with auth.type=platform; otherwise may be nil. + // MUST stay field-identical with ServerDeps (type conversion below). + Platform *types.PlatformConfig } // NewManager constructs a Manager. Fails fast if config validation diff --git a/forge-core/mcp/oauth_clientcreds_test.go b/forge-core/mcp/oauth_clientcreds_test.go index bce193c..77c786a 100644 --- a/forge-core/mcp/oauth_clientcreds_test.go +++ b/forge-core/mcp/oauth_clientcreds_test.go @@ -96,7 +96,7 @@ func TestBuildAuthFn_ClientCredentials_ResolvesSecretFromEnv(t *testing.T) { ClientID: "a", ClientSecretEnv: "MY_MCP_SECRET", TokenURL: srv.URL, }, } - authFn := buildAuthFn(spec, flow) + authFn := buildAuthFn(spec, ServerDeps{OAuth: flow}) if authFn == nil { t.Fatal("buildAuthFn returned nil for a client_credentials server") } @@ -131,7 +131,7 @@ func TestBuildAuthFn_ClientCredentials_EmptySecret(t *testing.T) { ClientID: "a", ClientSecretEnv: "UNSET_MCP_SECRET", TokenURL: srv.URL, }, } - _, err := buildAuthFn(spec, flow)(context.Background()) + _, err := buildAuthFn(spec, ServerDeps{OAuth: flow})(context.Background()) if err == nil || !strings.Contains(err.Error(), "resolved to an empty value") { t.Fatalf("want a clear empty-secret error, got %v", err) } diff --git a/forge-core/mcp/platform_token.go b/forge-core/mcp/platform_token.go new file mode 100644 index 0000000..c787715 --- /dev/null +++ b/forge-core/mcp/platform_token.go @@ -0,0 +1,143 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" +) + +// Platform token resolver — the managed half of the resolver seam (design +// §18/§19). An MCP server with auth.type "platform" runs under the +// AGENT-PRINCIPAL (service) identity: Forge POSTs the platform token +// endpoint with the agent's platform credential and receives a SHORT-LIVED +// access token for that server. The resource refresh token never reaches +// the agent (invariant 8) — the platform holds it and refreshes on our +// behalf. Startup-viable: no human, no browser, no stored token. +// +// The token is cached to its TTL and re-fetched on expiry; concurrent +// refreshes collapse behind one flight. Endpoint + identity support ${VAR} +// env expansion resolved at request time, so a rotated pod secret takes +// effect without a restart (same pattern as auth.token_env). + +// platformTokenSkew re-fetches slightly before nominal expiry so a token +// is never presented in its final moments. +const platformTokenSkew = 30 * time.Second + +// defaultPlatformTokenTTL applies when the endpoint omits expires_in. +const defaultPlatformTokenTTL = 5 * time.Minute + +type platformTokenSource struct { + endpoint string // raw, ${VAR}-expandable + identity string // raw, ${VAR}-expandable + ref string // registry entry ref the platform authorizes against + client *http.Client + + mu sync.Mutex + token string + expiresAt time.Time +} + +func newPlatformTokenSource(cfg PlatformSourceConfig) *platformTokenSource { + return &platformTokenSource{ + endpoint: cfg.TokenEndpoint, + identity: cfg.AgentIdentity, + ref: cfg.Ref, + client: cfg.HTTPClient, + } +} + +// PlatformSourceConfig carries what a platform-auth server needs. +type PlatformSourceConfig struct { + TokenEndpoint string + AgentIdentity string + Ref string + HTTPClient *http.Client +} + +// Token returns a valid access token, fetching from the platform when the +// cache is empty or expiring. +func (p *platformTokenSource) Token(ctx context.Context) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.token != "" && time.Now().Before(p.expiresAt.Add(-platformTokenSkew)) { + return p.token, nil + } + tok, ttl, err := p.fetch(ctx) + if err != nil { + return "", err + } + p.token = tok + p.expiresAt = time.Now().Add(ttl) + return tok, nil +} + +func (p *platformTokenSource) fetch(ctx context.Context) (string, time.Duration, error) { + endpoint := expandEnvVars(p.endpoint) + identity := expandEnvVars(p.identity) + if endpoint == "" { + return "", 0, fmt.Errorf("%w: platform.token_endpoint is empty (or its env var is unset)", ErrProtocolError) + } + if identity == "" { + return "", 0, fmt.Errorf("%w: platform.agent_identity is empty (or its env var is unset) — the platform did not materialize the agent credential", ErrProtocolError) + } + + body, err := json.Marshal(map[string]string{"server": p.ref}) + if err != nil { + return "", 0, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) + if err != nil { + return "", 0, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+identity) + + client := p.client + if client == nil { + client = &http.Client{Timeout: 15 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return "", 0, fmt.Errorf("%w: platform token request failed: %v", ErrTransportUnavailable, err) + } + defer func() { _ = resp.Body.Close() }() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("%w: platform token endpoint returned %d for server %q: %s", + ErrProtocolError, resp.StatusCode, p.ref, strings.TrimSpace(string(data))) + } + var out struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + // A refresh_token here would violate invariant 8 — the platform + // must never send one; if it does, it is deliberately ignored. + } + if err := json.Unmarshal(data, &out); err != nil { + return "", 0, fmt.Errorf("%w: parsing platform token response: %v", ErrProtocolError, err) + } + if out.AccessToken == "" { + return "", 0, fmt.Errorf("%w: platform token response carried no access_token", ErrProtocolError) + } + ttl := defaultPlatformTokenTTL + if out.ExpiresIn > 0 { + ttl = time.Duration(out.ExpiresIn) * time.Second + } + return out.AccessToken, ttl, nil +} + +// expandEnvVars resolves ${VAR}/$VAR against the process env at USE time — +// deliberately not at config load, so rotated pod secrets apply without a +// restart. Mirrors the runner's egress-domain expansion semantics. +func expandEnvVars(s string) string { + if !strings.Contains(s, "$") { + return s + } + return os.Expand(s, os.Getenv) +} diff --git a/forge-core/mcp/platform_token_test.go b/forge-core/mcp/platform_token_test.go new file mode 100644 index 0000000..8d15f32 --- /dev/null +++ b/forge-core/mcp/platform_token_test.go @@ -0,0 +1,186 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/initializ/forge/forge-core/types" +) + +// fake platform resolver: counts hits, asserts the agent credential and +// server ref, and can emit a (forbidden) refresh_token. +func newFakeResolver(t *testing.T, expiresIn int, includeRefresh bool) (*httptest.Server, *int) { + t.Helper() + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + if r.Header.Get("Authorization") != "Bearer agent-cred-1" { + w.WriteHeader(http.StatusUnauthorized) + return + } + var body struct { + Server string `json:"server"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Server != "mcp.atlassian" { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "not entitled to " + body.Server}) + return + } + out := map[string]any{"access_token": "at-1", "expires_in": expiresIn} + if includeRefresh { + out["refresh_token"] = "MUST-NEVER-BE-USED" + } + _ = json.NewEncoder(w).Encode(out) + })) + t.Cleanup(srv.Close) + return srv, &hits +} + +// The agent-principal path: token fetched with the agent identity + server +// ref, cached to TTL (one fetch serves many calls), and any refresh_token +// in the response is ignored — the agent never holds one (invariant 8). +func TestPlatformTokenSource_FetchCacheAndIgnoreRefresh(t *testing.T) { + srv, hits := newFakeResolver(t, 3600, true) + src := newPlatformTokenSource(PlatformSourceConfig{ + TokenEndpoint: srv.URL, + AgentIdentity: "agent-cred-1", + Ref: "mcp.atlassian", + HTTPClient: srv.Client(), + }) + for i := 0; i < 5; i++ { + tok, err := src.Token(context.Background()) + if err != nil { + t.Fatalf("token[%d]: %v", i, err) + } + if tok != "at-1" { + t.Fatalf("token = %q", tok) + } + } + if *hits != 1 { + t.Fatalf("resolver hit %d times for 5 calls — cache broken", *hits) + } +} + +// Expiry triggers a re-fetch (the skew makes a short-TTL token immediately +// stale). +func TestPlatformTokenSource_RefetchOnExpiry(t *testing.T) { + srv, hits := newFakeResolver(t, 1, false) // 1s < 30s skew → always stale + src := newPlatformTokenSource(PlatformSourceConfig{ + TokenEndpoint: srv.URL, AgentIdentity: "agent-cred-1", + Ref: "mcp.atlassian", HTTPClient: srv.Client(), + }) + if _, err := src.Token(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := src.Token(context.Background()); err != nil { + t.Fatal(err) + } + if *hits != 2 { + t.Fatalf("hits = %d, want re-fetch on expiry", *hits) + } +} + +// The resolver's entitlement rejection surfaces with the platform's error +// body — an agent asking for a server it isn't bound to gets refused. +func TestPlatformTokenSource_EntitlementRejectionSurfaces(t *testing.T) { + srv, _ := newFakeResolver(t, 3600, false) + src := newPlatformTokenSource(PlatformSourceConfig{ + TokenEndpoint: srv.URL, AgentIdentity: "agent-cred-1", + Ref: "mcp.other-server", HTTPClient: srv.Client(), + }) + _, err := src.Token(context.Background()) + if err == nil { + t.Fatal("unentitled server must be refused") + } +} + +// ${VAR} in endpoint/identity expands at USE time (rotation without restart). +func TestPlatformTokenSource_EnvExpansion(t *testing.T) { + srv, _ := newFakeResolver(t, 3600, false) + t.Setenv("TEST_PLATFORM_EP", srv.URL) + t.Setenv("TEST_AGENT_CRED", "agent-cred-1") + src := newPlatformTokenSource(PlatformSourceConfig{ + TokenEndpoint: "${TEST_PLATFORM_EP}", AgentIdentity: "${TEST_AGENT_CRED}", + Ref: "mcp.atlassian", HTTPClient: srv.Client(), + }) + if _, err := src.Token(context.Background()); err != nil { + t.Fatalf("env-expanded fetch: %v", err) + } +} + +// Unset identity fails with an actionable error, not a silent +// unauthenticated request. +func TestPlatformTokenSource_MissingIdentityActionable(t *testing.T) { + src := newPlatformTokenSource(PlatformSourceConfig{ + TokenEndpoint: "https://platform.example.com/token", + AgentIdentity: "${UNSET_AGENT_CRED_VAR}", + Ref: "x", + }) + if _, err := src.Token(context.Background()); err == nil { + t.Fatal("missing identity must fail actionably") + } +} + +// NewServer: platform requires the platform block; user cannot be Required; +// user auth fails lazily (ErrNoToken) instead of blocking construction. +func TestNewServer_PlatformAndUserRules(t *testing.T) { + t.Parallel() + base := types.MCPServer{ + Name: "s", Transport: "http", URL: "https://mcp.example.com/mcp", + Tools: types.MCPToolFilter{Allow: []string{"x"}}, + } + deps := ServerDeps{HTTPClient: http.DefaultClient} + + p := base + p.Auth = &types.MCPAuth{Type: "platform"} + if _, err := NewServer(p, deps); err == nil { + t.Fatal("platform without a platform block must fail construction") + } + depsWithPlatform := deps + depsWithPlatform.Platform = &types.PlatformConfig{TokenEndpoint: "https://plat/token", AgentIdentity: "${X}"} + if _, err := NewServer(p, depsWithPlatform); err != nil { + t.Fatalf("platform with block must construct: %v", err) + } + + u := base + u.Auth = &types.MCPAuth{Type: "user"} + u.Required = true + if _, err := NewServer(u, deps); err == nil { + t.Fatal("user + required:true must be rejected") + } + u.Required = false + if _, err := NewServer(u, deps); err != nil { + t.Fatalf("lazy user server must construct: %v", err) + } + + fn := buildAuthFn(u, deps) + if _, err := fn(context.Background()); !errors.Is(err, ErrNoToken) { + t.Fatalf("user auth must fail lazily with ErrNoToken, got: %v", err) + } +} + +// A platform token expiring mid-session re-resolves without any stored +// state — assert no persistence side effects by construction (the source +// holds everything in memory; nothing references the credentials store). +func TestPlatformTokenSource_DefaultTTLWhenOmitted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "at-2"}) + })) + defer srv.Close() + src := newPlatformTokenSource(PlatformSourceConfig{ + TokenEndpoint: srv.URL, AgentIdentity: "c", Ref: "r", HTTPClient: srv.Client(), + }) + tok, err := src.Token(context.Background()) + if err != nil || tok != "at-2" { + t.Fatalf("tok=%q err=%v", tok, err) + } + if time.Until(src.expiresAt) <= 0 { + t.Fatal("default TTL not applied") + } +} diff --git a/forge-core/mcp/server.go b/forge-core/mcp/server.go index 913ff35..432dd76 100644 --- a/forge-core/mcp/server.go +++ b/forge-core/mcp/server.go @@ -87,6 +87,9 @@ type ServerDeps struct { Logger ServerLogger // nil → no-op Audit *runtime.AuditLogger OAuth *OAuthFlow // nil → no OAuth servers in this config + // Platform is the managed token-resolver wiring, required by servers + // with auth.type=platform. nil → no platform servers in this config. + Platform *types.PlatformConfig } // knownMCPAuthTypes is the closed set of accepted Auth.Type values. @@ -102,9 +105,11 @@ type ServerDeps struct { // of types changes, both copies must move together — covered by // TestB6_KnownAuthTypes_MatchValidate. var knownMCPAuthTypes = map[string]bool{ - "bearer": true, - "static": true, - "oauth": true, + "bearer": true, + "static": true, + "oauth": true, + "platform": true, // managed: agent-principal token from the platform resolver + "user": true, // managed: delegated user identity (lazy; #317) } // NewServer constructs a Server. Returns an error when: @@ -128,7 +133,7 @@ func NewServer(spec types.MCPServer, deps ServerDeps) (*Server, error) { return nil, fmt.Errorf("%w: server %q: auth.type is required when auth block is set", ErrProtocolError, spec.Name) } if !knownMCPAuthTypes[spec.Auth.Type] { - return nil, fmt.Errorf("%w: server %q: unknown auth.type %q (must be one of: bearer, static, oauth)", ErrProtocolError, spec.Name, spec.Auth.Type) + return nil, fmt.Errorf("%w: server %q: unknown auth.type %q (must be one of: bearer, static, oauth, platform, user)", ErrProtocolError, spec.Name, spec.Auth.Type) } switch spec.Auth.Type { case "bearer", "static": @@ -159,6 +164,20 @@ func NewServer(spec types.MCPServer, deps ServerDeps) (*Server, error) { if deps.OAuth == nil { return nil, fmt.Errorf("%w: server %q requires oauth but no OAuthFlow supplied", ErrProtocolError, spec.Name) } + case "platform": + // Agent-principal identity from the platform resolver. The + // platform block is the contract — without it the server can + // never authenticate, so fail construction, not the first call. + if deps.Platform == nil || deps.Platform.TokenEndpoint == "" { + return nil, fmt.Errorf("%w: server %q: auth.type=platform requires the top-level platform block (token_endpoint + agent_identity) — platform-materialized config", ErrProtocolError, spec.Name) + } + case "user": + // Delegated user identity is INHERENTLY LAZY: there is no user + // at startup. A Required user-server would deadlock startup on + // a human — reject the combination (also caught by validate). + if spec.Required { + return nil, fmt.Errorf("%w: server %q: auth.type=user cannot be required:true — delegated identity connects lazily after consent", ErrProtocolError, spec.Name) + } } } logger := deps.Logger @@ -166,7 +185,7 @@ func NewServer(spec types.MCPServer, deps ServerDeps) (*Server, error) { logger = nopLogger{} } - authFn := buildAuthFn(spec, deps.OAuth) + authFn := buildAuthFn(spec, deps) factory := func(ctx context.Context) (Client, error) { tr, err := NewHTTPTransport(spec.URL, deps.HTTPClient, authFn) if err != nil { @@ -515,10 +534,11 @@ func filterTools(descs []MCPToolDescriptor, f types.MCPToolFilter) []MCPToolDesc // os.Getenv lookups happen INSIDE the closure so changes to env at // runtime (e.g., a K8s Secret rotated and the pod restarted) take // effect on the next call without a Manager restart. -func buildAuthFn(spec types.MCPServer, flow *OAuthFlow) AuthTokenFunc { +func buildAuthFn(spec types.MCPServer, deps ServerDeps) AuthTokenFunc { if spec.Auth == nil { return nil } + flow := deps.OAuth switch spec.Auth.Type { case "bearer", "static": env := spec.Auth.TokenEnv @@ -554,6 +574,30 @@ func buildAuthFn(spec types.MCPServer, flow *OAuthFlow) AuthTokenFunc { } return flow.BearerToken(ctx, name, c) } + case "platform": + // Agent-principal (service) identity: short-lived access token + // from the platform resolver, cached to TTL. The resource refresh + // token never reaches this process (invariant 8). + ref := spec.Auth.Ref + if ref == "" { + ref = spec.Name + } + src := newPlatformTokenSource(PlatformSourceConfig{ + TokenEndpoint: deps.Platform.TokenEndpoint, + AgentIdentity: deps.Platform.AgentIdentity, + Ref: ref, + HTTPClient: deps.HTTPClient, + }) + return src.Token + case "user": + // Delegated user identity — no grant exists until the + // platform-side consent flow (#317) completes. Fail each attempt + // with an actionable error; the server is non-Required so it + // never blocks startup. + serverName := spec.Name + return func(_ context.Context) (string, error) { + return "", fmt.Errorf("%w: server %q uses delegated user identity — no user grant yet (the platform consent flow completes it; writes are lazy by design)", ErrNoToken, serverName) + } } // Defense in depth — NewServer should have rejected an unknown // auth.Type already. If a future refactor accidentally bypasses diff --git a/forge-core/mcp/server_b6_test.go b/forge-core/mcp/server_b6_test.go index 57991e3..9a81333 100644 --- a/forge-core/mcp/server_b6_test.go +++ b/forge-core/mcp/server_b6_test.go @@ -222,7 +222,7 @@ func TestB6_BuildAuthFn_UnknownType_FailsOnFirstCall(t *testing.T) { fn := buildAuthFn(types.MCPServer{ Name: "x", Auth: &types.MCPAuth{Type: "Bearer"}, - }, nil) + }, ServerDeps{}) if fn == nil { t.Fatal("buildAuthFn returned nil for unknown type — silent no-auth (the B6 regression)") } diff --git a/forge-core/types/config.go b/forge-core/types/config.go index 682011e..c4a39ff 100644 --- a/forge-core/types/config.go +++ b/forge-core/types/config.go @@ -29,6 +29,7 @@ type ForgeConfig struct { Secrets SecretsConfig `yaml:"secrets,omitempty"` Auth AuthConfig `yaml:"auth,omitempty"` MCP MCPConfig `yaml:"mcp,omitempty"` + Platform *PlatformConfig `yaml:"platform,omitempty"` Schedules []ScheduleConfig `yaml:"schedules,omitempty"` Scheduler SchedulerConfig `yaml:"scheduler,omitempty"` CORSOrigins []string `yaml:"cors_origins,omitempty"` @@ -581,6 +582,24 @@ type MCPToolFilter struct { Deny []string `yaml:"deny,omitempty"` } +// PlatformConfig wires a deployed agent to its managing platform's token +// resolver (the managed half of the resolver seam): MCP servers with +// auth.type "platform" fetch a SHORT-LIVED access token from TokenEndpoint, +// authenticating with AgentIdentity. The resource refresh token stays +// platform-side, always — the agent never holds it. +// +// Both fields support ${VAR} env expansion resolved AT USE (like +// auth.token_env): the platform materializes the identity as a pod secret +// and rotation takes effect without a rebuild or restart. +type PlatformConfig struct { + // TokenEndpoint is the platform token resolver + // (POST {"server": } → {"access_token", "expires_in"}). + TokenEndpoint string `yaml:"token_endpoint"` + // AgentIdentity is the agent's platform credential (typically + // ${FORGE_PLATFORM_TOKEN}), sent as the Bearer on token requests. + AgentIdentity string `yaml:"agent_identity"` +} + // MCPAuth declares the authentication mechanism for an MCP server. type MCPAuth struct { // Type is one of: @@ -593,8 +612,23 @@ type MCPAuth struct { // - "bearer" → static Bearer token from env var TokenEnv. // - "static" → same as bearer; named separately for clarity in // forge.yaml. + // - "platform" → managed (platform-materialized): fetch a short-lived + // access token from ForgeConfig.Platform.TokenEndpoint + // under the AGENT-PRINCIPAL / service identity. + // Startup-viable — no human, no login, no stored token. + // - "user" → managed (platform-materialized): DELEGATED USER + // identity. Inherently lazy — there is no user at + // startup, so it must not be Required; calls fail with + // an auth-required error until the platform-side consent + // flow (#317) produces a grant. Type string `yaml:"type"` + // Ref names the platform tool-registry entry this server was + // materialized from — what the platform token resolver authorizes + // against. Platform-materialized; only meaningful for type + // platform/user (defaults to the server name when empty). + Ref string `yaml:"ref,omitempty"` + // Grant selects the OAuth grant for Type == "oauth": // - "" / "authorization_code" → 3-legged PKCE (the default; a user // consents once via `forge mcp login`). This is the delegated path. diff --git a/forge-core/validate/mcp_config.go b/forge-core/validate/mcp_config.go index 78bd4fa..f0cce73 100644 --- a/forge-core/validate/mcp_config.go +++ b/forge-core/validate/mcp_config.go @@ -25,7 +25,7 @@ var mcpToolNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_]{1,64}$`) var knownMCPTransports = map[string]bool{"http": true} // knownMCPAuthTypes is the closed set of accepted auth types. -var knownMCPAuthTypes = map[string]bool{"oauth": true, "bearer": true, "static": true} +var knownMCPAuthTypes = map[string]bool{"oauth": true, "bearer": true, "static": true, "platform": true, "user": true} // minMCPTimeout bounds the timeout knob. Anything tighter is almost // certainly a misconfiguration; the default (set in the runtime when @@ -100,6 +100,13 @@ func validateMCPServer(prefix string, s types.MCPServer, r *ValidationResult) { // Auth: when present, type must be known and required fields set. if s.Auth != nil { validateMCPAuth(prefix+".auth", *s.Auth, r) + // Delegated user identity is inherently lazy — there is no user + // at startup, so a Required user-server would deadlock startup on + // a human. Reject the combination outright. + if s.Auth.Type == "user" && s.Required { + r.Errors = append(r.Errors, + prefix+": auth.type=user cannot be combined with required:true — delegated identity connects lazily after consent") + } } // Tools: default-deny. Allow and Deny cannot both be empty — @@ -121,7 +128,7 @@ func validateMCPAuth(prefix string, a types.MCPAuth, r *ValidationResult) { } if !knownMCPAuthTypes[a.Type] { r.Errors = append(r.Errors, fmt.Sprintf( - "%s: type %q must be one of: oauth, bearer, static", prefix, a.Type)) + "%s: type %q must be one of: oauth, bearer, static, platform, user", prefix, a.Type)) return } diff --git a/forge-core/validate/mcp_config_test.go b/forge-core/validate/mcp_config_test.go index 52d5805..40975e0 100644 --- a/forge-core/validate/mcp_config_test.go +++ b/forge-core/validate/mcp_config_test.go @@ -342,3 +342,33 @@ func TestValidateMCPConfig_StdioErrorContainsRoadmapHint(t *testing.T) { } } } + +// §19 managed identities: platform/user are legal types; a Required +// user-server is rejected (delegated identity is inherently lazy). +func TestValidateMCPConfig_ManagedIdentityTypes(t *testing.T) { + base := func(auth *types.MCPAuth, required bool) types.MCPConfig { + return types.MCPConfig{Servers: []types.MCPServer{{ + Name: "x", Transport: "http", URL: "https://mcp.example.com/mcp", + Auth: auth, Required: required, + Tools: types.MCPToolFilter{Allow: []string{"y"}}, + }}} + } + + var r ValidationResult + ValidateMCPConfig(base(&types.MCPAuth{Type: "platform"}, true), &r) + if len(r.Errors) != 0 { + t.Fatalf("platform + required must validate (startup-viable): %v", r.Errors) + } + + r = ValidationResult{} + ValidateMCPConfig(base(&types.MCPAuth{Type: "user"}, false), &r) + if len(r.Errors) != 0 { + t.Fatalf("lazy user server must validate: %v", r.Errors) + } + + r = ValidationResult{} + ValidateMCPConfig(base(&types.MCPAuth{Type: "user"}, true), &r) + if len(r.Errors) == 0 { + t.Fatal("user + required:true must be a validation error") + } +}