Authenticated MCP In KDCube: Delegated Credentials, Not Shared Secrets
An external client speaks MCP and wants a KDCube service. The lazy answer — a shared secret — fails the moment you ask whose data it acts on, what it may do, who pays, and how to turn one connection off. KDCube's answer is a delegated credential: a bearer KDCube issues, scopes to one resource, narrows to consented tools and grants, and records back to the user. This Deep piece owns the wire: MCP as the transport, OAuth as the adapter, the delegated credential as the authorization model — and why that bearer is categorically not a shared secret.
An external client speaks MCP — tools/list, tools/call over HTTP —
and wants to reach a KDCube service. How does it authenticate?
The lazy answer is a shared secret: paste an API key into the connector, and any caller holding that string is "the integration." That answer fails the moment you ask the real questions. Whose data is this acting on? What may it do? Who pays? How do you turn one connection off without breaking the rest?
KDCube's answer is a delegated credential: a bearer that KDCube issues, scopes to one resource, narrows to the tools and grants a user consented to, and records back to that user. It is categorically different from a shared secret, and the difference is the subject of this piece.
This is the protocol layer the rest of the Connection Hub series pointed at. Delegating A KDCube Service To An External App (#2) told the connect → consent story from the user's side; Protecting KDCube Surfaces With Managed Credentials (#3) covered the guard that enforces it. This piece owns the wire: MCP as the transport, OAuth as the client-facing adapter, and the delegated credential as the authorization model that ties them together. It builds on the identity model (#1) and the guard (#3) by reference.
MCP is the transport surface. (what the client speaks)
OAuth is the client-facing adapter. (how the client gets a credential)
delegated is the authorization model. (what the bearer actually is)
credential
Three layers, one connection
The three words above are not synonyms, and keeping them apart is the whole design. Each layer has one job and a different owner.
MCP — the transport surface. The external client speaks the Model Context Protocol:
initialize, tools/list, tools/call, over streamable HTTP. In KDCube there is
no platform-level /mcp; MCP is exposed by individual bundles and served by proc. A bundle declares the
surface and points its handler at a descriptor:
@mcp(
alias="memories",
route="public",
transport="streamable-http",
auth_config="surfaces.as_provider.mcp.memories.auth",
)
async def memories_mcp(self, request: Request, **kwargs):
return build_memory_mcp_app(...)
The handler does not parse tokens, know the client's id, or check grants. It receives a request that already passed the guard (#3).
OAuth — the client-facing adapter. "OAuth" is how the client obtains a credential, not the authorization model itself. KDCube is the OAuth2 authorization server for this flow — it does not hand the integration step to an external IdP. The adapter exists because clients like Claude already speak OAuth; KDCube meets them on that protocol and hands back a credential.
Delegated credential — the authorization model. The bearer the client ends up holding is a Connection Hub delegated credential: a scoped, recorded, per-user, revocable grant. This is the layer that makes the connection safe, and the rest of this article is mostly about it.
The authenticated-MCP handshake
Here is the actual end-to-end sequence. The example resource is the user-memories@2026-06-26 memories
MCP.
1. DISCOVER
Client POSTs the bundle MCP URL with no valid credential.
Proc MCP bridge replies 401 with an RFC 9728 challenge:
WWW-Authenticate: Bearer resource_metadata="<.../oauth/.well-known/oauth-protected-resource?resource=...>"
2. READ METADATA
Client fetches the protected-resource doc (RFC 9728) and the
authorization-server doc (RFC 8414). It learns:
authorization_endpoint = .../connection-hub@1-0/public/oauth/authorize
token_endpoint = .../connection-hub@1-0/public/oauth/token
registration_endpoint = .../connection-hub@1-0/public/oauth/register
resource = the concrete bundle MCP URL
scopes + tool catalog (+ named-service catalog, if any)
3. REGISTER (if needed)
Client POSTs /oauth/register with its redirect URIs.
KDCube accepts only descriptor-allowlisted redirect URIs (DCR allowlist).
4. AUTHORIZE
Browser opens /oauth/authorize?response_type=code&code_challenge=<PKCE S256>
&scope=<grant>&client_id=<public client>&redirect_uri=<validated>
Connection Hub validates the existing platform session cookie (KDCube login),
then renders the consent screen.
5. CONSENT
The signed-in user approves selected tools + the platform delegation edge.
The consent POST is CSRF-protected and re-validates client/redirect/PKCE.
6. CODE
Redirect back to the client with code + state + iss.
7. TOKEN
Client POSTs /oauth/token: grant_type=authorization_code, code, code_verifier.
KDCube verifies code, client, redirect URI, and PKCE; mints a short-lived
kst1 integration session; stores the refresh token, selected-tool allowlist,
credential envelope, and the delegation edge(s).
Client now holds: access_token (least-privilege) + refresh_token (rotating).
8. CALL
Client POSTs the bundle MCP URL:
Authorization: Bearer <access_token>
JSON-RPC tools/list or tools/call
The managed guard verifies the bearer against the stored consent (#3),
then product code runs.
Two things to notice. First, the user authenticates in a normal browser session at step 4 — the
external client never sees those credentials; it only ever holds the token from step 7. Second, the flow uses
public clients with PKCE and needs no new secret in secrets.yaml. There is no shared
secret to hand out in the first place.
What the bearer actually is
The token the client presents is a kst1 session token for a derived integration identity — not
the user's session, and not an opaque shared key. Its meaning lives in a server-side record bound to a
kdcube.credential.v1 envelope, so product code never decodes grantor facts from the token body:
{
"schema": "kdcube.credential.v1",
"credential_kind": "delegated_client_access",
"issuer_authority_id": "delegated_client",
"issuer_authenticator_id": "delegated_client.bearer",
"subject": "integration:claude:<grantor-sub>",
"audience": "kdcube:delegated_client",
"attrs": {
"client_id": "claude",
"resource": ".../user-memories@2026-06-26/public/mcp/memories",
"scopes": ["memories:read"],
"tools": ["memory_search", "memory_get"],
"identity_scope": "grantor_identity_family"
}
}
The subject is a derived identity — integration:claude:<grantor-sub> —
minted from the consenting user's subject, never the user's own id. The external client gets its own actor identity
(#1); the consenting user remains the grantor. The token carries the resource it is bound to, the tools and scopes
consented, and the identity scope — and the durable delegation edge, selected-tool allowlist, and grantor authority
facts sit in the grant store, refreshed on rotation without re-consent.
Why this is not a shared secret
This is the title's claim, made concrete. A shared API secret and a delegated credential look superficially alike
— both arrive as Authorization: Bearer … — but they answer the hard questions in opposite ways.
| Question | Shared secret | Delegated credential |
|---|---|---|
| Who is acting? | nobody in particular — whoever holds the string | a derived actor, integration:claude:<grantor-sub>, recorded |
| On whose behalf? | unknown | the consenting grantor, on the stored edge |
| What may it do? | whatever the endpoint allows the key | only the consented tools, only if their grants are present |
| Which resource? | often any that accepts the key | exactly the one URL the credential is bound to |
| Who pays? | untraceable | the projected platform subject (#1) |
| Turn one off? | rotate the key — breaks every holder | revoke one credential record; others keep working |
| What leaks if stolen? | a long-lived, broad secret | a short-lived, single-resource, least-privilege token |
A shared secret is an authentication fact with no authorization model behind it. A delegated credential is the inverse: the bearer is almost incidental; the record is where authority, scope, provenance, and revocation live. That is why losing the Redis grant record fails closed rather than open — a credential with no record behind it authorizes nothing.
It is worth naming what this is explicitly not: not a replacement for browser login, not a way to mint platform-admin sessions, not a broad "admin API" token. It is a descriptor-configured adapter from an already-authenticated consent flow to a least-privilege credential.
The MCP runtime is stateless on purpose
KDCube's proc-served MCP endpoints build stateless FastMCP apps:
mcp = FastMCP(
"KDCube Memories",
stateless_http=True,
instructions="Call named_services_list first ...",
icons=[...],
website_url="https://...",
)
The reason is the distributed runtime. A request flows ingress → proc → bundle MCP app, and may be handled by a
different worker or a fresh app instance each time. FastMCP's stateful streamable-HTTP mode keeps
mcp-session-id transports in local Python memory — not a durable boundary when the next request lands
elsewhere. KDCube keeps durable state where it belongs instead:
credential + consent state -> Connection Hub grant store
identity edges + projection -> Connection Hub store / runtime
product state -> product store
MCP protocol exchange -> stateless handler, nothing to lose on reconnect
So reconnecting a client never depends on a Python object still being alive in one worker. (If stateful MCP protocol features are ever needed, the right owner is a platform MCP ingress/broker, not a bundle-local object — that direction is noted in #3.)
The worked example: user memories
The focused example is user-memories@2026-06-26, whose MCP surface exposes memory_search
and memory_get. The interesting part is what happens after the guard admits the call — identity
scope:
Claude calls memory_search (Bearer)
|
v
managed guard verifies credential / resource / tool / grants (#3)
|
v
identity_scope on the credential = grantor_identity_family
|
v
memory service asks Connection Hub: delegated_identity_scope_resolve
|
v
Connection Hub returns memory_user_ids = [grantor, linked Telegram user, ...]
|
v
memory store query runs over exactly those owner ids
A memory can be created from a browser session, from Telegram, or by a delegated client; each record keeps its real
owner for provenance. When the resource opts into grantor_identity_family, the product resolves the
connected family server-side — the client never sends user ids. The delegated client does not become the user's
browser session; it is a delegate actor operating through a selected edge and selected grants. (The resolution
mechanics are #1; the guard that gates it is #3.)
The generic named_services bridge — one connector reaching mem, task, and
cnv namespaces through a second, nested grant boundary — is the same model applied more broadly. That is
its own piece; see Named services can now leave KDCube through a delegated MCP connector.
What the client sees, and what is actually enforced
A good MCP server helps the external model use the surface: server instructions, tool names and descriptions, MCP
ToolAnnotations, a server icon, a website URL, and the protected-resource / dynamic-registration metadata
from discovery. KDCube advertises all of these.
But none of it is the security boundary. ToolAnnotations such as readOnlyHint and
destructiveHint let a client group tools in its UI — read-only apart from write/action — and that
is purely connector UX. The enforcement is always the managed guard checking the call against the stored credential.
Presentation and authorization are related surfaces; they are not the same boundary.
client UX -> instructions, tool annotations, icon, website_url
(helps the model; not a gate)
security boundary -> managed guard: resource, tool, grants, consent (#3)
(the only gate)
Where it is configured
Two descriptor locations, two owners — the same split #2 and #3 use. The bundle descriptor declares the surface; Connection Hub declares what is delegable.
# bundle: what the app provides
surfaces:
as_provider:
mcp:
memories:
auth:
mode: managed
authority_id: delegated_client
tools:
memory_search: { grants: [memories:read] }
memory_get: { grants: [memories:read] }
selected_tool_grants: true
# connection-hub@1-0: what can be delegated, and the OAuth protocol config
connections:
delegated_credentials:
oauth:
enabled: true
public_clients:
- client_id: claude
redirect_uris:
- https://claude.ai/api/mcp/auth_callback # illustrative
- http://localhost/callback
capabilities:
- grant: memories:read
label: Read memories
delegable_roles: [kdcube:role:chat-user, kdcube:role:paid, kdcube:role:super-admin]
resources:
- resource: "*/user-memories@2026-06-26/public/mcp/memories*"
identity_scope: grantor_identity_family
tools:
memory_search: { label: Search memories, grants: [memories:read] }
memory_get: { label: Read one memory, grants: [memories:read] }
mode: managed is what makes the proc MCP bridge own the credential and grant check before dispatching
into the bundle app; without it, the auth block is bundle-owned metadata. The OAuth protocol config —
public_clients, dynamic_client_registration.allowed_redirect_uris, issuer,
enabled — lives only on the Connection Hub bundle, and toggling enabled: false disables the
whole public OAuth operation.
Related publications
identity_scope and economics attribution.named_services bridge as a specific external-client case of this same authenticated-MCP model.Documentation on GitHub
The live docs behind this entry:
- OAuth delegated-credential protocol adapter
- Delegation edges
- Authority credential envelope
- Connection Hub — solution map
- Authority projection
- Identity family resolver
- Recipe — protect a bundle MCP with managed credentials
- Recipe — delegate a KDCube service to an external app
- Recipe — use connected identities in a feature