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.
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:
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:
| argument | what it is | where its value comes from |
|---|---|---|
source | the request scope | The platform threads it to your tool; the SDK reads tenant/project/user from it. Not something you invent. |
provider_id | the Delegated-to-KDCube provider row | A 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_id | which OAuth connector app of that provider | Resolved, 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. |
claim | the exact provider claim the op needs | A 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_name | your label for logs + consent surfaces | Free-form; name it after the tool/operation. |
account_id | which of the user's connected accounts | Runtime. 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.
1. The provider - the OAuth client(s) and the claim-to-scope map:
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:
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_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:
- 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_retryforce-refreshes a rejected token and retries once; a credential that can no longer refresh surfaces gate 2'sreconnect_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:
- Register the provider, connector app, and claims in Connection Hub:
the OAuth client, the connector app's
allowed_claims, and each claim's realprovider_scopes. For a Google service like Sheets you reuse thegoogleprovider and add a claim (sheets:read->.../auth/spreadsheets.readonly). - Point your door at the connector app - add it to the door's
connector_apps(the one lineresolve_connector_app_idreads). - Write the tool - resolve exactly as above with the new claim, then call
the provider API with
credential.access_token. - 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.