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, its server-side resource_grants
apply to this resource, this tool was consented, and the matching resource-grant entry carries the tool's
required grants. 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-grant matching, operation 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 + consent state (resource_grants, selected operations, 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 an application exposes. The provider application marks the surface managed. The tool/grant catalog for delegated external access is owned by Connection Hub resources, not by the MCP surface block:
surfaces:
as_provider:
mcp:
memories:
auth:
mode: managed
authority_id: delegated_client
selected_tool_grants: true
Three pieces matter here:
- authority (
authority_id) — which credential authority is accepted at this boundary (delegated_client); - selected_tool_grants — whether a tool must also have been individually consented, not just be grant-eligible (defaults to
true); - resource identity — the runtime MCP URL is matched against the Connection Hub resource catalog, where tool labels and grants are declared.
The guard combines the surface boundary with the Connection Hub resource catalog before product code runs. 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
as resource grants: a concrete resource pattern mapped to the grants and operations that may be delegated there.
This replaces any idea of a global resources list plus a separate global grants list. The descriptor still contains a
catalog section so the consent flow can find candidate surfaces; the issued credential stores the boundary as
resource_grants.
connections:
delegated_credentials:
oauth:
enabled: true
capabilities:
- grant: "memories:read"
label: "Read memories"
delegable_roles:
- "kdcube:role:registered"
- "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"]
# issued consent record, server-side:
resource_grants:
"https://.../public/mcp/memories": ["memories:read"]
operations:
- memory_search
The capability row answers who may delegate this grant (delegable_roles)
and what label the consent screen shows. The resource catalog row answers which concrete
resource this is, which tools it exposes, which grants each needs, and which identity scope it opts into. The
durable consent record then stores the actual boundary as resource_grants:
resource -> grants[], plus the selected operations/tools. That shape matters because grants are
evaluated only inside the matching resource entry, not as one global grant union.
Note where identity_scope lives: on the resource-grant/resource catalog boundary,
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. Server-side resource_grants has an entry matching this request resource?
no -> 403 forbidden (resource grant 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 ⊆ matching resource_grants entry? 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-grant matching is exact for issued credentials. Descriptor wildcard patterns help the
consent flow match a requested resource to catalog rows. The issued credential stores concrete
resource_grants; at request time the guard normalizes the current resource and derives grants only from
the matching resource_grants entry. A credential minted for one MCP URL does not work on another unless
the record explicitly carries an applicable wildcard such as the admin * resource grant.
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
resource_grants -> "which grants apply to this resource?" URL -> [memories:read]
tool/operation -> "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]
matching resource_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 resource 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 matching resource_grants entry 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