App as MCP Provider and Consumer: From Zero to Governed Access
One app consumes a narrowly allow-listed MCP service, exposes its own governed tools, and hands automations expiring access — credentials never travel.
One KDCube app (bundle) consumes a narrowly allow-listed MCP service with either an app credential or a per-user, per-agent grant, exposes its own async MCP tools, and gives external automation expiring access without putting provider credentials in the model, sandbox, app descriptor, or delegated token.
This recipe follows the full path from a running KDCube installation to a working provider and consumer. It also covers the provider-backed case where an external automation calls KDCube mail or Slack tools on a user's behalf.
Current code and descriptors still use bundle in
bundle_id, bundles.yaml, and integration URLs. In
builder language, app = bundle: one deployable KDCube
application unit.
The boundary in one minute
AS CONSUMER KDCube agent → per-agent MCP tool allow-list → trusted MCP client app credential for a shared external service OR bound kdcube-agent:<app>:<agent> grant → MCP server AS PROVIDER external agent → KDCube delegated bearer → Connection Hub managed guard → app MCP tool → async domain service PROVIDER-BACKED TOOL external agent’s KDCube delegation AND approving user’s connected-account claim AND product/economics policy → Slack/Gmail call succeeds
The external agent never receives the Slack/Gmail token. Connection Hub keeps that credential and uses it only after the KDCube delegation passes its own resource, operation, grant, identity, and expiry checks.
- A running KDCube tenant/project
- A maintainable app package, here called
reporting@1-0 connection-hub@1-0, when external clients need managed access- An authenticated user who may delegate the selected grants
- An external MCP endpoint to consume, for the consumer path
Blueprint map — know which owner and file you are editing
Use two source directories. Replace these example absolute paths with yours:
/absolute/path/to/descriptors/ assembly.yaml bundles.yaml deployment app inventory and non-secret config bundles.secrets.yaml deployment app secrets gateway.yaml secrets.yaml /absolute/path/to/apps/reporting@1-0/ entrypoint.py KDCube surface declarations services/ __init__.py reports.py transport-neutral domain behavior surfaces/ __init__.py mcp/ __init__.py reports.py app-native MCP tool definitions
The configuration ownership used throughout this recipe:
| Record | Owner | Location |
|---|---|---|
| Reporting app registration and non-secret settings | reporting@1-0 | bundles.yaml → bundles.items[id=reporting@1-0] |
| Reporting app's external MCP consumer credential | reporting@1-0 | bundles.secrets.yaml → bundles.items[id=reporting@1-0] → secrets |
| Reporting MCP code | reporting@1-0 source | entrypoint.py, services/reports.py, surfaces/mcp/reports.py |
| Delegated authority, capabilities, resource patterns, OAuth clients | connection-hub@1-0 | bundles.yaml → … → config.connections.delegated_credentials.oauth |
| A user's Slack/Gmail connection and consent | Connection Hub user state | Connection Hub → Delegated to KDCube / Provider connections |
| An external automation's KDCube access | Connection Hub grant record | Connection Hub → Delegated by KDCube |
| A hosted agent's KDCube access | One grant per user, agent, and resource | Connection Hub → Delegated by KDCube · kdcube-agent:<app>:<agent_id> |
There must be only one item with a given id in each
descriptor. Later YAML blocks show a path to merge into the existing item;
they are not instructions to append another reporting@1-0 or
connection-hub@1-0 item.
OP 10OF 110 Start from a descriptor-owned runtime
Register the local reporting app and make sure Connection Hub is present:
bundles: version: "1" items: - id: reporting@1-0 name: Reporting path: /absolute/path/to/apps/reporting@1-0 module: entrypoint singleton: false config: {{}} # If this item already exists, keep it and its current config. - id: connection-hub@1-0 singleton: false config: {{}}
Create the matching secret item for the reporting app:
bundles: version: "1" items: - id: reporting@1-0 secrets: {{}}
For a new local runtime, stage one canonical descriptor set and start it:
kdcube init \ --tenant demo-tenant \ --project demo-project \ --descriptors-location /absolute/path/to/descriptors kdcube start --tenant demo-tenant --project demo-project
The descriptor directory owns the app inventory and policy. Do not put MCP URLs, credentials, or grants into prompts or generated agent code.
When changing only bundles.yaml and
bundles.secrets.yaml later, inspect the semantic update first,
then apply and reload:
kdcube bundle config apply \ --workdir ~/.kdcube/kdcube-runtime/demo-tenant__demo-project \ --descriptors-location /absolute/path/to/descriptors \ --dry-run kdcube bundle config apply \ --workdir ~/.kdcube/kdcube-runtime/demo-tenant__demo-project \ --descriptors-location /absolute/path/to/descriptors \ --reload
OP 20OF 110 Keep app code async and transport-neutral
Put business behavior in an async service. MCP and REST adapters should call the same methods rather than becoming the product implementation. The service receives the already-authenticated request identity from a surface adapter and remains independent of MCP or REST.
from typing import Any class ReportService: async def search( self, *, query: str, limit: int, request: Any, ) -> list[dict[str, Any]]: # Use an async database/client here and enforce record policy from the # host-bound request identity. ... async def get(self, *, report_id: str, request: Any) -> dict[str, Any]: ... async def export(self, **params: Any) -> dict[str, Any]: # Optional REST branch in OP 100 calls this method. ...
KDCube apps share a concurrent proc event loop. A synchronous network,
database, filesystem, subprocess, sleep, or lock call blocks unrelated apps
and users. Use async libraries, or move an unavoidable bounded sync library
to await asyncio.to_thread(...).
OP 30OF 110 Consume an MCP service INBOUND · AS CONSUMER
Merge the server registration and agent allow-list into the existing
reporting item. private_docs is the server ID. The agent's
server_id must match it:
bundles: version: "1" items: - id: reporting@1-0 config: surfaces: as_consumer: mcp: services: mcpServers: private_docs: transport: streamable-http url: https://mcp.example.com/private-docs protocol_mode: auto auth: type: bearer secret: b:mcp.private_docs.token default_agent: main agents: main: tools: - name: private documentation kind: mcp server_id: private_docs alias: docs allowed: - search - read_document tool_traits: search: strategy: [exploration] read_document: strategy: [exploration]
protocol_mode: auto uses MCP 2026-07-28 discovery first and
falls back to the legacy initialize handshake when the peer is
older. Use protocol_mode: legacy only for an endpoint you know
cannot answer modern discovery.
b:mcp.private_docs.token means: resolve the path
mcp.private_docs.token from this app's secret object. Put that
value in the same app item in bundles.secrets.yaml:
bundles: version: "1" items: - id: reporting@1-0 secrets: mcp: private_docs: token: replace-through-the-secret-provider
The model sees only:
mcp.docs.search mcp.docs.read_document
The trusted MCP subsystem resolves the secret. In split isolated execution, the untrusted code executor receives neither that credential nor the full app configuration; it sends an approved tool request to the trusted supervisor.
Consume a KDCube MCP as the signed-in user, under this agent's grant
Use an app credential for a shared service identity. Use per-agent delegation when a hosted agent must act for the signed-in user at a managed KDCube boundary. Put the connection directly in that agent's tool list and configure no bearer secret:
surfaces: as_consumer: default_agent: main agents: main: tools: - name: KDCube named services kind: mcp server_id: kdcube_named_services alias: named_services url: https://runtime.example/api/integrations/bundles/demo-tenant/demo-project/kdcube-services@1-0/public/mcp/named_services resource: "*/api/integrations/bundles/*/*/kdcube-services@1-0/public/mcp/named_services*" transport: streamable_http delegated: true scopes: [named_services:use]
url is the endpoint the client dials. resource must byte-match the delegated-resource
id in connection-hub@1-0.config.connections.delegated_credentials.oauth.resources. Grant creation and
lookup use that id; the guard matches the actual request URL against it.
The agent becomes kdcube-agent:reporting@1-0:main. Each turn the runtime retrieves the bearer
already bound to that user-agent-resource grant. The user's browser session never enters the agent; a grant to
main grants nothing to a sibling. No grant means the connection remains unbound and no MCP contact is
made. With a partial grant, the door itself keeps the boundary per operation: a call needing claims outside the
granted set is denied naming exactly the missing ones, the scoped consent card rises in chat, and the approval
merges into the agent's record.
scopes carries only door admission (named_services:use).
Namespace operation grants are derived from Connection Hub's resource catalog after the MCP connection binds.
If this path reaches Slack, its real provider claims (slack:search, slack:post, ...) live
on this caller's selected-account account_scope. The bridge accepts that live binding for the operation
requirement and the provider broker enforces it before the upstream call. The provider token never enters the agent.
OP 40OF 110 Expose an ordinary MCP service OUTBOUND · AS PROVIDER
You do not need a named-service provider to expose MCP. Use named
services only when the domain also needs provider-owned refs, schema,
search, actions, materialization, and generic UI/agent behavior. Build a
stateless KDCubeMCPServer in a focused module:
from typing import Annotated, Any, Awaitable, Callable from mcp.types import ToolAnnotations from pydantic import Field from kdcube_ai_app.apps.chat.sdk.runtime.mcp.server import KDCubeMCPServer SearchReports = Callable[[str, int], Awaitable[list[dict[str, Any]]]] GetReport = Callable[[str], Awaitable[dict[str, Any]]] def build_reports_mcp_app( *, search_reports: SearchReports, get_report: GetReport, ): mcp = KDCubeMCPServer( "Reporting", stateless_http=True, instructions=( "Search before reading a report. Use only ids returned by search." ), ) @mcp.tool( name="search_reports", description="Search reports visible to the current principal.", annotations=ToolAnnotations( readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False, ), structured_output=False, ) async def search_reports_tool( query: Annotated[str, Field(description="Report search query.")], limit: Annotated[int, Field(ge=1, le=25)] = 10, ) -> dict[str, Any]: items = await search_reports(query.strip(), limit) return {{"items": items, "count": len(items)}} @mcp.tool( name="get_report", description="Read one report returned by search_reports.", annotations=ToolAnnotations( readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False, ), structured_output=False, ) async def get_report_tool( report_id: Annotated[str, Field(description="Stable report id.")], ) -> dict[str, Any]: return await get_report(report_id.strip()) return mcp
KDCubeMCPServer defaults to stateless_http=True
for proc-served app MCP. The explicit value keeps that distributed-serving
contract visible. The next request may run in another worker or after a
restart. Durable state belongs in product storage, not a process-local MCP
object. The same endpoint accepts modern MCP 2026-07-28
server/discover and legacy initialize callers; app
tools do not branch on the negotiated protocol.
Expose it from the app entrypoint:
from kdcube_ai_app.apps.chat.sdk.solutions.chatbot.entrypoint import BaseEntrypoint from kdcube_ai_app.infra.plugin.bundle_loader import ( bundle_entrypoint, bundle_id, mcp, ) from .services.reports import ReportService from .surfaces.mcp.reports import build_reports_mcp_app BUNDLE_ID = "reporting@1-0" @bundle_entrypoint(name="reporting", version="1.0.0", priority=10) @bundle_id(id=BUNDLE_ID) class ReportingApp(BaseEntrypoint): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.reports = ReportService() # construction only; no blocking I/O @mcp( alias="reports", route="public", transport="streamable-http", auth_config="surfaces.as_provider.mcp.reports.auth", ) async def reports_mcp(self, request=None, **kwargs): del kwargs return build_reports_mcp_app( search_reports=lambda query, limit: self.reports.search( query=query, limit=limit, request=request, ), get_report=lambda report_id: self.reports.get( report_id=report_id, request=request, ), )
These names form the route and configuration join:
@bundle_id(id="reporting@1-0")must equal thebundles.yamlitem ID.alias="reports"becomes the final MCP route segment and the descriptor surface key.route="public"becomes thepublic/mcpportion of the URL; authentication is still enforced by OP 50.auth_config="surfaces.as_provider.mcp.reports.auth"resolves relative tobundles.items[id=reporting@1-0].config; OP 50 creates exactly this path.- MCP tool names
search_reportsandget_reportmust match the Connection Hub tool catalog keys in OP 60.
/api/integrations/bundles/{{tenant}}/{{project}}/reporting@1-0/public/mcp/reports
The legacy word bundles remains part of the current protocol
URL.
OP 50OF 110 Put a managed guard in front of the MCP OUTBOUND · AS PROVIDER
bundles: items: - id: reporting@1-0 config: surfaces: as_provider: mcp: reports: auth: mode: managed authority_id: delegated_client selected_tool_grants: true
This is the exact authorization-owner join:
reporting@1-0 config asks for authority_id=delegated_client → proc managed MCP guard resolves that registered authority → connection-hub@1-0 registers delegated_client when config.connections.delegated_credentials.oauth.enabled=true
delegated_client is an authority identifier, not an app ID.
The reporting item selects it here; the Connection Hub item enables and
configures it in OP 60. The reporting app does not make an HTTP call to
Connection Hub or parse the token itself.
This keeps token parsing out of app tools. Before app code runs, the
managed guard checks the bearer, issuer, concrete resource, selected tool,
grants, expiry, revocation, and projected grantor identity.
route: public makes discovery and the OAuth challenge
reachable. It does not make a mode: managed tool publicly
callable.
OP 60OF 110 Describe what can be delegated OUTBOUND · AS PROVIDER
bundles: items: - id: connection-hub@1-0 config: connections: delegated_credentials: oauth: enabled: true capabilities: - grant: reports:read label: Read reports description: Search and read reports through delegated MCP tools. delegable_roles: - kdcube:role:registered - kdcube:role:paid resources: - resource: >- */api/integrations/bundles/*/*/reporting@1-0/public/mcp/reports* label: Reporting MCP identity_scope: grantor_identity_family tools: search_reports: label: Search reports description: Search reports visible to the approving user. grants: [reports:read] get_report: label: Read report description: Read one report visible to the approving user. grants: [reports:read]
The four names must line up:
delegated_clientin OP 50 matches the authority registered when this Connection Hub OAuth block is enabled.- The resource pattern contains the reporting item ID
reporting@1-0. - Its
public/mcp/reportssuffix matchesroute="public"andalias="reports"inentrypoint.py. search_reportsandget_reportmatch the MCP tool names insurfaces/mcp/reports.py.
identity_scope: grantor_identity_family tells the managed
boundary to project the approving KDCube user's identity family into the
reporting call. The app therefore applies record policy as that user while
audit provenance still retains the external delegated actor. If any one name
differs, discovery, consent, or tool authorization will not describe the
route that is actually called.
reporting app declares that its MCP uses managed delegated-client auth Connection Hub declares which concrete tools exist, what grants they need, and which users may delegate those grants
Product code must still enforce record-level policy. A valid delegated token does not make every report visible.
OP 70OF 110 Create working automation access now OUTBOUND · AS PROVIDER
The Reporting MCP and Read reports choices appear here because OP 60 placed them in the Connection Hub resource/capability catalog. For a script or agent that can set an authorization header:
- Sign in to KDCube.
- Open Connection Hub.
- Open Delegated by KDCube.
- Choose Create automation access.
- Select the reporting MCP resource and Read reports.
- Choose a short expiry.
- Create the token and copy its
Bearer ...value once.
Configure the external MCP client with:
URL https://runtime.example/api/integrations/bundles/ demo-tenant/demo-project/reporting@1-0/public/mcp/reports HTTP header Authorization: Bearer <the issued KDCube delegated token>
The token represents an automation acting for the approving KDCube user. It is not that user's browser session and it contains no reporting database secret or upstream provider token.
For this ordinary product-specific MCP resource, the manual screen
selects the resource grants and Connection Hub derives the compatible
top-level MCP tools. The exact nested picker appears only when the selected
resource declares a named_services catalog. Do not use the
all-platform admin resource for an ordinary agent.
OP 80OF 110 Choose how the external MCP caller identifies itself OUTBOUND · AS PROVIDER
KDCube resolves a public OAuth caller in this order:
1. public_clients descriptor pre-registration 2. client_id_metadata_documents HTTPS Client ID Metadata Document (CIMD) 3. dynamic_client_registration DCR compatibility
Enable only the paths the deployment intends to accept. A pre-registered
caller is the most explicit. CIMD lets a caller use its HTTPS metadata URL as
its client_id. DCR remains available for callers that still
register at runtime. All three enter the same PKCE, consent, grant, token,
refresh, and revocation path.
bundles: items: - id: connection-hub@1-0 config: connections: delegated_credentials: oauth: # Keep enabled, capabilities, and resources from OP 60. public_clients: - client_id: claude client_name: Claude application_type: native redirect_uris: - https://claude.ai/api/mcp/auth_callback - http://localhost/callback client_id_metadata_documents: enabled: true allowed_domains: [] allow_subdomains: true fetch_timeout_seconds: 5.0 max_document_bytes: 5120 cache_ttl_seconds: 3600 cache_max_ttl_seconds: 86400 dynamic_client_registration: enabled: true default_application_type: native allowed_redirect_uris: - https://claude.ai/api/mcp/auth_callback - http://localhost/callback
Replace ids and callbacks with values supported by the actual MCP caller.
Do not place any of these registrations under reporting@1-0.
For CIMD, the caller hosts a small JSON document at the exact HTTPS URL it
uses as client_id:
{
"client_id": "https://agent.example/.well-known/mcp-client.json",
"client_name": "Example agent",
"application_type": "web",
"redirect_uris": ["https://agent.example/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
KDCube fetches CIMD without ambient credentials, redirects, proxies, or
cookies; resolves and pins public addresses; bounds the document; and
requires exact client_id and callback matching. DCR runs before
any user authenticates, so its allowed_redirect_uris is the
corresponding fence. Native loopback entries match any port; scheme, host,
and path must match exactly.
client probes MCP → KDCube returns protected-resource metadata → Connection Hub resolves pre-registration, CIMD, or DCR → client opens Connection Hub authorization → user signs in and selects allowed tools/grants → client exchanges the code → client calls with its delegated KDCube credential
The external client becomes a delegated actor. The approving user remains the grantor, product-data subject, and economics subject unless a narrower approved identity scope says otherwise. Registration is complete before consent, but it is not consent: a recognized caller with no grant still cannot list or call protected tools.
OP 90OF 110 Use Slack, Gmail, or another connected provider safely BOTH DIRECTIONS
Use this branch only when the selected MCP tool ultimately calls a connected provider. The ordinary reporting MCP from OP 40–80 does not require a Slack or Gmail connection.
The provider credential is user state held by Connection Hub. It is not a secret copied into the reporting app or into the external automation token. Provider-backed tools require two independent permissions:
Delegated by KDCube "this external automation or hosted agent may call this KDCube resource/operation" Delegated to KDCube "KDCube may use this connected Slack/Gmail account with these claims"
Complete them through the same Connection Hub journey:
- In Delegated by KDCube → Create automation access, choose the concrete managed MCP resource and exact namespace operations.
- If a selected operation reports a provider prerequisite, use its Connect account, Approve access, or Reconnect account action to complete the required claim under Delegated to KDCube. For example, mail search needs
gmail:read; mail send needsgmail:send; Slack posting needsslack:post. - Return to the automation form, keep only the intended namespace operations, create the token, and give the external agent the KDCube bearer, never the Slack/Gmail token.
- Call the named-service MCP. KDCube projects the grantor, checks the nested namespace/operation grant, resolves that user's eligible connected account, and only then calls the provider.
Once admitted, let the client discover the realm progressively. Call
object_schema with the namespace for the recursive root, use
schema_path to browse a branch or query to search
capability declarations, then expand the returned object_kind
and schema_operation before calling it. That query searches the
app-owned capability catalog. search_objects(query=...) remains
the separate provider-owned search over mail, Slack messages, documents, or
other realm objects.
delegated client external automation or kdcube-agent:<app>:<agent> | KDCube delegated bearer ↓ managed named-services MCP | resource + tool + nested operation + KDCube grants ↓ mail/slack named-service provider | approving user’s connected-account claim ↓ provider API
If the provider account is missing, expired, or lacks the claim, the provider fails closed with an actionable connection/upgrade requirement. A normal MCP tool result is not universally treated by clients as a new OAuth challenge, so connect the provider first for a predictable first run.
Inside a hosted KDCube agent, the shared MCP client unwraps a single JSON text content block before returning the tool value. The model sees the provider's direct success or denial object, and the Steps view shows a compact summary. This does not make an external client open OAuth automatically; it makes the repair contract visible to the agent that must present it.
Both the interactive MCP connector consent and the manual Create automation access screen render the descriptor-backed named-service catalog. A hosted agent uses the same resource/claim catalog and is listed in the same registry. The manual screen makes every existing namespace operation selectable and sends the exact choice as:
{{
"named_service_operations": {{
"*/kdcube-services@1-0/public/mcp/named_services*": {{
"mail": ["object.search"],
"slack": ["object.search", "object.action"]
}}
}}
}}
Selecting an operation also selects its declared KDCube grants and the
common MCP entry grant. Removing a required grant removes the affected
operation. Connection Hub validates the selection and stores a narrowed copy
of the same named_services policy in the server-side grant
record. The KDCube Services bridge then rejects every unselected namespace
and operation through its normal runtime catalog.
The manual bearer remains a pointer to that live card. The update operation
can replace named_service_operations without reminting the token.
The current existing-card editor preserves the nested selection for surviving
resources but does not yet render the namespace-operation picker; changing
that inner selection in place currently uses
delegated_access_update directly.
Provider requirements remain a separate boundary. The screen shows only
the claims required by the operations currently selected and offers the
existing Connect account, Approve access,
or Reconnect account action into Delegated to
KDCube. A flat provider requirement stays flat; an
operation-specific claims_by_operation requirement stays
grouped by its provider operation. Those claims are not copied into the
automation bearer.
When the user edits an existing OAuth account's claims, Connection Hub launches
Re-approve with the provider for that same account_id. The callback uses
replacement mode, so unchecked claims are removed and the account ends with
exactly the selected set. Credential-backed accounts use a fresh-secret form
instead of an OAuth redirect.
An operation such as object.action remains one operation
with the grant set declared for it; Connection Hub does not invent delegated
action variants from future tool arguments. The caller's grant card binds
specific accounts per claim (account_scope, chosen by the user —
default-closed, so an unbound provider yields
agent_grant_required until the user ticks an account); when
several BOUND accounts are eligible, account_id selects among
them at call time.
If several accounts are eligible and none is selected, requirement
preflight returns account_required with labeled candidates. The
chosen account_id then reaches both preflight and provider execution.
Naming an account outside the caller's binding returns
agent_account_binding_required with a recovery URL for the
existing caller card, KDCube resource, account, and claim. That URL is for
the host or client to present; it does not edit the card or replay the failed
call.
OP 100OF 110 Use ordinary REST when the client does not speak MCP OUTBOUND · AS PROVIDER
MCP is optional at the external edge. An app can expose a normal async API and protect it with the same delegated credential model:
# Extend the existing imports and ReportingApp class from OP 40. from kdcube_ai_app.infra.plugin.bundle_loader import ( + api, bundle_entrypoint, bundle_id, mcp, ) class ReportingApp(BaseEntrypoint): # Keep __init__ and reports_mcp from OP 40. + @api(method="POST", alias="reports_export", route="public") + async def reports_export(self, **params): + return await self.reports.export(**params)
bundles: items: - id: reporting@1-0 config: surfaces: as_provider: api: public: reports_export: POST: auth: mode: managed authority_id: delegated_client selected_operation_grants: true operations: reports_export: grants: [reports:read]
The app descriptor alone is not enough. Add a sibling entry to the
resources list already created in OP 60:
bundles: items: - id: connection-hub@1-0 config: connections: delegated_credentials: oauth: resources: # Keep the Reporting MCP resource from OP 60 and add this one. - resource: >- */api/integrations/bundles/*/*/reporting@1-0/public/reports_export* label: Reporting REST API identity_scope: grantor_identity_family operations: reports_export: label: Export reports description: Export reports visible to the approving user. grants: [reports:read]
The REST join is the same pattern as MCP: app ID + route + operation
alias in the URL, authority_id=delegated_client at the app
boundary, and the matching resource/operation/grant in Connection Hub. Call
it with the same Authorization: Bearer ... shape.
OP 110OF 110 Verify every boundary
- Call the managed MCP without a token. Confirm authentication fails and the protected-resource challenge identifies Connection Hub.
- Connect with a modern MCP client. Confirm
server/discover,tools/list, and a boundedtools/callreach the deployed app surface. - Connect with a legacy client. Confirm
initialize,tools/list, and the same boundedtools/callreach the same app tools. - Exercise each enabled caller-registration path independently: a descriptor
public_clientsentry, an HTTPS CIMD URL, and a DCR caller. Confirm an unrecognized caller and an unapproved redirect are rejected before consent. - Before approving any resource, confirm each recognized caller still cannot list or call protected tools. Registration identifies the caller; it grants no authority.
- Complete manual access or the MCP OAuth flow and list/call only selected tools with a standard MCP client.
- Use a token minted for another resource. Confirm it fails.
- Remove a required KDCube grant or operation. Confirm the provider is never called.
- For the named-services MCP resource, select only one namespace operation. Confirm another operation in the same namespace and every unselected namespace fail at the inner bridge.
- With two eligible bound provider accounts, omit
account_idand confirmaccount_required; resend with one candidate and confirm preflight and the provider result name the same account. Name an unbound account and confirmagent_account_binding_requiredtargets the existing caller card. - For Slack/Gmail, revoke the connected provider claim while the KDCube token remains valid. Confirm the call fails closed.
- Revoke the automation in Delegated by KDCube. Confirm the same bearer is rejected immediately.
- Wait for a short token to expire and confirm authentication fails.
- Confirm logs/audit retain the external delegate, approving grantor, resource, operation, and economics subject without recording credentials.
- Confirm the consumer agent catalog contains only its allow-listed MCP tools.
- Search prompts, generated code, logs, and executor environment for the MCP and provider secret values. They must be absent.
- Configure two hosted agents for the same resource; grant one and confirm the sibling remains unbound.
- Confirm a later turn reuses the bound token without exposing it or the user session to the model.
- Make
resourcediffer from the catalog id and confirm lookup fails before MCP contact. - Revoke one hosted-agent resource row and confirm only that connection stops.
- For a provider-backed hosted call, revoke the agent grant and connected-account claim separately; either must stop it.
- For a file-bearing provider action, pass an existing artifact as a durable
conv:fifile_pathand confirm the provider materializes it under current actor, grant, and account authority. From a turn-less client, use staged upload and passstaged_ref; reservecontent_base64for tiny generated files with a filename. - Force a hosted-agent consent demand and confirm the card appears while the foreign runtime still receives the original request plus an unavailable-tool fact. After approval, "try again" should resume from that native transcript rather than starting from an empty session.
- The app consumes one MCP server through a per-agent tool allow-list.
- Consumer credentials live in
bundles.secrets.yamlor the configured secret provider, never inbundles.yamlor model context. - The app exposes stateless async MCP tools backed by ordinary domain services; modern and legacy callers reach the same tool implementation.
- Consumer connections use
protocol_mode: autounless a known old server requireslegacy. - Every enabled external caller-registration path — descriptor pre-registration, CIMD, or DCR — is fenced before consent and grants no authority by itself.
- Connection Hub owns managed external resource/tool/grant policy.
- External automation receives a short-lived KDCube delegation, not a browser session or provider credential.
- A hosted agent has its own
kdcube-agent:<app>:<agent>grant per resource; siblings inherit nothing. - Delegated MCP config distinguishes the concrete
urlfrom the exact catalogresource. - Provider-backed calls require both KDCube delegation and connected-account consent.
- File-consuming provider actions use durable
file_pathorstaged_refsources that the trusted runtime materializes; local workspace paths are not treated as provider-visible authority. - A manual named-service token contains only the selected namespace operations; sibling operations and namespaces fail closed.
- Record policy and economics checks still run at their proper boundaries.
- Wrong resource, missing operation/grant, provider revocation, delegated revocation, and expiry all fail closed.