KDCube
← Engineering
KDCube Engineering · The Interlock

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.

27 July 2026EngineeringDelegated MCPThe Interlock
MCPConnection Hubdelegated credentialsconsentdelegated MCPMCP 2026-07-28per-account bindingconnect and consent

WHAT THIS COVERS

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 INTERLOCK · THE PIECES EVERY CALL TRAVELS the caller agent OR external app the managed door authenticates the bearer the two gates operation → account claim the broker resolves at the boundary provider API Gmail · Sheets · Slack THE TOKEN LIVES ONLY PAST THE TRUSTED BOUNDARY TWO ACTS THE USER CONTROLS DELEGATED TO KDCUBE connect the account the user connects Gmail / Slack -> KDCube can hold a credential. Authorizes nobody yet. DELEGATED BY KDCUBE permit the agent the user ticks a claim on an account for THIS agent. Default-closed. Binds nothing alone.
Five pieces do the work - and the two acts the user controls sit at the centre.

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 INTERLOCK · WRITE IT ONCE bundles.yaml · before any consent 1 The provider connections.delegated_to_kdcube.providers.<p> · connector_apps + claims -> scopes connection-hub 2 The resource ceiling oauth.resources[] · per-tool grants · the most any grant can carry connection-hub 3 Delegable capabilities oauth.capabilities · which grants a user may delegate (by role or permission) connection-hub 4 Caller registration public_clients -> CIMD -> DCR compatibility · identity + callbacks, no grant connection-hub 5 The door + connector-app pick surfaces.as_provider.mcp.<door> · mode: managed · connector_apps: {google: gmail} your app
Five declarations, written once, before any user consents.
  1. The provider - connections.delegated_to_kdcube.providers.<provider>: the row, its connector apps (each an OAuth client with allowed_claims), and its claims (each mapping to real OAuth provider_scopes).
  2. The delegated resource ceiling - oauth.resources[]: the resource pattern plus, per tool, the grants it may ever carry. No issuance path exceeds it.
  3. Delegable capabilities - oauth.capabilities[]: which grants a given user is allowed to delegate, by role or permission.
  4. 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 uses oauth.dynamic_client_registration.allowed_redirect_uris. Registration identifies the caller and callback before consent. It grants no resource or tool authority.
  5. The door and its connector-app pick - surfaces.as_provider.mcp.<door> with mode: managed, and connector_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.

SCENARIO 1 · HOSTED AGENT · CHAT CONSENT A the attempt agent calls an account-backed tool -> reaches the gates B the ordered denial zero accounts -> connect leads · connect_required · chat banner C DELEGATED TO KDCUBE connect the account · only the ticked claim's scopes D DELEGATED BY KDCUBE permit the agent · tick the claim · nothing pre-checked E retry caller retries the identical call · both gates pass · the tool runs IF AN ACCOUNT BINDING IS MISSING agent_grant_required / agent_account_binding_required · exact card · user acts · caller retries
Phases A-E, with the two consent surfaces called out - and the missing-binding branch.

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.

SCENARIO 2 · EXTERNAL MCP · OAUTH + CALLER IDENTITY A probe requests the MCP URL -> protected-resource challenge B IDENTIFY THE CALLER register · pre-listed · CIMD · DCR compatibility · no grant C authorize review the ceiling · per-account default-closed binding D the card editable, revocable · applies on next call · no key to rotate E the call same as scenario 1 · two gates -> broker at the trusted boundary
Phases A-E: probe, identify the caller, authorize, the editable card, then the same call.
  • A - probe. The app requests the MCP URL and gets a protected-resource challenge.
  • B - identify the caller. A configured public_clients record wins first. Otherwise a URL-shaped client id may resolve through CIMD. DCR remains available for older clients and mints a dcr-... 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.

SCENARIO 3 · AUTOMATION · TOKEN + TTL A create resource · delegable grants · narrow the operations · TTL B the bounded token stores the narrowed boundary tree · exactly the selected ops C STILL TWO ACTS account prerequisite · deep-links Delegated to KDCube D the call same request flow · the caller is a bounded token
Phases A-D: create, the bounded token, the account prerequisite, the same call.
  • 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:

THE INTERLOCK · THE REQUEST FLOW re-run on every call the call agent · external app · automation token managed door guard bearer + live grant card · app code has not run AUTHORITY UNAVAILABLE 503 temporarily_unavailable stale snapshot rejected · app not called GATE 1 · BRIDGE OPERATION door + selected operation + live account_scope? deny NOT CONSENTED · GATE 1 delegated_consent_required namespace · tool · operation · missing_grants · hub_url pass GATE 2 · PROVIDER ACCOUNT does the selected account hold the claim, and does account_scope bind THIS caller? deny NOT CONSENTED · GATE 2 needs_connected_account_consent reason: connect · claim_upgrade · reconnect · account · agent_grant · agent_account_binding_required retry_hint · candidates · connection_hub_url AGENT SIDE · NO TOKEN THE TRUSTED BOUNDARY pass the broker resolves + refreshes the credential, this call only provider API runs on the user's account consented -> pass down not consented -> structured envelope never a bare 403 · the caller can act on it
The centrepiece: one call, two gates, then the broker - consented passes down, not-consented returns a structured envelope.

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 upgrade gmail:read on 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 ticks gmail:read on the Google account they choose.
  • 4 - several Google accounts, and the agent named none. account_required. The response carries labeled candidates; 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 agent gmail:read on 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.

THE INTERLOCK · INSIDE THE BROKER delegated_to_kdcube/broker.py ensure_claim provider · claim · account_id? · connector? · binding · NO token SELECT BY THE CLAIM, NOT A NAME candidates = accounts that are connected · allow(claim) · within binding · (match connector only if one was named) named app && claim ∉ allowed_claims → claim_outside_connector_app how many candidates? 0 · A REASON connect_required claim_upgrade_required reconnect_required agent_grant_required named unbound account -> agent_account_binding_required + retry_hint · exact recovery URL >1 · DISAMBIGUATE account_required labeled candidates -> the caller resends with account_id never picks one silently 1 · RESOLVE refresh if the token expired can't refresh -> reconnect_required a live credential this one call only MULTIPLE CLAIMS: EACH RESOLVES ON THE SAME ACCOUNT → ONE CONNECTOR APP MUST COVER THEM ALL A LIVE TOKEN, OR A REASON · NEVER A BARE FAILURE the token lives here, at the trusted boundary, and only for this call
A live token, or a reason — never a bare failure.

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:

candidatesthe broker returns
exactly onethe account's live credential (refreshed if the token expired)
zero, no account on the providerconnect_required
zero, account exists but claim not approvedclaim_upgrade_required
zero, account can do it but this caller is unboundagent_grant_required (default-closed)
zero, stored credential cannot refreshreconnect_required
more than oneaccount_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.

KDCube Deep
The Interlock · Delegated MCP