This repository provides the reusable MCP host/client layer for Anda runtimes.
Product crates such as anda-bot should own user configuration, credential
expansion, and launcher UX, then pass concrete server configs into
anda_engine::extension::mcp.
The implementation provides MCP tools plus opt-in resource and elicitation APIs:
- Protocol: MCP
2026-07-28plus the olderinitialize-based revisions. See Protocol Revisions. - Transports: stdio child process and Streamable HTTP.
- Discovery:
tools/listis cached as a dynamicToolProvidersnapshot. The server's self-description (title andinstructions, fromserver/discoveror theinitializehandshake) is captured alongside the tools so each server can be surfaced as a capability group. - Invocation:
tools/callis dispatched through the live MCP client session, including the2026-07-28shapes that replace a direct result (MRTRinput_requiredrounds and task handles). - Refresh:
notifications/tools/list_changedmarks a session dirty; the next async refresh or call refreshes the affected server. Concurrent callers share a serialized refresh; cancelling before publication restores the dirty flag. On2026-07-28peers the notification arrives on asubscriptions/listenstream the provider opens per session. - Reconnect: a closed session (for example a crashed stdio child) is re-established on the next call. Connection setup is serialized per server so racing callers do not spawn duplicate sessions.
- Runtime add: callers can keep an
Arc<McpToolProvider>after registering it with an engine, then calladd_serverto connect a new MCP server and expose its tools without rebuilding theEngine. - Authorization: a Streamable HTTP server may use a static bearer token or OAuth 2.1. See Authorization.
The implementation intentionally does not integrate SEP-2577-deprecated
capabilities: Roots, Sampling, and Logging control. Anda does not advertise
Roots, does not create Sampling messages, and does not call logging/setLevel.
anda_core exposes a generic ToolProvider<C> contract next to the static
Tool/ToolSet path. A provider returns a synchronous discovery snapshot for
model-facing tool selection and async methods for initialization, refresh, and
execution.
anda_engine::EngineBuilder registers providers with
register_tool_provider. During build, the engine initializes static tools
first and then initializes dynamic providers. Provider initialization is
fault-tolerant by default: an unreachable optional server is logged and skipped.
Set required = true to fail initialization instead. Optional servers can use
startup = "background"; their tools become visible only after live discovery
succeeds. No cached annotations or credentials are used as authorization. An explicit refresh() instead reports
per-server failures to the caller. Tool discovery and tool calls merge static
tools and provider-backed tools, with static tools retaining precedence if names
collide.
anda_engine::extension::mcp::McpToolProvider owns one MCP client session per
configured server. Each MCP server is isolated by id, transport, allowlist, and
denylist.
Servers configured at build time are loaded during provider initialization. New
servers can also be added later through the same provider instance. A successful
runtime add updates the provider snapshot used by tools_select,
Engine::tools, and tool calls. If the initial refresh fails, the server
registration is rolled back so callers do not observe a partially added server.
MCP 2026-07-28 removed the session: there is no initialize handshake, no
Mcp-Session-Id, and every request carries its protocol version, client
identity, and capabilities in _meta. Servers advertise themselves through
server/discover instead. Because the opener decides which revisions are
reachable, McpServerConfig::lifecycle selects it per server:
auto probe `server/discover`, fall back to `initialize` (default)
discover require the stateless lifecycle; fail on older servers
initialize legacy handshake only, negotiating at most 2025-11-25
auto covers both server generations. A pre-2026 server usually does not answer
the discovery probe with a JSON-RPC "method not found" it can recover from β an
stdio child rejects the message and exits β so the provider retries once on a
fresh transport with the legacy handshake. An authorization failure is not a
lifecycle problem and is reported as-is instead of being retried. 2026-07-28 is
only ever negotiated through server/discover, which proves the peer implements
the stateless lifecycle; the legacy handshake proposes at most 2025-11-25.
Three consequences of the revision shape the host behavior:
- List changes are opt-in. SEP-2575 replaced unsolicited server pushes with
subscriptions/listen. For peers that negotiated2026-07-28and advertisetools.listChanged, the provider opens atoolsListChangedstream per session and drains it in a background task. Streams are not resumable, so an ended stream is reopened while the transport is up, and the session is marked dirty across the gap rather than trusting a possibly stale route table. - A tool call may not return a result. An MRTR (SEP-2322)
input_requiredround that carries onlyrequestStateis echoed back and the call continues; a round requesting sampling or roots comes back as a failed tool result. Standard elicitation is supported only when both the server config and an application handler opt in; otherwise it also produces a tool-level error. The model sees a tool-level error and can choose another path instead of losing the turn. - Results may be cacheable. SEP-2549
ttlMs/cacheScopeare honored by rmcp's client response cache. An explicitrefresh()clears that cache first so a manual refresh always re-lists; the notification-driven path relies on rmcp invalidating the tool cache whentools/list_changedarrives.
The SEP-2663 tasks extension (io.modelcontextprotocol/tasks) is opt-in per
server through McpServerConfig::tasks. Declaring it tells a server it may
answer tools/call with a task handle instead of a result; the provider then
polls tasks/get β honoring the server's suggested interval, clamped to
250 msβ10 s β until the task reaches a terminal state, so a long-running tool
does not have to hold its response open. The tool call still blocks, bounded by
max_wait_secs (default 300). A task this host walks away from β timeout, an
undeclared extension, or an in-task input request it cannot answer β is
cancelled best-effort so the server can release it.
The task deadline includes each tasks/get response wait; cancellation also
runs when a parent drops the polling future. A tasks/cancel acknowledgement
is bounded to two seconds. Per-server McpTimeouts defaults are 90 seconds for
setup including fallback, 30 for complete listing, 180 for one tool request,
600 for a logical call including queueing/setup/MRTR/tasks, and 300 for an
elicitation callback. Each connection attempt additionally retains the
45-second bound and the discovery probe its 10-second bound. The logical-call
wall deadline includes human interaction; an application can configure it up
to one day. A task's max_wait_secs remains an additional, independent bound.
HTTP handshake and tools/list transient failures receive at most two retries
(250 ms and 1 s), within their outer deadline. Tool calls are never retried by
Anda after a transport failure; rmcp retains its protocol/auth challenge handling.
MCP tool names are not required to match Anda function naming rules. The provider maps every remote tool to a stable local name:
mcp_<server_id>_<remote_tool_name>
Every segment is lowercased and normalized to a-z, 0-9, and _. Names that
exceed 64 characters or collide after normalization receive a short hash suffix.
The route keeps both names so calls use the original MCP tool name. Initial
same-server normalization collisions are all hashed, independently of list
order. Published identities retain their assigned names across additions,
removals, and reordering for the life of the registration. The catalog item
budget also bounds these retained identities; explicitly re-register a server
to reset that budget. Existing unambiguous names are unchanged.
Bulk refresh fetches server listings concurrently and applies successful
snapshots in server-id order, so response timing does not decide which server
keeps an unsuffixed name when local tool names collide at startup.
A flat tool list hides which tools belong together and how they combine. The
provider therefore exposes one ToolGroup per server through the generic
ToolProvider::groups contract:
id: mcp:<server_id>
title: server title (falls back to `MCP server `<id>``)
description: server description (falls back to a generic line)
instructions: server `instructions` from the handshake or discovery (optional)
members: every local tool name for that server
Groups are a discovery-layer concept; they are never sent to model providers as function-calling schema (the completion API has no group concept). The built-in discovery helpers expose them top-down:
tools_groupslists the available groups as a compact directory (id,title,description,member_count) β no tool schemas β so the model can scan which bundles exist without flooding its context.tools_select { group: "<id>" }expands one group into the full schemas of its member tools in a single call, within the discovery byte budget.tools_search/tools_selectalso attach the groups that the returned tools belong to, so discovering one tool reveals the bundle's purpose, the server's usageinstructions, and the sibling member names.
The typical flow is therefore: tools_groups to survey bundles β tools_select
with a group id (or specific tools) to pull in schemas β call the tools.
The same group machinery also serves static tool bundles: built-in tools that
declare a ToolGroupInfo (for example the filesystem and persistent-memory
tools) are surfaced through the identical directory and expansion path.
Before discovery output is returned, group members are normalized against the
currently visible callable definitions. Stale members are dropped, provider
tools shadowed by static tools are hidden from the provider group, and duplicate
group ids are merged so tools_select { group } expands the whole visible
bundle deterministically.
Streamable HTTP servers may require credentials. McpStreamableHttpTransport
supports a static bearer_token, or McpOAuthConfig for OAuth 2.1:
- Client Credentials (
McpOAuthConfig::ClientCredentials) is headless: the token is obtained during session setup with no human in the loop. Per RFC 6749 Β§4.4.3 this grant issues no refresh token, so the session carries the token's deadline and re-establishes itself shortly before expiry to mint a new one.resource(RFC 8707) is required by the MCP auth spec even though the field is optional in the config type. - Authorization Code (
McpOAuthConfig::AuthorizationCode) is interactive.anda_engineis a library and does not open a browser or receive the redirect: callbegin_authorizationto get the URL, present it however the application likes, then callcomplete_authorizationwith the returned code. A present RFC 9207isson the redirect is validated against the recorded issuer before the code is redeemed (SEP-2468). Refresh happens on demand from the stored credentials. discover_http_oauthprobes an endpoint (RFC 9728 / RFC 8414) so an application can decide from a bare URL whether a flow is needed at all.
Credential persistence belongs to the application. Implement
McpCredentialStore to store tokens durably; InMemoryMcpCredentialStore is
provided for tests and short-lived processes, and loses tokens on restart.
The Authorization Code flow needs a browser, but not on the machine hosting the
engine. begin_authorization returns a plain URL and complete_authorization
accepts the full redirect URL as a string, so an agent on a remote server can
run the flow entirely through its conversation channel:
- The application surfaces the authorization URL as text (chat message, terminal output). The user opens it in the browser on their own machine.
- The redirect comes back one of two ways:
-
SSH port forwarding β the redirect URI is a loopback address (
http://127.0.0.1:<port>/callback), the application listens on that port on the server, and the user opens a tunnel alongside their interactive session so the local browser reaches that listener:ssh -N -L <port>:127.0.0.1:<port> user@your-server
-Nruns the tunnel without a remote shell (add-fto background it). Use the same port on both sides: the authorization server redirects the browser to the exactredirect_urithat was registered, so the local port must be the one that URI names. -
Manual paste, no listener at all β with the same loopback redirect URI and nothing listening, the browser lands on "connection refused", but the address bar holds the complete redirect URL including
codeandstate. The user copies that URL into the conversation and the application passes it verbatim tocomplete_authorization.
-
Both variants validate PKCE, the CSRF state, and a present RFC 9207 iss
in-process, so the pasted URL cannot complete a flow this provider did not
start. The only constraint is unchanged: begin_authorization and
complete_authorization must run on the same provider instance, because the
intermediate PKCE/CSRF state lives in memory.
begin_authorization can run at any time β it never consults the stored token,
so a re-consent for updated permission scopes works even while the current
access token is still valid. Completing the flow persists the new grant and
drops the server's live session: a session pins the credentials it connected
with, so without the drop the new grant would sit unused in the store until the
old session happened to die. The next refresh or tool call reconnects with the
new credentials; in-flight calls on the old session finish undisturbed.
Two related primitives:
disconnect_server(server_id)drops the session while keeping the server registered and its routes intact; the next call reconnects from the store. It takes the same per-server connect lockensure_sessionholds across a handshake, so a reconnect already in flight cannot install a session carrying the credentials being retired.clear_credentials(server_id)additionally deletes the persisted grant, so the next connection fails withMcpAuthorizationRequireduntil the interactive flow runs again β the "sign out / force re-consent" primitive.
Setup reports McpAuthorizationRequired. An authorization failure during a
live tool call instead returns is_error: true and
{"error":{"code":"authorization_required","server_id":"..."}}, allowing the
model and application to recover without exposing transport credentials. The setup error covers both "no
grant is stored" and "the stored grant is no longer usable" β a refresh token the
authorization server has revoked surfaces as an auth failure during credential
acquisition or the handshake, and is reported as McpAuthorizationRequired with
the underlying cause logged, so an application that downcasts for it always knows
when to re-run the interactive flow.
To request different scopes than the configured ones, update the server config
(remove_server + register_server with the new scopes; removal leaves
stored credentials in place) and run the flow again.
- MCP servers are never enabled implicitly by this crate.
- Stdio uses
commandplusargs; it does not invoke a shell string. It inherits only platform essentials by default; put extra values inenv, or explicitly opt into the old full-parent behavior withinherit_env = true. Unix child processes get a dedicated process group that is terminated when the session is released. Windows currently guarantees direct-child cleanup only. - Streamable HTTP validates URLs and custom headers before connecting. Protocol, routing, and conflicting Authorization headers are rejected. Both static and OAuth data clients disable redirects, including same-origin redirects.
- Bearer tokens, OAuth client secrets, and stdio
envvalues are redacted fromDebugoutput, so a config can be logged without leaking expanded secrets. - Remote tool descriptions and annotations are treated as untrusted metadata.
- Server title and
instructionsare likewise untrusted: they are surfaced as group data the model reads, never as system instructions or runtime directives. - Tool calls send arguments only. Resource reads are explicit separate operations; neither operation forwards conversation history or arbitrary context metadata.
- Tool results include
server_idand the original MCP tool name for audit.
anda-bot should translate its YAML config into McpServerConfig values and
register one McpToolProvider with the engine builder. Bot-specific concerns
remain outside this repository layer:
- Environment variable and secret expansion.
- Default per-server working directories.
- User-facing approval UX.
- Commands such as
anda mcp listoranda mcp ping.
Every server registration has an identity and cancellation token. Catalog
fetches are serialized through publication, and published routes carry a
revision plus the original MCP Tool (annotations, output schema and metadata).
Calls refresh dirty catalogs and validate their captured identity/definition
before execution. Publication waits for calls already using that catalog;
removal cancels the registration and prevents in-flight fetches from publishing.
Disconnect retires the session: calls already sent may finish, but queued calls
cannot begin on retired credentials. A reconnect refreshes the live catalog.
McpConcurrency defaults to Serial. ReadOnlyParallel permits concurrent
annotated reads while excluding writes; Parallel requires explicit host
configuration. Remote annotations are hints, never approval or permission grants.
Tools with MCP Apps visibility metadata must explicitly include model to enter
the model-facing catalog. server_statuses() observes state without connecting.
McpLimits defaults:
| Budget | Default |
|---|---|
| Catalog pages / items / cursor | 100 / 2,048 / 64 KiB |
| Input or output schema per tool | 64 KiB |
| Tool description / title | 8 KiB each |
| Combined server metadata | 32 KiB |
| Incoming stdio line, HTTP JSON body, SSE event | 8 MiB |
| Model result text / decoded media / media blocks | 32 KiB / 5 MiB / 8 |
Repeated cursors and duplicate raw tool names are rejected. Invalid or oversized catalogs leave the last published snapshot intact and retryable. Schema, description and title budgets apply only to tools that would be published, so an excluded or app-only tool cannot fail its server. SSE budgets include raw comments within each event. rmcp still owns lifecycle negotiation, JSON-RPC decoding/routing, subscriptions and authentication; the byte/HTTP adapters enforce bounds before SDK decoding. Header/body errors omit raw bodies.
Discovery outputs are limited to 256 KiB; complete schemas that do not fit are
omitted, and callers can select omitted tools by exact name. Accumulated
discovered schemas are additionally limited to 128 definitions and 256 KiB.
MCP schema adaptation only fills missing/null properties on object schemas;
it preserves constraints, compositions and references rather than using lossy
schema compaction.
ToolOutput.output retains the complete audited MCP envelope, including
server_id, original tool name, structured content, content blocks and _meta.
ToolOutput.model_output contains a bounded ToolPresentation: visible text
and inline media. Top-level and content-block _meta do not enter that view.
Unknown content and unsupported media produce explicit text notices; oversized
text carries a truncation marker. Structured data and content images can coexist.
A text block that only repeats the structured content as serialized JSON (the
spec's compatibility copy) is shown once; other text blocks are kept.
The runner persists the explicitly tagged presentation inside the existing
ContentPart::ToolOutput.output, preserving call IDs and tool/user boundaries.
OpenAI Responses and Anthropic project images into native tool-result blocks;
Chat Completions falls back to text. Gemini 3 projects supported image/document
MIME types into functionResponse.parts; older or unknown Gemini model names
fall back to text. Audio remains available to callers and in the neutral view,
but these model APIs currently receive a text notice instead of audio bytes.
Media fallbacks affect only provider requests; persisted neutral history retains
the original presentation for replay with another model. Anthropic omits blank
text blocks from image-only results and omits content for empty presentations.
See Gemini's function response restrictions.
Hooks that rewrite a result must update model_output too, or clear it to use
their replacement raw output. The original MCP envelope is not itself truncated.
Install McpToolProviderBuilder::elicitation_handler(Arc<dyn McpElicitationHandler>)
and set server.elicitation = true. The handler declares supported standard
form/URL modes and receives the server id, typed request and cancellation token.
The application owns UI, consent, URL presentation, input validation and approval.
Legacy server requests and modern tool-call MRTR both use that handler. An MRTR
round is checked for unsupported methods before any prompt is opened. Sampling,
Roots, Logging control and proprietary verification extensions remain disabled.
Task-level input requests still cancel the task and return a tool error.
Set server.resources = true to call list_resources,
list_resource_templates, and read_resource with an explicit server id and
cancellation token. These methods share the authenticated session and byte/time
budgets. They do not register new model tools or fetch returned resource URIs
from the local filesystem or an unrelated HTTP origin.
list_events, subscribe_events, subscribe_webhook and unsubscribe_webhook
implement the client side of the experimental MCP Events extension
(events/list, events/poll, events/stream, events/subscribe,
events/unsubscribe). They share the server's session, credentials and budgets.
- Detection. rmcp 3.5 drops
capabilities.events, solist_eventssendsevents/listand returnsNoneon "method not found". A server that ignores unknown methods is given 10 seconds. Event types over the description or schema limits are skipped. - Poll and push.
subscribe_eventsruns one subscription on a background task and reportsMcpEventSignals to an applicationMcpEventSink:Active(withtruncatedwhen events were lost),Eventswith the cursor to resume after them, recoverableErrors,ListChanged, and a finalTerminated. Poll followshasMoreandnextPollMs(clamped to 1 s β 5 min, 100 events a page). Push keeps oneevents/streamrequest open per subscription, outside the tool concurrency queue and without a request timeout; a stream silent for 60 s, a closed or retired session, or a full 256-notification buffer reopens it from the last accepted cursor. Cancelling sendsnotifications/cancelledon stdio and legacy HTTP; on2026-07-28HTTP, rmcp closes the request's response stream instead. - Ordering. rmcp dispatches each notification on its own task, which can
reorder a burst.
EventTapwraps every transport and handsnotifications/events/*to the session's router as they are read, keyed by_meta["io.modelcontextprotocol/subscriptionId"](a pushed event keeps the rest of its_meta); notifications that beat the stream's registration are held briefly. - Delivery guarantee. The provider keeps no event state. A cursor is used for
the next request only after the sink accepted the signal carrying it, so delivery
is at least once; the application persists cursors and deduplicates by
eventId. A sink error restarts the subscription from the last accepted cursor after a backoff (2 s doubling to 5 min). Events over 256 KiB are skipped with anError. - Webhooks. The provider subscribes, refreshes (subscribe again before
refreshBefore) and unsubscribes; the application supplies the callback URL andwhsec_secret and receives and verifies the deliveries itself. A host that relays webhooks through another MCP server (such as dMsg) drives that server's endpoint tools withcall_server_tool, which calls a tool by its remote name for the application rather than the model, whether or not the model sees it. - Errors.
McpEventErrorclassifies both generations of the draft's codes (-32023..-32027 and the earlier -32011..-32015 that OpenAI documents), keepsdata.reason/data.kind, and maps authorization failures toAuthorizationRequired. Not found, forbidden, unsupported, invalid arguments, authorization and a removed or re-registered server end a subscription; other failures are retried.
Event payloads, descriptions and schemas are untrusted server data, like tool results. Receiving an event does not authorize any action.
McpCredentialStore::acquire_refresh_guard is an optional extension with a
backward-compatible default. The adapter forwards it to rmcp, which holds it
across authoritative credential reread, token exchange and completed persistence.
The in-memory store coordinates managers sharing the same store/server id.
Persistent stores should implement equivalent process/database coordination;
load, save, and clear must not reacquire the same lock. Credential keys must
identify the intended server/account, and changing an endpoint must not reuse an
unrelated grant. OS keychains and browser/callback UX remain application-owned.
use std::sync::Arc;
use anda_engine::extension::mcp::{McpConcurrency, McpServerConfig, McpToolProvider};
# async fn example() -> Result<(), anda_core::BoxError> {
let mut server = McpServerConfig::streamable_http("catalog", "https://example.com/mcp");
server.required = true;
server.timeouts.call_secs = 120;
server.limits.catalog_items = 512;
server.concurrency = McpConcurrency::ReadOnlyParallel;
server.resources = true;
let provider = Arc::new(McpToolProvider::new(vec![server])?);
let builder = anda_engine::engine::Engine::builder().register_tool_provider(provider)?;
# let _ = builder;
# Ok(())
# }For Rust struct literals, use the server constructors and transport ..Default::default()
so new policy fields receive their defaults. ToolOutput literals must include
model_output or use ..Default::default(); persisted older outputs deserialize
with no presentation override. Servers requiring inherited process secrets must
configure env or explicitly select full inheritance.