Skip to content

Forge-driven Slack delivery of MCP consent prompts (channel-adapter ConsentDeliverer) #343

Description

@initializ-mk

Split out of #332. #332 keeps the standalone consent loop (Forge builds the authorize URL, hosts GET /mcp/oauth/callback, and delivers the link via the A2A auth-required artifact). This issue is the Forge-driven Slack delivery layer, which works for both standalone and managed token custody.

Goal

When a type: user MCP call parks on the auth-required gate (#330), Forge should present the "Connect <server>" login link to the requesting user over its existing Slack connection — Forge-driven, reusing the Slack channel adapter already wired for the agent. If no consent-capable channel adapter is active, delivery falls back to the platform reading the mcp_auth_required audit event.

Mirrors DEFER's delivery machinery exactly (SetDeferralNotifierApprovalDeliverer.DeliverApproval) rather than inventing a parallel one.

Key principle — the delivery leg is independent of the callback leg

Two separate legs, decided by different things:

  • Delivery leg = presenting the link to the right user. This is Forge's job over Slack (DM the subject). Pure presentation.
  • Callback leg = who the IdP redirects the browser to (who receives the code and does the code→token exchange, and therefore holds the refresh token). Decided entirely by the redirect_uri / client_id baked into the authorize URLnot by who sent the Slack message.

So Forge-driven Slack delivery works even when the platform holds the refresh token: the platform builds an authorize URL with redirect_uri = <platform>/callback, Forge just DMs it, the browser lands back at the platform, the platform exchanges + vaults the refresh token and calls POST /mcp/consent {subject, server, granted:true} to resume Forge. Forge never sees the code or the refresh token (managed-mode invariant preserved).

Who builds the authorize URL redirect_uri → callback Refresh token held by
Standalone (#332) Forge (buildAuthorizeURL + stateBinder.Issue) Forge's GET /mcp/oauth/callback Forge in-memory SubjectTokenStore
Managed Platform supplies the URL Platform's own callback Platform vault

Consequence: stateBinder + GET /mcp/oauth/callback are standalone-only. In managed-delivery mode they're bypassed — state/callback/CSRF protection on that leg is the platform's responsibility.

The AuthorizeURL abstraction + provider seam

ConsentPrompt.AuthorizeURL abstracts who built the URL, so the Slack delivery code is identical in both modes. Forge obtains the URL via a new seam:

// Runner.SetAuthorizeURLProvider supplies the login URL the deliverer sends.
//   - Standalone: Forge's own front-half (#332) provides it (buildAuthorizeURL + Issue(state)).
//   - Managed:    the platform provides its own pre-built URL (its client_id/state/redirect_uri).
// Forge's Slack layer DMs whatever URL it's handed — it never constructs a managed URL from local config.
runner.SetAuthorizeURLProvider(func(ctx context.Context, subject, server string) (string, error))

The delivery closure calls the provider to populate ConsentPrompt.AuthorizeURL, then hands it to the channel adapter.

Design (mirrors ApprovalDeliverer)

New capability interface in forge-core/channels/plugin.go:

type ConsentDeliverer interface {
    DeliverConsent(ctx context.Context, req ConsentPrompt) error
    SetConsentCanceler(c ConsentCanceler) // optional "Cancel" → fail fast
}

type ConsentPrompt struct {
    Subject      string         // requesting user (email preferred) — who to reach
    Server       string         // MCP server name — prompt copy
    AuthorizeURL string         // the login link (built by Forge standalone OR supplied by platform)
    Deadline     time.Time      // gate timeout — "expires in N min"
    Origin       *ChannelOrigin // optional: reply in the origin thread if the request came via Slack
}

type ChannelOrigin struct { Adapter, Channel, ThreadTS, UserID string }
type ConsentCanceler func(ctx context.Context, subject, server string) error

Slack adapter (forge-plugins/channels/slack/consent.go, new)

Reuses the existing Socket Mode connection, shared p.client, and p.botToken — no new bot/token/connection.

  • Routing to the requesting user (subject):
    1. req.Origin != nil (request came via Slack) → reply in Origin.Channel / Origin.ThreadTS — the exact conversation the user is in.
    2. else DM by email: users.lookupByEmail (single call, users:read.email — already used for DEFER; not paging users.list), cached → conversations.open {users} (needs new im:write scope), cached → chat.postMessage.
  • Message: Block Kit with a URL button "Connect <server>" → AuthorizeURL (plain link-open, no resolver round-trip), a deadline line, and an optional "Cancel" action.
  • Cancel click routes through the existing handleBlockAction dispatch → consentCanceler(subject, server)POST /mcp/consent {granted:false}engine.Resolve(subject, server, DecisionTimeout).
  • The Connect button needs no inbound handling — the OAuth callback (Forge's in standalone, platform's in managed) resolves the gate.

Wiring (forge-cli/cmd/run.go, mirror the DEFER block ~L362)

var consentAdapter corechannels.ConsentDeliverer
for _, plugin := range activePlugins {
    if cd, ok := plugin.(corechannels.ConsentDeliverer); ok {
        cd.SetConsentCanceler(func(ctx, subject, server) error {
            return postMCPConsent(ctx, agentURL, runner.AuthToken(), subject, server, false)
        })
        consentAdapter = cd; break
    }
}
if consentAdapter != nil {
    runner.SetConsentDeliverer(func(ctx, subject, server, taskID string, deadline time.Time) error {
        url, err := runner.AuthorizeURL(ctx, subject, server) // provider seam: standalone builds, managed supplies
        if err != nil { return err }
        return consentAdapter.DeliverConsent(ctx, corechannels.ConsentPrompt{
            Subject: subject, Server: server, AuthorizeURL: url,
            Deadline: deadline, Origin: originFromContext(ctx),
        })
    })
}
// consentAdapter == nil → deliverer stays nil → emit-only; platform reads mcp_auth_required.

Fallback (Forge-driven, platform-read backstop)

mcp_auth_required is emitted before delivery in mcpAuthGate.Await today, so platform-read is always a backstop:

Condition Delivery
Request came via Slack (origin ctx) Reply in the origin thread/DM
Slack active, no origin DM the subject by email (users.lookupByEmailconversations.open)
Slack active, subject unresolvable (guest / no email) Log + emit-only (platform may read)
No consent-capable adapter active Deliverer left nil → emit-only; platform reads mcp_auth_required

A Slack outage never strands the parked call.

Origin plumbing (for in-thread reply)

To reply where the user asked, the Slack adapter stashes ChannelOrigin{channel, thread_ts, user_id} on the request context at inbound (the one net-new plumbing item; that ctx already flows to mcpAuthGate.Await). If deferred, v1 falls back to DM-by-email — fully functional, just not in-thread.

Layering (matches DEFER)

  • forge-core/channelsConsentDeliverer / ConsentPrompt / ConsentCanceler / ChannelOrigin.
  • forge-plugins/channels/slackconsent.go (users.lookupByEmail, conversations.open, Block Kit).
  • forge-cli/cmd/run.go — wiring closure + postMCPConsent + originFromContext.
  • forge-cli/runtimeSetAuthorizeURLProvider seam; ConsentDeliverer func type + mcpAuthGate already call the delivery seam.
  • forge-core/security/authgateunchanged (Engine.Resolve already fits the Cancel path).

Acceptance

  • With the Slack adapter active, a parked type: user call delivers a "Connect <server>" link to the requesting user — origin thread when the request came via Slack, else DM resolved by email.
  • Works in both standalone (Forge-built URL via Standalone MCP consent delivery: build authorize URL + deliver login link (#317 follow-up) #332) and managed (platform-supplied URL via SetAuthorizeURLProvider) — same Slack code.
  • No consent-capable adapter active → deliverer unwired; only mcp_auth_required is emitted (platform-read path), no regression.
  • Slack delivery failure (unresolvable subject / API error) is non-fatal — the parked call still resumes via its callback.
  • Optional Cancel resolves the gate DecisionTimeout via POST /mcp/consent {granted:false}.
  • Reuses the existing Socket Mode connection + bot token; only im:write is added.

Depends on

Out of scope

  • Managed platform-side URL construction / callback (platform-owned; Forge only delivers + resumes).
  • MS Teams / other adapters implementing ConsentDeliverer — same interface, per-adapter follow-up.
  • Message-lifecycle edits ("Connected / expired") — v1.1.

Related

Standalone loop #332 · gate #330 · epic #317 · design-tool-registry.md §18.4.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestforge-cliAffects the forge-cli command-line tool (init, run, build, mcp commands)forge-coreAffects the forge-core library (runtime, security, types, llm, mcp, auth)

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions