Protecting KDCube Surfaces With Managed Credentials
Your MCP handler should never see an unauthorized call. A managed surface declares its auth in descriptors; one shared Connection Hub guard then runs a fixed sequence of fail-closed checks — credential valid → authority → resource (exact) → tool allowed → grants present → tool consented — before the bundle handler is ever called. This Deep piece walks the guard pipeline in order, what you declare versus what's enforced, and where identity scope fits. Product code is not a token parser.
Your MCP handler should never see an unauthorized call. By the time memory_search
runs, the request has already been checked: the credential is real, it was issued for this resource, this
tool was consented, and the tool's required grants are present. If any of those fails, the caller gets a denial and
your product code is never invoked.
That gate is the subject of this piece. Delegating A KDCube Service To An External App covered what the
user connects and consents to; this one covers what the guard actually checks at every
call, in order, before your handler sees the request. It is the enforcement side of the same story, and it
is the developer's view: descriptors, mode: managed, resource matching, tool grants, and where identity
scope fits.
bundle declares: I provide this surface. These tools exist. Each needs these grants.
Connection Hub holds: the issued credential + the consent edge (resource, tools, grants, scope).
the surface guard does: let this call into product code, or fail it closed.
The result is a clean boundary: product code is not a token parser. This builds on the vocabulary from the two prior pieces — actor, grant, identity family (#1) and the delegated credential + consent edge (#2) — and assumes them rather than re-deriving them.
Two modes: bundle-owned vs managed
A surface declares one of two auth modes, and they can coexist on the same bundle.
| Mode | Who owns auth | Use when |
|---|---|---|
mode: bundle |
the bundle owns its own auth contract (e.g. a private header token) | a bundle intentionally has a private integration contract — local and explicit |
mode: managed |
Connection Hub owns delegation, issuance, verification, consent, and enforcement | a KDCube service should be delegated to external apps through platform-owned consent and least privilege |
One bundle can do both:
surfaces:
as_provider:
mcp:
knowledge: # private contract
route: operations
auth:
mode: bundle
header_name: X-Knowledge-MCP-Token
memories: # delegated, platform-managed
route: public
auth:
mode: managed
authority_id: delegated_client
The goal is not to abolish bundle-owned auth. It is to make delegated external access a platform-managed surface, not a bundle-local invention.
What the surface declares
The security boundary lives in descriptors, so it is visible before any code runs and admin tooling can reason about what a bundle exposes. The provider bundle marks the surface managed and maps each tool to the grants it requires:
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
Three pieces matter here, and the guard reads each one:
- authority (
authority_id) — which credential authority is accepted at this boundary (delegated_client); - tool policy (
tools) — which tools exist and which grants each requires; - selected_tool_grants — whether a tool must also have been individually consented, not just be grant-eligible (defaults to
true).
That descriptor shape is exactly what the guard parses into its policy object — authority_id,
optional endpoint roles/permissions, per-tool grants, and
selected_tool_grants. The handler, by contrast, should be boring:
@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(...)
It does not parse the credential. It receives a request that has already passed the managed guard for the surface.
(One runtime note, expanded in the MCP piece: the proc-served FastMCP app should be built
stateless_http=True, because the durable credential and consent state lives in Connection Hub, not in
bundle-local protocol memory.)
What Connection Hub declares
The provider descriptor says what the surface needs. Connection Hub declares what is delegable and how grants map to concrete resources — the same two-row shape from Delegating A KDCube Service To An External App:
connections:
delegated_credentials:
oauth:
enabled: true
capabilities:
- grant: "memories:read"
label: "Read memories"
delegable_roles:
- "kdcube:role:chat-user"
- "kdcube:role:paid"
- "kdcube:role:privileged"
- "kdcube:role:super-admin"
resources:
- resource: "*/api/integrations/bundles/*/*/user-memories@2026-06-26/public/mcp/memories*"
identity_scope: "grantor_identity_family"
tools:
memory_search:
grants: ["memories:read"]
memory_get:
grants: ["memories:read"]
The capability row answers who may delegate this grant (delegable_roles)
and what label the consent screen shows. The resource row answers which concrete resource this
is, which tools it exposes, which grants each needs, and which identity scope it opts into. The split matters
because one grant can appear on several resources, and one resource exposes several tools. Both blocks are the real
shapes from the shipped user-memories@2026-06-26 and connection-hub@1-0 descriptors.
Note where identity_scope lives: on the resource row, not on the tool. It is not a
per-call gate the guard enforces — it is a setting the product reads after the guard passes (more
below).
The guard pipeline, in order
This is the load-bearing section. When a managed surface receives a request, the proc bridge runs the guard before dispatching to the bundle handler; if the guard returns a denial, the handler is never called. Here is the exact order the guard checks:
0. Is this surface mode: managed?
no -> skip the guard entirely (bundle owns its own auth)
yes -> continue
1. Bearer token present?
no -> 401 unauthorized (+ WWW-Authenticate pointing at OAuth metadata)
2. Token authenticates as a delegated-client session?
no -> 401 unauthorized
3. Endpoint-level roles/permissions satisfied? (if the descriptor sets any)
no -> 403 forbidden
4. Credential authority == descriptor authority_id?
no -> 403 forbidden (authority mismatch)
5. Credential resource present AND == this request's resource?
no -> 403 forbidden (resource missing / resource mismatch)
--- at this point a non-tools/call request (initialize, tools/list) is accepted ---
6. For each tools/call in the body:
a. tool is in the endpoint tool policy? no -> tool error: not allowed
b. tool roles/permissions satisfied? no -> tool error: missing role/permission
c. tool's required grants ⊆ credential grants? no -> tool error: missing grant
d. selected_tool_grants on, and tool ∈ consented tools? no -> tool error: not consented
7. All checks pass -> attach runtime authority context -> product handler runs.
Two details worth calling out.
Resource matching is exact, not prefix. Both sides are normalized — query string dropped,
trailing slash stripped — and then compared for equality. A credential minted for one MCP URL does not work on
another. (The wildcard * patterns you saw in the descriptors match resources to capability rows at
consent time; the per-request guard check is an exact string match against the credential's stored
resource.)
Tool checks are layered and all fail closed. A tool can be rejected for four distinct reasons — not in the endpoint policy, missing a role/permission, missing a grant, or never consented — and the guard returns a per-call JSON-RPC error for each, leaving other calls in the batch unaffected.
Why grant, tool, and consent are separate
The guard keeps three questions apart on purpose, and a credential can pass one while failing another.
authority -> "which credential kind is accepted here?" delegated_client
grant -> "what permission was delegated?" memories:read
tool -> "what action is being attempted?" memory_get
consent -> "did the user actually select this tool?" selected_tool_grants
So a credential with memories:read does not automatically authorize every
memories:read tool. If the user consented to memory_search only, a memory_get
call fails closed even though its grant is present:
tools/call: memory_get
descriptor requires: grants = [memories:read]
credential grants: [memories:read] (grant check: passes)
consented tools: [memory_search] (consent check: fails)
result: "tool not consented for this connection: memory_get" -> fail closed
That is what gives consent real teeth: approving a grant is not approving every tool it could unlock. And if a
tool needs two grants — say knowledge:read and knowledge:maintain — it declares
both, and a credential with only one fails the grant check.
Identity scope comes after the guard
Authorization is not the same as data selection. The guard decides whether the call may enter product code. It does not resolve which user ids the product should read — that is a separate step the product performs after the guard, and only if it needs cross-identity aggregation:
identity_scope, which the product resolves
past the guard, in product code.guard accepts the call
|
v
product handler runs
|
| (only if it aggregates across identities)
v
asks Connection Hub: delegated_identity_scope_resolve
|
v
Connection Hub returns memory_user_ids = [grantor, linked Telegram user, ...]
|
v
product reads by exactly those owner ids
The external app never sends memory_user_ids. The backend gets them from Connection Hub, bounded by
the resource's identity_scope and the grantor's connection edges. The mechanics of that resolution are
the identity model's job — see Connected Identities Are Not One User Id. The guard's runtime projection
simply carries the accepted credential's identity_scope forward so the product can ask later.
Generic bridges have a second, nested boundary
One managed surface can be a generic bridge over many namespaces.
kdcube-services@1-0/public/mcp/named_services is the example: its tools are generic
(named_services_search, named_services_get, …) and the namespace is a tool argument.
That creates two boundaries, checked by two owners:
outer (managed guard): the generic MCP tool + named_services:use
inner (named-service bridge): per-namespace grant, e.g.
mem.search -> memories:read
mem.upsert -> memories:write
task.search -> tasks:read
The outer guard from the pipeline above admits the generic tool. The named-service bridge then checks the
namespace operation's grant against the same credential before calling the provider; if it is missing, the
tool returns a delegated_consent_required result naming the missing grant. This is why the server
advertises instructions to call named_services_list first: the connector starts generic, discovers
namespaces, then uses each namespace's capability/schema before calling it.
What this enables
Once a bundle can declare managed surfaces, KDCube can host a real connection experience without each bundle reinventing auth:
Each connection then carries a clear service label, tool-level consent, grant-level enforcement, identity-scope control, revocation, economics projection, and actor provenance — every one of them declared in descriptors and enforced by one shared guard. That is the difference between "a token happens to work" and "a platform can safely host many user-delegated services."
Related publications
identity_scope, grantor/delegate edges, and economics attribution.Documentation on GitHub
The live docs behind this entry:
- Recipe — protect a bundle MCP with managed credentials
- Recipe — delegate a KDCube service to an external app
- Request authenticators
- Delegation edges
- Delegated-credential protocol adapters
- OAuth delegated-credential protocol adapter
- Authority credential envelope
- Bundle platform integration
- Bundles descriptor