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

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 clientapp credential or boundper-agent grant; outside modelMCP serverexternal or KDCubeCREDENTIAL 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
       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.

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                app-native MCP 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
A hosted agent's KDCube accessOne grant per user, agent, and resourceConnection 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

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

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:

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

EDITsurfaces/mcp/reports.py for MCP tools; entrypoint.py for the KDCube route
OWNERthe reporting app source — the MCP 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 KDCubeMCPServer in a focused module:

surfaces/mcp/reports.pyPYTHON
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:

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.
  • MCP 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 MCP 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 Choose how the external MCP caller identifies itself OUTBOUND · AS PROVIDER

EDITbundles.yaml → existing connection-hub@1-0 item → config.connections.delegated_credentials.oauth
OWNERConnection Hub — OAuth caller registration policy; it is not reporting-app configuration and grants no reporting authority

KDCube resolves a public OAuth caller in this order:

registration.orderDISPATCH
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.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
                  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:

mcp-client.jsonJSON
{
  "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.

the.handshakeDISPATCH
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.

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

  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.

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.

the.provider.laneDISPATCH
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:

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.

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

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. Connect with a modern MCP client. Confirm server/discover, tools/list, and a bounded tools/call reach the deployed app surface.
  3. Connect with a legacy client. Confirm initialize, tools/list, and the same bounded tools/call reach the same app tools.
  4. Exercise each enabled caller-registration path independently: a descriptor public_clients entry, an HTTPS CIMD URL, and a DCR caller. Confirm an unrecognized caller and an unapproved redirect are rejected before consent.
  5. Before approving any resource, confirm each recognized caller still cannot list or call protected tools. Registration identifies the caller; it grants no authority.
  6. Complete manual access or the MCP OAuth flow and list/call only selected tools with a standard MCP client.
  7. Use a token minted for another resource. Confirm it fails.
  8. Remove a required KDCube grant or operation. Confirm the provider is never called.
  9. 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.
  10. With two eligible bound provider accounts, omit account_id and confirm account_required; resend with one candidate and confirm preflight and the provider result name the same account. Name an unbound account and confirm agent_account_binding_required targets the existing caller card.
  11. For Slack/Gmail, revoke the connected provider claim while the KDCube token remains valid. Confirm the call fails closed.
  12. Revoke the automation in Delegated by KDCube. Confirm the same bearer is rejected immediately.
  13. Wait for a short token to expire and confirm authentication fails.
  14. Confirm logs/audit retain the external delegate, approving grantor, resource, operation, and economics subject without recording credentials.
  15. Confirm the consumer agent catalog contains only its allow-listed MCP tools.
  16. Search prompts, generated code, logs, and executor environment for the MCP and provider secret values. They must be absent.
  17. Configure two hosted agents for the same resource; grant one and confirm the sibling remains unbound.
  18. Confirm a later turn reuses the bound token without exposing it or the user session to the model.
  19. Make resource differ from the catalog id and confirm lookup fails before MCP contact.
  20. Revoke one hosted-agent resource row and confirm only that connection stops.
  21. For a provider-backed hosted call, revoke the agent grant and connected-account claim separately; either must stop it.
  22. For a file-bearing provider action, pass an existing artifact as a durable conv:fi file_path and confirm the provider materializes it under current actor, grant, and account authority. From a turn-less client, use staged upload and pass staged_ref; reserve content_base64 for tiny generated files with a filename.
  23. 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.
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; modern and legacy callers reach the same tool implementation.
  • Consumer connections use protocol_mode: auto unless a known old server requires legacy.
  • 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 url from the exact catalog resource.
  • Provider-backed calls require both KDCube delegation and connected-account consent.
  • File-consuming provider actions use durable file_path or staged_ref sources 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.

Build it in depth

Read the architecture and position

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