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, 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.
The boundary in one minute
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.
- 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 FastMCP 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 |
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 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: 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.
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 FastMCP app in a focused module:
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:
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.- FastMCP 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 FastMCP 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 Let an MCP client perform the handshake OUTBOUND · AS PROVIDER
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: 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:
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.
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 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.
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:
{{
"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
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.
- 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.
- 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.
- 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.
- 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.