dendrux
v0.4.0Get started →

Production MCP for Dendrux agents - a process-wide managed runtime with shared connections, per-tenant credentials, capacity limits, circuit breaking, clean shutdown, and operational telemetry.

MCP

Model Context Protocol (MCP) servers expose tools that execute outside the agent. Dendrux is an MCP client and managed runtime: it discovers those external tools, converts them into normal Dendrux tools, and sends calls through the same deny, approval, guardrail, timeout, and audit pipeline as local @tool functions.

The recommended production path is MCPRuntime: one process-wide object that owns every physical MCP connection, shares them across agents and requests, isolates tenants, enforces capacity, breaks circuits on failing servers, and shuts down cleanly.

Install the optional integration:

pip install "dendrux[mcp]"

Who hosts the MCP servers

Dendrux runs MCP clients, never remote MCP servers itself:

  • Remote HTTP servers are hosted by their vendor (a SaaS MCP endpoint), by your platform team, or behind an MCP gateway you deploy. Dendrux connects to them over streamable HTTP.
  • Stdio servers are subprocesses that Dendrux spawns on your machine, with your process's operating-system privileges. You own what they can touch. MCP is not a sandbox: run only trusted stdio servers directly, and put untrusted ones in a container or isolated MCP server or gateway reached over HTTP.
  • Dendrux does not help you build MCP servers. Use the official MCP SDKs for that, then consume the result from Dendrux.

Pin subprocess package versions, as these examples do, instead of executing an unreviewed future latest release.

The recommended path

import asyncio
 
from dendrux import Agent
from dendrux.mcp import MCPRuntime, MCPSource
 
 
async def main() -> None:
    runtime = MCPRuntime()  # one per worker event loop, at startup
 
    connection = runtime.bind(
        connection_key="github-1",
        source=MCPSource.http(
            name="github",
            url="https://mcp.example.com/github",
            headers={"Authorization": "Bearer ..."},
        ),
    )
 
    async with Agent(
        provider="anthropic:claude-haiku-4-5",
        prompt="Help maintain the repository.",
        tool_sources=[connection.tools()],
    ) as agent:
        result = await agent.run("List my open issues.")
        print(result.answer)
 
    await runtime.close()  # application shutdown, not per request
 
 
asyncio.run(main())

Construction, bind(), and tools() are synchronous, perform no I/O, and resolve no credentials. The physical connection opens lazily on the first tool discovery and is then shared: a second agent bound to the same key reuses the live connection instead of opening its own.

User-supplied HTTP destinations

MCPSource.http() and MCPServer() expose per-source follow_redirects (default True), max_redirects (default 20), and destination_policy (default None). Set follow_redirects=False to stop at the first redirect; there is no connection to its target. The SDK can report the unfollowed response as a connection error during initialization.

All configured headers, including custom API-key headers and headers supplied by credential providers, are scoped to the source's original scheme, hostname, and effective port. Authentication-generated headers, Authorization, and Cookie are also stripped before requests to another origin. This applies on every redirect hop and authentication retry. The one exception is a new request that an opaque auth= object's flow builds itself for another origin, such as an OAuth token request with Authorization: Basic to an authorization server. That request keeps only the headers its auth flow introduced (absent from, or changed from, the original request before the flow ran); configured source headers, cookies, any credential copied unchanged from the original request, and any value the flow set on the original request itself are still stripped. A redirect of such a request, and any redirect of the MCP request itself, is stripped as usual, and the destination policy still applies to its socket. Same-origin redirects retain credentials; even an HTTP-to-HTTPS upgrade is a different origin. Redirect URLs containing userinfo are rejected. MCP 2.2 or newer follows only method-preserving redirects within the endpoint origin (also allowing the SDK's default-port HTTP-to-HTTPS upgrade). Other cross-origin redirects are blocked before contacting the target; configure that target as a separate source with its own credentials. The blocked target is available as MCPConnectionError.redirect_target, while destination names the origin actually contacted.

Applications own the destination policy. Dendrux calls the async policy before opening each TCP socket, with an immutable MCPDestination(host, port, ip):

from dendrux.mcp import MCPDestination, MCPSource
 
 
async def destination_policy(destination: MCPDestination) -> bool:
    # Example baseline; extend with your application's network and tenant rules.
    return destination.port == 443 and destination.ip.is_global
 
 
source = MCPSource.http(
    "customer_server",
    "https://mcp.example.com/mcp",
    headers={"X-API-Key": "..."},
    follow_redirects=False,
    destination_policy=destination_policy,
)

Only an explicit True permits an address. Denied candidates are skipped; callback exceptions abort the connection without exposing their text. If no candidate is permitted, connection and discovery raise MCPDestinationDeniedError, a subclass of MCPConnectionError.

Dendrux resolves each new connection, checks each candidate before attempting it, and dials the approved numeric IP without resolving the hostname again. IPv4-mapped IPv6 addresses are normalized to IPv4 before policy evaluation and dialing. The original hostname remains the HTTP Host and TLS verification name. DNS, policy evaluation, and TCP attempts share the connect timeout. Redirect targets, new sockets after disconnect, and runtime reconnects use the same path.

With a policy configured, environment proxies and environment TLS overrides are disabled; connections go directly to approved destinations using normal certificate verification. There is no implicit private-network deny list when destination_policy=None. The hook controls Dendrux's MCP HTTP connections; HTTP clients independently created inside application credential callbacks or custom authentication implementations remain application-owned.

Existing pooled sockets retain the approval from when they were opened. To change or revoke policy for a live source, call await runtime.rebind(...) to retire the existing connection and install the replacement policy. Use force mode when active calls must be interrupted. Do not mutate a policy in place and expect it to close already-open sockets. Each distinct policy object is part of the runtime's connection configuration.

The objects

ObjectResponsibilityTypical lifetime
MCPSourceImmutable connection, authentication, and limit configuration.Application configuration.
MCPRuntimeEvent-loop-affine owner of physical connections, capacity, circuits, and telemetry.Normally one per worker process, closed at shutdown.
MCPConnectionLazy handle for one (tenant_key, connection_key) identity returned by bind().Cheap; re-bind per request if convenient.
MCPToolViewNamespace and tool policy one agent consumes via tool_sources.One per agent.
MCPHost / MCPServerSimpler facades that predate the runtime. Still supported.Scripts, notebooks, single-agent tools.

The official MCP Python SDK owns protocol framing, transport negotiation, and the underlying client session. Dendrux owns agent-facing names, lifecycle policy, result boundaries, governance, and evidence.

Process-local ownership

MCPRuntime is the MCP equivalent of a database connection pool. It is deliberately process-local and event-loop-affine: no database, no coordination between workers, no shared state beyond the process boundary. A conventional async worker has one event loop and creates one runtime at startup. If your process deliberately runs multiple event loops, create one runtime per loop. Capacity limits and telemetry describe that runtime only.

This matches how connection pools are operated: a deployment with four workers and max_connections=100 can hold up to 400 physical MCP connections in total, and each worker reports its own snapshot. The RunStore remains the durable, cross-process record of what agents did; the runtime is the live, in-memory record of what connections are doing right now.

runtime = MCPRuntime(
    max_connections=100,       # physical connections across all tenants
    max_in_flight_calls=100,   # concurrently executing MCP tool calls
    idle_timeout=300.0,        # retire connections unused this long (None keeps them warm)
    connection_wait_timeout=10.0,  # queue wait for a connection slot; 0 fails fast
    call_wait_timeout=10.0,        # queue wait for a call slot; 0 sheds load immediately
    shutdown_timeout=30.0,     # drain budget before close() interrupts work
    close_timeout=2.0,         # grace for one transport to close before cleanup goes to the background
    circuit_failure_threshold=5,   # consecutive connect failures before fail-fast (None disables)
    circuit_reset_timeout=30.0,    # cooldown before one probe connection is allowed
)

Identities and tenants

Every managed connection is addressed by (tenant_key, connection_key):

connection = runtime.bind(
    connection_key="github-1",
    tenant_key=user_id,       # omit for single-tenant applications
    source=github_source,
    credentials=provider,
)

Two tenants bound to the same endpoint get two physical connections. The runtime never merges authentication contexts: a tenant's tools always execute over a transport established with that tenant's credentials. Circuit state, eviction, connection identity, and telemetry are partitioned by (tenant_key, connection_key), so one tenant's failing server opens only that tenant's circuit.

Connection and call capacity are intentionally process-wide budgets, not per-tenant quotas. FIFO queues and bounded wait timeouts prevent indefinite starvation, but one noisy tenant can still add bounded latency for others. Applications that need contractual per-tenant quotas should enforce them before invoking the runtime.

Binding is cheap and idempotent for equivalent configuration, so a web handler can call bind() on every request. Rebinding the same key with a different endpoint or transport raises MCPBindingConflictError; changing configuration for a live connection requires an eviction first.

Credentials

Static headers work for shared secrets. For per-tenant or expiring credentials, pass a provider:

class TenantTokens:
    def __init__(self, tenant_id: str) -> None:
        self.tenant_id = tenant_id
 
    async def get_auth(self) -> dict[str, str]:
        token = await fetch_token(self.tenant_id)   # your secret store
        return {"Authorization": f"Bearer {token}"}
 
 
connection = runtime.bind(
    connection_key="github-1",
    tenant_key=tenant_id,
    source=github_source,
    credentials=TenantTokens(tenant_id),
)

The provider is called lazily, once per physical connection attempt — never per agent or per tool call. A connection retired between requests may therefore resolve credentials again on the next request. Its result establishes that one transport (request headers for HTTP, environment variables for stdio) and remains only in that connection's in-memory, connect-scoped configuration for the physical connection lifetime; it is never persisted, logged, or rendered into errors. Because the runtime knows the exact secret strings, it scrubs them from any transport or tool error text before that text can reach the run store or the model. Opaque auth objects are rejected at bind() for the same reason: their secret values cannot be enumerated for redaction.

Use a stable non-secret credential_identity (a credential row id or rotation counter) to distinguish credential versions. bind() still rejects changes to live configuration. For routine HTTP credential rotation, use:

connection = await runtime.rotate_credentials(
    connection_key="github-1",
    tenant_key=tenant_id,
    credentials=TenantTokens(tenant_id),
    credential_identity="version-2",
    timeout=10.0,
    preserve_catalog=False,
)

Rotation pauses new calls, drains admitted calls, closes the old transport, and reconnects lazily on the next call or discovery. Existing handles and leased views remain usable. The drain timeout defaults to connection_wait_timeout; if it expires, the old transport and credentials remain intact. Cancellation of one waiting caller does not cancel shared rotation. A close failure prevents a replacement from being published and requires reacquiring a connection.

The previous catalog stays in memory. By default it is revalidated after the new handshake, and existing executors reject changed or removed tool definitions before sending. Set preserve_catalog=True only when you can assert unchanged principal and permissions: it skips tools/list after reconnect. Changes to endpoint, source headers, transport policy, or other configuration still use rebind().

Recovering from expired access tokens

Opt in with MCPSource.http(..., reauthenticate_on_401=True) and a provider implementing the public MCPRefreshingCredentialProvider protocol:

class RefreshingTokens(TenantTokens):
    async def on_auth_rejected(self) -> None:
        await invalidate_cached_access_token(self.tenant_id)  # application-owned

After a correlated HTTP 401 rejects a tool POST at the source origin, the runtime drains other calls, invokes on_auth_rejected(), and opens a fresh transport through get_auth(). Concurrent rejections of the same credential generation share one recovery. The rejected call is retried at most once; a second 401 raises MCPAuthenticationError. 403s, background SSE failures, arbitrary tool error text, timeouts, and uncertain outcomes do not trigger replay. Recovery revalidates the catalog before retrying. The existing destination and credential origin policies apply to the replacement transport.

on_auth_rejected() has a connect_timeout budget. A callback that raises MCPCredentialError or MCPAuthorizationRequired propagates unchanged; any other exception becomes a value-free MCPCredentialError with the original detached. A failed rejection callback is shared for that credential generation; explicitly rotate credentials or rebind to try again. One logical call emits one start and one terminal event, even if it recovers; those events retain the first attempt's connection instance ID. Physical rotation emits a connection-close event with reason="credentials_rotated".

Recovery also covers connect and discovery. When the initialize handshake or tools/list of a new connection is rejected with a 401 from the source origin, the runtime closes that transport, invokes on_auth_rejected() once, resolves credentials again through get_auth(), and retries the connect and discovery once. A provider whose cached access token has expired therefore recovers on the first connection.discover() or Agent turn instead of failing it. Waiters for the same connection share the one recovery, and the attempt still emits exactly one MCPConnectionOpening and one terminal event. A second 401 raises MCPAuthenticationError; a 403, a 401 from another origin, or a provider without on_auth_rejected() is not recovered. A failing callback raises the same value-free MCPCredentialError as on the call path. Each rejected operation notifies the provider at most once: the reconnect that follows a tool-call rejection does not run a second connect-time recovery.

With credentials=, the provider owns token exchange, storage, and coordination across workers; for OAuth servers, bind with oauth= instead (see OAuth sign-in). Without the opt-in flag, a live 401 on a credentials= binding is typed but is not automatically recovered.

Safe connection diagnostics

Adapter-generated MCPConnectionError messages contain no URLs. Optional origin, destination, and redirect_target attributes are MCPOrigin values with only scheme, host, and effective port; error.host is a convenience for the attempted host. They exclude userinfo, paths, queries, and fragments. Origins are omitted from their default representation and from runtime events. Hostnames can still identify private infrastructure, so persist these fields only under your application's diagnostic policy. Diagnostics unavailable for a particular failure remain None.

transport_detail and wrapped transport tool-error messages remove URLs as well as configured secrets. Real server-authored tool results retain the existing credential-redaction behavior. MCPAuthenticationError.status_code identifies 401/403; request_rejected=True identifies an HTTP rejection attributed to the specific tool POST at the original source origin rather than an unrelated response. A cross-origin redirect challenge is typed but never refreshes the original source's credentials.

MCPAuthenticationError.challenge is the parsed WWW-Authenticate header of the rejecting 401/403 response, at connect, discovery, and tool-call time, or None when the server sent none. Only the Bearer challenge is read, across every WWW-Authenticate header; other challenges are ignored. It is an MCPAuthChallenge with scheme, error (an RFC 6750 error code), scope (up to 32 RFC 6749 scope tokens), and resource_metadata_url (the RFC 9728 protected-resource metadata hint: an absolute http(s) URL without userinfo or fragment, at most 2048 characters). Each field is validated against that grammar and left empty when it does not match; free text such as error_description and realm is never stored. A 403 with error="insufficient_scope" names the scopes the server wants, for a scope step-up. The values are still server-chosen, so they are never copied into the exception message, and repr() of a challenge shows only the well-known scheme and error codes, a scope count, and whether a metadata URL is present. A header that cannot be parsed yields a challenge with empty fields rather than an error. One limitation: the third-party httpcore2 transport logger prints raw request and response headers, including WWW-Authenticate and Authorization, at DEBUG level, so keep that logger above DEBUG in production.

from dendrux.mcp import MCPAuthenticationError
 
try:
    await connection.discover()
except MCPAuthenticationError as error:
    challenge = error.challenge
    if error.status_code == 403 and challenge and challenge.error == "insufficient_scope":
        request_more_scopes(challenge.scope)  # application-owned

OAuth sign-in

For remote MCP servers protected by OAuth 2.1 (the MCP authorization spec), Dendrux owns the protocol and the token lifecycle: discovery (RFC 9728 protected-resource metadata, RFC 8414 / OpenID authorization-server metadata), client resolution (pre-registered, client ID metadata documents, or RFC 7591 dynamic registration), PKCE S256, the RFC 8707 resource indicator, RFC 9207 iss checks, refresh with cross-process coordination, scope step-up, and RFC 7009 revocation. Your application owns durable storage (encrypted, through a small store protocol), user identity, and the browser redirect. The MCP OAuth in a web app recipe shows the routes, the store contract, and every documented limitation.

from dendrux.mcp import MCPAuthKey, MCPOAuth, MCPOAuthClient, MCPRuntime, MCPSource
 
oauth = MCPOAuth(
    store=my_encrypted_store,                     # implements MCPOAuthStore
    client=MCPOAuthClient.dynamic(
        client_name="Acme Assistant",
        redirect_uris=["https://app.example.com/oauth/callback"],
    ),
)
source = MCPSource.http("linear", "https://mcp.linear.app/mcp")
key = MCPAuthKey("linear", tenant_key=user.id)
 
# GET /connect: send the user's browser to the authorization server.
url = await oauth.begin_authorization(key, source, redirect_uri=CALLBACK_URL)
 
# GET /oauth/callback, on any worker: key comes from the authenticated session.
await oauth.complete_authorization(key=key, state=state, code=code, iss=iss, error=error)
 
# Agent turns: bind the identity to the user's sign-in.
connection = runtime.bind(
    connection_key="linear", tenant_key=user.id, source=source, oauth=oauth
)

bind(oauth=) is exclusive with credentials=, accepts HTTP sources only, and rejects a source whose headers contain Authorization in any case. The runtime keeps one credential provider per registration; an equivalent bind reuses it, and binding a different MCPOAuth while the connection is live raises MCPBindingConflictError. Exactly one Authorization header reaches the server.

Every delivery re-reads the stored row and re-validates it in a fixed order: the user must be signed in, the credential must have been issued for exactly this source's canonical URL (query included) by the issuer its client belongs to, and the access token must not be expired. An expired access token is never delivered. Tokens are refreshed shortly before expiry and on a 401; a store refresh claim makes sure a refresh token is redeemed at most once across every worker, and an interrupted, uncertain, or abandoned refresh retires the refresh token instead of replaying it.

Recovery is on for OAuth bindings whether or not reauthenticate_on_401 is set. A 401 at connect, discovery, or a tool call refreshes once and retries once. A 403 with error="insufficient_scope" is also recoverable: if another worker already stepped up, the connection reconnects and retries; otherwise the call raises MCPAuthorizationRequired with reason="insufficient_scope" and the challenged scopes. Begin a new sign-in with the union of (await oauth.status(key)).scope and those scopes, then call runtime.refresh_authorization(...) so the next call reconnects with the new token and re-reads the catalog.

MCPAuthorizationRequired (a subclass of MCPConnectionError) means the user must sign in again: not_signed_in, expired, revoked, insufficient_scope, or resource_mismatch. MCPOAuthRefreshError (a transient MCPCredentialError) means a refresh could not complete right now. Neither marks the connection's credentials failed nor counts toward the circuit breaker, and the next rejection may recover. Sign-in and sign-out failures raise MCPOAuthFlowError with a fixed reason code and the HTTP status_code when there was one. Messages and representations never carry tokens, codes, scopes, URLs, or server text.

runtime.revoke_authorization(connection_key=..., tenant_key=...) fences new leases and calls immediately, writes a sign-out tombstone, revokes at the authorization server (the outcome is reported, never assumed), and drains and evicts the connection. Cancelling the caller never cancels the revoke, and close() waits for it.

The runtime emits MCPOAuthRequired(reason, scope_count) once per shared failure, MCPOAuthRefreshed(expires_in), MCPOAuthRefreshFailed(outcome, cause) with outcome unsent, uncertain, rejected, contention, or expired_claim, and MCPOAuthRevoked(remote). Their representations show only the reason, outcome, and counts.

OAuth requests never go through httpx2.AsyncClient: they are sent straight through a transport with no redirects, an overall deadline, a 1 MiB body cap, and the destination policy enforced at the socket, so no library logs a token endpoint's reason phrase. The MCP connection itself still uses the SDK's client, which logs request URLs, reason phrases, and session ids at INFO; see the recipe's logging section.

Views and tool policy

connection.tools() returns the object an agent consumes. It selects and names tools without touching the network:

view = connection.tools(
    namespace="gh",                          # default: the source name
    allowed_tools=["read_file", "create_issue"],  # default: every server tool
    force_serial_tools=["create_issue"],     # never run concurrently
)
 
agent = Agent(provider=provider, prompt=..., tool_sources=[view])

MCP tools are exposed as namespace__tool_name (gh__create_issue); characters outside the portable provider subset become _, and the final name must be at most 64 characters. Use the namespaced name in deny and approval rules:

agent = Agent(
    provider=provider,
    prompt="Help maintain the repository.",
    tool_sources=[view],
    deny=["gh__delete_repo"],
    require_approval=["gh__create_issue"],
)

Exposing every server tool (the no-argument default) is an explicit governance decision; prefer allowed_tools allowlists in production. MCP tool annotations are advisory: Dendrux schedules an MCP tool concurrently only when it is explicitly marked read-only and not destructive; everything else runs serially.

Three more options control naming and how a view treats tools it cannot expose. They apply to runtime views (connection.tools()); MCPServer and MCPHost keep the prefixed, fail-loudly behaviour.

prefix=False exposes bare tool names. The prefix costs tokens, means nothing to the model, and counts against the 64-character limit. namespace remains the view's identity for governance events, telemetry, and ToolDef.meta["namespace"]:

view = connection.tools(namespace="gh", prefix=False)
# the model sees create_issue, read_file, ...
agent = Agent(provider=provider, prompt=..., tool_sources=[view], deny=["delete_repo"])

rename maps a server tool name to the exposed base name, in either mode. Use it to resolve a clash between two sources, or to shorten a name that would exceed 64 characters with the prefix:

view = connection.tools(prefix=False, rename={"search_code": "gh_search"})
# prefixed mode would expose gh__gh_search

Rename values must match [a-zA-Z0-9_-] and be at most 64 characters, including the namespace__ prefix when prefix=True, and must be unique; a bad value raises ValueError when the view is created. A rename value that equals another tool's sanitized name makes whichever of the two comes later in server order the duplicate_name skip, which may be the renamed tool itself; pick a value no other tool sanitizes to. Rules (deny, require_approval) and telemetry use the exposed name; the server is always called by its own name, which stays in ToolDef.meta["mcp_tool_name"] next to meta["exposed_name"].

strict=True restores raising at discovery. By default one bad tool no longer drops its whole source: a tool whose exposed name is not provider-safe or whose base name is empty (name_unsafe), a tool whose exposed name an earlier tool in server order already took (duplicate_name), and a name in allowed_tools, force_serial_tools, or rename that the server does not list (unknown_tool) are skipped and reported in RunResult.meta["mcp_skipped_tools"] (see Failure modes at discovery). With strict=True the first of these raises ValueError instead:

view = connection.tools(allowed_tools=["read_file", "create_issue"], strict=True)

Clashes between a view and anything else — another MCP source, a local tool, or a reserved skill tool name (use_skill, read_skill_resource, run_skill_script) — always raise MCPToolNameCollisionError (a ValueError) when the agent discovers its tools, whatever strict says: a cross-source clash nobody renamed fails loudly.

Sizing

Size the runtime like a connection pool:

  • max_connections bounds physical connections, which map one-to-one to bound identities in active use. Estimate it as concurrently active tenants x sources per tenant, plus headroom. Idle retirement (idle_timeout) returns slots from tenants that went quiet; set it to None for stdio servers whose subprocess startup costs more than holding the connection warm.
  • max_in_flight_calls bounds concurrently executing tool calls process-wide, protecting your process and the servers behind it. Agents rarely need more than a few calls in flight each; start near your worker's realistic agent concurrency.
  • The wait timeouts decide queueing behaviour at the two capacity boundaries. Non-zero values absorb bursts; 0 fails fast (MCPConnectionCapacityError) or sheds load (MCPCallCapacityError) immediately. Both errors are transient and never mean the tool call was sent.
  • Multiply everything by worker count when budgeting against server-side rate limits — each process enforces its own limits.

Watch snapshot().connection_waiters and call_waiters in production: persistent waiters mean the limits are too tight or a server is too slow.

Failure handling

  • Rejected credentials (HTTP 401 or 403 from the server) raise MCPAuthenticationError, a subclass of MCPConnectionError carrying status_code, so an application can tell "your token was rejected" from "the URL is wrong" without parsing transport text. The SDK itself reports a rejected handshake as a generic JSON-RPC error; Dendrux classifies it from the HTTP response it observed.
  • Connect failures count toward a per-identity circuit breaker. After circuit_failure_threshold consecutive failures, new connection attempts fail fast with MCPCircuitOpenError (carrying retry_after) instead of hammering a dead server. After circuit_reset_timeout, one probe connection is allowed through; success closes the circuit, failure re-arms it. A tenant's broken sandbox cannot consume the whole process's capacity retrying.
  • Transport loss mid-call fences the dead connection so no new work routes to it, and interrupted calls raise MCPOutcomeUnknownError: the server may already have applied the call, so Dendrux never retries it automatically. The next lease opens a fresh connection.
  • Ordinary tool errors (MCPToolCallError) are just results: the model reads the redacted error text and corrects itself. They never poison the shared connection.

To remove a connection deliberately — a tenant offboarded, credentials revoked, an endpoint migrated:

await runtime.evict(connection_key="github-1", tenant_key=user_id)          # drain, then close
await runtime.evict(connection_key="github-1", tenant_key=user_id, mode="force")  # fence immediately

Draining waits for active calls; force interrupts them (MCPOutcomeUnknownError). Afterwards the key may be rebound with new configuration.

To change a live connection's configuration in one step — a rotated token, a new endpoint, a different timeout — use rebind(), the settings-save path:

connection = await runtime.rebind(
    connection_key="github-1",
    tenant_key=user_id,
    source=MCPSource.http("github", new_url, call_timeout=30.0),
    credentials=UserToken(user_id),
)

rebind() validates the new arguments, drains (or with mode="force" interrupts) the live connection, forgets its registration and circuit, and binds the new configuration. Handles issued before the call are stale; use the returned one. When nothing is live it is simply a bind. Every MCPSource field except name counts as configuration — endpoint, headers, connect_timeout, call_timeout, max_result_bytes, and failure_mode — so a plain bind() with any of them changed raises MCPBindingConflictError while the connection is live.

Shutdown

await runtime.close()

close() stops new leases and calls, waits up to shutdown_timeout for active work to drain, force-evicts whatever remains, and closes every transport. It is idempotent and safe to call concurrently. Each transport gets close_timeout to close; a Streamable HTTP close is a network round trip because the SDK sends a session-terminating DELETE, which is why the default is seconds rather than milliseconds. A transport still closing after that is abandoned to background cleanup (logged as "exceeded the shutdown grace") rather than hanging your shutdown. The same grace applies to eviction and idle retirement. MCPRuntime is also an async context manager (async with MCPRuntime() as runtime:) when your application frame is a single scope.

Closing an agent never closes runtime connections — it only releases that agent's lease. The runtime is the single owner of every physical connection.

Operational telemetry

The runtime answers "what is happening right now" without any storage. snapshot() is synchronous, thread-safe, and cheap — call it from a metrics scraper thread on an interval (poll it, don't spin on it):

snapshot = runtime.snapshot()
snapshot.connections          # per-connection status, leases, active calls
snapshot.in_flight_calls      # admitted calls occupying capacity
snapshot.connection_waiters   # callers queued for a connection slot
snapshot.call_waiters         # callers queued for a call slot
snapshot.open_circuits        # identities currently failing fast

For events — connections opening and closing, circuits opening, capacity rejections, tool call outcomes — pass an observer and bridge to Prometheus, OpenTelemetry, Datadog, or logs:

class Metrics:
    def on_event(self, event) -> None:   # sync, fast, never raises into the runtime
        EVENT_COUNTER.labels(type(event).__name__).inc()
 
runtime = MCPRuntime(observer=Metrics())

Events are immutable, typed, and value-free by construction: identities, class names, counts, and durations — never credentials, URLs, tool arguments, results, or error text. Observer exceptions are swallowed and logged without detail; telemetry can never break MCP work.

Complete example: multi-user agents on one runtime

One process serves two users. Each gets their own tenant partition, workspace, and credentials over the same runtime:

runtime = MCPRuntime(max_connections=10, max_in_flight_calls=8)
 
async def serve(user_id: str, workspace: Path, question: str) -> str:
    connection = runtime.bind(
        connection_key="filesystem",
        tenant_key=user_id,
        source=MCPSource.stdio(
            name="filesystem",
            command=["npx", "-y", "@modelcontextprotocol/server-filesystem@2026.7.4", str(workspace)],
        ),
    )
    async with Agent(
        provider="anthropic:claude-haiku-4-5",
        prompt="You are this user's filesystem assistant.",
        tool_sources=[connection.tools(allowed_tools=["list_directory", "read_text_file"])],
    ) as agent:
        result = await agent.run(question)
        return result.answer
 
answers = await asyncio.gather(
    serve("user-a", alice_dir, "What files do I have?"),
    serve("user-b", bob_dir, "Summarize notes.txt."),
)
await runtime.close()

The complete runnable version — concurrent users, fresh agents reusing warm connections, a telemetry observer, a post-request snapshot, and graceful shutdown — is packages/python/examples/31_mcp_runtime_multi_user.py.

For a live end-to-end check, packages/python/examples/32_mcp_github_remote.py connects to GitHub's hosted MCP endpoint and exposes only its read-only get_me tool. An Anthropic Agent must select that tool before answering, while runtime lifecycle events print explicit start/completion proof. Set ANTHROPIC_API_KEY in the repository-root .env and a narrowly scoped GITHUB_MCP_PAT in the process environment; never commit either token.

Configure an HTTP source

Streamable HTTP is the normal production transport for a separately deployed MCP server or gateway:

from dendrux.mcp import MCPSource
 
github = MCPSource.http(
    name="github",
    url="https://mcp.example.com/github",
    headers={"Authorization": "Bearer ..."},
    connect_timeout=10,
    call_timeout=60,
    max_result_bytes=500_000,
    failure_mode="best_effort",
)

Prefer headers= or a credential provider over credentials embedded in a URL; Dendrux rejects credential-bearing URLs. Unmanaged use (MCPHost, direct sources) additionally accepts auth= objects supported by the MCP SDK's HTTP client; the managed runtime rejects opaque auth objects because their secrets cannot be redacted.

Configure a stdio source

A stdio source launches a subprocess and speaks MCP over its standard input and output:

filesystem = MCPSource.stdio(
    name="filesystem",
    command=[
        "npx",
        "-y",
        "@modelcontextprotocol/server-filesystem@2026.7.4",
        "/workspace",
    ],
    env={"LOG_LEVEL": "warning"},
    cwd="/workspace",
    call_timeout=30,
)

The subprocess runs with the Dendrux process's operating-system privileges — see who hosts the MCP servers.

Discovery and catalog lifetime

Configuration, binding, and view creation perform no network or subprocess I/O. Discovery happens lazily when agent.run() or agent.get_tool_lookups() first needs the tools:

  • Every paginated tools/list page is collected.
  • The agent receives a stable catalog for its lifetime; a run executes against one capability set.
  • Catalogs belong to the physical connection: agents sharing a live managed connection share its catalog, and a reconnect (after idle retirement, transport loss, or eviction) rediscovers fresh.
  • To force rediscovery of a managed connection deliberately, evict it; the next agent reconnects and rediscovers. For unmanaged owners, await agent.refresh() and await host.refresh() remain available.

You can force discovery during application startup for a health check:

lookups = await agent.get_tool_lookups()
print(sorted(lookups.fn))

Discovery without an Agent

A settings page needs "test this connection and show me its tools" before any chat exists. discover() on a managed connection does exactly that:

connection = runtime.bind(connection_key="github-1", tenant_key=user_id, source=source, credentials=UserToken(user_id))
discovery = await connection.discover()
 
discovery.server_name, discovery.server_version, discovery.protocol_version, discovery.instructions
for tool in discovery.tools:          # raw server names, before namespacing or policy
    tool.name, tool.description, tool.input_schema, tool.annotations

It takes the same path an Agent lease takes — credential resolution, circuit breaker, capacity, single-flight connect — and raises the same errors, so MCPAuthenticationError here means the same thing it means at run time. The lease is released before it returns, which leaves the connection warm for idle_timeout: the next Agent reuses it without reconnecting. Calling discover() ahead of a user's first turn is therefore also the warm-up for first-connect latency, including after idle retirement. The returned catalog is the live physical connection's; to force rediscovery, evict.

Calling a tool directly

An application sometimes needs to run a server tool itself, outside any Agent run: executing an action a reviewer just signed off (the approve-later recipe), a health check, a scheduled job. call_tool on a managed connection does that:

result = await connection.call_tool("create_issue", {"title": "Rotate the key"})
result = await connection.call_tool("export_report", {"id": 7}, timeout=30)
async def call_tool(
    self,
    name: str,
    arguments: Mapping[str, Any] | None = None,
    *,
    timeout: float | None = None,
) -> Any
  • name is the server's own tool name, as discover() lists it, not the prefixed or renamed name an Agent view exposes. A name the server does not list raises MCPToolNotFoundError (error code tool_not_found) without sending a tools/call request. The error carries the name with any configured credential scrubbed.
  • It takes the same path as an Agent-run call. The connection lease, the runtime-wide in-flight cap (max_in_flight_calls), the stale-definition check, one recoverable 401 re-authentication, telemetry, result normalization, credential redaction and the error mapping are all shared. You get back the payload an Agent tool would receive, and a result the server flags as an error raises MCPToolCallError.
  • timeout is an outer deadline in seconds, covering queueing, connecting and the call. On expiry you get a TimeoutError, and if the call had already started its outcome is unknown: the server may still apply it, so do not retry automatically. timeout=None adds no deadline of its own but does not disable the source's transport call_timeout, which always applies.
  • Like discover(), await it on the event loop the runtime is bound to.
  • Image results keep the legacy shape. call_tool returns the same value it did before 0.4.0: image blocks stay inside the content envelope with their base64 data. Only Agent runs turn them into image parts.

call_tool bypasses all Agent governance. There is no deny=, no require_approval=, no before_tool hook, no guardrails, no tenant authorization beyond the connection's own identity, no run row, no tool_calls record and no budget. It is your application's call and your application's audit. Authorize each call in your own code, and never expose call_tool (or a route wrapping it with caller-chosen names and arguments) directly to a model or an untrusted client.

A malicious or buggy server can advertise a tool whose name echoes the credential it was just sent. Dendrux's own logs, error messages and telemetry scrub credential-shaped tool names. The MCP SDK's own DEBUG logging is outside Dendrux's control and may echo raw tool names, so keep SDK loggers above DEBUG in production.

Failure modes at discovery

Each source chooses how discovery failure affects the run:

ModeBehaviour
strictDefault. Discovery failure emits mcp.error; initialization stops before the first LLM call.
best_effortRecords mcp.error for that source and continues with the remaining local and MCP tools.

A successful source emits mcp.connected with its source name, tool count, and namespaced tool list. Zero-tool sources still emit mcp.connected with tool_count=0.

mcp.error carries source_name, namespace, error, error_type (the exception class, such as MCPAuthenticationError or MCPCircuitOpenError), code, and failure_mode. For a best_effort run the same facts are on the result, so an application can tell the user which optional tools were missing for an answer:

result = await agent.run("...")
for skipped in result.meta.get("mcp_skipped_sources", []):
    skipped["source_name"], skipped["code"], skipped["error_type"], skipped["error"]

Branch on code, never on error text. It is derived from the exception class (the most specific match wins), and error_code_for(exc) from dendrux.mcp returns the same value for an exception you caught yourself:

Exceptioncode
MCPAuthorizationRequiredauthorization_required, plus reason (not_signed_in, expired, revoked, insufficient_scope, resource_mismatch)
MCPAuthenticationErrorauthentication_failed
MCPCredentialError, including MCPOAuthRefreshErrorcredential_error
MCPDestinationDeniedErrordestination_denied
MCPCircuitOpenErrorcircuit_open
MCPConnectionCapacityError, MCPCallCapacityErrorcapacity
MCPConnectionLostError, any other MCPConnectionErrorconnection_failed
MCPStaleConnectionError, MCPConnectionEvictingError, any other MCPConnectionStateErrorconnection_state
MCPToolNameCollisionError (an exposed name equals another source's tool, a local tool, or a reserved skill tool name)tool_name_collision
MCPToolNotFoundError (connection.call_tool named an unlisted tool)tool_not_found
ValueError from tool naming with strict=Trueinvalid_tool_names
anything elseunknown

The run-fatal mcp.error (a strict source, or a collision) carries code too, and reason for an OAuth verdict.

Individual tools a view skipped (see Views and tool policy) are listed separately. RunResult.meta["mcp_skipped_tools"] is present whenever mcp_skipped_sources is, each entry naming the source, namespace, server tool, and reason:

for skipped in result.meta.get("mcp_skipped_tools", []):
    skipped["source_name"], skipped["namespace"], skipped["tool"], skipped["reason"]
    # reason: "name_unsafe" | "duplicate_name" | "unknown_tool"

When the list is non-empty the run also records one mcp.tools_skipped governance event at initialization, with the same entries under skipped. Entries hold names only, never arguments or results.

Discovery metadata is a snapshot taken before execution, including resumed execution. mcp_skipped_sources: [] means discovery completed with nothing skipped. An absent key means the outcome is unavailable (for example, initialization failed before discovery completed, or an idempotent result was reconstructed from storage). Error handling never repeats discovery to populate this metadata.

Stream error events include the snapshot in event.run_result.meta. After a terminal event or client closure, read stream.result; closure does not yield an additional event. The on_run_finished(run_id, result) lifecycle hook also receives the cancellation result with the same metadata:

async with agent.stream("...") as stream:
    async for event in stream:
        if should_stop(event):
            break
if stream.result is not None:
    skipped = stream.result.meta.get("mcp_skipped_sources")

Blocking execution failures preserve their original exception type and identity. Once execution has entered the runner's error-handling scope, the exception exposes run_id and run_meta, including the discovery snapshot when available:

try:
    result = await agent.run("...")
except Exception as error:
    run_id = getattr(error, "run_id", None)
    meta = getattr(error, "run_meta", {})
    skipped = meta.get("mcp_skipped_sources")

Pre-execution validation errors can occur before a run exists and therefore need not have these attributes. stream.result is None before a terminal outcome, including a stream closed before its first iteration.

An Agent caches discovery for its lifetime, so a source skipped once stays skipped for that Agent until agent.refresh(); a fresh Agent retries.

Calls, errors, and result limits

MCP calls made by the model pass through the ordinary Dendrux server-tool execution path: deny rules, the before_tool policy hook, approval pauses, guardrails, call limits, timeouts, persisted tool calls, tool_result_transform, and completion/error events work the same way as for local tools.

The integration preserves:

  • structuredContent as a JSON-compatible Python value;
  • text-only content as a string;
  • audio, resource-link, and embedded-resource blocks in a JSON content envelope, and image blocks as image parts the model can see;
  • negotiated protocol version and server identity in ToolDef.meta.

max_result_bytes is enforced before a result enters the model conversation. Errors an application should be ready to handle:

ErrorMeaningWas anything sent?
MCPToolCallErrorAn admitted tool call failed; some local lifecycle rejections also use this base type.Usually. Do not infer delivery from this base type alone.
MCPOutcomeUnknownErrorThe call was interrupted; the server may have applied it.Maybe — never retry automatically.
MCPToolNotFoundErrorconnection.call_tool named a tool the server does not list.No tools/call request.
MCPCircuitOpenErrorConnect attempts are failing fast; carries retry_after.No.
MCPConnectionCapacityError / MCPCallCapacityErrorCapacity limit reached within the wait budget.No.
MCPConnectionErrorThe connection could not be established.No.
MCPDestinationDeniedErrorDestination policy denied a connection or a redirect URL was rejected. Subclass of MCPConnectionError.No — correct the destination or policy.
MCPAuthenticationErrorThe server rejected the source's credentials (HTTP 401/403). Subclass of MCPConnectionError; carries status_code.No — refresh or rotate the credential, then evict and rebind.
MCPResultTooLargeErrorThe result exceeded max_result_bytes.Yes — the result was discarded.

All are subclasses of MCPError, importable from dendrux.mcp.

Images from MCP tools

In agent runs, image content from an MCP tool is passed to the model as an image, not as base64 text. Text and structured content are unchanged; the JSON payload keeps a stub for each image in place of its data:

{"content": [{"type": "text", "text": "chart ready"},
             {"type": "image", "image_index": 0, "mime_type": "image/png"}]}

Before 0.4.0 the same block was {"type": "image", "data": "<base64>", "mimeType": "image/png"} inside the payload. Consequences:

  • tool_result_transform receives a ToolOutput as ctx.result for image-bearing results (.result is the envelope or structured value, .images the images). Return it, or a new ToolOutput, to keep the images; any other return drops them.
  • Reads of data from MCP image blocks in tool_calls.result or traces no longer find it; the bytes are stored as blobs (Images).
  • An image the runtime cannot use (unsupported type such as SVG, corrupt, over a cap) does not fail the call: the model sees [image from MCP could not be used: <reason>] and receives the other images. Base64 wrapped over several lines is accepted, and a wrong mimeType is ignored in favour of the detected type.
  • MCPConnection.call_tool(...) is unchanged: it returns the same envelope as before, base64 included, and an is_error result with image blocks keeps its previous text. Treat that text as sensitive.
  • max_result_bytes applies to the text envelope; images use the ImagePolicy caps on the Agent.

Simple ownership without the runtime

For a script or notebook with one short-lived agent, pass a source directly; the agent owns and closes the connection:

async with Agent(
    provider=provider,
    prompt="You are a filesystem assistant.",
    tool_sources=[filesystem],
) as agent:
    result = await agent.run("List the workspace files.")

MCPHost remains a middle ground — application-owned connections shared across a few agents, without tenancy, capacity, circuits, or telemetry — and MCPServer(...) code keeps working. New multi-user or long-running services should use MCPRuntime.

Configuration reference

Common options on MCPSource.http(...) and MCPSource.stdio(...):

OptionDefaultPurpose
namerequiredStable namespace used in tool and audit names.
connect_timeout30.0Maximum initial connection/negotiation time in seconds.
call_timeout120.0Per-tool SDK and Dendrux execution timeout.
max_result_bytes1_000_000Maximum normalized result size.
failure_mode"strict""strict" or "best_effort".

HTTP-only options are url, headers, and auth. Stdio-only options are command, env, and cwd; mixing transport-specific options raises ValueError during construction.

MCPRuntime constructor options are listed under process-local ownership.

Where this fits