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 every request against the caller's delegated grant, 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 the DCR fence connection-hub oauth.dynamic_client_registration allowed redirect URIs L5 namespace boundary connection-hub oauth.resources[].named_services claims per operation, per call then, at call time, two gates in sequence gate 1 the caller's delegated grant (resource, tool, operation, grants, expiry) gate 2 the user's connected account (the account's own claims) + the per-account binding (which of them THIS caller may use)
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.
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.
L4FENCE The registration fence: DCR redirect allowlist
Owner: Connection Hub. External MCP-speaking apps not pre-listed as public clients register themselves via dynamic client registration (DCR). DCR runs before any user authenticates, so the redirect allowlist is what keeps an authorization code deliverable solely to a known callback:
dynamic_client_registration: allowed_redirect_uris: - https://claude.ai/api/mcp/auth_callback - http://localhost/callback - http://127.0.0.1/callback
Loopback entries match any port; scheme, host, and path must match exactly.
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 its own consent, distinct from the realm claim, and the gate
checks a tool's grants as one set - so every operation states its complete
requirement: door admission plus its realm claim(s).
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. - Account-backed claims never sit in a connection
scope. A consuming connection'sscopescarry door admission (named_services:use) plus namespace claims only for realms with no account behind them (conv, memories, tasks). Read/write on an account-backed realm is decided per account at gate 2, so the user consents per account, per claim.
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 - the caller's own grant, then the user's connected account plus the per-account binding. Each gate answers a denial with an actionable, machine-readable block.
Gate 1 denies with delegated_consent_required - the
caller's delegated credential lacks a grant the 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.
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 retry_hint true -> the same call succeeds after the user acts provider_id / connector_app_id / claims / account_id candidates labeled account summaries connection_hub_url open this to connect / approve / reconnect
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 |
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.
Demand ordering: connect leads on zero accounts
When an operation is account-backed and the user has zero connected accounts on
the backing provider, fixing the gate-1 agent grant first binds nothing. 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; retrying the same call then succeeds because retry_hint was
true. 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. Retrying the same call succeeds.
External MCP connection
An MCP-speaking app (Claude Code, for one) probes the URL, gets the protected-resource challenge, registers via DCR inside the layer-4 fence, and opens the OAuth authorize page: the resource's ceiling with collapsible sections and per-account default-closed binding. 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.