Skip to content
Merged
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
4 changes: 4 additions & 0 deletions forge-cli/runtime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions forge-cli/runtime/runner_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runtime
import (
"context"
"net/http"
"net/url"
"os"

"github.com/initializ/forge/forge-core/llm/oauth"
Expand Down Expand Up @@ -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
Expand All @@ -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()}
}
5 changes: 5 additions & 0 deletions forge-core/mcp/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions forge-core/mcp/oauth_clientcreds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
}
Expand Down
143 changes: 143 additions & 0 deletions forge-core/mcp/platform_token.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading