Full-surface integration suite (tests/full_surface.rs, mcp feature): - one HttpAdapter over real TCP (ProtocolHandler::handle path) serving gateway endpoints, /openapi.json, /mcp, and the WS channels session - gateway: search/schema/call/subscribe/batch/publish presence, envelope shapes, error fidelity end-to-end - from_openapi import -> Internal-by-default invisible from the wire -> External facade composes it via env.invoke -> upstream HTTP API called end-to-end (ADR-015 composition model exercised) - to_openapi 6-path doc validated against openapiv3 over the wire - to_mcp: MCP client connects to /mcp on the served adapter, lists the 4 gateway tools, search returns ACL-filtered ops (Sub excluded) Production fix: the WS upgrade route was reserved but never wired into HttpAdapter's router (the ws-upgrade-session tests built their own router). Now wired with ws_bearer_auth (401 without a resolvable token) around ws_upgrade_handler. Docs sync: all 28 'Port notes' sections/blockquotes stripped from ported ADRs/specs; OQ-01/OQ-02 statuses corrected to resolved in overview.md, websocket.md, and the README table (open-questions.md was already current). Publish prep: cargo publish --dry-run --allow-dirty succeeds; cargo doc --no-deps warning-free (ADR link targets fixed); feature combinations (default / test-support / mcp / wss / all) compile warning-free under clippy -D warnings. Verified: cargo test (182 lib default), --all-features (227 lib + 29 integration), clippy -D warnings x3 feature sets, fmt, doc, publish --dry-run.
21 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-08-27 |
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 alkhttp, both behind the mcp feature gate:
from_mcp— discovers remote MCP tools via the MCPtools/listcall over streamable HTTP, and registers each as aHandlerRegistrationbundle with a forwarding handler that calls the remote tool viatools/call. Uses rmcp'sStreamableHttpClientTransport(reqwest-based). Provenance isFromMCP(leaf,composition_authority: None,scoped_env: None,Internalby default — alkcall ADR-017/018). ImplementsOperationAdapter(the async trait fromalkcall::client).to_mcp— exposes the local registry'sExternaloperations as MCP tools over streamable HTTP, using rmcp'sStreamableHttpService(an axum-compatible tower service). An external MCP client (an editor, an AI tool) discovers and calls the local deployment's operations through the MCP protocol. A pure projection (consumes the registry, does not produce entries — alkcall ADR-022 §5).
Streamable HTTP only (ADR-037)
MCP defines two transports: streamable HTTP and stdio. alkhttp
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 alkhttp. 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:
- Connects to the MCP server's streamable HTTP endpoint using rmcp's
StreamableHttpClientTransport::from_uri(endpoint)(the rmcpstreamable_http.rsclient example shows the pattern:client_info .serve(transport).await, thenclient.list_tools(),client.call_tool()). On connection failure, returnsAdapterError::DiscoveryFailed; on 401,AdapterError::Unauthorized. - Calls
tools/list→ the list of MCP tools (name, description,inputSchema, optionaloutputSchema). - For each tool, constructs a
HandlerRegistration:spec.name= the tool name (ornamespace/tool_nameif a namespace prefix is configured — same local-naming sugar asfrom_call'sFromCallConfig::namespace_prefix, alkcall ADR-024 §5).spec.namespace= the configurednamespace.spec.op_type=Mutation(MCP tools are call/response; the MCP spec doesn't have a native streaming/tool-subscription distinction —tools/callreturns a result. If MCP adds a streaming-tool extension, aSubmapping would be added.) Allfrom_mcphandlers areHandlerKind::Once(alkcall ADR-021);from_mcpnever produces aStreamingHandler(nor aHandlerKind::Sink— thePubtype, alkcall ADR-046, has no MCP representation).spec.visibility=Internal(adapter-registered, alkcall ADR-017).spec.input_schema= the tool'sinputSchema(JSON Schema).spec.output_schema= depends on whether the tool declaresoutputSchema(MCP 2025-06-18+):outputSchemapresent →output_schema= the declared schema (converted from JSON Schema). The result arrives inCallToolResult.structured_contentand is composable with local operations (the data matches the declared type).outputSchemaabsent (older MCP servers) →output_schema= the MCPContentBlockunion (text | image | audio | resource | resource_link— a well-defined MCP type, notType.Unknown()). The result arrives inCallToolResult.contentas aVec<ContentBlock>. The common sub-case is a singleTextblock (which older servers often fill with JSON-stringified data), but the type is theContentBlockunion regardless of what the text contains. See "Output handling" below.
spec.error_schemas= the MCP tool's error description mapped toErrorDefinition(alkcall ADR-016 — 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 — alkcall ADR-018).capabilities= the bearer token for the MCP server (injected by the assembly layer at registration — see No-Env-Vars below).
- Returns the bundles. The caller (the assembly layer) registers them
in the
OperationRegistry.
Forwarding handler
At call time, the from_mcp forwarding handler:
- Reads the call input (
serde_json::Value— the tool arguments). - Calls
client.call_tool({ name: tool_name, arguments: input })via the rmcp client (thestreamable_http.rsexample showsclient.call_tool(CallToolRequestParams::new(name).with_arguments(...))). - On success: extracts the result from the
CallToolResult, following thestructuredContent-preferred-over-content-blocks rule (see "Output handling" below), wraps in aResponseEnvelope, returns. - On
result.isError: maps to aCallErrorwith the MCP error content (the TSfrom_mcp.tshandler shows the error mapping), returns. - 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 CallAdapter — a HandlerKind::Once
wrapping an Arc<dyn Handler> that the registry dispatches. alkcall
never sees rmcp.
Output handling (structuredContent vs content blocks)
MCP CallToolResult (rmcp model.rs) carries two result fields:
content: Vec<ContentBlock> (always present, defaults to []) and
structured_content: Option<Value> (present when the tool declared
outputSchema). The from_mcp handler follows the same rule the TS
adapter (@alkdev/operations/src/from_mcp.ts) and the rmcp SDK
(CallToolResult::into_typed) use:
structured_contentpresent (tool declaredoutputSchema): the handler usesstructured_contentas the result, validated/cast against the declaredoutput_schema. This is the composable case — the data matches the declared type, so a composing handler can use it as a typed value.structured_contentabsent (older server, nooutputSchema): the handler mapscontent: Vec<ContentBlock>to theContentBlock-unionoutput_schema(text/image/audio/resource/ resource_link). The TSmapMCPContentBlocksshows the mapping; the RustContentBlockenum (rmcp/src/model/content.rs) is the same shape. The common sub-case is a singleTextblock — older servers often JSON-stringify structured data into thetextfield. The adapter does not attempt toJSON.parsethe text heuristically (fragile, not the adapter's concern); it carries theContentBlockunion as the typed result. A consumer that knows the text is JSON can parse it downstream.
The isError: true case is handled separately (step 4 above) — it
maps to a CallError, not to the output handling path.
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));
alkhttp'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 (alkcall ADR-016). |
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.
Sub and Pub exclusion
The gateway exposes only Query and Mutation operations
(request/response). Sub operations (streaming responses — many
call.responded events) and Pub operations (streaming requests —
the consumer publishes chunks to a HandlerKind::Sink handler,
alkcall ADR-046) are both filtered out of search results and cannot
be invoked via call — MCP tool calls are request/response by
protocol design; neither a streaming response nor a client-side
publish fits the LLM tool-call pattern. This is unaffected by the
streaming handler work (alkcall ADR-021): the StreamingHandler type
and invoke_streaming() dispatch path exist in alkcall and are used
by to_openapi's /subscribe endpoint (and invoke_sink() by
/publish — ADR-068),
but to_mcp does not expose them — it filters by op_type and only
dispatches Query/Mutation via invoke(). See
ADR-041 §2.
to_mcp service behavior
- 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 throughsearch. - On MCP
tools/call:search→ dispatchesservices/list(filtered by the caller'sAccessControl), returns operation names + descriptions.schema→ dispatchesservices/schema, returns theOperationSpec.call→ dispatchesOperationRegistry::invoke()(the same dispatch spine the HTTP gateway uses — the gateway surface per ADR-047, the HTTP-to-call mapping per ADR-036). The result is mapped to an MCPCallToolResult(structuredContentfor the output, orisError: truefor aCallErrorwith typeddetailsper alkcall ADR-016).batch→ dispatches multiplecall.requestedevents, returns an array of results.
- 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; noPeerId(browsers and MCP clients are not alk peers — ADR-034 §4).AccessControlgatessearchresults andcalldispatch — the LLM sees only what it's authorized to call.
Shared dispatch spine with to_openapi
to_mcp's call tool and to_openapi's /call endpoint share the
same dispatch spine: resolve caller identity (Bearer →
IdentityProvider::resolve_from_token) → build a root
OperationContext → OperationRegistry::invoke() → map the
ResponseEnvelope to the gateway's wire shape (CallToolResult for
MCP, HTTP JSON for OpenAPI). The wire framing, discovery listing
(tools/list vs /search), streaming (excluded in to_mcp vs
to_openapi's /subscribe SSE and /publish NDJSON — the OpenAPI
gateway is 6 endpoints per
ADR-068, while to_mcp
stays the 4 fixed tools), and server integration (rmcp
StreamableHttpService tower service vs axum route handlers) are
genuinely per-gateway and are not shared. See also
http-adapters.md §"Shared dispatch spine with
to_mcp".
Research findings
(/workspace/@alkdev/alknet/docs/research/alknet-http-gateway-factoring/findings.md)
recommend extracting a thin shared spine (the concrete
GatewayDispatch struct holding Arc<OperationRegistry> +
Arc<dyn IdentityProvider> with a resolve + build_context + invoke
method returning a ResponseEnvelope, named in alkcall ADR-021 and
extended with invoke_streaming() for the streaming path), not a
trait or gateway abstraction. The spine is small (~15–30 lines per
endpoint), but it is the one place where a divergence bug (identity
resolved differently, OperationContext.internal set inconsistently,
CallError mapped asymmetrically) would be a security/correctness
issue. The server-integration and wire-framing layers stay
per-gateway; a third gateway (GraphQL, gRPC) is not on the horizon,
and if one appears its server-integration layer needs its own shape
anyway. This is an implementation factoring note, not an ADR — the
decision is internal to alkhttp and does not cross crate boundaries.
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 the alk stack 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 the local
deployment's 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: alkhttp does not import the MCP stdio RCE vector. The streamable HTTP path is network-isolated, auth-gatable, and runs under alkhttp'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
mcpfeature pulls in rmcp with streamable HTTP transport features only. from_mcp-registered ops areInternalby default. Composition material, not directly callable from the wire (alkcall ADR-017).from_mcphandlers read credentials fromOperationContext.capabilities. No env vars (ADR-014).to_mcpis a pure projection. Consumes the registry, does not produce entries. Not anOperationAdapter.- MCP clients are not alk peers. A browser or MCP client
connecting to
to_mcpauthenticates by bearer token, gets noPeerId, is not in the peer graph (ADR-034 §4). - The
mcpfeature is optional. A deployment that doesn't need MCP doesn't compile rmcp. The default feature set ish2+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; Sub and Pub excluded |
from_mcp is an OperationAdapter |
alkcall ADR-022 (Call Protocol Client and Adapter Contract) | Async trait (alkcall::client); produces HandlerRegistration bundles |
to_mcp is a projection |
alkcall ADR-022 (Call Protocol Client and Adapter Contract) | Consumes the registry, doesn't produce entries |
Adapter-registered ops are Internal |
alkcall ADR-017 (Privilege Model and Authority Context) | from_mcp ops are composition material |
from_mcp provenance is a leaf |
alkcall ADR-018 (Handler Registration, Provenance, and Composition Authority) | composition_authority: None, scoped_env: None |
| Error fidelity | alkcall ADR-016 (Operation Error Schemas) | MCP tool errors mapped to ErrorDefinitions |
| No-env-vars credential injection | ADR-014 | Handler reads context.capabilities, not env vars |
| MCP clients are not alk peers | ADR-034 §4 | Bearer token, no PeerId |
| Streaming handler for subscriptions | alkcall ADR-021 (Streaming Handler for Subscription Operations) | from_mcp handlers are always HandlerKind::Once (MCP tools are request/response); to_mcp excludes Sub (and Pub) ops (unchanged by the streaming handler) |
Open Questions
See open-questions.md for full details.
- OQ-40 (resolved): reqwest client config — the shared
ClientWithMiddlewareused byfrom_mcp(same client asfrom_openapi).
References
- ADR-037 — the stdio exclusion this document enforces
- overview.md — adapter location map, feature gates
- the alkcall crate's
docs/architecture/client-and-adapters.md—OperationAdaptertrait,AdapterErrorvariants /workspace/rust-sdk/— MCP Rust SDK (rmcp v1.8.0); streamable HTTP transport/workspace/rust-sdk/crates/rmcp/src/model/tool.rs—Toolwithoutput_schema: Option<Arc<JsonObject>>(theoutputSchemafield)/workspace/rust-sdk/crates/rmcp/src/model/content.rs—ContentBlockenum (text/image/audio/resource/resource_link — the fallbackoutput_schematype whenoutputSchemais absent)/workspace/rust-sdk/crates/rmcp/src/model.rs(~line 2868) —CallToolResultwithcontent: Vec<ContentBlock>andstructured_content: Option<Value>(the two result fields); see alsointo_typed(~line 3057) for the SDK's own structured-content-preferred-over-text-block fallback logic/workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs— streamable HTTP MCP server with Bearer auth (theto_mcppattern)/workspace/rust-sdk/examples/clients/src/streamable_http.rs— streamable HTTP MCP client (thefrom_mcppattern)/workspace/@alkdev/operations/src/from_mcp.ts— TypeScript prior art (createMCPClient,mapMCPContentBlocks, theMCPClientLoader; thestructuredContent-preferred-over-content-blocks logic)/workspace/@alkdev/operations/docs/architecture/adapters.md— TypeScript adapter architecture doc (thefrom_mcpoutputSchema/structuredContenthandling, theMUTATIONtool-type decision)/workspace/@alkdev/alknet/docs/research/alknet-http-gateway-factoring/findings.md— research on the shared dispatch spine betweento_mcpandto_openapi(recommendation: thin shared struct, not a trait)