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 accountsauthenticated MCPMCP 2026-07-28consent gatesautomation 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 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.

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 IDENTITY caller registration public_clients -> CIMD -> DCR connection-hub identity + callbacks; no grant L5 BOUNDARY the namespace boundary oauth.resources[].named_services connection-hub live catalog requirements per call THEN, EVERY CALL GATE 1 · BRIDGE OPERATION door + selected catalog operation resource · tool · door grant · live account_scope may this caller run this operation? passes GATE 2 · PROVIDER ACCOUNT the selected connected account + this caller's account_scope the exact provider claim is enforced 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  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:

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.

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:

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.

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:

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

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 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 through account_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 · 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: 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.

THE INTERLOCK · TWO GATES THE CALL CROSSES A DENIAL ANSWERS WITH ONE TOOL CALL GATE 1 · BRIDGE OPERATION door + selected catalog operation caller grant + live account_scope 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 · PROVIDER ACCOUNT selected account + exact claim does this account hold the claim, and does account_scope bind THIS caller? deny DENIED · GATE 2 needs_connected_account_consent reason (one of): connect_required claim_upgrade_required reconnect_required account_required agent_grant_required agent_account_binding_required retry_hint: true → caller may retry after the named fix provider_id · connector_app_id · claims · account_id candidates labeled account summaries connection_hub_url exact recovery path · no automatic replay passes both connect once checked on every call TWO GATES IN SEQUENCE · EACH DENIAL IS ACTIONABLE
Two gates in sequence: the selected bridge operation, then the exact provider account and claim. Connect once; checked on every call.

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:

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.

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:

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 |
                       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:

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
agent_account_binding_requiredthe call named an account that is outside this caller's bindingopen 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 upgrade gmail:read on 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 ticks gmail:read on the Google account they choose.
  • 4 - several Google accounts and the caller named none - account_required. The response carries labeled candidates. The caller resends the identical call with an account_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 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 - 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:

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.

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.

THE CLICK

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.

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 + caller identity 1 probe → challenge 2 caller registration pre-listed · CIMD · DCR 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. 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.

WAY 2

External MCP connection

OAuth + caller identity

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.

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.

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.

Build it in depth

Read the architecture and position

Sibling recipes

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