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 asks does this caller hold a grant for this operation; gate 2 asks does a connected account authorize this claim, and is this caller bound to use it there.
- 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. - The DCR fence -
oauth.dynamic_client_registration.allowed_redirect_uris: the redirect allowlist dynamic client registration (DCR) is checked against, before any user authenticates. - 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.
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 same call runs.
- 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 identical call replays; 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.
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 - register (the DCR fence). Dynamic client registration mints a
dcr-...client id. It runs before any user logs in, so the redirect allowlist is what stops anyone registering a client pointing at their own server. Loopback matches any port; scheme, host and path must match exactly. - C - authorize. The user sees the ceiling in collapsible sections and the per-account binding - default-closed checkboxes per connected account.
- 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 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. Only account_required
is fixed by resending; everything else needs a human act first.
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 |
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.