Delegated MCP, End to End: Three Doorways, Every Fence, Every Phase
My agents act on users' Gmail and Slack every day - external agents like Claude Code included - and none has ever held a provider token. Every access is something the user granted, granular and on demand, and it takes two acts they control: connect the account, and permit the agent. Here is the whole machine, fence by fence.
The complete delegated-MCP picture: what must exist in configuration, the three ways an agent gets in - hosted agent, external MCP connection, bounded automation token - and exactly what happens, fence by fence, when a consented agent makes a call. Including the two answers that matter most: what happens when the claim is consented, and when it is not.
None of my agents has ever held a provider token. Every access is something the user granted - granular, on demand, and revocable - and it takes two separate acts the user controls: connect the account, and permit this agent to use it. This article walks the whole machine end to end.
The pieces
- The caller - a hosted KDCube agent, or an external MCP app (Claude Code). Both are delegated clients: an identity with a grant, nothing more.
- The managed door - an MCP URL your app exposes with
mode: managed. The platform's guard authenticates the bearer before your MCP code runs; your code never parses a token. - The two consent surfaces - Delegated to KDCube (the user connects a provider account) and Delegated by KDCube (the user permits a specific agent to use specific claims on specific accounts). Two tabs, two different acts.
- The two gates - at every call: gate 1 checks the selected catalog operation
against effective caller authority, including matching provider claims in live
account_scope; gate 2 makes the broker enforce the exact connected account and claim. - The broker - resolves the real provider credential at the trusted boundary, per call, and refreshes it. The token lives here and only here.
What must exist in configuration first
Consent has nothing to bind to until the app builder declares the catalog. This is a one-time
write, in bundles.yaml. The built-in productivity door is the worked
example throughout - one managed MCP surface re-exposing Slack search and Gmail read.
- The provider -
connections.delegated_to_kdcube.providers.<provider>: the row, its connector apps (each an OAuth client withallowed_claims), and its claims (each mapping to real OAuthprovider_scopes). - The delegated resource ceiling -
oauth.resources[]: the resource pattern plus, per tool, the grants it may ever carry. No issuance path exceeds it. - Delegable capabilities -
oauth.capabilities[]: which grants a given user is allowed to delegate, by role or permission. - Client registration policy - Connection Hub resolves a caller in order:
descriptor
public_clients, an HTTPS Client ID Metadata Document (CIMD), then DCR compatibility. Pre-registration uses exact configured callbacks; CIMD uses an exact URL client id and callback from a bounded, SSRF-safe metadata fetch; DCR usesoauth.dynamic_client_registration.allowed_redirect_uris. Registration identifies the caller and callback before consent. It grants no resource or tool authority. - The door and its connector-app pick -
surfaces.as_provider.mcp.<door>withmode: managed, andconnector_apps: {google: gmail, slack: slack-demo}. Tool code resolves this at call time; it is never hardcoded.
What MCP 2026-07-28 changes in this flow
The MCP 2026-07-28 specification
makes the protocol core stateless. A modern caller can use
server/discover or send a self-contained RPC directly; legacy callers
still use initialize. KDCube accepts both at the same stateless MCP URL, and both reach
the same managed door, tools, and call-time authorization gates.
That wire transition does not replace OAuth caller identification or grant state. Pre-registration, CIMD, and DCR compatibility all lead into the same PKCE, consent, token, refresh, and revocation machinery. The 2026-07-28 authorization specification defines the protocol-facing OAuth flow; Connection Hub supplies the concrete resource, tool, account, and delegation decisions described below.
The official MCP Conformance Test Framework can test the wire independently. KDCube's current claim is precise: modern and legacy paths are implemented and covered by direct server, client-fallback, and proc-bridge regressions. Full upstream conformance remains a separate release verification, not something inferred from a successful governed call.
Two acts, not one: connect the account, permit the agent
This is the idea people miss, so it leads. A connected account authorizes nobody by itself. And permitting an agent binds nothing if no account is connected. Access exists only where the two overlap: on Delegated to KDCube the user connects the account (now KDCube can hold a credential); on Delegated by KDCube the user ticks a claim on that account for a specific agent (default-closed - an untouched account grants nothing).
Both are the user's decisions, made on demand, and both are editable and revocable. The three scenarios differ only in how the user is brought to these two acts.
Scenario 1 - a hosted agent, consenting in chat
The agent tries to do its job and hits a fence; the fence becomes a consent card; the user acts; the agent retries; the call runs.
The hosted MCP connection first asks only for named_services:use, the door
grant. It does not front-load conversation grants or Slack/Gmail claims into
connection scopes. Once the connection binds, the attempted named-service
operation supplies its catalog requirements and any provider-backed permission
is granted on the caller's account_scope for a selected account.
The account_scope projection is rebound at the entry of every named-service
tool invocation, not only when the streamable MCP application object is built.
The protocol request may execute in a later task or worker context; binding at
the actual call boundary keeps the provider broker attached to the live caller
card. A provider claim present on that account binding satisfies the bridge's
operation requirement without a duplicate outer resource grant, after which
the broker performs the account-specific check shown as Gate 2.
- A - the attempt. The agent calls an account-backed tool; the call crosses gate 1 and reaches gate 2.
- B - the ordered denial. With zero connected accounts, fixing the grant first binds
nothing, so the connect demand leads:
needs_connected_account_consent,reason: connect_required, scoped to exactly the claims this attempt needs. A banner appears. - C - connect (Delegated to KDCube). The user authorizes the provider. The OAuth screen requests only the scopes for the claims they ticked, never the connector app's whole ceiling. The broker stores the credential.
- D - the hand-off (Delegated by KDCube). "Continue - grant it to this agent." The card opens with the account listed and the claim named, nothing pre-checked. The user ticks it.
- E - retry. The agent retries the identical call; both gates pass; the broker resolves at the trusted boundary; the tool runs.
What if the claim is not consented. Later the agent needs gmail:send but
the user only ticked read. Gate 2 denies with agent_grant_required - the account can send, but
this agent is not bound to send there. Its link opens the card with send named and unticked;
the fix is one tick, then retry. If no account can send at all, it is claim_upgrade_required
or connect_required. The denial always names which.
If the call explicitly names an account outside the caller's binding, the
client-facing reason is agent_account_binding_required. Its
recovery URL focuses the same existing caller card plus the protected KDCube
resource, account, and claim. The host may present that URL; returning it
does not open the Hub, edit the card, or replay the call.
For a hosted runtime that owns its own transcript, a consent denial is also turn context. The host should render the access request and carry the blocked connection as an unavailable fact into the still-running agent turn. Otherwise approval plus "try again" can reach a native runtime session that never saw the original request. The denied operation is not replayed automatically; the runtime or caller decides what to do next from the recorded state.
Scenario 2 - an external MCP connection (Claude Code)
No chat, no hosted agent - an outside app OAuths into the door and becomes the same kind of delegated citizen.
- A - probe. The app requests the MCP URL and gets a protected-resource challenge.
- B - identify the caller. A configured
public_clientsrecord wins first. Otherwise a URL-shaped client id may resolve through CIMD. DCR remains available for older clients and mints adcr-...id inside the redirect allowlist. All three paths validate the callback before the user consents; none grants access by itself. - C - authorize. The user sees the ceiling in collapsible sections and the per-account binding - default-closed checkboxes per connected account. An Accounts this connection needs panel names the accounts the requested scope needs and lets them connect a missing one right here - a claim more than one provider can serve asks for one of those providers, not all - so the page never dead-ends.
- D - the card is the relationship. Editable, revocable; an edit applies on the next call. There is no key to rotate because no key exists.
- E - the call. Identical to scenario 1: two gates, then the broker resolves at the trusted boundary. A claim not on the card returns the same structured gate-2 denial.
Scenario 3 - bounded automation access
For a script or scheduled job: the user mints a narrow, expiring token instead of connecting a live client.
- A - create. The Create automation access panel renders the same catalog: a resource, grants delegable to this user, an exact set of operations, a TTL.
- B - the bounded token. Its grant record stores that narrowed boundary tree; the bridge prefers it over the deployment default. The token reaches exactly the selected operations.
- C - the account is a separate prerequisite. A provider-backed operation still needs a connected account behind it. The panel shows this and deep-links Delegated to KDCube - the two acts stay two acts.
- D - the call. Same request flow; the only difference is the caller is a bounded token.
The bearer is a pointer to this live manual card, so the update operation
can replace grants, account bindings, and
named_service_operations without reminting it. The current
existing-card UI preserves the nested namespace selection but does not yet
render its picker; that particular in-place edit is currently API-level.
The request flow, fence by fence
Strip away how the caller arrived. Once any consented delegate makes a call, this is what happens - and it re-runs on every call, because connect time was only the ceiling:
When the claim is consented (both gates pass): the broker resolves the exact claim the
gate approved, on the account the binding names, refreshes it if the access token expired, and the tool gets
a live credential for one call. If the provider rejects it, the retry contract force-refreshes once; if that
still fails, the account is marked reconnect_required - never a silent failure.
When the claim is not consented (a gate denies): no bare 403. The caller gets a structured
envelope naming the level, the reason, a retry_hint, labeled account
candidates where relevant, and a connection_hub_url. For a binding denial, that URL
identifies the exact caller card, protected KDCube resource, account, and claim. The agent or host may relay
it, but KDCube does not open it, mutate the card, or replay the call. Only account_required is fixed
directly by resending with an account_id; everything else needs a human act first.
The selected account_id reaches both requirement preflight and
provider execution. That keeps the retry account-consistent: preflight cannot
approve one account while the operation runs against another.
For a hosted agent, the result path is direct too. The MCP SDK may put JSON in a
single v2 content[].text block, but KDCube unwraps that transport envelope
before returning the LangChain tool value. The model receives the actual
{ok, error, ret} object, while the Steps view records a compact success or
denial summary. A self-contained error such as file_path_required can therefore
change the next action instead of being hidden inside escaped transport JSON.
What the managed guard refuses to guess
Gate 1 reads live authority, not only the grants embedded when a bearer was
issued. Every pointer-backed managed call and refresh resolves the current
Connection Hub card, so an edit or revocation applies to the next request. A
missing or expired card is revoked authority. If the current card cannot be
read, decoded, validated, or bound to this caller, the guard returns a logged
503 temporarily_unavailable; it does not continue with a stale
snapshot and the app handler does not run.
The short-lived OAuth transitions obey the same distributed rule. One authorization code or OAuth consent-CSRF record has one consumer, and one old refresh record produces one successor, even when requests overlap on different workers. The browser POST used to edit or revoke the card has its own separate, one-time operation-CSRF proof. That proof protects the mutation; it is not a grant and is not sent by the MCP caller.
Worked example: "search my email"
One request, five different consent states. Which banner a user sees depends entirely on the gate-2 state the broker returns, and three of the five are fixed on a different surface than the other two.
- 1 - no Google account connected.
connect_required. The banner guides the user to connect Google on Delegated to KDCube. Nothing to bind yet. - 2 - connected, but the account lacks the Gmail scope.
claim_upgrade_required. Still Delegated to KDCube: approve or upgradegmail:readon that account. - 3 - the account has
gmail:read, but this agent may not use it.agent_grant_required, and the case most often mistaken for the first two. The account is connected and claim-capable; what is missing is the per-account binding, which is default-closed. The banner must open Delegated by KDCube on this agent's grant card - not the provider-connection card - where the user ticksgmail:readon the Google account they choose. - 4 - several Google accounts, and the agent named none.
account_required. The response carries labeledcandidates; the model can ask the user which one, or surface a generic grant link. This is the one reason a caller fixes by choosing and resending without changing authority. - 5 - the model names an account outside its binding.
agent_account_binding_required. The ask becomes account-specific: "grant this agentgmail:readon KDCube Demo Reader." The recovery path targets the same Delegated by KDCube card with that account and claim named, and the user still ticks it explicitly. - 6 - the model retries with that explicit
account_id. Preflight and provider execution resolve that same account. The retry is a new caller action after consent, not a platform replay.
States 1 and 2 are about the user and the provider. States 3 through 6 are about this caller and an account the user has already connected. Sending a state-3 user to the provider card is a dead end: they connect an account that was already connected, and the call fails again for the same reason. The two surfaces are two different acts, and the reason field is what tells them apart.
Inside the credential broker
Every path above ends at one sentence — "the broker resolves the credential." That
broker is delegated_to_kdcube/broker.py,
and it is the piece that makes no token in your code true. Its whole job is to
turn "this user needs claim gmail:read" into "here is a live Gmail token for
account A" — or into a structured reason the caller can act on. It never trusts a
caller-supplied account blindly; it decides.
What it receives. resolve_connected_account_claim
(connected_accounts.py)
hands the broker a provider, a claim, an optional account_id, an optional
connector app, and the caller's per-account binding (account_claim_scope).
Note what it does not receive: a token, or a hardcoded choice of which account to use.
It selects the account by the claim, not by a name. The broker lists
the user's connected accounts for the provider and keeps the ones that are all of:
connected, hold the claim (account.allows(claim)), inside the
caller's binding, and — only if a connector app was explicitly named — belong to that app.
So a tool never names a connector app: the account that holds the claim is selected. A
provider can have several connector apps and several accounts; the claim is the selector.
(Why hardcoding is unnecessary is the whole point of
Resolve a Connected Credential.)
The allowed_claims fence. If a connector app is
named — a door that pins one — the claim must be within that app's allowed_claims,
or the broker refuses with claim_outside_connector_app. A connector app cannot
lend a claim it was never allowed to carry.
What comes back — a credential, or a reason:
| candidates | the broker returns |
|---|---|
| exactly one | the account's live credential (refreshed if the token expired) |
| zero, no account on the provider | connect_required |
| zero, account exists but claim not approved | claim_upgrade_required |
| zero, account can do it but this caller is unbound | agent_grant_required (default-closed) |
| zero, stored credential cannot refresh | reconnect_required |
| more than one | account_required with labeled candidates |
The broker's generic missing-binding result is
agent_grant_required. When a client call explicitly names the
unbound account, the client-facing adapter specializes it to
agent_account_binding_required and retains that account in the
recovery data.
Refresh is the broker's job, not yours. On a match it returns a live
credential, refreshing the access token first if it expired; a credential that cannot be
refreshed comes back as reconnect_required, never a silently dead token. That
is why your tool body stays a single happy path.
Multiple claims resolve one account at a time. A tool that needs read
and send (Gmail's forward_gmail_message does) resolves each claim in
turn on the same account. Because the selector is the claim, both must be
holdable on one account — which means the connector app that account was connected through
must allow both. Co-required claims belong on one connector app; splitting them across apps
means no single account can satisfy the tool.
Why it is shaped this way
The agent side stays trivial: it calls a tool, and when authority is missing it gets an answer it can act on. Everything hard - the ceiling, the two consent surfaces, the per-account binding, the call-time fences, the credential resolution and refresh - lives in the layer in front, owned by the platform, not the agent. The user's two acts are always visible and always reversible. And because the credential resolves only at the trusted boundary, per call, there is nothing on the agent's side of the fence to leak.
Read more
Documentation on GitHub
The design docs cover each decision in depth; this article ties them together.