KDCube
← Recipes
KDCube Recipes · The Interlock

Resolve a Connected Credential: Your Tools, the User's Token, No Token in Your Code

The builder-facing half of authenticated MCP. A service that re-exposes a third-party API resolves the approving user's credential through the Connection Hub broker - per call, at the trusted boundary. Every argument traces to a config key; the connector app is resolved, not hardcoded.

27 July 2026RecipeConnected CredentialsThe Interlock
Connection Hubdelegated credentialsconnected accountscredential resolutionconnector apptrusted boundaryadd a new service

WHAT YOU GET

A tool that acts on the user's connected account with no provider token in your code, the prompt, or the model - and every argument you pass traces to a real configuration key, nothing hardcoded. Swap the connector app, or add Google Sheets next to Gmail, and no tool code moves.

The authenticated-MCP chain is the door: who may call, which account authorizes the claim, the two gates every call crosses. This is the other half - what your tool code does once a call is through. A service that re-exposes a third-party API (Gmail, Slack, Google Sheets, your own S1) never receives a token, never stores one, never reads one from config. It asks the broker for the approving user's credential, gets a live one for exactly one call, at the trusted boundary, and lets it go.

Resolve one claim

Ask the broker for one provider claim, for the current invocation. This is the exact shape the built-in GmailTools uses:

your tool code · resolve one claimPY
from kdcube_ai_app.apps.chat.sdk.integrations.connected_accounts import (
    resolve_connected_account_claim,
    connected_account_auth_failure,
    run_with_connected_account_retry,
)
from kdcube_ai_app.apps.chat.sdk.solutions.connections.connector_app_resolution import (
    resolve_connector_app_id,
)

PROVIDER_ID = "google"      # a Delegated-to-KDCube provider row
READ_CLAIM  = "gmail:read"  # a claim declared under that provider

async def read_message(source, message_id: str, account_id: str = ""):
    async def _run():
        credential = await resolve_connected_account_claim(
            source,
            provider_id=PROVIDER_ID,
            connector_app_id=resolve_connector_app_id(PROVIDER_ID),  # resolved, never hardcoded
            claim=READ_CLAIM,
            tool_name="mail.read_message",
            account_id=account_id,          # empty -> the broker picks or asks
        )
        if not credential.ok:
            return credential.error_envelope(where="mail.read_message")  # relay, don't retry

        resp = await call_gmail_api(access_token=credential.access_token, message_id=message_id)
        if resp.status_code in (401, 403):
            return connected_account_auth_failure(credential, "Gmail rejected the token")
        return resp.json()

    return await run_with_connected_account_retry(source, where="mail.read_message", run=_run)

Every argument, and where its value comes from - nothing is hardcoded magic:

argumentwhat it iswhere its value comes from
sourcethe request scopeThe platform threads it to your tool; the SDK reads tenant/project/user from it. Not something you invent.
provider_idthe Delegated-to-KDCube provider rowA key under connections.delegated_to_kdcube.providers.<id> (google, slack, your own). It names a provider you registered - adding a new one is Adding a new service below.
connector_app_idwhich OAuth connector app of that providerResolved, never hardcoded. resolve_connector_app_id(provider_id) reads the guarded service's declaration (connector_apps: {google: gmail}), which names one of providers.<provider>.connector_apps.<app_id>. A provider can have several (gmail, sheets); the service picks. Empty = provider-wide.
claimthe exact provider claim the op needsA key under providers.<provider>.claims.<claim> (e.g. gmail:read), mapping to real OAuth provider_scopes; also fenced by the connector app's allowed_claims.
tool_nameyour label for logs + consent surfacesFree-form; name it after the tool/operation.
account_idwhich of the user's connected accountsRuntime. Empty lets the broker pick or ask - account_required returns labeled candidates, and you resend with the chosen id.

The credential comes back as a ConnectedAccountCredential: credential.ok / credential.access_token for the live token, or credential.error_envelope(...) - a structured gate-2 consent envelope, ready to return to the caller, never a bare 403.

The config trace: three declarations, one vocabulary

Every value above is a key in bundles.yaml, in three places that must agree. The built-in productivity MCP door is the live reference - one surface re-exposing Slack and Google over the user's connected accounts.

THE INTERLOCK · ONE VOCABULARY 1 PROVIDER THE PROVIDER · DEFINES connections.delegated_to_kdcube.providers.google: connector_apps.gmail: client_id, allowed_claims claims.gmail:read.provider_scopes: [ ..., "https://www.googleapis.com/auth/gmail.readonly" ] the OAuth client + real scopes 2 DOOR THE DOOR · PICKS THE CONNECTOR APP surfaces...mcp.productivity.connector_apps: { google: gmail } resolve_connector_app_id reads this 3 TOOL THE TOOL · DECLARES THE NEED connected_accounts: [{ provider_id: google, claims: [gmail:read] }] names provider + claim - never the connector app (that is resolved) google = provider gmail = connector app gmail:read = claim EACH IS A CONFIG KEY THAT MUST EXIST · NOTHING HARDCODED
The tool declares provider + claim; the surface picks which connector app; the provider config defines the OAuth client and the real scopes.

1. The provider - the OAuth client(s) and the claim-to-scope map:

bundles.yaml · the providerYAML
connections:
  delegated_to_kdcube:
    providers:
      google:
        connector_apps:
          gmail:                         # <- connector_app_id
            client_id: <FILL_ME>
            allowed_claims: [gmail:read, gmail:send]
        claims:
          gmail:read:                    # <- claim
            provider_scopes: [ openid, email, profile,
                               "https://www.googleapis.com/auth/gmail.readonly" ]

2. The service's connector-app pick - which connector app this door uses per provider; resolve_connector_app_id("google") returns exactly this:

bundles.yaml · the door's pickYAML
surfaces:
  as_provider:
    mcp:
      productivity:
        connector_apps: { slack: slack-demo, google: gmail }

3. The tool's declared need - the tool names the provider and claim (never the connector app - that is resolved):

productivity.py · the tool's needPY
"productivity_mail_search": {
    "connections": {"delegated_to_kdcube": {"connected_accounts": [
        {"provider_id": "google", "claims": ["gmail:read"]},
    ]}},
}

The tool declares provider + claim; the surface picks which connector app; the provider config defines the OAuth client and the real scopes. Change the connector app for the whole door in one line, and no tool code moves.

The credential resolves only at the trusted boundary

Both gates decide whether the call may run; neither hands you a token. When they pass, the broker resolves the real provider credential at the trusted boundary, for this one call:

THE INTERLOCK · RESOLVE THE CREDENTIAL the authorized call gate 1 + gate 2 pass · resolved user, NO token your tool code resolve_connected_account_claim(provider, claim, account) connector_app = resolve_connector_app_id(provider) not hardcoded 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 NO TOKEN IN YOUR CODE · RESOLVED PER CALL, AT THE BOUNDARY
No token in your code; the credential resolves per call, at the trusted boundary, and is gone.
  • No token in your code. You resolve a live credential for one call and let it go; the secret lives in Connection Hub.
  • No token in the model. Never in the prompt, the context, the tool arguments, or generated code - prompt injection has nothing to leak.
  • Refresh is the broker's. run_with_connected_account_retry force-refreshes a rejected token and retries once; a credential that can no longer refresh surfaces gate 2's reconnect_required - a directed denial, never a silent failure.
  • Claim- and binding-scoped. The broker resolves the exact claim the gate approved, on the account the caller is bound to - never a broader token than the call earned.

Adding a new service (Google Sheets, or your own)

"I want to support a new service and get its token from code" is four steps - each string you pass above becomes a config key:

  1. Register the provider, connector app, and claims in Connection Hub: the OAuth client, the connector app's allowed_claims, and each claim's real provider_scopes. For a Google service like Sheets you reuse the google provider and add a claim (sheets:read -> .../auth/spreadsheets.readonly).
  2. Point your door at the connector app - add it to the door's connector_apps (the one line resolve_connector_app_id reads).
  3. Write the tool - resolve exactly as above with the new claim, then call the provider API with credential.access_token.
  4. Declare the tool's need - the {provider_id, claims} policy so the gates, consent surfaces, and demand ordering know what to ask for.

The user connects the account once; from then on your tool gets a live token per call, at the trusted boundary, with no secret in your code.

Go deeper

KDCube Recipe
The Interlock · Connected Credentials