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 credentialsconsenttwo gatesconnector appper-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 grant, then account+binding 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 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 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 The DCR fence oauth.dynamic_client_registration.allowed_redirect_uris · checked before any login 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. The DCR fence - oauth.dynamic_client_registration.allowed_redirect_uris: the redirect allowlist dynamic client registration (DCR) is checked against, before any user authenticates.
  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.

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.

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 the identical call · both gates pass · the tool runs IF A CLAIM IS NOT CONSENTED gate 2 -> agent_grant_required · the card opens with it unticked · one tick, retry
Phases A-E, with the two consent surfaces called out - and the claim-not-consented branch.
  • 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.

SCENARIO 2 · EXTERNAL MCP · OAUTH + DCR A probe requests the MCP URL -> protected-resource challenge B THE DCR FENCE register · dcr-... client id · redirect allowlist, before any login 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, the DCR fence, authorize, the editable card, then the same call.
  • 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.

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 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 authenticates the bearer · your MCP code has not run GATE 1 · THE CALLER'S GRANT may this caller run this operation? deny NOT CONSENTED · GATE 1 delegated_consent_required namespace · tool · operation · missing_grants · hub_url pass GATE 2 · ACCOUNT + BINDING does an account hold the claim, and is THIS caller bound to use it there? deny NOT CONSENTED · GATE 2 needs_connected_account_consent reason: connect · claim_upgrade · reconnect · account · agent_grant _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. 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.

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 + retry_hint · hub_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

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