KDCube
← Recipes
KDCube Recipes · The Dispatch Form

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.

15 July 2026RecipesHands-onThe Dispatch Form
MCPapp surfacesdelegated accessConnection Hubnamed servicesas_consumeras_providerautomation token

WHAT LEAVES THE OFFICE

One KDCube app (bundle) consumes a narrowly allow-listed MCP service, exposes its own async MCP tools, and gives an 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.

ONE OFFICE · BOTH MCP DIRECTIONSSecrets stay at trusted boundaries. Authority travels as a delegation.INBOUND · CONSUMEKDCube agentselected tool catalogper-agent allow-listserver id + allowed toolstrusted MCP clientresolves the app secret,outside untrusted codeexternal MCPvendor/serviceSECRET NEVER ENTERS MODEL CONTEXTOUTBOUND · PROVIDEexternal agentdelegated actormanaged MCP guardresource · tool · grant · identityexpiry · revocationyour app MCPstateless async tools;record policy remainsdomain serviceaccounted operationCONNECTION HUB GRANT · the delegationCONNECTED ACCOUNT · the user’s Slack/Gmail claimPROVIDER-BACKED CALL = DELEGATION AND CONNECTED-ACCOUNT CONSENT AND PRODUCT/ECONOMICS POLICYTWO INDEPENDENT PERMISSIONS · NEITHER TOKEN EVER CHANGES HANDS
Both directions through one office: secrets stay at trusted boundaries; authority travels as a delegation.

The boundary in one minute

the.boundaryDISPATCH
AS CONSUMER
KDCube agent
  → per-agent MCP tool allow-list
  → trusted MCP client resolves app secret
  → external 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.

YOU WILL NEED
  • 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:

the.two.directoriesLAYOUT
/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                FastMCP tool definitions

The configuration ownership used throughout this recipe:

RecordOwnerLocation
Reporting app registration and non-secret settingsreporting@1-0bundles.yaml → bundles.items[id=reporting@1-0]
Reporting app's external MCP consumer credentialreporting@1-0bundles.secrets.yaml → bundles.items[id=reporting@1-0] → secrets
Reporting MCP codereporting@1-0 sourceentrypoint.py, services/reports.py, surfaces/mcp/reports.py
Delegated authority, capabilities, resource patterns, OAuth clientsconnection-hub@1-0bundles.yaml → … → config.connections.delegated_credentials.oauth
A user's Slack/Gmail connection and consentConnection Hub user stateConnection Hub → Delegated to KDCube / Provider connections
An external automation's KDCube accessConnection Hub grant recordConnection Hub → Delegated by KDCube

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

EDIT/absolute/path/to/descriptors/bundles.yaml and bundles.secrets.yaml
OWNERthe deployment descriptor set — where the app source is, and the app-owned config/secret roots

Register the local reporting app and make sure Connection Hub is present:

bundles.yamlYAML
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.secrets.yamlYAML
bundles:
  version: "1"
  items:
    - id: reporting@1-0
      secrets: {{}}

For a new local runtime, stage one canonical descriptor set and start it:

terminalSHELL
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:

terminalSHELL
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

EDIT/absolute/path/to/apps/reporting@1-0/services/reports.py
OWNERthe reporting app source — no descriptor change in this step

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.

services/reports.pyPYTHON
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

EDITthe existing reporting@1-0 item in bundles.yaml, then the same app item in bundles.secrets.yaml
OWNERthe reporting app — server address and agent allow-list under its config; the bearer under its secrets

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.yamlYAML
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
                    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]

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.secrets.yamlYAML
bundles:
  version: "1"
  items:
    - id: reporting@1-0
      secrets:
        mcp:
          private_docs:
            token: replace-through-the-secret-provider

The model sees only:

the.model.seesCATALOG
mcp.docs.search
mcp.docs.read_document
CHECK · STAMPED RECEIVED

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.

OP 40OF 110 Expose an ordinary MCP service OUTBOUND · AS PROVIDER

EDITsurfaces/mcp/reports.py for MCP tools; entrypoint.py for the KDCube route
OWNERthe reporting app source — the FastMCP module defines protocol tools; the entrypoint publishes them as one app surface

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 FastMCP app in a focused module:

surfaces/mcp/reports.pyPYTHON
from typing import Annotated, Any, Awaitable, Callable

from pydantic import Field


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,
):
    from mcp.server.fastmcp import FastMCP
    from mcp.types import ToolAnnotations

    mcp = FastMCP(
        "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

stateless_http=True is required for the current proc-served app MCP path. The next request may run in another worker or after a restart. Durable state belongs in product storage, not a process-local FastMCP session.

Expose it from the app entrypoint:

entrypoint.pyPYTHON
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 the bundles.yaml item ID.
  • alias="reports" becomes the final MCP route segment and the descriptor surface key.
  • route="public" becomes the public/mcp portion of the URL; authentication is still enforced by OP 50.
  • auth_config="surfaces.as_provider.mcp.reports.auth" resolves relative to bundles.items[id=reporting@1-0].config; OP 50 creates exactly this path.
  • FastMCP tool names search_reports and get_report must match the Connection Hub tool catalog keys in OP 60.
the.mcp.urlROUTE
/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

EDITbundles.yaml → existing reporting@1-0 item → config.surfaces.as_provider.mcp.reports.auth
OWNERthe reporting app declares what authority its reports surface accepts; it does not implement that authority itself
bundles.yamlYAML
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:

the.authority.joinDISPATCH
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

EDITbundles.yaml → existing connection-hub@1-0 item → config.connections.delegated_credentials.oauth
OWNERConnection Hub — this separate app registers the delegated_client authority and owns what users may delegate
bundles.yamlYAML
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:

  1. delegated_client in OP 50 matches the authority registered when this Connection Hub OAuth block is enabled.
  2. The resource pattern contains the reporting item ID reporting@1-0.
  3. Its public/mcp/reports suffix matches route="public" and alias="reports" in entrypoint.py.
  4. search_reports and get_report match the FastMCP tool names in surfaces/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.

two.ownersDISPATCH
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

USEthe running KDCube UI, not a descriptor file
OWNERConnection Hub creates a server-side grant record and issues the one-time bearer

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:

  1. Sign in to KDCube.
  2. Open Connection Hub.
  3. Open Delegated by KDCube.
  4. Choose Create automation access.
  5. Select the reporting MCP resource and Read reports.
  6. Choose a short expiry.
  7. Create the token and copy its Bearer ... value once.

Configure the external MCP client with:

client.settingsDISPATCH
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 Let an MCP client perform the handshake OUTBOUND · AS PROVIDER

EDITbundles.yaml → existing connection-hub@1-0 item → …oauth.public_clients
OWNERConnection Hub — registration of the external OAuth client, not reporting-app configuration

For clients that support remote MCP OAuth discovery, configure the public client and redirect URI by merging this under the same connection-hub@1-0 item used in OP 60:

bundles.yamlYAML
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
                  redirect_uris:
                    - https://claude.ai/api/mcp/auth_callback
                    - http://localhost/callback

Replace client_id and callback URLs with values supported by the actual MCP client. Do not place an OAuth client registration under reporting@1-0. Then give the client the same managed MCP URL:

the.handshakeDISPATCH
client probes MCP
  → KDCube returns protected-resource metadata
  → 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.

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.

CONFIGmanaged named-services MCP surface: bundles.yaml → kdcube-services@1-0 → …mcp.named_services.auth; delegable catalog: connection-hub@1-0 → …oauth.resources[].named_services
STATEthe user's provider credential and claims: Delegated to KDCube / Provider connections; the automation grant: Delegated by KDCube

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:

two.permissionsDISPATCH
Delegated by KDCube
  "this external automation 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:

  1. In Delegated by KDCube → Create automation access, choose the concrete managed MCP resource and exact namespace operations.
  2. 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 needs gmail:send; Slack posting needs slack:post.
  3. 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.
  4. 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.
the.provider.laneDISPATCH
external 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.

Both the interactive MCP connector consent and the manual Create automation access screen render the descriptor-backed named-service catalog. The manual screen makes every existing namespace operation selectable and sends the exact choice as:

selection.payloadJSON
{{
  "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.

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.

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. Multiple eligible provider accounts are selected by account_id at call time; the delegated token is not itself bound to one provider account.

OP 100OF 110 Use ordinary REST when the client does not speak MCP OUTBOUND · AS PROVIDER

EDITthe existing ReportingApp class in entrypoint.py; managed auth under reporting@1-0 in bundles.yaml; the REST resource under connection-hub@1-0
OWNERSthe reporting item owns its REST surface guard; Connection Hub owns what an external client may delegate for that URL

MCP is optional at the external edge. An app can expose a normal async API and protect it with the same delegated credential model:

entrypoint.pyDIFF
# 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.yamlYAML
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.yamlYAML
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

USEthe deployed URLs, Connection Hub UI, and proc/audit logs
OWNERyou — test the deployed route, not only the Python function; service-method tests bypass the descriptor and managed-guard joins this recipe proves
  1. Call the managed MCP without a token. Confirm authentication fails and the protected-resource challenge identifies Connection Hub.
  2. Complete manual access or the MCP OAuth flow and list/call only selected tools with a standard MCP client.
  3. Use a token minted for another resource. Confirm it fails.
  4. Remove a required KDCube grant or operation. Confirm the provider is never called.
  5. 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.
  6. For Slack/Gmail, revoke the connected provider claim while the KDCube token remains valid. Confirm the call fails closed.
  7. Revoke the automation in Delegated by KDCube. Confirm the same bearer is rejected immediately.
  8. Wait for a short token to expire and confirm authentication fails.
  9. Confirm logs/audit retain the external delegate, approving grantor, resource, operation, and economics subject without recording credentials.
  10. Confirm the consumer agent catalog contains only its allow-listed MCP tools.
  11. Search prompts, generated code, logs, and executor environment for the MCP and provider secret values. They must be absent.
FINAL INSPECTION · DONE MEANS
  • The app consumes one MCP server through a per-agent tool allow-list.
  • Consumer credentials live in bundles.secrets.yaml or the configured secret provider, never in bundles.yaml or model context.
  • The app exposes stateless async MCP tools backed by ordinary domain services.
  • Connection Hub owns managed external resource/tool/grant policy.
  • External automation receives a short-lived KDCube delegation, not a browser session or provider credential.
  • Provider-backed calls require both KDCube delegation and connected-account consent.
  • 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.

Build it in depth

Read the architecture and position

KDCube Recipe
№ 2026-07-15 · kdcube.tech