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 (SetDeferralNotifier → ApprovalDeliverer.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 URL — not 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):
req.Origin != nil (request came via Slack) → reply in Origin.Channel / Origin.ThreadTS — the exact conversation the user is in.
- 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.lookupByEmail → conversations.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/channels — ConsentDeliverer / ConsentPrompt / ConsentCanceler / ChannelOrigin.
forge-plugins/channels/slack — consent.go (users.lookupByEmail, conversations.open, Block Kit).
forge-cli/cmd/run.go — wiring closure + postMCPConsent + originFromContext.
forge-cli/runtime — SetAuthorizeURLProvider seam; ConsentDeliverer func type + mcpAuthGate already call the delivery seam.
forge-core/security/authgate — unchanged (Engine.Resolve already fits the Cancel path).
Acceptance
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.
Goal
When a
type: userMCP 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 themcp_auth_requiredaudit event.Mirrors DEFER's delivery machinery exactly (
SetDeferralNotifier→ApprovalDeliverer.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:
subject). Pure presentation.codeand does the code→token exchange, and therefore holds the refresh token). Decided entirely by theredirect_uri/client_idbaked into the authorize URL — not 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 callsPOST /mcp/consent {subject, server, granted:true}to resume Forge. Forge never sees the code or the refresh token (managed-mode invariant preserved).redirect_uri→ callbackbuildAuthorizeURL+stateBinder.Issue)GET /mcp/oauth/callbackSubjectTokenStoreConsequence:
stateBinder+GET /mcp/oauth/callbackare standalone-only. In managed-delivery mode they're bypassed — state/callback/CSRF protection on that leg is the platform's responsibility.The
AuthorizeURLabstraction + provider seamConsentPrompt.AuthorizeURLabstracts who built the URL, so the Slack delivery code is identical in both modes. Forge obtains the URL via a new seam: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:Slack adapter (
forge-plugins/channels/slack/consent.go, new)Reuses the existing Socket Mode connection, shared
p.client, andp.botToken— no new bot/token/connection.subject):req.Origin != nil(request came via Slack) → reply inOrigin.Channel/Origin.ThreadTS— the exact conversation the user is in.users.lookupByEmail(single call,users:read.email— already used for DEFER; not pagingusers.list), cached →conversations.open {users}(needs newim:writescope), cached →chat.postMessage.AuthorizeURL(plain link-open, no resolver round-trip), a deadline line, and an optional "Cancel" action.handleBlockActiondispatch →consentCanceler(subject, server)→POST /mcp/consent {granted:false}→engine.Resolve(subject, server, DecisionTimeout).Wiring (
forge-cli/cmd/run.go, mirror the DEFER block ~L362)Fallback (Forge-driven, platform-read backstop)
mcp_auth_requiredis emitted before delivery inmcpAuthGate.Awaittoday, so platform-read is always a backstop:users.lookupByEmail→conversations.open)mcp_auth_requiredA 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 tomcpAuthGate.Await). If deferred, v1 falls back to DM-by-email — fully functional, just not in-thread.Layering (matches DEFER)
forge-core/channels—ConsentDeliverer/ConsentPrompt/ConsentCanceler/ChannelOrigin.forge-plugins/channels/slack—consent.go(users.lookupByEmail,conversations.open, Block Kit).forge-cli/cmd/run.go— wiring closure +postMCPConsent+originFromContext.forge-cli/runtime—SetAuthorizeURLProviderseam;ConsentDelivererfunc type +mcpAuthGatealready call the delivery seam.forge-core/security/authgate— unchanged (Engine.Resolvealready fits the Cancel path).Acceptance
type: usercall delivers a "Connect <server>" link to the requesting user — origin thread when the request came via Slack, else DM resolved by email.SetAuthorizeURLProvider) — same Slack code.mcp_auth_requiredis emitted (platform-read path), no regression.DecisionTimeoutviaPOST /mcp/consent {granted:false}.im:writeis added.Depends on
AuthorizeURLfor the standalone mode and theSetConsentDelivererseam is already in place (Auth-required gate: DEFER park/resume for delegated MCP consent (#317 follow-up) #330).Out of scope
ConsentDeliverer— same interface, per-adapter follow-up.Related
Standalone loop #332 · gate #330 · epic #317 ·
design-tool-registry.md§18.4.