Files
alknet/docs/architecture/crates/http/http-mcp.md
glm-5.2 3327d585da docs(http): resolve OQ-40 reqwest client config — ClientWithMiddleware + retry/retry-after middleware stack
OQ-40 resolved: alknet-http owns a shared reqwest_middleware::ClientWithMiddleware
(not a bare reqwest::Client) with a two-layer middleware stack —
RetryTransientMiddleware (reqwest-retry, exponential backoff on transient
failures) + inlined RetryAfterMiddleware (from melotic/reqwest-retry-after, MIT,
~50 lines, inlined to bound the upstream's unbounded HashMap storage). The two
are complementary: reqwest-retry's default strategy does not honor Retry-After.

Hot-reload is rebuild-and-swap via ArcSwap (same pattern as
ConfigIdentityProvider, ADR-035); a rebuild drops the connection pool, which
is acceptable since a config change wanting a fresh pool is the trigger. The
three one-way constraints stand unchanged: alknet-http owns its client (no
env-var config, no shared global), credentials inject per-request from
OperationContext.capabilities, outbound TLS uses the system trust store.

Records the downstream layering boundary: the agent crate's provider SSE
normalization (the solid part of aisdk's pattern — Vercel-UI-message
normalization) sits on top of this client, consuming the reqwest::Response
stream; it does not replace the client. The aisdk core/client.rs reference for
client construction is dropped (env-var config + hand-rolled retry are the
anti-patterns discarded); the from_openapi.ts SSE normalization reference in
the forwarding-handler section is kept (separate, solid pattern).

No ADR — the decision is internal to alknet-http: the client type does not
cross crate boundaries (alknet-call never sees reqwest), the library choice is
reversible, and it does not touch the system's structure, constraints, or
cross-crate API surface.

Updates: http-adapters.md (HTTP client section rewritten, references updated,
constraints/OQ bullets updated), http-mcp.md (OQ-40 status flip), open-
questions.md (OQ-40 resolved with full config-shape table), README.md (OQ-40
folded into the existing two-way-doors bucket), and three secondary docs
(crates/http/README.md, overview.md, http-server.md) that carried stale 'open'
OQ-40 references.
2026-06-30 08:02:30 +00:00

14 KiB

status, last_updated
status last_updated
draft 2026-06-29

HTTP MCP — from_mcp and to_mcp

The MCP-direction adapters (feature-gated behind mcp): from_mcp imports remote MCP tools as call-protocol operations over streamable HTTP (reqwest client), and to_mcp exposes local operations as MCP tools over streamable HTTP (axum server). This document covers both, the rmcp integration, and the stdio exclusion (ADR-037).

What

Two adapters, both in alknet-http, both behind the mcp feature gate:

  1. from_mcp — discovers remote MCP tools via the MCP tools/list call over streamable HTTP, and registers each as a HandlerRegistration bundle with a forwarding handler that calls the remote tool via tools/call. Uses rmcp's StreamableHttpClientTransport (reqwest-based). Provenance is FromMCP (leaf, composition_authority: None, scoped_env: None, Internal by default — ADR-015/022). Implements OperationAdapter.
  2. to_mcp — exposes the local registry's External operations as MCP tools over streamable HTTP, using rmcp's StreamableHttpService (an axum-compatible tower service). An external MCP client (an editor, an AI tool) discovers and calls alknet operations through the MCP protocol. A pure projection (consumes the registry, does not produce entries — ADR-017 §5).

Streamable HTTP only (ADR-037)

MCP defines two transports: streamable HTTP and stdio. alknet-http supports only streamable HTTP. Stdio is not built — it is the spawn- arbitrary-executable RCE vector that the rest of the architecture is designed to avoid (ADR-037). The mcp feature gate pulls in rmcp with the streamable HTTP transport features only; the stdio transport (transport-child-process) is not a dependency, not optional, not behind a separate feature.

If an operator wants a stdio-only MCP server, they run a small streamable-HTTP-to-stdio bridge themselves, outside alknet. The bridge is where the RCE risk lives, explicitly in the operator's hands. See ADR-037.

from_mcp

pub struct FromMCP {
    /// The MCP server's streamable HTTP endpoint URL.
    endpoint: String,
    /// Bearer token for the MCP server (from Capabilities at registration).
    auth_token: Option<String>,
    /// The importing deployment's name for this MCP server (becomes the
    /// operation namespace).
    namespace: String,
}

#[async_trait]
impl OperationAdapter for FromMCP {
    async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}

The adapter:

  1. Connects to the MCP server's streamable HTTP endpoint using rmcp's StreamableHttpClientTransport::from_uri(endpoint) (the rmcp streamable_http.rs client example shows the pattern: client_info .serve(transport).await, then client.list_tools(), client.call_tool()). On connection failure, returns AdapterError::DiscoveryFailed; on 401, AdapterError::Unauthorized.
  2. Calls tools/list → the list of MCP tools (name, description, inputSchema, optional outputSchema).
  3. For each tool, constructs a HandlerRegistration:
    • spec.name = the tool name (or namespace/tool_name if a namespace prefix is configured — same local-naming sugar as from_call's FromCallConfig::namespace_prefix, ADR-029 §5).
    • spec.namespace = the configured namespace.
    • spec.op_type = Mutation (MCP tools are call/response; the MCP spec doesn't have a native streaming/tool-subscription distinction — tools/call returns a result. If MCP adds a streaming-tool extension, a Subscription mapping would be added.)
    • spec.visibility = Internal (adapter-registered, ADR-015).
    • spec.input_schema = the tool's inputSchema (JSON Schema).
    • spec.output_schema = the tool's outputSchema, or Type.Unknown() if absent (the TS from_mcp.ts shows this fallback).
    • spec.error_schemas = the MCP tool's error description mapped to ErrorDefinition (ADR-023 — MCP tool definitions carry error descriptions; the adapter maps them).
    • spec.access_control = AccessControl::default().
    • handler = a forwarding handler (see Forwarding Handler below).
    • provenance = FromMCP, composition_authority: None, scoped_env: None (leaf — ADR-022).
    • capabilities = the bearer token for the MCP server (injected by the assembly layer at registration — see No-Env-Vars below).
  4. Returns the bundles. The caller (the assembly layer) registers them in the OperationRegistry.

Forwarding handler

At call time, the from_mcp forwarding handler:

  1. Reads the call input (serde_json::Value — the tool arguments).
  2. Calls client.call_tool({ name: tool_name, arguments: input }) via the rmcp client (the streamable_http.rs example shows client.call_tool(CallToolRequestParams::new(name).with_arguments(...))).
  3. On success: extracts structuredContent (if present) or maps the content blocks (the TS mapMCPContentBlocks shows the mapping: text/image/audio/resource/resource_link → MCPContentBlock), wraps in a ResponseEnvelope, returns.
  4. On result.isError: maps to a CallError with the MCP error content (the TS from_mcp.ts handler shows the error mapping), returns.
  5. The rmcp client connection is maintained for the lifetime of the registration (the MCP server is a persistent streamable HTTP endpoint, not a per-call connection).

The handler is opaque to the CallAdapterArc<dyn Handler> the registry dispatches. alknet-call never sees rmcp.

to_mcp

pub fn to_mcp_service(
    registry: Arc<OperationRegistry>,
    identity_provider: Arc<dyn IdentityProvider>,
) -> StreamableHttpService<...>;

to_mcp exposes the local registry's operations as a fixed gateway tool set over streamable HTTP — not one MCP tool per operation. This is the tool-gateway pattern (ADR-041): the LLM has a few tools in context (search, schema, call, batch), not hundreds, and discovers operations on demand through the gateway. See ADR-041 for the rationale (the tool-bloat problem, the memory/worktree tool pattern that informed the design).

The rmcp simple_auth_streamhttp.rs server example shows the streamable-HTTP-service-into-axum-Router pattern:

// From the rmcp example:
let mcp_service: StreamableHttpService<Counter, LocalSessionManager> =
    StreamableHttpService::new(
        || Ok(Counter::new()),
        LocalSessionManager::default().into(),
        StreamableHttpServerConfig::default(),
    );

let protected_mcp_router = Router::new()
    .nest_service("/mcp", mcp_service)
    .layer(middleware::from_fn_with_state(token_store, auth_middleware));

alknet-http's to_mcp follows the same axum integration pattern, but the rmcp Service impl is a gateway service (4 fixed tools) rather than a per-operation tool registry.

The gateway tool set

to_mcp exposes four MCP tools that gate access to the full operation registry:

MCP tool Call protocol operation Purpose
search services/list List/search available operations (filtered by the caller's AccessControl). Returns names + descriptions, not full schemas.
schema services/schema Get an operation's full OperationSpec (input/output JSON Schemas, error schemas).
call call.requested (Query/Mutation) Invoke an operation by name with a JSON input. Returns the output or a typed error (ADR-023).
batch multiple call.requested Invoke multiple operations in one tool call (correlated request IDs, OQ-14).

The LLM calls search to discover operations, schema to learn an operation's input shape, call to invoke. Same pattern as man <command> — discover on demand, don't preload. See ADR-041 for the rationale.

Subscription exclusion

The gateway exposes only Query and Mutation operations (request/response). Subscription operations (streaming) are filtered out of search results and cannot be invoked via call — MCP tool calls are request/response by protocol design; streaming subscriptions don't fit the LLM tool-call pattern. See ADR-041 §2.

to_mcp service behavior

  1. On MCP tools/list: returns the fixed gateway tool set (4 tools: search, schema, call, batch), not the registry's operations. The gateway tools have stable names and schemas; the registry's operations are discovered through search.
  2. On MCP tools/call:
    • search → dispatches services/list (filtered by the caller's AccessControl), returns operation names + descriptions.
    • schema → dispatches services/schema, returns the OperationSpec.
    • call → dispatches OperationRegistry::invoke() (the same dispatch path the HTTP server uses, ADR-036). The result is mapped to an MCP CallToolResult (structuredContent for the output, or isError: true for a CallError with typed details per ADR-023).
    • batch → dispatches multiple call.requested events, returns an array of results.
  3. Auth: the Bearer middleware resolves the token via IdentityProvider::resolve_from_token(), same as the HTTP server's auth (ADR-004). The MCP client authenticates by bearer token; no PeerId (browsers and MCP clients are not alknet peers — ADR-034 §4). AccessControl gates search results and call dispatch — the LLM sees only what it's authorized to call.

No-Env-Vars

The from_mcp forwarding handler reads the MCP server's bearer token from context.capabilities (the same injection path as from_openapi), not from std::env::var. The assembly layer injects the token at registration; the handler reads it per-call. This is the no-env-vars invariant (ADR-014, overview.md).

Why

MCP is the protocol editors and AI tools use to discover and call tools. from_mcp lets alknet compose external MCP servers (a remote tool server, a third-party MCP endpoint) into the call protocol — the same composition pattern as from_openapi and from_call. to_mcp lets external MCP clients (an editor, an AI tool) discover and call alknet operations through the MCP protocol, without those clients needing to speak EventEnvelope.

to_mcp uses the tool-gateway pattern (ADR-041): a fixed set of meta-tools (search, schema, call, batch) gates access to the full operation registry, so the LLM has a few tools in context instead of hundreds. This addresses the tool-bloat problem — an LLM connecting to a node with 200 operations gets 4 MCP tools, not 200, and discovers operations on demand through search + schema. Same pattern as the memory and worktree tools (one entry point, large dataset behind it), and the same principle as Linux's man command (don't preload all documentation; query on demand).

The streamable-HTTP-only constraint (ADR-037) is a security position: alknet does not import the MCP stdio RCE vector. The streamable HTTP path is network-isolated, auth-gatable, and runs under alknet's auth/identity/capabilities machinery — the same machinery that gates every other HTTP request.

Constraints

  • Streamable HTTP only. Stdio is not built (ADR-037). The mcp feature pulls in rmcp with streamable HTTP transport features only.
  • from_mcp-registered ops are Internal by default. Composition material, not directly callable from the wire (ADR-015).
  • from_mcp handlers read credentials from OperationContext.capabilities. No env vars (ADR-014).
  • to_mcp is a pure projection. Consumes the registry, does not produce entries. Not an OperationAdapter.
  • MCP clients are not alknet peers. A browser or MCP client connecting to to_mcp authenticates by bearer token, gets no PeerId, is not in the peer graph (ADR-034 §4).
  • The mcp feature is optional. A deployment that doesn't need MCP doesn't compile rmcp. The default feature set is h2 + http1.

Design Decisions

Decision ADR Summary
MCP stdio transport excluded ADR-037 Streamable HTTP only; stdio is not built
to_mcp tool-gateway pattern ADR-041 4 fixed gateway tools (search/schema/call/batch), not one tool per operation; Subscription excluded
from_mcp is an OperationAdapter ADR-017 Async trait; produces HandlerRegistration bundles
to_mcp is a projection ADR-017 Consumes the registry, doesn't produce entries
Adapter-registered ops are Internal ADR-015 from_mcp ops are composition material
from_mcp provenance is a leaf ADR-022 composition_authority: None, scoped_env: None
Error fidelity ADR-023 MCP tool errors mapped to ErrorDefinitions
No-env-vars credential injection ADR-014 Handler reads context.capabilities, not env vars
MCP clients are not alknet peers ADR-034 Bearer token, no PeerId

Open Questions

See open-questions.md for full details.

  • OQ-40 (resolved): reqwest client config — the shared ClientWithMiddleware used by from_mcp (same client as from_openapi).

References

  • ADR-037 — the stdio exclusion this document enforces
  • overview.md — adapter location map, feature gates
  • ../call/client-and-adapters.mdOperationAdapter trait, AdapterError variants
  • /workspace/rust-sdk/ — MCP Rust SDK (rmcp v1.8.0); streamable HTTP transport
  • /workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs — streamable HTTP MCP server with Bearer auth (the to_mcp pattern)
  • /workspace/rust-sdk/examples/clients/src/streamable_http.rs — streamable HTTP MCP client (the from_mcp pattern)
  • /workspace/@alkdev/operations/src/from_mcp.ts — TypeScript prior art (createMCPClient, mapMCPContentBlocks, the MCPClientLoader)