docs(http): draft alknet-http architecture specs and ADRs 036-039
First speccing pass for alknet-http (HTTP interface crate: h2/http1.1/h3 server + from_openapi/to_openapi/from_mcp/to_mcp adapters). Specs (crates/http/): - README.md, overview.md — crate index, two-roles-in-one-crate framing, adapter location map, feature gates (h3, mcp), no-env-vars invariant - http-server.md — HttpAdapter for h2/http1.1, axum over QUIC stream, Bearer auth, SSE projection for subscriptions, /healthz, stealth decoy - http-adapters.md — from_openapi (reqwest) and to_openapi (projection), error fidelity (HTTP_<status> per ADR-023), type definitions - http-mcp.md — from_mcp/to_mcp (feature-gated), streamable-HTTP-only - webtransport.md — h3/WebTransport handler, browser streaming path, HTTP/3 request vs WebTransport session distinguished at framing layer ADRs: - ADR-036 HTTP-to-Call Operation Mapping (Proposed) — direct path mapping; to_openapi is projection, not router (the load-bearing one-way door from Phase 0 DH-3) - ADR-037 MCP Stdio Transport Exclusion (Proposed) — streamable HTTP only; stdio is not built (RCE-vector security position) - ADR-038 HTTP/3 and WebTransport as First-Class HTTP Transports (Proposed) — corrects the Phase 0 DH-2 deferral framing; h3 is in scope, not deferred, per ADR-009 §'What this framework is NOT' - ADR-039 HTTP Server and Client Host Colocated in alknet-http (Proposed) — one crate for server + client host (shared HTTP deps, shared operation-spec->HTTP mapping) - ADR-003 Amendment 1 — clarifies alknet-call is a protocol-foundation crate (the alknet-http -> alknet-call dependency edge) Open questions (OQ-38, OQ-39, OQ-40 added under 'Theme: alknet-http'): - OQ-38 WebTransport relay-as-proxy scope (genuine scope question, not a deferral — the decision is made when the use case becomes concrete) - OQ-39 to_openapi published-spec versioning (one-way after first publication) - OQ-40 reqwest client config and connection pooling (two-way-door) Architecture README and overview updated with doc table, ADR table (036-039), current-state note, and crate graph (alknet-http -> alknet-call edge). Reviewed by architecture-reviewer subagent: 3 critical, 4 warning, 5 suggestion issues found and fixed (missing ADR-039, WebTransport stream routing conflation, undefined types, stale OQ-37 deferral language, README OQ table completeness, Bearer-only attribution, cross-references, ADR-038 ALPN quote, feature-gate placeholder, MCP temporal language).
This commit is contained in:
@@ -72,4 +72,31 @@ alknet-napi is a thin projection layer — it exposes the Rust call protocol cli
|
||||
- ADR-001: ALPN-based protocol dispatch
|
||||
- ADR-002: ProtocolHandler trait
|
||||
- ADR-004: Auth as shared core (IdentityProvider)
|
||||
- ADR-005: irpc as call protocol foundation
|
||||
- ADR-005: irpc as call protocol foundation
|
||||
|
||||
## Amendments
|
||||
|
||||
### Amendment 1 (2026-06-29): `alknet-call` is a protocol-foundation crate
|
||||
|
||||
The Decision table lists `alknet-call` as a handler crate that "depends
|
||||
on alknet-core, irpc." The dependency-flow diagram and the "No handler
|
||||
crate depends on another handler crate" rule were written before
|
||||
`alknet-http` (which implements `from_openapi`/`from_mcp`/`to_openapi`/
|
||||
`to_mcp` and therefore needs `alknet-call`'s `OperationSpec`, `Handler`,
|
||||
`HandlerRegistration`, and `OperationAdapter` trait) was specced.
|
||||
|
||||
**Clarification:** `alknet-call` is both a handler crate (it implements
|
||||
`ProtocolHandler` on ALPN `alknet/call`) *and* the protocol-foundation
|
||||
crate that `alknet-agent`, `alknet-napi`, and `alknet-http` consume for
|
||||
the operation registry, adapter contract, and call client. The "no
|
||||
handler crate depends on another handler crate" rule applies to peer
|
||||
handler crates (e.g., `alknet-http` does not depend on `alknet-ssh`);
|
||||
`alknet-call` is a protocol-foundation crate in the same spirit that
|
||||
`alknet-core` is, just at a different layer (operations/RPC vs.
|
||||
transport/auth/config).
|
||||
|
||||
`alknet-http` depending on `alknet-call` is "HTTP uses the call protocol
|
||||
types," not "HTTP depends on SSH." This is within the spirit of this
|
||||
ADR's decomposition. The `alknet-call` → `alknet-http` edge is recorded
|
||||
in the `alknet-http` spec (`crates/http/overview.md`) and in the adapter
|
||||
location map (`crates/call/client-and-adapters.md`).
|
||||
@@ -0,0 +1,197 @@
|
||||
# ADR-036: HTTP-to-Call Operation Mapping
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
`alknet-http` implements `ProtocolHandler` for the standard HTTP ALPNs (`h2`,
|
||||
`http/1.1`, `h3`). An inbound HTTP request that targets an alknet operation
|
||||
must become a call-protocol `call.requested` dispatch — the HTTP handler is a
|
||||
*projection* of the call protocol, not a parallel routing layer. The
|
||||
question is how an HTTP request maps to an operation invocation.
|
||||
|
||||
Three options were considered in the alknet-http Phase 0 research
|
||||
(`docs/research/alknet-http/phase-0-findings.md`, decision point DH-3):
|
||||
|
||||
- **(a) Direct path mapping.** `POST /{service}/{op}` → `call.requested` for
|
||||
`/{service}/{op}`. The HTTP handler parses the request body as the
|
||||
operation input, sends `call.requested`, and returns the response as JSON.
|
||||
The HTTP surface is a thin projection of the call protocol's
|
||||
`/{service}/{op}` operation path format (resolved by OQ-13).
|
||||
- **(b) OpenAPI-defined routes.** The HTTP surface is defined by the
|
||||
`to_openapi` projection — routes, methods, schemas are generated from the
|
||||
registry's `External` operations, and the HTTP handler dispatches based on
|
||||
the generated OpenAPI spec's path mapping.
|
||||
- **(c) Explicit route registration.** The assembly layer registers HTTP
|
||||
routes explicitly, mapping URL paths to operations. Most flexible, most
|
||||
boilerplate.
|
||||
|
||||
This is a load-bearing architectural choice. Once the HTTP surface's routing
|
||||
contract is published and external clients build against it, changing the
|
||||
mapping (e.g., from "the HTTP path IS the operation path" to "the HTTP path
|
||||
is a generated alias") is a one-way door: every client breaks. It needs an
|
||||
ADR before implementation.
|
||||
|
||||
The call protocol's operation path format is `/{service}/{op}` (OQ-13,
|
||||
resolved). The HTTP handler serves these operations over HTTP. The mapping
|
||||
must be a *projection* of that single operation surface, not a second
|
||||
routing table that has to be kept in sync with the registry.
|
||||
|
||||
## Decision
|
||||
|
||||
**Direct path mapping is the default HTTP surface; `to_openapi` is the
|
||||
discovery/projection layer, not a parallel router.**
|
||||
|
||||
The `HttpAdapter` receives an HTTP request whose path is `/{service}/{op}`
|
||||
(e.g., `POST /fs/readFile`, `POST /agent/chat`), constructs a
|
||||
`call.requested` dispatch with `operationId: /{service}/{op}` and `input:
|
||||
<parsed body>`, and returns the operation's response as JSON. The HTTP path
|
||||
IS the operation path — one routing surface, the call protocol's.
|
||||
|
||||
`to_openapi` generates the OpenAPI spec that *describes* this surface for
|
||||
external consumers (route paths, methods, request/response schemas, error
|
||||
schemas per ADR-023). It does not define separate routes — the generated
|
||||
spec's `paths` mirror the `/{service}/{op}` operation paths. An external
|
||||
client reading the OpenAPI doc learns the same routes the HTTP handler
|
||||
serves; there is no second mapping.
|
||||
|
||||
### HTTP method semantics
|
||||
|
||||
The call protocol's `OperationType` (`Query`, `Mutation`, `Subscription`,
|
||||
per operation-registry.md) maps to HTTP methods on the default surface:
|
||||
|
||||
| `OperationType` | Default HTTP method | Notes |
|
||||
|-----------------|----------------------|-------|
|
||||
| `Query` | `GET` | Read-only, idempotent. Input from query parameters + optional body. |
|
||||
| `Mutation` | `POST` (or `PUT`/`PATCH`/`DELETE` if the operation declares it) | Default `POST`; the op may declare a specific mutation method in its spec metadata. |
|
||||
| `Subscription` | `GET` with `Accept: text/event-stream` | Streaming — the HTTP handler projects the subscription's `call.responded` stream as SSE chunks. |
|
||||
|
||||
The default method for an `External` operation with no explicit HTTP method
|
||||
declared is `POST` for `Mutation`, `GET` for `Query`. This is the
|
||||
least-surprise default; an operation that wants a specific HTTP verb
|
||||
declares it. The method-to-`OperationType` mapping is a two-way-door
|
||||
default (changing it later is additive — a new method is added, existing
|
||||
methods keep working).
|
||||
|
||||
### Streaming projection (SSE)
|
||||
|
||||
A `Subscription` operation served over HTTP/1.1 or HTTP/2 projects its
|
||||
`call.responded` stream as Server-Sent Events. Each `call.responded` event
|
||||
becomes an SSE `data:` frame; `call.completed` closes the SSE stream;
|
||||
`call.aborted` closes the stream with an SSE error event. This is the
|
||||
HTTP/1.1 + HTTP/2 streaming projection. Over WebTransport (`h3`), the
|
||||
subscription projects directly onto a WebTransport bidirectional stream —
|
||||
no SSE framing is needed (see ADR-038 for the WebTransport path).
|
||||
|
||||
### Auth
|
||||
|
||||
Inbound HTTP auth is `Authorization: Bearer <token>`, resolved via
|
||||
`IdentityProvider::resolve_from_token()` (auth.md's handler table —
|
||||
`HttpAdapter`, Bearer header, `resolve_from_token`). This is settled by
|
||||
ADR-004 and OQ-11; this ADR does not change it. Bearer-only is the auth
|
||||
mechanism; other HTTP auth schemes (Basic, API key in query param) are not
|
||||
implemented. An unauthenticated request to an operation with
|
||||
`AccessControl` restrictions returns `401`/`403` (mapped from the call
|
||||
protocol's `FORBIDDEN` protocol code).
|
||||
|
||||
### Stealth mode
|
||||
|
||||
The HTTP handler on `h2`/`http/1.1` serves a decoy (configurable: fake
|
||||
404, a static site, a redirect) for paths that are not registered
|
||||
operations. This is the ALPN-based stealth mapping from endpoint.md —
|
||||
clients that don't offer alknet ALPNs get the HTTP handler, and unknown
|
||||
HTTP paths get the decoy. The decoy is a two-way-door config default (an
|
||||
operator picks what to serve); the *existence* of the stealth path is fixed
|
||||
by ADR-010.
|
||||
|
||||
### `/healthz` and operational endpoints
|
||||
|
||||
`GET /healthz` is a raw HTTP route outside the call protocol — no auth, no
|
||||
operation registration. It exists for infrastructure (load balancers,
|
||||
orchestrators). Other operational endpoints (metrics, dashboard) are
|
||||
call-protocol operations if built (`/metrics/list`, `/dashboard/view`),
|
||||
not raw HTTP routes. `healthz` is the one exception: it must be callable
|
||||
without auth before identity is resolvable.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- One routing surface. The HTTP handler does not maintain a second routing
|
||||
table; it projects the call protocol's `/{service}/{op}` paths directly.
|
||||
No sync drift between the operation registry and the HTTP routes.
|
||||
- `to_openapi` is a pure projection (generate a spec that *describes* the
|
||||
existing surface), not a routing authority. The generated spec is always
|
||||
consistent with what the handler actually serves because they're the same
|
||||
paths.
|
||||
- External HTTP clients (curl, axios, browser `fetch`) can call alknet
|
||||
operations without knowing about the call protocol — the HTTP surface is
|
||||
a standard REST-like API.
|
||||
- The abort cascade (ADR-016) is preserved: an HTTP client disconnecting
|
||||
mid-subscription is detected as a stream close, and the HTTP handler
|
||||
sends `call.aborted` for the in-flight subscription, which cascades to
|
||||
descendants.
|
||||
- The HTTP method mapping (`Query`→`GET`, `Mutation`→`POST`,
|
||||
`Subscription`→`SSE`) is the standard REST projection — no surprise
|
||||
verbs, no exotic method semantics.
|
||||
|
||||
**Negative:**
|
||||
- The HTTP surface inherits the call protocol's `/{service}/{op}` path
|
||||
shape. An operation named `fs/readFile` is served at `POST /fs/readFile`,
|
||||
not at a REST-nested `POST /fs/files/:id/read` or any other
|
||||
REST-conventional path. Operations that want a REST-nested HTTP path
|
||||
must declare it in spec metadata (a two-way-door extension); the
|
||||
default is the operation path verbatim. This is a deliberate
|
||||
least-surprise-for-alknet choice, not a REST-purist choice.
|
||||
- HTTP request/response semantics don't map cleanly onto every call
|
||||
protocol operation. A `Query` with a large input has to put the input in
|
||||
the body (GET-with-body is non-standard). A `Mutation` that is
|
||||
idempotent doesn't get `PUT` semantics unless it declares them. The
|
||||
projection is lossy at the edges; operations that need precise HTTP
|
||||
semantics declare them.
|
||||
- `to_openapi` is a published compatibility contract (ADR-017 Consequences:
|
||||
once external clients build against the generated spec, the mapping is
|
||||
one-way). The generated spec's versioning (tied to the registry's
|
||||
`External` operation set version) must be emitted as a spec marker so
|
||||
consumers can detect mapping changes. This is OQ-17's published-artifact
|
||||
concern, applied to the HTTP projection.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **The operation path IS the HTTP path.** An operation `fs/readFile` is
|
||||
served at `/fs/readFile`. There is no separate HTTP path mapping layer.
|
||||
If a deployment wants different HTTP paths (e.g., a REST-nested
|
||||
convention), that's a future projection layer, not a change to this
|
||||
mapping.
|
||||
|
||||
2. **`External` operations are the HTTP surface.** `Internal` operations
|
||||
(composition-only, ADR-015) are not served over HTTP — they return `404`
|
||||
on the HTTP handler, matching the call protocol's `NOT_FOUND` for wire
|
||||
calls to Internal ops. The HTTP handler dispatches only `External`
|
||||
operations.
|
||||
|
||||
3. **HTTP auth is Bearer-only.** The HTTP handler resolves identity from
|
||||
the `Authorization: Bearer` header via `resolve_from_token`. Basic auth,
|
||||
API keys in query params, and other HTTP auth schemes are not
|
||||
implemented. A deployment that needs a different auth scheme adds it as
|
||||
middleware (two-way door), but the default surface is Bearer-only.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-004](004-auth-as-shared-core.md) — `IdentityProvider`, Bearer →
|
||||
`resolve_from_token` (the auth model this ADR uses, unchanged)
|
||||
- [ADR-010](010-alpn-router-and-endpoint.md) — stealth mode as ALPN
|
||||
dispatch (the HTTP handler on standard ALPNs serves the decoy)
|
||||
- [ADR-015](015-privilege-model-and-authority-context.md) — External/Internal
|
||||
visibility (Internal ops are not served over HTTP)
|
||||
- [ADR-016](016-abort-cascade-for-nested-calls.md) — abort cascade (HTTP
|
||||
client disconnect → `call.aborted` → cascade to descendants)
|
||||
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) —
|
||||
`to_openapi` as a projection; published-spec compatibility contract
|
||||
- [ADR-023](023-operation-error-schemas.md) — error schema fidelity in
|
||||
`from_openapi`/`to_openapi`; HTTP status mapping
|
||||
- OQ-13 (resolved) — operation path format `/{service}/{op}`
|
||||
- `docs/research/alknet-http/phase-0-findings.md` DH-3 — the decision this
|
||||
ADR resolves
|
||||
- `crates/http/http-server.md` — the spec that implements this mapping
|
||||
173
docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md
Normal file
173
docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# ADR-037: MCP Stdio Transport Exclusion
|
||||
|
||||
## 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 alknet-http 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 alknet-http 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.
|
||||
|
||||
alknet's security posture is the opposite of this. alknet-vault is
|
||||
local-only by construction (ADR-025); the no-env-vars invariant
|
||||
(ADR-014, 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 alknet-http
|
||||
would import the exact RCE vector the rest of the architecture is
|
||||
designed to avoid.
|
||||
|
||||
## Decision
|
||||
|
||||
**alknet-http 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." alknet-http'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 alknet. 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 — alknet
|
||||
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
|
||||
alknet feature flag.
|
||||
|
||||
This is the same posture as ADR-025 (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:**
|
||||
- alknet-http does not import the MCP stdio RCE vector. There is no code
|
||||
path in alknet-http that spawns an arbitrary executable.
|
||||
- The streamable HTTP path is network-isolated, auth-gatable (Bearer
|
||||
middleware), and runs under alknet'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, ADR-022) — 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 alknet operations through
|
||||
streamable HTTP, with alknet'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 alknet user who wants that UX
|
||||
has to build the bridge and own the RCE risk. This is the correct
|
||||
tradeoff for alknet's threat model, but it means alknet-http is not a
|
||||
drop-in client for the stdio MCP ecosystem.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **Streamable HTTP is the supported MCP transport in alknet.** 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.** alknet 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
|
||||
- [ADR-025](025-vault-local-only-dispatch.md) — the analogous "dangerous
|
||||
thing is not built by default; a separate crate with its own ADR" pattern
|
||||
- `docs/research/alknet-http/phase-0-findings.md` §4 (MCP stdio exclusion)
|
||||
- `/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)
|
||||
- `crates/http/http-mcp.md` — the spec that implements `from_mcp`/`to_mcp`
|
||||
@@ -0,0 +1,233 @@
|
||||
# ADR-038: HTTP/3 and WebTransport as First-Class HTTP Transports
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The alknet-http Phase 0 research findings
|
||||
(`docs/research/alknet-http/phase-0-findings.md`, decision point DH-2)
|
||||
framed HTTP/3 + WebTransport as "deferred past v1" — a two-way-door
|
||||
addition to land "when the agent service needs browser streaming." That
|
||||
framing was a residual of the "two-way door as deferral" anti-pattern
|
||||
that ADR-009 §"What this framework is NOT" was later written to prevent.
|
||||
The deferral framing is rejected here; this ADR records the decision as
|
||||
made.
|
||||
|
||||
WebTransport is not a "later" feature. It is the browser-streaming
|
||||
transport — QUIC streams are cheap (better than WebSocket/SSE by far for
|
||||
multiplexed bidirectional streaming), and WebTransport is supported in
|
||||
major browsers. The `alknet-http` crate's purpose is to be the HTTP
|
||||
interface library for downstream crates that need to expose HTTP, and
|
||||
browser streaming is a first-class requirement of that purpose, not a
|
||||
fast-follow.
|
||||
|
||||
### The ALPN registry already reserves h3
|
||||
|
||||
The overview ALPN Registry maps `h3` to `HttpAdapter (HTTP/3 +
|
||||
WebTransport)`. The `h3` ALPN is reserved; the implementation lands as
|
||||
part of `alknet-http`, not as a future crate. This ADR confirms that
|
||||
reservation and records the architectural decision: HTTP/3 + WebTransport
|
||||
is in scope, in this crate, as a first-class transport alongside `h2` and
|
||||
`http/1.1`.
|
||||
|
||||
### Why WebTransport matters
|
||||
|
||||
- **QUIC streams are cheap.** A browser opens a WebTransport connection
|
||||
once and multiplexes many bidirectional streams over it. This is
|
||||
fundamentally better than WebSocket (one stream, one connection) or
|
||||
SSE (one-directional, one stream per subscription) for the
|
||||
subscription-heavy, streaming-heavy patterns the call protocol
|
||||
supports natively.
|
||||
- **Browser support.** WebTransport is supported in modern Chromium-based
|
||||
browsers (Chrome, Edge). The browser path for alknet is WebTransport,
|
||||
not WebSocket.
|
||||
- **The call protocol maps cleanly onto WebTransport streams.** A
|
||||
`call.requested` over a WebTransport bidirectional stream is the same
|
||||
EventEnvelope framing over a different QUIC stream source. The
|
||||
`CallConnection`/`Dispatcher` dispatch loop is stream-agnostic
|
||||
(ADR-012) — the `h3` handler hands a bidirectional stream to the call
|
||||
protocol the same way the `h2`/`http/1.1` handler hands a hyper
|
||||
connection to axum.
|
||||
|
||||
### The TLS constraint (browsers require X.509)
|
||||
|
||||
Browsers do not support RFC 7250 raw public keys (ADR-027, OQ-12). A
|
||||
WebTransport session from a browser requires an X.509 cert — meaning the
|
||||
`h3` handler is a domain-hosted-service concern, not a P2P concern. A
|
||||
node serving WebTransport must have an X.509 identity (`TlsIdentity::X509`
|
||||
or `TlsIdentity::Acme`). This is a property of the browser, not a
|
||||
decision this ADR makes — it's recorded so the spec doesn't pretend a
|
||||
raw-key node can serve browsers.
|
||||
|
||||
### WebTransport relay-as-proxy
|
||||
|
||||
A distinct WebTransport feature — a proxy that terminates the browser's
|
||||
WebTransport connection and forwards encrypted traffic to a P2P hub's
|
||||
Ed25519 endpoint (so the hub need not expose its own public X.509 cert)
|
||||
— was recorded in ADR-034 §5. That feature does not change the auth model
|
||||
(bearer token + `PeerEntry.auth_token_hash`; the proxy is transport-only)
|
||||
and was explicitly placed in the same bucket as the rest of h3/
|
||||
WebTransport. With this ADR, h3/WebTransport is in scope; the
|
||||
relay-as-proxy is a genuine scope question (does the proxy belong in
|
||||
alknet-http or in a separate relay crate?), tracked as OQ-38 — not
|
||||
deferred, just scoped to a separate decision when the proxy use case
|
||||
becomes concrete.
|
||||
|
||||
## Decision
|
||||
|
||||
**HTTP/3 + WebTransport is a first-class HTTP transport in `alknet-http`,
|
||||
implemented alongside `h2` and `http/1.1`. It is not deferred.**
|
||||
|
||||
The `HttpAdapter` implements `ProtocolHandler` for three ALPNs:
|
||||
|
||||
| ALPN | Transport | Use case | Browser? |
|
||||
|------|-----------|----------|----------|
|
||||
| `http/1.1` | HTTP/1.1 over QUIC stream | Legacy clients, curl | No (browsers use h2/h3) |
|
||||
| `h2` | HTTP/2 over QUIC stream | Modern HTTP clients, curl | No (browsers use h3) |
|
||||
| `h3` | HTTP/3 / WebTransport | Browser streaming | Yes (X.509 required) |
|
||||
|
||||
All three are served by `alknet-http`. The `h3` ALPN handler upgrades to
|
||||
WebTransport sessions and serves both HTTP/3 requests (the standard
|
||||
HTTP/3 over QUIC framing) and WebTransport streams (the bidirectional/
|
||||
unidirectional stream API). The handler dispatches HTTP/3 requests
|
||||
through the same axum `Router` as `h2`/`http/1.1`; WebTransport streams
|
||||
that target the call protocol are handed to the call protocol's dispatch
|
||||
loop directly (a WebTransport stream is a QUIC bidirectional stream, the
|
||||
same stream type the call protocol already speaks).
|
||||
|
||||
### WebTransport as the browser streaming path
|
||||
|
||||
The `h3` handler drives two distinct stream types, distinguished at the
|
||||
HTTP/3 framing layer (not by peeking application bytes):
|
||||
|
||||
1. **HTTP/3 request streams** — standard HTTP/3 GET/POST carrying
|
||||
`:method`/`:path`. Dispatched through the axum `Router`, same as
|
||||
`h2`/`http/1.1` (ADR-036). These are not WebTransport streams.
|
||||
2. **WebTransport sessions** — opened by a browser's
|
||||
`new WebTransport(url)` call (an HTTP/3 extended CONNECT request).
|
||||
The handler accepts the session (the `wtransport` crate's
|
||||
`Endpoint::server` + `accept` + `accept_bi` pattern, or the quinn
|
||||
HTTP/3 endpoint's WebTransport extension). Within an established
|
||||
session, the browser creates bidirectional streams via
|
||||
`transport.createBidirectionalStream()`; the handler accepts each
|
||||
and dispatches by sub-protocol. For a call-protocol session, the
|
||||
first frame is an `EventEnvelope` and the handler hands the stream
|
||||
to the call protocol's `Dispatcher` — no SSE translation, no HTTP
|
||||
framing. The browser's `WebTransport` JS API speaks to this handler
|
||||
directly.
|
||||
|
||||
The stream-type distinction (HTTP/3 request vs. WebTransport session)
|
||||
is made at the HTTP/3 frame layer (regular request headers vs. extended
|
||||
CONNECT), not by reading the first application byte. The first-frame
|
||||
routing applies *within* a WebTransport session (determining the
|
||||
sub-protocol), not between an HTTP/3 request and a WebTransport stream.
|
||||
|
||||
This means the browser's subscription/streaming path uses WebTransport
|
||||
streams directly, not the SSE projection (ADR-036) that HTTP/1.1 + HTTP/2
|
||||
clients use. The same `Subscription` operation is served as SSE over
|
||||
`h2` and as a native WebTransport stream over `h3` — the projection is
|
||||
transport-dependent, the operation is the same.
|
||||
|
||||
### h3 and the stealth mapping
|
||||
|
||||
The `h3` handler participates in the same stealth model as `h2`/
|
||||
`http/1.1` (ADR-010, ADR-036): a client that offers `h3` gets the HTTP
|
||||
handler. Unknown WebTransport paths and unknown HTTP/3 paths get the
|
||||
decoy (configurable). Real services use `alknet/ssh`, `alknet/call`, etc.
|
||||
|
||||
### Implementation reference: wtransport
|
||||
|
||||
The `wtransport` crate (`/workspace/wtransport/`, v0.7.1) is a pure-Rust
|
||||
WebTransport implementation built on `quinn` + `h3`/`qpack`. It provides
|
||||
the `Endpoint::server` + `accept` + `accept_bi` API that the `h3`
|
||||
handler uses. `wtransport` is a candidate dependency for the `h3`
|
||||
feature gate; the exact WebTransport library choice (wtransport vs a
|
||||
quinn-native HTTP/3 + WebTransport extension) is a two-way-door
|
||||
implementation detail. The one-way constraint is: `h3` is served, by this
|
||||
crate, as a first-class transport.
|
||||
|
||||
### Feature gating
|
||||
|
||||
The `h3`/WebTransport support is behind an `h3` feature gate (the
|
||||
WebTransport/HTTP/3 dependencies are heavier than `h2`/`http/1.1`):
|
||||
```toml
|
||||
[features]
|
||||
default = ["h2", "http1"]
|
||||
h3 = ["dep:wtransport"] # or the quinn h3 extension
|
||||
mcp = ["dep:rmcp"] # MCP feature gate (ADR-037)
|
||||
```
|
||||
A deployment that only needs `h2`/`http/1.1` (a non-browser-facing
|
||||
node) does not compile the WebTransport dependencies. A browser-facing
|
||||
node enables `h3`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Browser streaming uses QUIC streams directly, not SSE-over-HTTP/2.
|
||||
The call protocol's subscription model maps onto WebTransport streams
|
||||
with no translation loss — a `call.responded` stream over a
|
||||
WebTransport bidirectional stream is the native representation.
|
||||
- The `h3` ALPN reservation in the overview is honored — the
|
||||
implementation lands in this crate, not a future one.
|
||||
- A browser-facing node (a hub with an X.509 cert) serves the same
|
||||
operations over `h3` as a non-browser-facing node serves over `h2` —
|
||||
the operation registry is transport-agnostic, the projection is
|
||||
transport-dependent.
|
||||
- The WebTransport relay-as-proxy (ADR-034 §5) has a clear home: it's a
|
||||
feature that lives in or near `alknet-http`'s `h3` handler, scoped by
|
||||
OQ-38.
|
||||
|
||||
**Negative:**
|
||||
- `alknet-http` gains the `wtransport` (or equivalent HTTP/3 +
|
||||
WebTransport) dependency behind the `h3` feature. This is a heavier
|
||||
dependency than `h2`/`http/1.1`. The feature gate keeps it out of
|
||||
non-browser-facing builds.
|
||||
- WebTransport is still a draft standard (the `wtransport` README notes
|
||||
it). The API may change. This is inherent to being an early adopter
|
||||
of WebTransport; the `h3` feature gate isolates the risk.
|
||||
- Browsers require X.509 (ADR-027). A raw-key-only node cannot serve
|
||||
WebTransport. This is a browser limitation, not an alknet decision,
|
||||
but it means the `h3` feature is useful only on domain-hosted nodes
|
||||
with X.509 certs.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **WebTransport is the browser streaming transport for alknet.** The
|
||||
browser path is WebTransport, not WebSocket and not SSE-over-HTTP/2.
|
||||
SSE remains the streaming projection for non-WebTransport HTTP
|
||||
clients (curl, axios over h2); WebTransport is the native path for
|
||||
browsers.
|
||||
|
||||
2. **The `wtransport` crate (or an equivalent quinn-native HTTP/3 +
|
||||
WebTransport implementation) is the dependency for the `h3` feature.**
|
||||
The exact library is a two-way-door implementation detail; the
|
||||
one-way constraint is that `h3` is served by this crate.
|
||||
|
||||
3. **The WebTransport relay-as-proxy (ADR-034 §5) is a separate scope
|
||||
decision (OQ-38).** It is not deferred — it's a feature with a clear
|
||||
home (the `h3` handler or a sibling relay crate) that gets designed
|
||||
when the browser-to-P2P-peer proxy use case becomes concrete.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-009](009-one-way-door-decision-framework.md) §"What this framework
|
||||
is NOT" — the anti-pattern this ADR corrects (two-way door as deferral)
|
||||
- [ADR-010](010-alpn-router-and-endpoint.md) — the ALPN router; `h3` is
|
||||
one of the ALPNs the `HttpAdapter` registers for
|
||||
- [ADR-012](012-call-protocol-stream-model.md) — stream-agnostic
|
||||
correlation; a WebTransport stream is a QUIC bidirectional stream
|
||||
- [ADR-027](027-tls-identity-redesign-acme-rawkey-decoupling.md) — the
|
||||
browser limitation (no RFC 7250); WebTransport requires X.509
|
||||
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §4 (browsers
|
||||
are not alknet peers) and §5 (WebTransport relay-as-proxy, recorded
|
||||
for this bucket)
|
||||
- [ADR-036](036-http-to-call-operation-mapping.md) — the SSE projection
|
||||
for `h2`/`http/1.1` that WebTransport replaces for the browser path
|
||||
- `docs/research/alknet-http/phase-0-findings.md` DH-2 — the deferral
|
||||
framing this ADR rejects
|
||||
- `/workspace/wtransport/` — pure-Rust WebTransport (the `h3` feature's
|
||||
reference implementation)
|
||||
- `crates/http/webtransport.md` — the spec that implements the `h3`
|
||||
handler
|
||||
@@ -0,0 +1,154 @@
|
||||
# ADR-039: HTTP Server and Client Host Colocated in alknet-http
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
`alknet-http` has two roles: an HTTP server (the `HttpAdapter`
|
||||
`ProtocolHandler` for `h2`/`http/1.1`/`h3`, built on `axum`/`hyper`)
|
||||
and an HTTP client host (the `from_openapi`/`from_mcp` forwarding
|
||||
handlers, built on `reqwest`). The question is whether these two
|
||||
directions live in one crate (`alknet-http`) or are split into two
|
||||
crates (`alknet-http-server` + `alknet-http-client`).
|
||||
|
||||
ADR-003 lists `alknet-http` as a single crate with dependency
|
||||
`alknet-core, axum` and justifies the per-handler-crate decomposition
|
||||
with "each handler is self-contained — it receives a byte stream and
|
||||
manages its own protocol." That rationale covers the server side (the
|
||||
`HttpAdapter` is self-contained), but it does not address the
|
||||
within-crate dual-role question: should the inbound HTTP server and
|
||||
the outbound HTTP client (the adapter forwarding handlers) be
|
||||
colocated, or split?
|
||||
|
||||
This is a load-bearing choice. Once published, downstream consumers
|
||||
build import paths against the crate boundary; the shared `reqwest::Client`
|
||||
and the no-env-vars invariant boundary (ADR-014) are scoped by it; the
|
||||
`to_openapi`/`to_mcp` projections are pure-registry-consumers that
|
||||
*describe* the server surface but live where the adapter types do.
|
||||
Splitting later would be a rewrite of every consumer's import paths,
|
||||
not a cheap revert. It needs an ADR.
|
||||
|
||||
## Decision
|
||||
|
||||
**One crate — `alknet-http` houses both the HTTP server and the HTTP
|
||||
client host (the adapter forwarding handlers and the `to_*` projections).**
|
||||
|
||||
The two directions share the HTTP dependencies and HTTP-specific
|
||||
concerns that make splitting them counterproductive:
|
||||
|
||||
- **Shared HTTP dependencies.** Both `axum` (server) and `reqwest`
|
||||
(client) pull in `hyper`, `http`, `http-body`, `rustls`/TLS stack
|
||||
types, and the HTTP header/status code types. A split into two crates
|
||||
would either duplicate these dependencies across both crates or
|
||||
force a third shared-types crate, neither of which is an improvement.
|
||||
- **Shared HTTP-specific concerns.** Both directions care about HTTP
|
||||
headers, status codes, content types, SSE framing, streaming vs
|
||||
non-streaming bodies, and TLS trust stores. The `from_openapi`
|
||||
forwarding handler's error mapping (HTTP status → `HTTP_<status>`
|
||||
error codes, ADR-023) and the `to_openapi` projection's error mapping
|
||||
(`ErrorDefinition.http_status` → HTTP response status) are *the same
|
||||
mapping* read in two directions — splitting them would put the two
|
||||
halves in different crates.
|
||||
- **The `to_*` projections describe the server surface.** `to_openapi`
|
||||
generates an OpenAPI doc whose paths mirror the `/{service}/{op}`
|
||||
HTTP routes the `HttpAdapter` serves (ADR-036). `to_mcp` exposes the
|
||||
same operations as MCP tools. These projections consume the
|
||||
`OperationRegistry` and produce specs; they live with the adapter
|
||||
types (in `alknet-http`, per the adapter location map — see
|
||||
[client-and-adapters.md](../crates/call/client-and-adapters.md))
|
||||
because they share the operation-spec→HTTP mapping logic with the
|
||||
server's request dispatch.
|
||||
- **The no-env-vars invariant boundary is crate-scoped.** The
|
||||
`from_openapi`/`from_mcp` forwarding handlers are the credential
|
||||
injection point (ADR-014). The invariant — "no handler reads outbound
|
||||
credentials from any source other than `OperationContext.capabilities`"
|
||||
— is verified against the handler implementations in this crate. A
|
||||
split would put the invariant verification boundary across two crates.
|
||||
|
||||
### What this does NOT change
|
||||
|
||||
- ADR-003's rule "no handler crate depends on another handler crate"
|
||||
applies to peer handler crates (`alknet-http` does not depend on
|
||||
`alknet-ssh`). The `alknet-http` → `alknet-call` edge is the
|
||||
protocol-foundation exception (ADR-003 Amendment 1). This ADR is
|
||||
about the *internal* structure of `alknet-http`, not its dependency
|
||||
edges.
|
||||
- The adapter location map (the `OperationAdapter` trait in
|
||||
`alknet-call`; the HTTP-backed adapter implementations in
|
||||
`alknet-http`) is unchanged. This ADR records *why* the HTTP-backed
|
||||
adapters live in the same crate as the HTTP server, not whether they
|
||||
live in `alknet-http` vs `alknet-call`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- One crate, one set of HTTP dependencies, one HTTP-specific concern
|
||||
surface. No duplicated `hyper`/`http` types across two crates, no
|
||||
shared-types crate needed.
|
||||
- The `to_*` projections live with the server whose surface they
|
||||
describe, and with the adapter types they consume. The operation-spec
|
||||
→ HTTP mapping logic is in one place.
|
||||
- The no-env-vars invariant verification boundary is one crate. The
|
||||
`from_openapi`/`from_mcp` handlers and the credential injection
|
||||
logic they share are co-located.
|
||||
- A downstream consumer wires one crate (`alknet-http`) into the
|
||||
`HandlerRegistry` and gets the full HTTP surface — server + adapters +
|
||||
projections. No two-crate wiring.
|
||||
|
||||
**Negative:**
|
||||
- A deployment that only needs the HTTP server (no `from_openapi`/`from_
|
||||
mcp` forwarding) still compiles the `reqwest` dependency. Mitigated:
|
||||
the `mcp` feature is already gated (ADR-037); the `from_openapi`
|
||||
forwarding is always available but the `reqwest` client is only
|
||||
constructed if a `from_openapi`/`from_mcp` adapter is registered at
|
||||
assembly time. The dependency is compiled, the client is lazy.
|
||||
- A deployment that only needs the HTTP client (e.g., an agent crate
|
||||
that only uses `from_openapi` forwarding, no inbound HTTP) still
|
||||
compiles `axum`/`hyper`. This is the rarer case — the agent crate
|
||||
(`alknet-agent`) consumes `alknet-call` directly for tool dispatch
|
||||
and uses `from_openapi` via `alknet-http`'s adapter, but doesn't
|
||||
serve inbound HTTP itself. In practice, the agent deployment wires
|
||||
`alknet-http` for the adapters and the CLI wires it for the server;
|
||||
the compile cost is paid once per workspace, not once per deployment.
|
||||
- The crate is larger than a single-direction crate would be. This is
|
||||
the cost of colocating shared concerns; the alternative (two crates
|
||||
+ a shared types crate) is more crates, not less code.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **The shared-HTTP-dependencies argument holds.** `axum` and
|
||||
`reqwest` both pull in `hyper` and the `http` crate's types; the
|
||||
shared types (headers, status codes, method, URI) are the same. If
|
||||
a future version of `axum` or `reqwest` diverges its HTTP types
|
||||
(e.g., `axum` moves to a different HTTP implementation), this
|
||||
argument weakens. As of `axum` 0.7+ and `reqwest` 0.12+, both are
|
||||
built on `hyper` 1.x and share `http` types.
|
||||
|
||||
2. **The `to_*` projections share enough mapping logic with the server
|
||||
to justify colocation.** The operation-spec → HTTP path/method/
|
||||
error-status mapping is the same in both directions. If the
|
||||
projections turn out to be pure registry-consumers with no
|
||||
HTTP-mapping logic (just spec serialization), the colocation
|
||||
argument is weaker — but the current design (ADR-036, ADR-023)
|
||||
has them sharing the mapping.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-003](003-crate-decomposition.md) — crate decomposition (this
|
||||
ADR addresses the within-`alknet-http` dual-role question, not the
|
||||
dependency edge; Amendment 1 covers the `alknet-call` edge)
|
||||
- [ADR-014](014-secret-material-flow-and-capability-injection.md) —
|
||||
the no-env-vars invariant whose verification boundary is crate-scoped
|
||||
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
|
||||
adapter contract; `to_*` are projections
|
||||
- [ADR-023](023-operation-error-schemas.md) — the error mapping shared
|
||||
between `from_openapi` (status → code) and `to_openapi` (code →
|
||||
status)
|
||||
- [ADR-036](036-http-to-call-operation-mapping.md) — the HTTP path =
|
||||
operation path mapping shared between server dispatch and `to_openapi`
|
||||
- [ADR-037](037-mcp-stdio-transport-exclusion.md) — the `mcp` feature
|
||||
gate
|
||||
- `crates/http/overview.md` — the crate overview (the inline rationale
|
||||
for this decision is replaced by a pointer to this ADR)
|
||||
Reference in New Issue
Block a user