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
38 changes: 36 additions & 2 deletions docs/mcp/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ and never reaches the agent. Both types use the top-level `platform` block:
platform:
token_endpoint: ${INITIALIZ_TOKEN_ENDPOINT} # ${VAR}-expanded at use
agent_identity: ${FORGE_PLATFORM_TOKEN} # the agent's platform credential
authorize_endpoint: ${INITIALIZ_AUTHORIZE_ENDPOINT} # optional: consent-link resolver (#343)

mcp:
servers:
Expand Down Expand Up @@ -114,8 +115,41 @@ mcp:
established lazily on the first call. See [Materialized tool schemas](#materialized-tool-schemas-317).
- **`ref`** names the platform tool-registry entry the token is authorized
against (defaults to the server name).
- **Egress:** the platform token-endpoint host is auto-merged into the
allowlist.
- **Egress:** the platform token-endpoint (and `authorize_endpoint`) hosts are
auto-merged into the allowlist.

##### Delivering the consent prompt (managed, #343)

When a `type: user` call parks awaiting consent, Forge can present a
"Connect `<server>`" login link to the requesting user **over its own Slack
connection** — the same bot the agent already runs. Two config pieces:

- **`platform.authorize_endpoint`** — where Forge fetches the login URL.
Forge POSTs `{"server": <ref>, "subject": <email>}` (same Bearer + tenancy
headers as `token_endpoint`) and the platform returns
`{"authorize_url": "https://…"}`. The platform builds that URL with **its own**
`client_id` / `redirect_uri` / `state` and **hosts the callback**, so the
authorization `code` and refresh token land at the platform, never at Forge
(managed invariant). Forge treats the URL as opaque and only delivers it.
Unset ⇒ no managed link; the parked call still surfaces via the
`mcp_auth_required` audit event.
- **A running Slack adapter** (`forge run --with slack`) — Forge DMs the subject
the link (resolving the Slack user by email via `users.lookupByEmail` →
`conversations.open`). Requires the bot scopes `chat:write`,
`users:read.email`, and `im:write`.

The login link is **always published on the task's A2A `auth-required`
artifact** as a durable record; Slack (when active) is an **additive push** on
top. So a per-subject Slack failure (e.g. the email isn't in the workspace, or
`users.lookupByEmail` is missing a scope) still leaves the user a clickable link
on the task — it's logged, never fatal, and the parked call still resumes when
the callback lands.

After the user consents at the platform's callback, the platform resumes the
parked call with `POST /mcp/consent {subject, server, granted:true}`. A Slack
"Cancel" button fails the call fast (`granted:false`). Delivery is decoupled from
token custody — the same Slack path serves standalone (Forge-built URL) and
managed (platform-supplied URL).

> **Per-user connection isolation.** For `type: user`, Forge establishes one
> MCP **connection per requesting user** — the connection's `initialize` runs
Expand Down
65 changes: 65 additions & 0 deletions forge-cli/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,45 @@ func runRun(cmd *cobra.Command, args []string) error {
Target: target,
})
})

// Wire up MCP delegated-consent delivery (#343): route a parked
// type: user call's "Connect <server>" link to the first active
// channel adapter that supports it (Slack DM to the subject). The
// link itself comes from runner.AuthorizeURL — standalone builds it,
// managed fetches it from the platform. When no consent-capable
// adapter is active, SetConsentDeliverer is not called and the link
// falls back to the A2A auth-required artifact (standalone) /
// mcp_auth_required audit event (platform-read).
for _, plugin := range activePlugins {
consentAdapter, ok := plugin.(corechannels.ConsentDeliverer)
if !ok {
continue
}
consentAdapter.SetConsentCanceler(func(ctx context.Context, subject, server string) error {
return postMCPConsent(ctx, agentURL, runner.AuthToken(), subject, server, false)
})
runner.SetConsentDeliverer(func(ctx context.Context, subject, server, taskID string, deadline time.Time) error {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] In standalone + Slack, a per-subject delivery failure leaves the user with no link.

SetConsentDeliverer is a single slot, and this wires the Slack deliverer before Run(). enableStandaloneConsent only sets its A2A-artifact link deliverer if r.consentDeliverer == nil, so when Slack is active the standalone A2A-artifact link deliverer is displaced.

Now the common case where the requesting user's email isn't in the Slack workspace (users.lookupByEmail fails): deliver logs a Warn and moves on (correctly non-fatal) — but

  • the task artifact (setAuthRequired) shows only bare text "Authorization required: connect X", no link (the link lived only in the displaced standalone deliverer), and
  • the mcp_auth_required audit event carries server/subject/deadline but no authorize_url, and in standalone mode nothing reads it.

So a supported config (standalone + Slack) dead-ends the user on any per-subject Slack miss. The documented "falls back to the A2A artifact" only holds when no channel is active — not when the active channel fails for a subject.

Suggest: always publish the A2A-artifact link (durable record on the task) and treat Slack as an additive push, or chain to the artifact on DeliverConsent error. That also gives managed mode a richer backstop than an authorize_url-less audit event.

link, err := runner.AuthorizeURL(ctx, subject, server)
if err != nil {
opsLog.Warn("mcp consent: building authorize URL failed", map[string]any{
"subject": subject, "server": server, "error": err.Error(),
})
return err
}
// Always publish the durable A2A-artifact link first, so a
// per-subject Slack failure (e.g. the email isn't in the
// workspace) still leaves the user a link on the task. Slack
// is an additive push on top.
runner.PublishConsentArtifact(taskID, subject, server, link, deadline)
return consentAdapter.DeliverConsent(ctx, corechannels.ConsentPrompt{
Subject: subject,
Server: server,
AuthorizeURL: link,
Deadline: deadline,
})
})
break // one deliverer; first consent-capable adapter wins
}
}
}

Expand Down Expand Up @@ -556,6 +595,32 @@ func postDeferDecision(ctx context.Context, agentURL, token string, d corechanne
return nil
}

// postMCPConsent POSTs a consent resume/refuse signal to the agent's
// POST /mcp/consent endpoint (#330) — the same endpoint a platform would call.
// Used by the Slack "Cancel" button to fail a parked call fast (granted=false).
// Topology-agnostic: works whether the channel adapter runs in-process or out.
func postMCPConsent(ctx context.Context, agentURL, token, subject, server string, granted bool) error {
body, _ := json.Marshal(map[string]any{"subject": subject, "server": server, "granted": granted})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL+"/mcp/consent", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("mcp consent endpoint HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return nil
}

// buildRateLimitOverride translates the runRateLimit* flag vars into
// the resolver's pointer-typed override struct. Only flags that were
// explicitly set (non-zero float / non-zero int / non-empty bool
Expand Down
87 changes: 74 additions & 13 deletions forge-cli/runtime/mcp_standalone_consent.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,62 @@ type AuthorizeURLProvider func(ctx context.Context, subject, server string) (str
// wires its own by default; a managed platform sets one that returns its
// own authorize URL (its client_id/state/redirect_uri) so Forge never
// constructs a managed URL from local config. Must be called before Run().
// Takes precedence over the config-driven managed/standalone providers.
func (r *Runner) SetAuthorizeURLProvider(fn AuthorizeURLProvider) {
r.authorizeURLProvider = fn
}

// AuthorizeURL returns the delegated-consent login link for {subject, server}
// via the configured provider — standalone builds it locally, managed fetches
// it from the platform, an embedder may inject its own. The consent deliverer
// (e.g. Slack, #343) calls this to populate the prompt. Errors when no provider
// is wired (delivery then falls back to the mcp_auth_required audit event).
func (r *Runner) AuthorizeURL(ctx context.Context, subject, server string) (string, error) {
if r.authorizeURLProvider == nil {
return "", fmt.Errorf("no authorize-URL provider configured: set platform.authorize_endpoint (managed), a standalone type:user server, or SetAuthorizeURLProvider")
}
return r.authorizeURLProvider(ctx, subject, server)
}

// enableManagedConsentProvider wires the managed (platform-fetched) authorize-URL
// provider (#343): when a platform block sets authorize_endpoint and at least
// one type: user server exists, Forge fetches the consent link from the platform
// (which owns the client_id/redirect_uri/state and hosts the callback) and only
// delivers it. No-op when an embedder or standalone already set a provider, or
// when there's nothing to serve. egressClient routes the fetch through the
// allowlist. Managed mode wires ONLY the provider — the platform hosts the
// callback and brokers the token, so there is no local completer/store here.
func (r *Runner) enableManagedConsentProvider(egressClient *http.Client) {
if r.authorizeURLProvider != nil {
return
}
p := r.cfg.Config.Platform
if p == nil || p.AuthorizeEndpoint == "" {
return
}
hasUser := false
for _, s := range r.cfg.Config.MCP.Servers {
if s.Auth != nil && s.Auth.Type == "user" {
hasUser = true
break
}
}
if !hasUser {
return
}
client := egressClient
if client == nil {
client = http.DefaultClient
}
r.authorizeURLProvider = func(ctx context.Context, subject, server string) (string, error) {
ref := server
if spec, ok := r.mcpServerSpec(server); ok && spec.Auth != nil && spec.Auth.Ref != "" {
ref = spec.Auth.Ref
}
return mcp.FetchAuthorizeURL(ctx, client, p.AuthorizeEndpoint, p.AgentIdentity, ref, subject)
}
}

// standaloneDelegatedServers returns the type: user MCP servers running in
// standalone mode (no platform block). Empty ⇒ nothing to wire.
func (r *Runner) standaloneDelegatedServers() []types.MCPServer {
Expand Down Expand Up @@ -196,33 +248,42 @@ func tokenTTL(tok *oauth.Token) time.Duration {
return 5 * time.Minute
}

// standaloneConsentDeliverer is the ConsentDeliverer that "delivers" the login
// link in-band: it builds the link and writes it onto the parked task's
// auth-required artifact so a UI/A2A client renders a clickable prompt. Channel
// (Slack) delivery is #343; this is the platform-free default.
func (r *Runner) standaloneConsentDeliverer(ctx context.Context, subject, server, taskID string, deadline time.Time) error {
link, err := r.authorizeURLProvider(ctx, subject, server)
if err != nil {
return err
}
if r.taskStore == nil {
return fmt.Errorf("standalone consent: no task store to publish the auth-required artifact")
// PublishConsentArtifact writes the "Connect <server>" login link onto the
// parked task's auth-required artifact — a DURABLE record a UI/A2A client can
// render. It is the always-on backstop: channel delivery (Slack, #343) is an
// additive push on top of it, so a per-subject channel failure never leaves the
// user without a link. nil-safe (no task store / empty link ⇒ no-op).
func (r *Runner) PublishConsentArtifact(taskID, subject, server, authorizeURL string, deadline time.Time) {
if r.taskStore == nil || taskID == "" || authorizeURL == "" {
return
}
r.SetStatus(taskID, a2a.TaskStatus{
State: a2a.TaskStateAuthRequired,
Message: &a2a.Message{
Role: a2a.MessageRoleAgent,
Parts: []a2a.Part{
a2a.NewTextPart(fmt.Sprintf("Authorization required: connect %s (as %s). Open this link to continue: %s", server, subject, link)),
a2a.NewTextPart(fmt.Sprintf("Authorization required: connect %s (as %s). Open this link to continue: %s", server, subject, authorizeURL)),
a2a.NewDataPart(map[string]any{
"type": "mcp_auth_required",
"server": server,
"subject": subject,
"authorize_url": link,
"authorize_url": authorizeURL,
"expires_at": deadline.UTC().Format(time.RFC3339),
}),
},
},
})
}

// standaloneConsentDeliverer is the ConsentDeliverer used when no channel
// adapter is active: it builds the link and publishes it on the task artifact.
// (When Slack is active, cmd/run.go's deliverer publishes the same artifact and
// additionally DMs the user.)
func (r *Runner) standaloneConsentDeliverer(ctx context.Context, subject, server, taskID string, deadline time.Time) error {
link, err := r.authorizeURLProvider(ctx, subject, server)
if err != nil {
return err
}
r.PublishConsentArtifact(taskID, subject, server, link, deadline)
return nil
}
57 changes: 57 additions & 0 deletions forge-cli/runtime/mcp_standalone_consent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,60 @@ func TestStandaloneConsent_NotWiredWhenManaged(t *testing.T) {
// Sanity: the helper wouldn't create the in-memory store either.
_ = mcp.NewInMemorySubjectTokenStore
}

// TestManagedConsentProvider_FetchesFromPlatform proves that in platform mode
// with authorize_endpoint set, Runner.AuthorizeURL fetches the consent link
// from the platform (#343) — and that the standalone loop stays unwired.
func TestManagedConsentProvider_FetchesFromPlatform(t *testing.T) {
var gotBody map[string]string
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
_ = json.NewDecoder(req.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"authorize_url": "https://platform.example/idp/authorize?state=managed"})
}))
defer plat.Close()

r := &Runner{
logger: nopLogger{},
cfg: RunnerConfig{Config: &types.ForgeConfig{
Platform: &types.PlatformConfig{
TokenEndpoint: "https://platform.example/token",
AuthorizeEndpoint: plat.URL,
AgentIdentity: "agent-cred",
},
MCP: types.MCPConfig{Servers: []types.MCPServer{{
Name: "atl", Auth: &types.MCPAuth{Type: "user", Ref: "mcp.atl"},
}}},
}},
}
r.enableStandaloneConsent(plat.Client()) // no-op (managed)
r.enableManagedConsentProvider(plat.Client()) // wires the provider

if r.standaloneSubjectStore != nil || r.callbackCompleter != nil {
t.Error("managed mode must not host a local callback/store")
}
url, err := r.AuthorizeURL(context.Background(), "alice@corp.com", "atl")
if err != nil {
t.Fatalf("AuthorizeURL: %v", err)
}
if url != "https://platform.example/idp/authorize?state=managed" {
t.Errorf("url = %q, want the platform-supplied URL", url)
}
if gotBody["server"] != "mcp.atl" || gotBody["subject"] != "alice@corp.com" {
t.Errorf("platform request = %+v, want {server: ref, subject}", gotBody)
}
}

// TestAuthorizeURLProvider_EmbedderPrecedence: an explicitly-set provider wins
// over both standalone and managed config.
func TestAuthorizeURLProvider_EmbedderPrecedence(t *testing.T) {
r := standaloneRunner(t, "https://idp.example/token")
r.SetAuthorizeURLProvider(func(context.Context, string, string) (string, error) {
return "https://embedder.example/custom", nil
})
r.enableManagedConsentProvider(http.DefaultClient) // must not override
url, err := r.AuthorizeURL(context.Background(), "alice@corp.com", "atl")
if err != nil || url != "https://embedder.example/custom" {
t.Fatalf("AuthorizeURL = %q,%v, want the embedder override", url, err)
}
}
6 changes: 6 additions & 0 deletions forge-cli/runtime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,12 @@ func (r *Runner) Run(ctx context.Context) error {
// store) and before the consent endpoints register. No-op unless a
// standalone type: user server is configured.
r.enableStandaloneConsent(egressClient)
// Managed delegated consent (#343): in platform mode Forge fetches
// the consent link from platform.authorize_endpoint and delivers it
// (e.g. over Slack). Provider only — the platform hosts the callback
// and token custody. No-op unless authorize_endpoint + a type: user
// server are configured.
r.enableManagedConsentProvider(egressClient)

// Start MCP servers (Phase 1: HTTP-only) and register their
// discovered tools as namespaced "<server>__<tool>" entries.
Expand Down
22 changes: 14 additions & 8 deletions forge-cli/runtime/runner_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,22 @@ 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.
// platformResolverHost returns the platform resolver hosts for the egress
// allowlist: the token endpoint (auth.type=platform/user fetch tokens from it)
// and the authorize endpoint (managed consent fetches the login URL from it,
// #343). Empty when no platform block is configured.
func platformResolverHost(p *types.PlatformConfig) []string {
if p == nil || p.TokenEndpoint == "" {
if p == nil {
return nil
}
u, err := url.Parse(os.Expand(p.TokenEndpoint, os.Getenv))
if err != nil || u.Hostname() == "" {
return nil
var hosts []string
for _, ep := range []string{p.TokenEndpoint, p.AuthorizeEndpoint} {
if ep == "" {
continue
}
if u, err := url.Parse(os.Expand(ep, os.Getenv)); err == nil && u.Hostname() != "" {
hosts = append(hosts, u.Hostname())
}
}
return []string{u.Hostname()}
return hosts
}
Loading
Loading