Phase 1 (SDD) — architecture documentation: Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint gateway, channels-over-WS, Sub/Pub operation types): - overview.md, http-server.md, http-adapters.md, http-mcp.md - README.md index (rewritten for alkhttp) New ADRs: - 067: WebSocket carries the channels protocol (8-byte chunk demux, channel 0 = alk/call, upgrade path /alk/channels) - 068: gateway /publish endpoint for Pub operations (NDJSON body) - 069: WebTransport out of scope in alkhttp (alknet concern) - 070: from_wss consumer adapter (wss feature, tokio-tungstenite) Ported ADRs (25, same numbers, port notes + amendments where the extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027, 034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066. websocket.md rewritten for the channels session; open-questions.md seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing, OQ-03 from_wss reconnect, OQ-04 browser client ownership). Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
204 lines
9.7 KiB
Markdown
204 lines
9.7 KiB
Markdown
# ADR-037: MCP Stdio Transport Exclusion
|
|
|
|
*Ported from alknet ADR-037 (MCP Stdio Transport Exclusion); re-targeted to alkhttp.*
|
|
|
|
## Status
|
|
|
|
Proposed
|
|
|
|
## Context
|
|
|
|
The Model Context Protocol (MCP) defines multiple transports for
|
|
communicating between an MCP client and an MCP server. The MCP Rust SDK
|
|
(`rmcp` at `/workspace/rust-sdk/`) implements two:
|
|
|
|
1. **Streamable HTTP** (`transport-streamable-http-client-reqwest` for
|
|
clients, `transport-streamable-http-server` for servers). The client
|
|
connects to an HTTP endpoint; the server serves an HTTP endpoint.
|
|
Network-isolated, auth-gatable (Bearer token middleware, per the rmcp
|
|
`simple_auth_streamhttp.rs` example), and runs under whatever auth/
|
|
identity/capabilities machinery the host applies to HTTP.
|
|
|
|
2. **stdio** (`transport-child-process`). The client spawns the MCP server
|
|
as a child process and pipes JSON-RPC over its stdin/stdout. This is
|
|
the model the MCP spec promotes for "just download an MCP server and
|
|
run it locally."
|
|
|
|
The alkhttp crate implements `from_mcp` (import remote MCP tools as
|
|
call-protocol operations) and `to_mcp` (expose local operations as MCP
|
|
tools). Both are feature-gated behind an `mcp` feature (the rmcp
|
|
dependency is optional). The question this ADR resolves is which MCP
|
|
transports alkhttp supports.
|
|
|
|
### The stdio security problem
|
|
|
|
MCP stdio transport is `transport-child-process` — the rmcp client calls
|
|
`StdioClientTransport { command, args, env, cwd }`, which spawns an
|
|
arbitrary executable and pipes JSON-RPC over its stdin/stdout. An MCP
|
|
server is an arbitrary program that the MCP client executes with whatever
|
|
privileges the client process has.
|
|
|
|
The "download untrusted MCP servers and run them via stdio" model is
|
|
indistinguishable from `curl | sh` with extra steps:
|
|
|
|
- **Arbitrary code execution.** The MCP server is an executable. Running
|
|
it is RCE. There is no sandbox — the child process has the full
|
|
privileges of the client process (filesystem, network, environment
|
|
variables, ability to spawn further processes).
|
|
- **No auth boundary.** The MCP protocol messages flow over stdin/stdout;
|
|
there is no TLS, no auth token, no identity resolution. The server is
|
|
trusted by construction (you spawned it).
|
|
- **The "download untrusted MCP server" UX.** The MCP ecosystem's
|
|
promoted workflow is: find an MCP server on a registry, install it,
|
|
point your client at it. This is the npm-without-the-checksums model,
|
|
but the "package" is a process with full local privileges, not a
|
|
library that runs in-process.
|
|
|
|
alkhttp's security posture is the opposite of this. alknet-vault is
|
|
local-only by construction (alknet ADR-025 — the vault crate and its
|
|
decision record live in the alknet mono-repo); the no-env-vars invariant
|
|
(ADR-014; spec in the alkcall crate's `client-and-adapters.md`) exists
|
|
specifically to avoid the "download untrusted code that reads your
|
|
secrets" pattern; capabilities are injected by the assembly layer, not
|
|
read from the environment a spawned process can inspect. Building stdio
|
|
support into alkhttp would import the exact RCE vector the rest of the
|
|
architecture is designed to avoid.
|
|
|
|
## Decision
|
|
|
|
**alkhttp supports only streamable HTTP for MCP. Stdio is not built.**
|
|
|
|
The `mcp` feature gate pulls in rmcp with the streamable HTTP transport
|
|
features only:
|
|
|
|
```toml
|
|
[features]
|
|
mcp = [
|
|
"dep:rmcp",
|
|
# rmcp client transport (for from_mcp) — streamable HTTP only
|
|
# rmcp server transport (for to_mcp) — streamable HTTP only
|
|
]
|
|
```
|
|
|
|
The stdio transport (`transport-child-process`) is explicitly **not** a
|
|
dependency and **not** feature-gated. It is not built, not optional, not
|
|
"behind a separate feature." alkhttp's `from_mcp` uses rmcp's
|
|
`StreamableHttpClientTransport` (reqwest-based); `to_mcp` uses rmcp's
|
|
`StreamableHttpService` (axum-based, a tower service that nests into an
|
|
axum `Router` — see the rmcp `simple_auth_streamhttp.rs:134-159`
|
|
example). No stdio code path exists in the crate.
|
|
|
|
### If someone wants stdio MCP
|
|
|
|
They run it themselves, outside alkhttp. An operator who wants to use a
|
|
stdio-only MCP server can spawn it as a subprocess, run a small
|
|
streamable-HTTP-to-stdio bridge, and point `from_mcp` at the bridge's
|
|
HTTP endpoint. That bridge is the operator's responsibility — alkhttp
|
|
does not ship it, does not endorse it, and the bridge is where the RCE
|
|
risk lives, explicitly in the operator's hands, not hidden behind an
|
|
alkhttp feature flag.
|
|
|
|
This is the same posture as alknet ADR-025 (vault local-only dispatch:
|
|
remote vault access requires a separate crate with its own ADR and
|
|
threat model): the dangerous thing is not built by default; if someone
|
|
wants it, they build it themselves and own the security model.
|
|
|
|
## Consequences
|
|
|
|
**Positive:**
|
|
- alkhttp does not import the MCP stdio RCE vector. There is no code
|
|
path in alkhttp that spawns an arbitrary executable.
|
|
- The streamable HTTP path is network-isolated, auth-gatable (Bearer
|
|
middleware), and runs under alkhttp's auth/identity/capabilities
|
|
machinery — the same machinery that gates every other HTTP request.
|
|
- `from_mcp` operations (imported MCP tools) are `Internal` by default
|
|
([ADR-015](015-privilege-model-and-authority-context.md),
|
|
[ADR-022](022-handler-registration-provenance-and-composition-authority.md))
|
|
— composition material, not directly callable from the wire. The MCP
|
|
server is reached over HTTP with a Bearer token from `Capabilities`
|
|
(the no-env-vars invariant), not by spawning a process that could read
|
|
the environment.
|
|
- `to_mcp` (expose local ops as MCP tools) serves an axum route with
|
|
Bearer auth middleware, matching the rmcp
|
|
`simple_auth_streamhttp.rs` pattern. An external MCP client (an
|
|
editor, an AI tool) discovers and calls alkhttp operations through
|
|
streamable HTTP, with alkhttp's auth/identity model applied at the
|
|
HTTP boundary.
|
|
|
|
**Negative:**
|
|
- MCP servers that only support stdio (a significant fraction of the
|
|
current MCP ecosystem) cannot be consumed by `from_mcp` directly. The
|
|
operator runs a bridge (above). This is a deliberate exclusion, not a
|
|
feature gap.
|
|
- The "just download an MCP server and run it" UX that the MCP
|
|
ecosystem promotes is not supported. An alkhttp user who wants that UX
|
|
has to build the bridge and own the RCE risk. This is the correct
|
|
tradeoff for alkhttp's threat model, but it means alkhttp is not a
|
|
drop-in client for the stdio MCP ecosystem.
|
|
|
|
## Assumptions
|
|
|
|
1. **Streamable HTTP is the supported MCP transport in alkhttp.** This
|
|
is a one-way door: removing stdio support later (if it were ever
|
|
added) would break deployments that depend on it; not adding it is
|
|
the stable position. The streamable HTTP transport is the MCP
|
|
spec's network-isolated path and is what the rmcp examples use for
|
|
auth-gated servers.
|
|
|
|
2. **The MCP ecosystem's stdio UX is not a target.** alkhttp is not
|
|
trying to be a drop-in client for "download untrusted MCP servers."
|
|
If a user wants that, the bridge approach puts the RCE risk
|
|
explicitly in their hands.
|
|
|
|
3. **rmcp's streamable HTTP features are the right subset.** The
|
|
`mcp` feature gate pulls in `transport-streamable-http-client-reqwest`
|
|
(for `from_mcp`) and `transport-streamable-http-server` (for
|
|
`to_mcp`). The exact rmcp feature names are a two-way-door
|
|
implementation detail (rmcp may rename features across versions); the
|
|
one-way constraint is "streamable HTTP only, no stdio."
|
|
|
|
## References
|
|
|
|
- [ADR-014](014-secret-material-flow-and-capability-injection.md) — the
|
|
no-env-vars invariant; spawned processes reading env vars is the
|
|
pattern this ADR's exclusion prevents
|
|
- [ADR-015](015-privilege-model-and-authority-context.md) —
|
|
adapter-registered ops (`from_mcp`) are `Internal` by default
|
|
- [ADR-022](022-handler-registration-provenance-and-composition-authority.md)
|
|
— `from_mcp` provenance is a leaf
|
|
- alknet ADR-025 (vault local-only dispatch) — the analogous "dangerous
|
|
thing is not built by default; a separate crate with its own ADR"
|
|
pattern (textual reference; the vault decision record lives in the
|
|
alknet mono-repo's `docs/architecture/decisions/`)
|
|
- `docs/research/alknet-http/phase-0-findings.md` §4 (MCP stdio
|
|
exclusion) — alknet mono-repo research doc
|
|
- `/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)
|
|
- `docs/architecture/http-mcp.md` — this crate's spec that implements
|
|
`from_mcp`/`to_mcp`
|
|
|
|
## Port notes
|
|
|
|
- Renames: "alknet-http" → alkhttp throughout (crate name, security
|
|
posture, feature-flag and threat-model references).
|
|
- alknet ADR-025 (vault local-only dispatch) is cited textually: it is
|
|
not ported to alkhttp or alkcall; the vault decision record remains in
|
|
the alknet mono-repo. ADR-014/015/022 are ported to this crate under
|
|
the same numbers and linked.
|
|
- The `client-and-adapters.md` citation became a textual "the alkcall
|
|
crate's `client-and-adapters.md`" reference (no relative link; the
|
|
document lives in alkcall's docs/architecture/).
|
|
- `crates/http/http-mcp.md` → `docs/architecture/http-mcp.md` (this
|
|
crate's docs/architecture/). The alknet mono-repo research-doc path
|
|
(`docs/research/alknet-http/phase-0-findings.md`) is kept and labeled
|
|
as a mono-repo doc.
|
|
- MCP server/client terminology untouched (inherent MCP directionality).
|
|
- No frontmatter in the source; status kept as Proposed.
|
|
- No decision content changed — the streamable-HTTP-only decision, the
|
|
stdio exclusion rationale, the bridge posture, the feature-gate shape,
|
|
and the consequences/assumptions are verbatim from the alknet ADR
|
|
modulo the renames logged above. |