KDCube
← Recipes
KDCube Recipes · The Interlock

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.

26 July 2026RecipeAuthenticated MCPThe Interlock
MCPConnection Hubdelegated credentialsconnected accountsconsentconfiguration layersconnected_accountsconsent gatesdemand orderingautomation token

WHAT LOCKS IN

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.

THE INTERLOCK · THE CHAIN WRITE ONCE · CONFIGURATION five layers, in order L1 DOOR the door surfaces.as_provider.mcp.<door>.auth · mode: managed your app managed guard checks the bearer L2 CEILING the resource ceiling oauth.resources[] · tools + grants connection-hub the most any grant can carry L3 DELEGABLE delegable capability oauth.capabilities connection-hub who may delegate each grant L4 FENCE the DCR fence oauth.dynamic_client_registration connection-hub allowed redirect URIs L5 BOUNDARY the namespace boundary oauth.resources[].named_services connection-hub claims per operation, per call THEN, EVERY CALL GATE 1 · THE AGENT GRANT the caller's delegated grant resource · tool · operation · grants · expiry may this caller run this operation? passes GATE 2 · ACCOUNT + BINDING the user's connected account + the per-account binding which of its claims THIS caller may use does an account hold the claim, and may THIS caller use it there? THE TRUSTED BOUNDARY · PASSES BOTH the real provider call credential resolves only here WRITE THE CHAIN ONCE · EVERY CALL CROSSES BOTH GATES
Write the chain once at the top (configuration); every call crosses both gates at the bottom (runtime) before a credential resolves.
the.chain.at.a.glanceMAP
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:

your app · surfaces.as_provider.mcpYAML
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:

connection-hub · delegated_credentials.oauthYAML
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:

connection-hub · oauth.capabilitiesYAML
      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:

connection-hub · dynamic_client_registrationYAML
      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:

connection-hub · resources[].named_servicesYAML
      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's scopes carry 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 · metadataPYTHON
@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:

mail · claims_by_operationPYTHON
"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.

THE INTERLOCK · TWO GATES THE CALL CROSSES A DENIAL ANSWERS WITH ONE TOOL CALL GATE 1 · THE AGENT GRANT the caller's delegated grant may this caller run this operation? deny DENIED · GATE 1 delegated_consent_required namespace · tool · operation required_grants · missing_grants · available_grants connection_hub_url deep link, missing claims pre-checked consent full grant block (+ one-click for agents) passes GATE 2 · ACCOUNT + BINDING connected account + binding does an account hold the claim, and may THIS caller use it there? deny DENIED · GATE 2 needs_connected_account_consent reason (one of): connect_required claim_upgrade_required reconnect_required account_required agent_grant_required retry_hint: true → same call succeeds after the user acts provider_id · connector_app_id · claims · account_id candidates labeled account summaries connection_hub_url connect / approve / reconnect passes both connect once checked on every call TWO GATES IN SEQUENCE · EACH DENIAL IS ACTIONABLE
Two gates in sequence: the caller's grant, then the account and its per-account binding. Connect once; checked on every call.

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:

gate 1 · delegated_consent_requiredDENIAL
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:

gate 2 · needs_connected_account_consentDENIAL
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:

reasonstateuser action
connect_requiredno eligible account on the backing providerconnect the provider at connection_hub_url
claim_upgrade_requiredan account exists, claim not approvedapprove the listed claims
reconnect_requiredthe stored credential no longer worksreconnect that account
account_requiredseveral accounts matchresend the same call with account_id from candidates
agent_grant_requiredaccount 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:

THE INTERLOCK · BEHIND THE DOOR caller hosted agent OR external MCP app delegated bearer managed door guard gate 1 + gate 2 pass authorized request · NO token your tool code "this user's gmail:read on account A" AGENT SIDE · NO TOKEN THE TRUSTED BOUNDARY · TOKEN RESOLVES BELOW account broker resolves + refreshes the stored credential live provider credential · this call only provider API Gmail · Sheets · Slack CREDENTIAL RESOLVES ONLY AT THE TRUSTED BOUNDARY · PER CALL
The credential resolves only at the trusted boundary, per call — no provider token ever sits on the agent's side of the fence.
  • 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_apps declaration, 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.

THE CLICK

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.

THE INTERLOCK · THREE WAYS IN ONE CATALOG the same chain, the same resources L2 ceiling · L3 delegable · L5 boundary three doorways WAY 1 Hosted agent chat consent 1 chat consent banner the ordered denial 2 guided connect plan Delegated to KDCube 3 per-account grant card Delegated by KDCube default-closed · retry succeeds WAY 2 External MCP connection OAuth + DCR 1 probe → challenge 2 DCR registration inside the layer-4 fence 3 OAuth authorize page the resource ceiling 4 editable, revocable card applies on next call Delegated by KDCube WAY 3 Automation access token + TTL 1 Create automation access 2 resource + delegable grants only ones delegable to you 3 narrow operations, exactly 4 set a TTL → bounded token exactly the selected ops boundary tree the bridge prefers SAME CHAIN · THREE DOORWAYS
One catalog, three doorways: a hosted agent, an external MCP connection, and a bounded automation token.
WAY 1

Hosted agent

acting on the user's accounts

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.

WAY 2

External MCP connection

OAuth + DCR

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.

WAY 3

Automation access

bounded token + TTL

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.

Build it in depth

Read the architecture and position

Sibling recipes

KDCube Recipe
№ 2026-07-26 · kdcube.tech