Expose a Governed Service over MCP: Your Tools, the User's Accounts, No Tokens in Your Code
One KDCube app exposes its functionality over a governed MCP door; its tools act on a user's accounts and your own OAuth-protected server, while Connection Hub authorizes every call and your code never touches a token.
One KDCube app exposes its own functionality over an MCP door; its tools act on a user's Gmail account and on your own OAuth-protected server; Connection Hub authorizes every call against the caller's delegated grant, resolves the account credential only at the trusted boundary, and your code never touches a token or a session. A resident agent and an external app (Claude Code) both call the same governed door.
You want functionality that reaches a user's third-party accounts and your own protected server, exposed over MCP — and you want the platform, not your code, to own the governance. This recipe builds exactly that: the minimal chain first, then the full setup, then the one thing that quietly breaks a governed service — where each claim has to live.
Current code and descriptors still say bundle in names such
as bundle_id, bundles.yaml, and
@bundle_entrypoint. In this recipe, app = bundle:
one deployable KDCube runtime unit.
Who owns what
YOUR APP my-service@1-0 CONNECTION HUB connection-hub@1-0 ----------------------------- ----------------------------------- services/ domain logic (Gmail calls, providers which accounts a your-server calls) - no auth in it (delegated_to_kdcube) user may connect surfaces/ @mcp door (managed auth) + the claims each grants tools/ per-operation account claims delegable which doors may be ("this op needs gmail:send") resources delegated, and to whom | (delegated_credentials.oauth) | a call arrives THE GOVERNANCE STORE v connection cards one per (user, caller, Connection Hub authorizes <---- resource): the live grant by the caller's grant, then delegation edges the authority chain each resolves the account credential grant was minted through only at the trusted boundary
Your app declares the door and the required consents. Connection Hub is the authority: it captures the connection cards (the per-caller grants a user approves) and the delegation edges (the chain each grant was minted through), and it resolves the real provider credential only after a call passes. Your tool code sees an authorized request for a resolved user — never a credential.
The minimal chain (read this first)
The smallest working shape — one third-party (Gmail), one door, one consumer:
(1) connection-hub@1-0 . connections.delegated_to_kdcube.providers.google -> the Gmail app + claims
(2) connection-hub@1-0 . connections.delegated_credentials.oauth.resources -> make your door delegable
(3) my-service@1-0 . surfaces.as_provider.mcp.<door>.auth (managed) -> the guarded door
(4) my-service@1-0 . tool code: connected_accounts [{provider_id, claims, claims_by_operation}]
(5) my-agent@1-0 . surfaces.as_consumer ... tools[] (delegated: true) -> the consumer
(6) runtime . user connects Gmail + grants the caller (per account)
Everything below fills in these six and adds your own OAuth service as a second provider. The full map, with every descriptor path, is The Connected-Services Config Chain.
- a running KDCube tenant/project;
- an app package to expose, here called
my-service@1-0; connection-hub@1-0deployed (it owns the governance);- a Google OAuth client (client id + secret) for the Gmail connection;
- your own OAuth/OIDC service's client credentials and endpoints, for the second provider;
- an authenticated KDCube user who may delegate the grants;
- optionally, an external MCP client (Claude Code) to prove the external path.
OP 10OF 70 Configure the Gmail provider and its claims
Edit: connection-hub@1-0 →
config.connections.delegated_to_kdcube.
Owner: Connection Hub. It owns which providers a user may connect and the claims each connection can grant.
Gmail uses the built-in google.oauth adapter,
so you supply only the OAuth client and the claims:
# connection-hub@1-0 · config.connections.delegated_to_kdcube delegated_to_kdcube: enabled: true oauth: { public_base_url: "https://<host>" } # where Google redirects back providers: google: adapter: google.oauth enabled: true connector_apps: gmail: client_id: "…apps.googleusercontent.com" client_secret_ref: "connections.delegated_to_kdcube.providers.google.connector_apps.gmail.client_secret" allowed_claims: [gmail:read, gmail:send] # the ceiling for this app claims: gmail:read: { label: Read Gmail, provider_scopes: [openid, email, profile, "https://www.googleapis.com/auth/gmail.readonly"] } gmail:send: { label: Send Gmail, provider_scopes: [openid, email, profile, "https://www.googleapis.com/auth/gmail.send"] }
allowed_claimsis the per-provider ceiling — the most a connected Gmail account may ever grant here.claims.*.provider_scopesmaps each KDCube claim to the Google OAuth scopes requested at connect time.- The secret is a
*_refpointer intobundles.secrets.yaml, never inline:
# bundles.secrets.yaml · items[id=connection-hub@1-0].secrets connections.delegated_to_kdcube.providers.google.connector_apps.gmail.client_secret: "<google-client-secret>"
Provider/account setup in depth: Delegated Provider Accounts.
Apply and reload the config, open Connection Hub → Delegated to KDCube; Google appears as connectable, and a user can OAuth a Gmail account and approve gmail:read / gmail:send.
OP 20OF 70 Configure your own external OAuth/OIDC service
Edit: connection-hub@1-0 →
config.connections.delegated_to_kdcube.providers.
Owner: Connection Hub. Your server is just a second provider.
Unlike Google, your server has no built-in adapter, so you register it as a custom OAuth/OIDC provider. The full walkthrough (authorize/token/userinfo endpoints, claim mapping, the adapter contract) is Custom OAuth/OIDC Provider Accounts; the shape mirrors Step 1:
# connection-hub@1-0 · config.connections.delegated_to_kdcube.providers my_server: adapter: custom.oauth # the OIDC/OAuth adapter — see the custom-oauth recipe enabled: true connector_apps: default: client_id: "<your-client-id>" client_secret_ref: "connections.delegated_to_kdcube.providers.my_server.connector_apps.default.client_secret" redirect_uri: "https://<host>/api/…/delegated_to_kdcube_oauth_callback" allowed_claims: [my_server:read, my_server:write] claims: my_server:read: { label: "Read your server", provider_scopes: [read] } my_server:write: { label: "Write your server", provider_scopes: [write] }
A single provider declares its own real claims —
my_server:read / my_server:write are the exact
permissions your server enforces at its API, not an invented wrapper. (More on
why in The claim layering below.)
A user can now connect both a Gmail account and a "my_server" account under Delegated to KDCube, each approving its own claims.
OP 30OF 70 Write the app: domain service + MCP surface + required consents
Edit: the my-service@1-0 source —
services/, surfaces/mcp/,
entrypoint.py.
Owner: the app source. There is no governance here — only domain logic that holds no credentials, an MCP door, and a declaration of what each operation needs.
Keep the app modular. Put the third-party calls in a domain service that holds no credentials (it receives a resolved credential per call; it never stores or fetches one), expose them through an MCP surface, and declare, per operation, which connected-account claim it needs.
my-service@1-0/ entrypoint.py thin composition root; declares the @mcp door services/ gmail_ops.py domain logic that calls Gmail with a passed-in token my_server_ops.py domain logic that calls your server with a passed-in token surfaces/ mcp/service.py the MCP tool definitions (the door body) config/ interface/ docs/ tests/
@mcp(alias="ops", route="public", transport="streamable-http", auth_config="surfaces.as_provider.mcp.ops.auth") def ops_mcp(self, request=None, **kwargs): return build_ops_mcp_app(request=request, ...)
Declare what each operation needs from the connected accounts. This is the machine-readable source the proactive consent picker reads, so a user can consent before the agent ever calls:
OPS_CONNECTED_ACCOUNT_REQUIREMENTS = [
{ "provider_id": "google", "connector_app_id": "gmail",
"claims": ["gmail:read", "gmail:send"],
"claims_by_operation": { "object.search": ["gmail:read"], "object.action.send": ["gmail:send"] } },
{ "provider_id": "my_server", "connector_app_id": "default",
"claims": ["my_server:read", "my_server:write"],
"claims_by_operation": { "object.search": ["my_server:read"], "object.action.push": ["my_server:write"] } },
]
Read/write is the per-account provider claim declared here —
not a door scope. The door admits on named_services:use; the
per-account claim (resolved by Connection Hub's broker) is the read/write gate.
Keep it here, once.
The door route resolves at /api/integrations/bundles/<t>/<p>/my-service@1-0/public/mcp/ops, and the per-operation requirements surface in the chat consent picker before any call.
OP 40OF 70 Guard the door and make it delegable
Edit: two descriptors — my-service@1-0 (the
guard) and connection-hub@1-0 (the delegable resource).
Owner: the app declares what authority it accepts; Connection Hub is that authority.
First, guard the door with managed auth in your app:
# my-service@1-0 · config.surfaces.as_provider.mcp mcp: ops: auth: { mode: managed, authority_id: delegated_client, selected_tool_grants: true }
mode: managed hands authorization to Connection Hub — it checks
every call against the caller's grant (resource, operation, claims, identity,
expiry) before your tool runs. delegated_client is an
authority id, not an app id; Connection Hub registers it when its OAuth block
is enabled.
Second, declare your door as a delegable resource in Connection Hub, and (for external callers) whitelist their client:
# connection-hub@1-0 · config.connections.delegated_credentials.oauth oauth: public_clients: # external apps (Claude Code) allowed to connect - { client_id: "<external-app>", redirect_uris: [...] } resources: - resource: "*/api/integrations/bundles/*/*/my-service@1-0/public/mcp/ops*" label: "My service MCP" tools: { … per-operation grants … } # named_services:use + the op's real claim
The resource pattern must byte-match the door
URL and the consumer's resource in Step 5. Door mechanics and the
managed guard in depth:
Expose an MCP Service from a KDCube App
and
Protect App MCP with Managed Credentials.
Call the door with no grant — the managed guard refuses and the consent card rises in chat; with a grant, the same call runs.
The claim layering — where every claim lives, and which are real
This is the part that makes or breaks a governed service, and it is easy to
get wrong: one claim (say gmail:send or
slack:search) appears in up to four places, and
they must agree.
one claim, four layers (all must agree) L1 grant vocabulary connection-hub . oauth.capabilities DEFINES the claim (label, who may delegate it) L2 access map connection-hub . oauth.resources[].named_services which OPERATION requires it (per-call enforcement) L3 connection scope consumer . as_consumer.agents.<a>.tools[].scopes the agent's CEILING at this door L4 per-account bind account_scope on the grant (runtime, in the Hub) which account + which of its claims this caller may use
| Layer | Where (descriptor path) | Declares |
|---|---|---|
| 1 · Grant vocabulary | connection-hub@1-0 · …oauth.capabilities — the - grant: <claim> entries with label, delegable_roles, delegable_permissions | DEFINES each delegatable claim. A claim used anywhere else MUST exist here, or it has no label and cannot be delegated. |
| 2 · Access map | connection-hub@1-0 · …oauth.resources[].named_services.<ns>.tools.<op>.grants | Which grants each OPERATION requires — the door's per-call enforcement: named_services:use (door entry) plus the operation's specific claim. |
| 3 · Connection scope | <consumer> · surfaces.as_consumer.agents.<a>.tools[].scopes | The agent's CEILING — the most it may be granted at this door. |
| 4 · Per-account binding | account_scope on the grant (runtime — Connection Hub UI, no descriptor) | Which connected account, and which of its real claims, this caller may use. |
Layers 1 and 2 are authored in the connection-hub config;
the access map is a projection of it, invented nowhere else. This is why
editing only one place (an agent's scopes, say) does not change
what a demand asks for — the operation's requirement lives in the access
map.
Which claims are REAL — the rule that avoids invented "wrapper" claims
The claims in layers 1–2 must be the real permissions the underlying service needs at its API, not an invented namespace wrapper:
- A single provider — Gmail, Slack, your own OAuth service — declares its own real claims. Slack uses
slack:search,slack:post,slack:channels,slack:history,slack:files:read,slack:files:write,slack:assistant:search(the exact Slack capabilities), never an inventedslack:read/slack:write. These are the SAME tokens the connected account authorizes at layer 4, so all four layers speak one vocabulary. Your own server is the same:my_server:read/my_server:writeare its real permissions. - A multi-provider realm — mail is the example: it serves Gmail (OAuth) AND IMAP/SMTP (username/password, which has no OAuth scopes at all). There is no single real claim across providers, so mail keeps a provider-agnostic namespace claim
mail:read/mail:sendat layers 1–2, with the real provider claim (gmail:read/gmail:send) resolved per account at layer 4.
So: real provider claims for a single-provider service; a provider-agnostic namespace claim only when the realm genuinely spans providers with no shared vocabulary (mail).
Account-backed claims never sit in a connection
scope. For Gmail, Slack, or your own server, the
read/write is the real provider claim, consented per account
in Connection Hub — layer 4, not layer 3. The connection scopes
(layer 3) carry just named_services:use plus any
internal namespace claim (like conv:read) that has
no per-account layer.
The built-in apps implement exactly this — read them as the worked reference:
…/examples/bundles/kdcube-services@1-0/— the named-services door + provider code (mail, slack, conv, tasks) withclaims_by_operation.…/examples/bundles/connection-hub@1-0/config/bundles.template.yaml— the grant vocabulary (oauth.capabilities) and the access map (oauth.resources[].named_services), showing mail (provider-agnosticmail:read) beside slack (realslack:search/slack:post/ …).
OP 50OF 70 Consume it
Edit: the consumer — either your agent's descriptor, or nothing at all for an external app.
Owner: the consumer app (for a resident agent); Connection
Hub's public_clients (for an external app).
A resident agent declares a delegated connection to the door:
# my-agent@1-0 · config.surfaces.as_consumer.agents.<agent>.tools[] - name: ops kind: mcp delegated: true url: "https://<host>/api/integrations/bundles/<t>/<p>/my-service@1-0/public/mcp/ops" resource: "*/api/integrations/bundles/*/*/my-service@1-0/public/mcp/ops*" scopes: [named_services:use] # account-backed claims consent per account, not here
The two fields differ on purpose: url is the concrete endpoint
dialed; resource is the exact delegated-resource id from Step 4,
under which the grant is stored and looked up. They must byte-match the Step 4
pattern.
An external app (Claude Code) needs no consumer block — it
connects to the same door over OAuth via the public_clients entry
from Step 4. Consumer detail:
Connect an MCP Service to a KDCube Agent.
The resident agent lists the ops tools only after a grant exists; with no grant the connection stays unbound and KDCube never contacts the door.
OP 60OF 70 Runtime: the user connects and grants
Use: the running Connection Hub UI — no descriptor here.
Owner: the user. Two independent consents, in the Hub.
Delegated to KDCube "KDCube may use my Gmail / my_server account, with these claims" Delegated by KDCube "this caller (agent or external app) may call this resource"
- Connect accounts (Delegated to KDCube): OAuth a Gmail account and a "my_server" account, approving each provider's claims.
- Grant the caller (Delegated by KDCube): the first call raises a consent card; the user grants per account, per claim (
account_scope) — e.g. Gmail read+send via account A, read-only via account B.
These are the two gates every account-backed call passes, in order: the provider gate (the account's own scope, Delegated to KDCube) and the agent gate (this caller's per-account binding, Delegated by KDCube). A call whose account holds the claim but whose caller is not yet bound to use it there is refused with a distinct grant-this-agent ask — not a reconnect — and the connect step hands straight off to it ("Continue → grant it to this agent"). The full model is Claim-Driven Consent.
At this moment Connection Hub writes a connection card for
(user, this caller, this resource) and records the
delegation edge (the authority chain the grant was minted
through). Both are inspectable and revocable in the Hub; the card is the live
authority the guard resolves on every subsequent call.
caller (resident agent OR external app) | KDCube delegated grant (bearer) v managed door guard -- resource + operation + claims + identity + expiry | passes v your tool code -- an authorized request for a resolved user (NO token) | asks the broker for THIS user's account credential v Connection Hub broker -- resolves the connected-account credential at the trusted boundary | gmail:send on account A v Gmail API
Bind read-only on one Gmail account and read+send on another; the send is refused on the read-only account, naming the allowed one — even though that account is itself send-capable. The binding decides, not the account.
OP 70OF 70 Verify every boundary
Use: the deployed URLs, the Connection Hub UI, and proc/audit logs — not the Python methods alone; that would bypass the descriptor and managed-guard joins this recipe exists to prove.
- Publish — Gmail and "my_server" appear under Delegated to KDCube; a user can connect each.
- No grant — a call with no grant raises the consent card; with a grant it runs; revoking the card stops it on the next call.
- Per-account — bind read-only on one Gmail account, read+send on another; the send is refused on the read-only one, naming the allowed account.
- Wrong resource — a token minted for another resource fails before server contact.
- Layer agreement — remove the op's claim from the access map (L2); the demand stops asking for it even though the account still holds it. Restore it.
- External path — connect the door from Claude Code via
public_clients; the same guard authorizes it, and it never receives the Gmail token. - No credentials leak — grep prompts, generated code, logs, and the executor environment for the Google / my_server secret values; they must be absent.
- The app exposes its functionality over a managed MCP door; app code holds no token and no session.
- Gmail and your own OAuth server are both connectable under Delegated to KDCube, each with its real claims.
- Each operation declares its required per-account claim in
connected_accounts/claims_by_operation— once, in code. - The door is guarded by
mode: managedand registered as a delegable Connection Hub resource whose pattern byte-matches the URL and the consumer'sresource. - Every claim agrees across its four layers; account-backed claims live at layer 4 (per account), never in a connection
scope. - Single-provider services use their real claims; only a genuinely multi-provider realm (mail) uses a provider-agnostic claim.
- A resident agent and an external app both call the same door; each gets its own grant, and a grant to one is authority for no other caller.
- Per-account binding is enforced: a write is refused on a read-only-bound account even when that account is write-capable.
- Wrong resource, missing grant, and provider/grant revocation all fail closed; no secret appears in prompts, code, logs, or the executor.