Authenticated MCP in KDCube: The Full Chain, Three Ways In
One chain of configuration, written once. An app declares a protected MCP door; Connection Hub declares what may be delegated there and to whom; the provider behind the door declares which user accounts back its work; and every call crosses two gates before a real credential resolves at the trusted boundary.
You can read the whole authenticated-MCP chain in one pass: the five configuration layers in the order a builder writes them, the contract a provider uses to describe its account needs, the two gates every call crosses and the exact errors each answers with, and the three access scenarios the same chain serves - a hosted agent consenting in chat, an external MCP connection over OAuth, and a bounded automation token.
Authenticated MCP in KDCube is one chain of configuration. An app declares a protected MCP door; Connection Hub declares what may be delegated at that door, to whom, and per which operation; the provider behind the door declares which user accounts back its work; and at call time the platform authorizes the selected operation against effective caller authority, resolving real provider credentials only at the trusted boundary. Write the chain once and three different consumers work through it, each with its own consent surface.
Current code and descriptors say bundle in names such as
bundle_id, bundles.yaml, and integration URLs. Here,
app = bundle: one deployable KDCube runtime unit.
The chain at a glance
Five layers author the chain, top to bottom, in the order a builder writes them. Then every call, at runtime, crosses two gates in sequence before it reaches the real provider.
five layers, in configuration order L1 the door your app surfaces.as_provider.mcp.<door>.auth mode: managed L2 the resource ceiling connection-hub oauth.resources[] tools + grants the most any grant can carry L3 delegable capability connection-hub oauth.capabilities who may delegate each grant L4 caller registration connection-hub public_clients -> CIMD -> DCR identity + callback, no grant L5 namespace boundary connection-hub oauth.resources[].named_services live catalog requirements per call then, at call time, two gates in sequence gate 1 the bridge operation (door + selected catalog operation) effective caller authority (door grant + live account_scope) gate 2 the provider account (selected account + exact claim) account_scope (binds THIS caller to that account)
L1DOOR The door: a managed MCP surface
Owner: your app. The app that exposes the MCP endpoint
declares the surface with mode: managed. Managed means the platform's
delegated-credential guard authenticates the bearer before the app's MCP code
runs; the app never parses OAuth tokens itself:
surfaces: as_provider: mcp: named_services: auth: mode: managed authority_id: delegated_client selected_tool_grants: true
authority_id: delegated_client names the managed authority accepted
at this boundary; selected_tool_grants: true requires the concrete MCP
tool to be present in the caller's grant record. The surface declares only how the
endpoint is protected - the tool and grant catalog lives in the next layers.
The app returns a stateless KDCubeMCPServer. One endpoint accepts
the MCP 2026-07-28
server/discover flow and legacy initialize callers, then
dispatches both to the same tools and managed guard. Protocol compatibility does
not change the grant model below.
L2CEILING The delegated resource: the tool ceiling
Owner: Connection Hub. It declares each protected resource - the URL pattern, a label, and per-tool grant requirements. This is the ceiling; every issuance path (OAuth consent, hosted-agent grant, automation token) stays inside it:
connections: delegated_credentials: oauth: resources: - resource: '*/api/integrations/bundles/*/*/kdcube-services@1-0/public/mcp/named_services*' label: KDCube named services MCP tools: named_services_search: label: Named service search grants: [named_services:use] named_services_action: label: Named service action grants: [named_services:use]
The resource pattern is also the grant key: a consuming
connection's resource field must byte-match it, because grant
creation, validation, and per-call lookup all key under it.
L3DELEGABLE Delegable capabilities: who may delegate a grant
Owner: Connection Hub. Each grant used anywhere in the catalog gets a capability row: the label and description the consent screens show, plus who may delegate it - by role and by permission. A user qualifies through either axis:
capabilities: - grant: slack:post label: Post to Slack description: Post messages through connected Slack accounts. delegable_roles: [kdcube:role:registered, kdcube:role:paid, kdcube:role:privileged, kdcube:role:super-admin] delegable_permissions: [slack:post]
Consent screens show only capabilities the signed-in user may delegate; a grant whose rows match nothing the user holds is filtered out.
L4IDENTITY Caller registration: three paths, no authority
Owner: Connection Hub. It resolves an external OAuth caller
before consent in this order: a descriptor-owned public_clients
entry; an HTTPS Client ID Metadata Document (CIMD), where the metadata URL is
the client_id; then Dynamic Client Registration (DCR)
compatibility.
Enable only the paths the deployment accepts:
public_clients: - client_id: claude client_name: Claude application_type: native redirect_uris: - https://claude.ai/api/mcp/auth_callback client_id_metadata_documents: enabled: true allowed_domains: [] allow_subdomains: true dynamic_client_registration: enabled: true default_application_type: native allowed_redirect_uris: - https://claude.ai/api/mcp/auth_callback - http://localhost/callback - http://127.0.0.1/callback
For CIMD, KDCube fetches a bounded metadata document over HTTPS without
redirects, proxies, cookies, or ambient credentials; it resolves and pins
only public addresses and requires exact client_id and callback
matching. For DCR, the redirect allowlist is the pre-authentication fence.
Loopback entries match any port; scheme, host, and path must match exactly.
All three paths establish the caller identity and valid callback set. They do not grant a resource, tool, operation, or connected account. They enter the same PKCE, consent, grant, token, refresh, and revocation machinery only after the user decides what to delegate. The MCP 2026-07-28 authorization specification defines the protocol flow; Connection Hub remains responsible for application authority.
L5BOUNDARY The namespace boundary: door claims per operation
Owner: Connection Hub. The generic named-services bridge exposes namespace-agnostic tools, so the per-tool grants of layer 2 only admit the caller to the bridge. Which grants each namespace operation consumes is a nested boundary tree, checked by the bridge on every call:
resources: - resource: '*/api/integrations/bundles/*/*/kdcube-services@1-0/public/mcp/named_services*' named_services: namespaces: slack: tools: search: operation: object.search grants: [named_services:use, slack:search] action: operation: object.action operations: object.action: grants: [named_services:use, slack:post, slack:files:write, slack:files:read, slack:assistant:search]
Every namespace operation lists named_services:use.
Door admission is distinct from the operation requirement, and each catalog row
states the full set needed for that call. For provider-backed rows, the bridge can
satisfy the provider claim from the caller's matching account_scope; the same
claim does not need to be copied into outer resource_grants.
The claim rule: which vocabulary sits at the door
- A single-provider realm (Slack is one provider) uses its
real provider claims as door claims:
slack:search,slack:history,slack:post, ... - the exact capabilities the Slack API needs per operation. The door demand, the connected-account consent, and the per-account binding all speak one vocabulary, checked twice. - A multi-provider realm (mail spans Gmail OAuth today, with
app-password mail providers reserved in the same catalog) uses a
provider-neutral namespace claim at the door -
mail:read/mail:send- and the real per-account claim (gmail:read/gmail:send) is resolved by the account broker behind the door. - Operation claims never sit in a hosted connection
scope. A consuming named-services connection asks only for door admission (named_services:use). Conversation grants, internal namespace grants, and provider-backed claims are derived from the selected catalog operation after the door binds. Provider claims are then authorized per account throughaccount_scope.
The provider describes itself: connected_accounts
A named-service provider that runs on the user's connected accounts declares which provider backs its operations, and which per-account claims each operation needs, once, in its registration metadata:
@named_service_provider( ..., metadata={ "connected_accounts": [ { "provider_id": ..., # Delegated-to-KDCube provider id "connector_app_id": ..., # connector app under that provider "provider_label": ..., # human name for consent surfaces "claims": [...], # the realm's full claim vocabulary "claim_labels": {...}, # human label per claim "claims_by_operation": {...} # optional: exact claims per operation } ], }, )
The mail realm differentiates claims per operation, so consent surfaces can scope an ask to exactly what a configuration allows:
"claims_by_operation": { "object.search": ["gmail:read"], "object.action.send": ["gmail:send"], "object.action.forward": ["gmail:read", "gmail:send"], }
The Slack realm declares one flat claim set with labels, and consumers show the
whole set. A custom service - a namespace over your own OAuth/OIDC provider,
configured as a Delegated-to-KDCube provider row - declares the same shape with
its own real claims (acme:contacts:read, acme:deals:write,
...).
This one declaration is the contract every catalog consumer reads: the composer menu's proactive consent, the capabilities picker and agent inventory, the Create Automation Access screen, and the demand ordering at denial time. Declare it once, on the provider; nothing else hardcodes which provider backs a namespace.
The two gates at call time
A single tool call crosses two sequential gates: first, the selected catalog
operation against effective caller authority; then, the exact provider account
and claim. A matching provider claim in live account_scope can satisfy
gate 1 without a duplicate outer resource_grant; gate 2 still enforces
that account and claim at the broker. Each gate answers a denial with an actionable,
machine-readable block.
Gate 1 denies with delegated_consent_required -
effective caller authority cannot satisfy a grant the selected operation needs.
The denial names the exact grants, per operation:
error = delegated_consent_required namespace / tool / operation required_grants / missing_grants / available_grants exact, per operation connection_hub_url deep link landing on the caller's card, missing claims pre-checked consent the full grant block; for hosted agents also the one-click grant-create action
A hosted agent's tool wrap raises this block as the standard scoped chat demand; the approval merges into the agent's existing grant record.
At each named-service tool call, KDCube rebinds the live card's
account_scope and agent identity before bridge authorization and provider
resolution. This avoids relying on the async context in which the streamable
MCP application object happened to be constructed.
Gate 2 denies with needs_connected_account_consent -
the caller holds the MCP grant, but the user-to-provider side cannot satisfy the
call:
error.code = needs_connected_account_consent error.details: reason connect_required | claim_upgrade_required | reconnect_required | account_required | agent_grant_required | agent_account_binding_required retry_hint true -> the caller may retry after the named fix provider_id / connector_app_id / claims / account_id candidates labeled account summaries connection_hub_url present this exact recovery path to the user
The hosted MCP client unwraps a single v2 JSON content[].text block before
returning the tool value. The model therefore receives this direct error object
or the direct success object, and the Steps view can show a compact code,
message, claim, candidate, or item summary instead of escaped transport JSON.
How a caller acts on reason:
| reason | state | user action |
|---|---|---|
connect_required | no eligible account on the backing provider | connect the provider at connection_hub_url |
claim_upgrade_required | an account exists, claim not approved | approve the listed claims |
reconnect_required | the stored credential no longer works | reconnect that account |
account_required | several accounts match | resend the same call with account_id from candidates |
agent_grant_required | account connected and claim-capable, but this caller has no per-account binding (default-closed) | tick the claim for an account on the caller's grant card |
agent_account_binding_required | the call named an account that is outside this caller's binding | open the exact caller card and tick the named claim on that account |
For both binding reasons, the recovery URL identifies the caller's
existing grant card, the protected KDCube resource, and the
affected account and claim. It is data for a hosted UI or external client to
present. Returning it never opens Connection Hub, changes the card, or
replays the failed operation.
Worked example: "search my email"
The reason table above is abstract until you walk one request through it. "Search my email" can deny in five different ways, and they do not all get fixed on the same surface.
- 1 - no Google account connected -
connect_required. Connect Google on Delegated to KDCube. There is 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. This is the one that looks like the first two and is not. The account is connected and claim-capable; the missing piece is the per-account binding, which is default-closed. The banner opens Delegated by KDCube on this caller's grant card - not the provider-connection card - and the user ticksgmail:readon the Google account they choose. - 4 - several Google accounts and the caller named none -
account_required. The response carries labeledcandidates. The caller resends the identical call with anaccount_id, or asks the user which account to use. This is the only state in this sequence fixed by resending rather than changing authority. - 5 - the caller 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 - the picker never arrives pre-checked. - 6 - the caller retries with that explicit
account_id. Requirement preflight and the provider operation receive the same selector, so authorization cannot pass for one account and execute against another. KDCube does not perform this retry on the caller's behalf.
States 1 and 2 are about the user and the provider. States 3 to 6 are about this caller and an account
the user has already connected. Routing a state-3 user to the provider card is a dead end: they reconnect
an account that was already connected, and the call fails again for the same reason. reason is
what tells the two surfaces apart, which is why a caller reads it instead of retrying blindly.
Behind the door: where the token resolves
Both gates decide whether a call may run; neither hands the caller a
token. When a call passes both, your tool code runs with an authorized
request for a resolved user — namespace, operation, account_id,
and the caller's identity — and asks the account broker for that
user's credential. The real provider token is fetched only here, at the
trusted boundary, for this one call:
- Your tool never stores or fetches a token. It receives a live credential for the one call and lets it go; nothing on the agent's side of the fence holds a provider secret.
- The token never enters the model. Not in the prompt, the context, the tool arguments, or any generated code — so prompt injection has nothing to leak. The agent sees the result of the call, never the key that made it.
- The broker owns refresh. The stored connected-account
credential (from the user's connect on Delegated to KDCube) is
refreshed at resolution time; a credential that can no longer be refreshed is
exactly what surfaces gate 2's
reconnect_required— never a silent failure. - Resolution is claim-scoped. The broker resolves the exact
provider claim the gate approved (
gmail:read) on the account the binding names — never a broader token than the call earned. - The connector app is resolved, not hardcoded. The tool names
only the provider and the claim; which OAuth connector app backs that provider is
the door's one-line
connector_appsdeclaration, resolved at call time — so swapping it (or adding Google Sheets alongside Gmail) moves no tool code.
This is what lets the whole chain be "no tokens in your code": the secret lives in Connection Hub, resolves at the boundary for one authorized call, and is gone. The credential-resolution recipe walks the mechanism end to end in tool code.
What stays live after consent
The card remains the authority after OAuth completes. A pointer-backed
bearer does not freeze the approved grants into an old token snapshot: every
managed call and refresh resolves the current Connection Hub card. Narrowing
or revoking it applies on the next request. A missing or expired card is
revoked; if current authority cannot be read or validated, a managed MCP/REST
call returns a logged 503 temporarily_unavailable and your app code
does not run. Refresh refuses to rotate that state: a store failure returns
temporarily_unavailable, while invalid authority returns
invalid_grant.
The protocol's single-use steps also remain single-use under horizontal scale. Authorization-code exchange and OAuth consent-CSRF consumption are atomic, and refresh first validates current authority before atomically replacing the old refresh record with one successor. Two workers cannot both accept the same code, consent proof, or refresh state.
There are two different CSRF contracts in this journey. OAuth consent-CSRF protects the authorize decision. The browser POST that later edits or revokes the card uses KDCube's separate operation-CSRF proof, bound to the signed-in user and exact app operation and consumed once. Neither proof is authority, and neither changes the bearer protocol used by the external MCP caller.
Demand ordering: connect leads on zero accounts
When an operation is account-backed and the user has zero connected accounts on
the backing provider, adding the missing provider claim to Gate 1 first has no
account to bind. So the
connect demand leads: the denial carries the Connection Hub guided plan, scoped per
the provider's connected_accounts contract to the claims the attempt
actually needs, and the plan ends in the agent-grant hand-off -
"Continue - grant it to this agent". The agent-grant demand
(agent_grant_required) leads only when an account exists and the agent
is merely unbound.
On zero accounts, the connect plan seats first and hands off to the agent
grant. retry_hint tells the caller that it may retry after the
user acts; the platform does not replay the call. The order is a property
of the state, not a fixed script.
The three ways in
Each scenario is one issuance path over the same catalog, and each maps to an exact Connection Hub surface.
Hosted agent
The agent attempts an account-backed tool; the ordered denial surfaces as a chat consent banner. It opens the guided connect plan on Delegated to KDCube, then hands off to the agent's card under Delegated by KDCube, where the user ticks claims per account - default-closed, nothing pre-checked. After the user acts, the caller retries; nothing is replayed silently.
For a hosted foreign runtime, surfacing the card is not the end of the turn. The host should keep the runtime running with an explicit "unavailable connection" fact and with the blocked tool omitted or marked unavailable. That keeps the runtime's own transcript aligned with the platform conversation; after approval, "try again" means retry in a session that remembers the original task.
External MCP connection
An MCP-speaking app (Claude Code, for one) probes the URL, gets the protected-resource challenge, resolves its caller identity through descriptor pre-registration, CIMD, or DCR compatibility, and opens the OAuth authorize page: the resource's ceiling with collapsible sections and per-account default-closed binding. Registration alone grants nothing. After approval its card sits under Delegated by KDCube, editable and revocable on the next call.
Automation access
The Create automation access panel renders the same catalog: the user selects a resource, its grants (only ones delegable to them), narrows named-service operations to an exact selection, and sets a TTL. The token's grant record stores the narrowed boundary tree, which the bridge prefers over the deployment default. Provider-backed namespaces keep the connected account as a separate upstream prerequisite.
The bearer remains a live pointer to the manual access card. Resource
grants, account bindings, and the inner namespace selection can be replaced
through delegated_access_update without reminting the token. The
current existing-card editor preserves the inner namespace selection but
does not yet expose that nested picker; changing it in place currently uses
the update operation.