Skip to content

feat(mcp): Forge-driven Slack delivery of MCP consent prompts (#343)#345

Merged
initializ-mk merged 2 commits into
mainfrom
feat/mcp-slack-consent-343
Jul 19, 2026
Merged

feat(mcp): Forge-driven Slack delivery of MCP consent prompts (#343)#345
initializ-mk merged 2 commits into
mainfrom
feat/mcp-slack-consent-343

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Implements #343 — Forge-driven Slack delivery of MCP consent prompts. When a type: user MCP call parks on the auth-required gate (#330), Forge presents a "Connect <server>" login link to the requesting user over its own Slack connection — in both standalone and managed modes.

Stacked on #344 (uses the SetAuthorizeURLProvider seam it introduced). Review/merge #344 first.

The key principle — delivery leg ⊥ callback leg

Who delivers the link is independent of who receives the code / holds the refresh token (decided by the URL's redirect_uri/client_id). So the same Slack code serves both modes; the only difference is where ConsentPrompt.AuthorizeURL comes from:

Who builds the authorize URL Callback / refresh token
Standalone (#332) Forge (buildStandaloneConsentLink) Forge's /mcp/oauth/callback → in-memory SubjectStore
Managed (this PR) Platform (FetchAuthorizeURLplatform.authorize_endpoint) Platform's callback + vault — Forge never sees the code

How managed mode fetches the URL (answering the design question raised in review)

New platform.authorize_endpoint: Forge POSTs {"server": <ref>, "subject": <email>} (same Bearer agent-identity + Org-Id/Workspace-Id headers as token_endpoint) and the platform returns {"authorize_url": "https://…"}, built with its own client_id/redirect_uri/state. Forge treats it as opaque and only delivers it. The authorization code and refresh token land at the platform (managed invariant preserved).

Layering (mirrors DEFER)

  • forge-core/channelsConsentDeliverer capability (DeliverConsent + SetConsentCanceler), ConsentPrompt, ChannelOrigin, ConsentCanceler.
  • forge-core/mcpPlatformConfig.AuthorizeEndpoint + FetchAuthorizeURL (the managed contract, mirrors doPlatformTokenRequest).
  • forge-plugins/slackconsent.go: DM the subject (users.lookupByEmailconversations.openchat.postMessage) or reply in the Slack origin thread; Block Kit Connect URL button + optional Cancel; Cancel routes through the existing block-action dispatch. Reuses the socket-mode connection + bot token; adds only the im:write scope.
  • forge-cliRunner.AuthorizeURL (provider resolves: embedder override › managed › standalone); enableManagedConsentProvider auto-wires the HTTP provider; authorize_endpoint host merged into the egress allowlist. cmd/run.go wires the deliverer + canceler to the first active consent-capable adapter (mirrors the DEFER block); postMCPConsent for the Cancel path.

Fallback

No consent-capable channel active → SetConsentDeliverer isn't called → the link falls back to the A2A auth-required artifact (standalone) / mcp_auth_required audit event (platform-read). mcp_auth_required is emitted before delivery, so a Slack outage never strands the parked call.

Tests

  • Slack: Block Kit shape (Connect URL button; Cancel only when canceler wired), DM-by-email delivery, origin-thread delivery (no email lookup), cancel-click routing, non-consent action ignored.
  • FetchAuthorizeURL: request shape ({server, subject} + Bearer), success, empty-subject ErrNoToken, non-200, missing authorize_url.
  • Runner.AuthorizeURL: managed fetch from platform, embedder-precedence, standalone unwired-in-managed.

All forge-core/{channels,mcp,validate}, forge-plugins/channels/slack, forge-cli/{runtime,cmd} suites green; lint 0 issues.

Follow-ups (not in this PR)

  • Origin-thread plumbing for requests that arrived via Slack (v1 uses DM-by-email; the ChannelOrigin field is in place).
  • MS Teams / other adapters implementing ConsentDeliverer.

@initializ-mk
initializ-mk changed the base branch from main to feat/mcp-standalone-consent-332 July 19, 2026 18:17

@initializ-mk initializ-mk left a comment

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.

Review — Forge-driven Slack delivery of MCP consent prompts (#343)

Approve with comments. The core architecture is right and the managed invariant holds. One Medium availability gap, two Low notes. Merge-ordering: stacked on #344 (base: feat/mcp-standalone-consent-332) — merge #344 first, then retarget this to main.

Verified solid

  • Managed invariant preserved: FetchAuthorizeURL sends {server, subject} with the same Bearer-agent-identity + Org-Id/Workspace-Id headers as the token endpoint and treats the returned URL as opaque — the code + refresh token land at the platform, Forge never sees them. Delivery-leg ⊥ callback-leg holds.
  • Fallback safety is real (mcp_authgate.go:74-82): EventMCPAuthRequired is emitted before deliver(...), and deliver logs a delivery failure as a Warn without returning an error — a Slack outage or unreachable subject never fails or hangs the parked call.
  • Egress allowlist now merges both token_endpoint and authorize_endpoint hosts; the authorize fetch is allowlist-routed.
  • ref resolution in the managed provider matches the token-endpoint path, so the platform can correlate the two requests.
  • Cancel routing is keyed on the exact consentCancelActionID, routed first, so it can't collide with DEFER approvals or the Connect URL button. Socket Mode ⇒ workspace-authenticated transport, no signature gap. Cancel goes through POST /mcp/consent with runner.AuthToken(), mirroring DEFER.

Findings

  1. [Medium] standalone + Slack: a per-subject delivery failure leaves the user with no link — the single-slot Slack deliverer displaces the standalone A2A-artifact link deliverer, and the audit backstop has no reader in standalone. Inline on the wiring.
  2. [Low] Cancel click doesn't verify the clicker is the subject — latent today (v1 is DM-only), but a cross-user DoS once origin-thread delivery is enabled. Inline.
  3. [Low / defense-in-depth] platform-returned authorize_url is delivered to a clickable button unvalidated — an https:// assertion is cheap insurance. Inline.

Comment thread forge-cli/cmd/run.go
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.

if p.consentCanceler == nil {
return true, fmt.Errorf("consent cancel click for %s/%s but no canceler wired", subject, server)
}
if cErr := p.consentCanceler(ctx, subject, server); cErr != nil {

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.

[Low] The Cancel handler doesn't verify the clicker is the subject.

handleConsentCancel cancels the parked call for the {subject, server} in the button value without checking interaction.User.ID against the subject. Latent today — v1 never populates Origin, so every prompt is a DM the subject alone sees. But consentTarget already supports origin-thread delivery, and once that's enabled, any member of a shared channel could click Cancel and fail another user's parked consent (a DoS). Worth a clicker-identity check (in.User.ID maps to subject) before wiring origin-thread delivery.

if out.AuthorizeURL == "" {
return "", fmt.Errorf("%w: platform authorize response carried no authorize_url", ErrProtocolError)
}
return out.AuthorizeURL, nil

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.

[Low / defense-in-depth] The platform-returned authorize_url is delivered unvalidated.

This value flows straight to a clickable Slack button (buildConsentPayloadurl). The platform is trusted, so this is low — but an https:// scheme assertion here (and optionally a host allowlist) is cheap insurance against a misconfigured or compromised platform turning the bot into a phishing relay. Mirrors the open-redirect reasoning applied to /mcp/oauth/start in #344, where the redirect target was checked to be server-built.

initializ-mk added a commit that referenced this pull request Jul 19, 2026
… check (#345 review)

[Medium] Standalone + Slack dead-ended the user on a per-subject Slack failure:
SetConsentDeliverer is a single slot, so the Slack deliverer displaced the
standalone A2A-artifact link deliverer — and on a users.lookupByEmail miss the
artifact showed only bare text with no link. Fix: the login link is now ALWAYS
published on the task's auth-required artifact (Runner.PublishConsentArtifact, a
durable record) and Slack is an additive push on top. Both the standalone
deliverer and the cmd/run.go Slack closure publish the artifact first, so a
Slack failure still leaves the user a clickable link. Also gives managed mode a
richer backstop than an authorize_url-less audit event.

[Low] handleConsentCancel now verifies the clicker is the subject (resolves the
clicker's email via users.info, compares to the parked subject; fails open only
when the email can't be resolved — a DM-only deployment has no cross-user
exposure). Latent today (v1 is DM-only) but fails closed once origin-thread
delivery lands, preventing a cross-user cancel DoS in a shared channel.

[Low] FetchAuthorizeURL now asserts the platform-returned authorize_url is an
absolute https URL before it flows to a clickable Slack button — cheap
defense-in-depth against a misconfigured/compromised platform (mirrors the
open-redirect check on /mcp/oauth/start).

Docs updated: the A2A artifact is always published; Slack is additive.
@initializ-mk

Copy link
Copy Markdown
Contributor Author

All three addressed in 1916fd4.

🟡 [Medium] Standalone + Slack dead-ends the user on a per-subject Slack miss — fixed. Took your first suggestion: the login link is now always published on the A2A auth-required artifact (Runner.PublishConsentArtifact, a durable record on the task), and Slack is a purely additive push on top. Both the standalone deliverer and the cmd/run.go Slack closure publish the artifact first, so a users.lookupByEmail miss (or any DeliverConsent error) still leaves the user a clickable link — logged, non-fatal, call still resumes on callback. This also gives managed mode a richer backstop than the authorize_url-less audit event, as you noted. Docs corrected (the artifact is always published; it's not "only when no channel is active").

🟢 [Low] Cancel clicker-identity — guarded. handleConsentCancel now resolves the clicker's email (users.info, the same users:read.email scope the DM lookup already needs) and cancels only if it matches the parked subject. Fails open only when the email can't be resolved — a DM-only deployment has no cross-user exposure — so it's latent today but fails closed the moment origin-thread delivery lands, killing the shared-channel cancel-DoS before it can exist. New test asserts a non-subject click doesn't cancel.

🟢 [Low] Unvalidated authorize_url — asserted https. FetchAuthorizeURL now rejects any platform-returned URL that isn't an absolute https:// URL before it reaches a clickable button — cheap insurance against a misconfigured/compromised platform turning the bot into a phishing relay, mirroring the open-redirect check on /mcp/oauth/start. (Scoped to the platform-returned URL per your inline; the standalone URL is Forge-built from config and may be http on localhost dev.)

Local: forge-core/mcp + forge-plugins/channels/slack + forge-cli/{runtime,cmd} green, lint clean. Watching CI.

Merge-ordering unchanged: #344 → retarget this to main → merge.

@initializ-mk
initializ-mk changed the base branch from feat/mcp-standalone-consent-332 to main July 19, 2026 19:14

@initializ-mk initializ-mk left a comment

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.

Re-review — fix commit 1916fd4

All three findings resolved, each via the stronger option; base retargeted to main; CI green (Test/Lint/Integration/Build). Approve.

🟡 [Medium] Durable link — fixed the unconditional-artifact way

New nil-safe PublishConsentArtifact(...), and cmd/run.go calls it first, always, then the Slack push:

runner.PublishConsentArtifact(taskID, subject, server, link, deadline) // durable, always
return consentAdapter.DeliverConsent(ctx, ...)                         // additive push

A Slack miss (email not in workspace / missing scope) no longer dead-ends the user — the clickable link is on the task artifact regardless. standaloneConsentDeliverer was refactored onto the same helper (one writer, no duplication), and this also upgrades managed mode's backstop. This is the stronger fix over chain-on-error. Docs updated to match.

🟢 [Low] Cancel clicker-identity guard — fixed with a sound fail-open

parseConsentCancel now surfaces in.User.ID; handleConsentCancel resolves the clicker's email (the existing #313 users.info path) and rejects a non-subject click with a user-facing message. The fail-open-on-resolve-error is well-reasoned: the guard only bites in the origin-thread/shared-channel case where the users:read.email scope is present and resolution succeeds; a DM-only deployment has no cross-user exposure anyway. Both directions tested through the real handleBlockAction dispatch.

One to revisit when origin-thread delivery actually ships: a transient users.info failure would then fail open and admit a non-subject cancel — worth flipping to fail-closed at that point. Not blocking while that path is unwired.

🟢 [Low] authorize_url https validation — fixed as suggested

FetchAuthorizeURL now rejects any non-absolute-https platform URL, referencing the #344 open-redirect check. Correctly scoped: the standalone button opens the agent's own /mcp/oauth/start (self, https) so the raw IdP URL is only followed server-side — the managed fetch is the right and only place this guard is needed.

Clean series. The whole #317 delegated-consent arc (#330 gate → #331 callback → #332 standalone → #343 Slack) now hangs together with the managed invariant intact and a durable, always-present fallback under it.

When a type: user MCP call parks on the auth-required gate, Forge now presents
a "Connect <server>" login link to the requesting user over its existing Slack
connection, in both standalone and managed modes. Delivery is decoupled from
token custody — the same Slack code serves both.

forge-core:
- channels.ConsentDeliverer capability (DeliverConsent + SetConsentCanceler),
  ConsentPrompt{Subject,Server,AuthorizeURL,Deadline,Origin}, ChannelOrigin,
  ConsentCanceler — mirrors ApprovalDeliverer.
- PlatformConfig.AuthorizeEndpoint + mcp.FetchAuthorizeURL: the managed
  consent-URL contract — POST {server, subject} (Bearer agent identity +
  tenancy headers) → {authorize_url}. The platform builds the URL with its own
  client_id/redirect_uri/state and hosts the callback, so the code + refresh
  token never reach Forge.

forge-plugins/slack:
- consent.go: DeliverConsent DMs the subject (users.lookupByEmail →
  conversations.open → chat.postMessage) or replies in the Slack origin thread;
  Block Kit Connect URL button + optional Cancel; Cancel routes through the
  existing block-action dispatch to the wired canceler. Reuses the socket-mode
  connection + bot token (adds im:write scope).

forge-cli:
- Runner.AuthorizeURL resolves the link via a provider — standalone builds it
  (#332), managed fetches it from the platform, an embedder may inject its own
  (SetAuthorizeURLProvider takes precedence). enableManagedConsentProvider
  auto-wires the managed HTTP provider; the authorize_endpoint host is merged
  into the egress allowlist.
- cmd/run.go wires SetConsentDeliverer + SetConsentCanceler to the first active
  consent-capable adapter (mirrors the DEFER block); postMCPConsent helper for
  the Cancel path. No adapter → falls back to the A2A artifact / audit event.

Tests: Slack payload shape, DM-by-email + origin-thread delivery, cancel
routing; FetchAuthorizeURL contract; managed provider fetch + embedder
precedence. Docs: managed consent delivery + authorize_endpoint in
docs/mcp/configuration.md.

Stacked on #344 (uses the SetAuthorizeURLProvider seam).
… check (#345 review)

[Medium] Standalone + Slack dead-ended the user on a per-subject Slack failure:
SetConsentDeliverer is a single slot, so the Slack deliverer displaced the
standalone A2A-artifact link deliverer — and on a users.lookupByEmail miss the
artifact showed only bare text with no link. Fix: the login link is now ALWAYS
published on the task's auth-required artifact (Runner.PublishConsentArtifact, a
durable record) and Slack is an additive push on top. Both the standalone
deliverer and the cmd/run.go Slack closure publish the artifact first, so a
Slack failure still leaves the user a clickable link. Also gives managed mode a
richer backstop than an authorize_url-less audit event.

[Low] handleConsentCancel now verifies the clicker is the subject (resolves the
clicker's email via users.info, compares to the parked subject; fails open only
when the email can't be resolved — a DM-only deployment has no cross-user
exposure). Latent today (v1 is DM-only) but fails closed once origin-thread
delivery lands, preventing a cross-user cancel DoS in a shared channel.

[Low] FetchAuthorizeURL now asserts the platform-returned authorize_url is an
absolute https URL before it flows to a clickable Slack button — cheap
defense-in-depth against a misconfigured/compromised platform (mirrors the
open-redirect check on /mcp/oauth/start).

Docs updated: the A2A artifact is always published; Slack is additive.
@initializ-mk
initializ-mk force-pushed the feat/mcp-slack-consent-343 branch from 3024144 to 193b123 Compare July 19, 2026 20:29
@initializ-mk
initializ-mk merged commit 7e113a8 into main Jul 19, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant