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:
@@ -18,7 +18,9 @@ The storage and auth strategy research (`docs/research/alknet-storage-strategy/f
|
||||
|
||||
The alknet-call crate is **implemented and reviewed** — both the server-side core and the client/adapter surface (207 lib + 2 integration tests passing). The alknet-core and alknet-call crate specs are in draft; the alknet-vault crate specs are stable.
|
||||
|
||||
**Next step**: The storage/repo-pattern ADRs (030–033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http (Phase 0 findings in `docs/research/alknet-http/`), which consumes the `CredentialStore` trait and the `OperationAdapter` contract.
|
||||
**alknet-http specs drafted.** The alknet-http crate (HTTP interface — `h2`/`http/1.1`/`h3` server + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, http-adapters, http-mcp, webtransport) and four new ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping), [ADR-037](decisions/037-mcp-stdio-transport-exclusion.md) (MCP stdio exclusion), [ADR-038](decisions/038-http3-and-webtransport-as-first-class.md) (HTTP/3 + WebTransport as first-class, correcting the Phase 0 deferral framing), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http` → `alknet-call` dependency edge). The specs are in draft; implementation has not started. Three open questions carried: OQ-38 (WebTransport relay-as-proxy scope), OQ-39 (`to_openapi` published-spec versioning), OQ-40 (reqwest client config).
|
||||
|
||||
**Next step**: The storage/repo-pattern ADRs (030–033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http implementation (specs drafted, ADRs 036–038 proposed), which consumes the `CredentialStore` trait and the `OperationAdapter` contract. The alknet-ssh crate (the other post-core crate, specced in parallel) proceeds independently — it depends on `alknet-core`, not `alknet-call`.
|
||||
|
||||
## Architecture Documents
|
||||
|
||||
@@ -35,6 +37,12 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
||||
| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
|
||||
| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery, irpc integration |
|
||||
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (outbound connection opener), from_call / from_jsonschema, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern |
|
||||
| [crates/http/README.md](crates/http/README.md) | draft | alknet-http crate index |
|
||||
| [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
|
||||
| [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1, axum over QUIC, Bearer auth, stealth, /healthz |
|
||||
| [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest) and to_openapi (projection); no-env-vars injection point |
|
||||
| [crates/http/http-mcp.md](crates/http/http-mcp.md) | draft | from_mcp / to_mcp (feature-gated), streamable-HTTP-only, stdio exclusion |
|
||||
| [crates/http/webtransport.md](crates/http/webtransport.md) | draft | h3/WebTransport handler — the browser streaming path |
|
||||
| [crates/vault/README.md](crates/vault/README.md) | stable | alknet-vault crate index |
|
||||
| [crates/vault/mnemonic-derivation.md](crates/vault/mnemonic-derivation.md) | stable | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
|
||||
| [crates/vault/encryption.md](crates/vault/encryption.md) | stable | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) |
|
||||
@@ -80,6 +88,10 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
||||
| [033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Accepted |
|
||||
| [034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Outgoing-Only X.509 and the Three Peer Roles | Accepted |
|
||||
| [035](decisions/035-concrete-persistence-adapter-shapes.md) | Concrete Persistence Adapter Shapes — Read/Write Split, honker+SQLite | Accepted |
|
||||
| [036](decisions/036-http-to-call-operation-mapping.md) | HTTP-to-Call Operation Mapping | Proposed |
|
||||
| [037](decisions/037-mcp-stdio-transport-exclusion.md) | MCP Stdio Transport Exclusion | Proposed |
|
||||
| [038](decisions/038-http3-and-webtransport-as-first-class.md) | HTTP/3 and WebTransport as First-Class HTTP Transports | Proposed |
|
||||
| [039](decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | Proposed |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -127,6 +139,9 @@ See [open-questions.md](open-questions.md) for the full tracker.
|
||||
- **OQ-32**: Multi-hop federation — the one-hop model is the architectural commitment; multi-hop is a feature extension that doesn't break downstream
|
||||
- **OQ-36**: ~~Concrete persistence adapter shapes~~ — **resolved by ADR-035** (read-sync / write-async / honker-NOTIFY cache invalidation; `alknet-store-sqlite` crate; `IdentityStore` write trait; `CredentialStore::put`/`delete` async)
|
||||
- **OQ-37**: ~~X.509 outgoing-only case~~ — **resolved by ADR-034** (three remote roles named: public X.509 endpoint, transport relay, hub; `PeerEntry` asymmetry is correct; client-side verifier selection by `PeerEntry` presence)
|
||||
- **OQ-38**: WebTransport relay-as-proxy scope — does the proxy live in `alknet-http` or a separate relay crate? (scope question, not deferral; ADR-038 brought h3 into scope)
|
||||
- **OQ-39**: `to_openapi` published-spec versioning — versioning strategy for generated OpenAPI specs (one-way after first publication)
|
||||
- **OQ-40**: reqwest client config and connection pooling — two-way-door config shape for the outbound HTTP client
|
||||
|
||||
**Deferred (not active):**
|
||||
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
|
||||
|
||||
103
docs/architecture/crates/http/README.md
Normal file
103
docs/architecture/crates/http/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# alknet-http
|
||||
|
||||
HTTP interface for alknet: serves HTTP/1.1, HTTP/2, and HTTP/3 (WebTransport)
|
||||
on standard ALPNs, and hosts the HTTP-backed call-protocol adapters
|
||||
(`from_openapi`, `to_openapi`, `from_mcp`, `to_mcp`).
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
|
||||
| [http-server.md](http-server.md) | draft | `HttpAdapter` (`ProtocolHandler` for `h2`/`http/1.1`), axum over QUIC, Bearer auth, stealth, `/healthz` |
|
||||
| [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client) and `to_openapi` (OpenAPI projection); no-env-vars invariant point |
|
||||
| [http-mcp.md](http-mcp.md) | draft | `from_mcp` / `to_mcp` (feature-gated), streamable-HTTP-only, stdio exclusion |
|
||||
| [webtransport.md](webtransport.md) | draft | `h3`/WebTransport handler — the browser streaming path |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
| ADR | Title | Relevance |
|
||||
|-----|-------|-----------|
|
||||
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | `HttpAdapter` registers on standard HTTP ALPNs |
|
||||
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | `HttpAdapter` implements `ProtocolHandler` |
|
||||
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | `alknet-http` depends on `alknet-core` + `alknet-call` (protocol-foundation exception, Amendment 1) |
|
||||
| [004](../../decisions/004-auth-as-shared-core.md) | Auth as Shared Core | Bearer → `resolve_from_token` |
|
||||
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | `HttpAdapter` receives `Connection`, accepts a stream for hyper |
|
||||
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Stealth mode = HTTP handler on standard ALPNs |
|
||||
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow | `from_openapi`/`from_mcp` are the credential injection point |
|
||||
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model | Adapter-registered ops are `Internal` by default |
|
||||
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `OperationAdapter` trait; `to_*` are projections; published-spec contract |
|
||||
| [022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, Composition Authority | `from_openapi`/`from_mcp` produce leaf bundles |
|
||||
| [023](../../decisions/023-operation-error-schemas.md) | Operation Error Schemas | `from_openapi`/`to_openapi` error fidelity; `HTTP_<status>` error codes |
|
||||
| [027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | TLS Identity Redesign | Browsers require X.509; WebTransport requires X.509 |
|
||||
| [034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Outgoing-Only X.509 and Three Peer Roles | Browsers are not alknet peers; WebTransport relay-as-proxy recorded |
|
||||
| [036](../../decisions/036-http-to-call-operation-mapping.md) | HTTP-to-Call Operation Mapping | Direct path mapping; `to_openapi` is projection, not router |
|
||||
| [037](../../decisions/037-mcp-stdio-transport-exclusion.md) | MCP Stdio Transport Exclusion | Streamable HTTP only; stdio not built |
|
||||
| [038](../../decisions/038-http3-and-webtransport-as-first-class.md) | HTTP/3 and WebTransport as First-Class HTTP Transports | `h3` in scope, not deferred |
|
||||
| [039](../../decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | One crate for server + client host (shared HTTP deps, shared mapping) |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
| OQ | Title | Status | Relevance |
|
||||
|----|-------|--------|-----------|
|
||||
| OQ-11 | Handler-level auth resolution observability | resolved | HTTP handler stores resolved identity on `Connection` via `set_identity` |
|
||||
| OQ-12 | TLS identity provisioning | resolved | Browsers require X.509 (gates the entire `h3` feature) |
|
||||
| OQ-13 | Operation path format | resolved | `/{service}/{op}` is the HTTP path (ADR-036) |
|
||||
| OQ-17 | Call protocol client and adapter contract | resolved | `OperationAdapter` trait; `to_*` projections |
|
||||
| OQ-24 | Operation error schemas | resolved | `from_openapi`/`to_openapi` error fidelity |
|
||||
| OQ-26 | OperationAdapter error type | resolved | `AdapterError` variants reused by HTTP adapters |
|
||||
| OQ-37 | X.509 outgoing-only / three peer roles | resolved | Browsers are not peers; hub with mixed fingerprints |
|
||||
| OQ-38 | WebTransport relay-as-proxy scope | open (scope, not deferral) | Does the proxy live in `alknet-http` or a separate relay crate? |
|
||||
| OQ-39 | `to_openapi` published-spec versioning | open | Versioning strategy for generated OpenAPI specs |
|
||||
| OQ-40 | reqwest client config and connection pooling | open | Two-way-door: pooling/retry config shape |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **HTTP is both a server surface and a client transport for adapters.**
|
||||
Inbound HTTP (`h2`/`http/1.1`/`h3`) is served by `axum` over a QUIC
|
||||
stream; outbound HTTP (`from_openapi`/`from_mcp` forwarding) uses
|
||||
`reqwest`. Both directions share the same HTTP dependencies, which is
|
||||
why they live in one crate rather than being split. See
|
||||
[overview.md](overview.md).
|
||||
2. **The HTTP surface is a projection of the call protocol.** An HTTP
|
||||
request at `POST /fs/readFile` becomes a `call.requested` for
|
||||
`/fs/readFile`. The HTTP path IS the operation path; `to_openapi`
|
||||
describes this surface, it does not define a second one. See
|
||||
[ADR-036](../../decisions/036-http-to-call-operation-mapping.md).
|
||||
3. **Standard ALPNs, not alknet ALPNs.** `h2`, `http/1.1`, `h3` are
|
||||
IANA-registered ALPN strings. Any HTTP client (browser, curl, axios)
|
||||
connects without knowing about alknet — the TLS handshake negotiates
|
||||
`h2` or `http/1.1` normally. This is the stealth mapping (ADR-010).
|
||||
4. **`from_openapi`/`from_mcp` are the no-env-vars injection point.** The
|
||||
forwarding handlers read `context.capabilities`, not `std::env::var`.
|
||||
This is the architectural mechanism that makes aisdk's env-var reads
|
||||
unreachable. See ADR-014,
|
||||
[client-and-adapters.md](../call/client-and-adapters.md).
|
||||
5. **MCP streamable HTTP only; stdio is not built.** stdio = spawn
|
||||
arbitrary executable = RCE. Streamable HTTP is network-isolated,
|
||||
auth-gatable, and runs under alknet's auth model. See
|
||||
[ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md).
|
||||
6. **HTTP/3 + WebTransport is a first-class transport, not a deferral.**
|
||||
The browser streaming path uses QUIC streams directly. See
|
||||
[ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md).
|
||||
7. **`h3` requires X.509.** Browsers don't support RFC 7250 raw keys
|
||||
(ADR-027). A node serving WebTransport must have an X.509 identity.
|
||||
This is a browser limitation, not an alknet decision.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-http/phase-0-findings.md` — Phase 0 research
|
||||
(directionally close; DH-2's deferral framing is corrected by ADR-038)
|
||||
- `docs/research/alknet-call-completion/gap-analysis.md` — adapter
|
||||
location map, no-env-vars invariant
|
||||
- `/workspace/@alkdev/operations/src/from_openapi.ts`,
|
||||
`/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art
|
||||
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP
|
||||
transport examples
|
||||
- `/workspace/wtransport/` — pure-Rust WebTransport reference
|
||||
implementation (the `h3` feature's candidate dependency)
|
||||
326
docs/architecture/crates/http/http-adapters.md
Normal file
326
docs/architecture/crates/http/http-adapters.md
Normal file
@@ -0,0 +1,326 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# HTTP Adapters — from_openapi and to_openapi
|
||||
|
||||
The OpenAPI-direction adapters: `from_openapi` imports external HTTP APIs
|
||||
as call-protocol operations (reqwest-backed forwarding handlers), and
|
||||
`to_openapi` generates an OpenAPI spec from the local registry's
|
||||
`External` operations. This document covers both, the error fidelity
|
||||
(ADR-023), and the no-env-vars credential injection point.
|
||||
|
||||
## What
|
||||
|
||||
Two adapters, both in `alknet-http`:
|
||||
|
||||
1. **`from_openapi`** — parses an OpenAPI document, constructs a
|
||||
`HandlerRegistration` bundle per OpenAPI operation with a forwarding
|
||||
handler that calls the external HTTP endpoint via `reqwest`, and
|
||||
returns the bundles for registration in the `OperationRegistry`. The
|
||||
adapter implements `OperationAdapter` (the async trait from
|
||||
`alknet-call`, ADR-017 §5). Provenance is `FromOpenAPI` (leaf,
|
||||
`composition_authority: None`, `scoped_env: None`, `Internal` by
|
||||
default — ADR-015/022).
|
||||
2. **`to_openapi`** — generates an OpenAPI document from the local
|
||||
registry's `External` operations. A pure projection: it consumes the
|
||||
registry, it does not produce entries for it (ADR-017 §5 — the `to_*`
|
||||
adapters are outbound projections, not `OperationAdapter`
|
||||
implementations). Served at `GET /openapi.json` by the HTTP server.
|
||||
|
||||
### from_openapi
|
||||
|
||||
```rust
|
||||
pub struct FromOpenAPI {
|
||||
spec: OpenAPISpec,
|
||||
config: HttpServiceConfig,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OperationAdapter for FromOpenAPI {
|
||||
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Type definitions
|
||||
|
||||
```rust
|
||||
/// A parsed OpenAPI document. The concrete type is a two-way-door
|
||||
/// implementation detail (openapiv3::OpenApi, a local alknet-http
|
||||
/// type, or a serde_json::Value-based parse); the one-way constraint is
|
||||
/// that `from_openapi` accepts a standard OpenAPI 3.x JSON/YAML doc and
|
||||
/// `to_openapi` produces one. Both directions share the same type.
|
||||
pub struct OpenAPISpec {
|
||||
pub info: OpenAPIInfo,
|
||||
pub paths: BTreeMap<String, PathItem>,
|
||||
pub components: Option<Components>,
|
||||
// ... OpenAPI 3.x fields as needed
|
||||
}
|
||||
|
||||
/// Configuration for an HTTP-backed adapter (`from_openapi`). Carries
|
||||
/// the base URL, auth credentials (from `Capabilities` at registration,
|
||||
/// not env vars — the no-env-vars invariant), and optional headers. The
|
||||
/// `auth` field is the auth scheme the external API expects (bearer,
|
||||
/// apiKey, basic); the credential itself is read from
|
||||
/// `OperationContext.capabilities` at call time, not stored here.
|
||||
pub struct HttpServiceConfig {
|
||||
pub namespace: String,
|
||||
pub base_url: String,
|
||||
pub auth: Option<HttpAuthScheme>,
|
||||
pub default_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub enum HttpAuthScheme {
|
||||
Bearer, // Authorization: Bearer <token>
|
||||
ApiKey { header_name: String }, // e.g., X-API-Key: <key>
|
||||
Basic, // Authorization: Basic <credentials>
|
||||
}
|
||||
```
|
||||
|
||||
The adapter:
|
||||
|
||||
1. Parses the OpenAPI document (`OpenAPISpec` — `paths`, `components`,
|
||||
`$ref` resolution). On parse failure, returns
|
||||
`AdapterError::SchemaParse`. The TS prior art
|
||||
(`@alkdev/operations/src/from_openapi.ts`) shows the parsing patterns:
|
||||
`resolveRef` for `$ref`, `resolveRefsRecursive` for nested refs,
|
||||
`buildInputSchema` (parameters + request body → input JSON Schema),
|
||||
`buildOutputSchema` (200/201 response → output JSON Schema),
|
||||
`detectOperationType` (SSE response → `Subscription`, GET → `Query`,
|
||||
else `Mutation`).
|
||||
2. For each `(path, method, operation)` in `spec.paths`, constructs a
|
||||
`HandlerRegistration`:
|
||||
- `spec.name` = the `operationId` (or a generated
|
||||
`${method}_${path_parts}` name if `operationId` is absent — same
|
||||
normalization as the TS `normalizeOperationId`).
|
||||
- `spec.namespace` = the `config.namespace` (the importing
|
||||
deployment's name for the service, not the OpenAPI doc's `info.title`).
|
||||
- `spec.op_type` = `Query` / `Mutation` / `Subscription` (detected
|
||||
from the method + response content type, same as TS).
|
||||
- `spec.visibility` = `Internal` (adapter-registered ops are
|
||||
composition material, not directly callable from the wire —
|
||||
ADR-015).
|
||||
- `spec.input_schema` / `output_schema` = the JSON Schemas built
|
||||
from the OpenAPI parameters/responses.
|
||||
- `spec.error_schemas` = the `ErrorDefinition`s built from the
|
||||
non-2xx OpenAPI responses (ADR-023 §5 — see Error Fidelity below).
|
||||
- `spec.access_control` = `AccessControl::default()` (the adapter
|
||||
doesn't declare scopes; the composing handler that reaches the
|
||||
imported op gates access).
|
||||
- `handler` = a forwarding handler (see Forwarding Handler below).
|
||||
- `provenance` = `FromOpenAPI`, `composition_authority: None`,
|
||||
`scoped_env: None` (leaf — ADR-022).
|
||||
- `capabilities` = the credentials the forwarding handler needs (the
|
||||
bearer token / API key for the external HTTP endpoint, injected by
|
||||
the assembly layer at registration — see No-Env-Vars below).
|
||||
3. Returns the bundles. The caller (the assembly layer) registers them
|
||||
in the `OperationRegistry`.
|
||||
|
||||
### Forwarding handler
|
||||
|
||||
The forwarding handler is the `Arc<dyn Handler>` stored in the
|
||||
`HandlerRegistration`. At call time, it:
|
||||
|
||||
1. Reads the call input (`serde_json::Value`).
|
||||
2. Builds the outbound HTTP request:
|
||||
- URL path: substitutes path parameters (`{id}` → input value),
|
||||
appends query parameters from input fields not in the path.
|
||||
- Method: the OpenAPI operation's method.
|
||||
- Headers: `Content-Type: application/json` + the auth header built
|
||||
from `context.capabilities` (see No-Env-Vars below).
|
||||
- Body: the `body` field of the input (for `Mutation`/`Subscription`).
|
||||
3. Sends the request via the shared `reqwest::Client` (see HTTP Client
|
||||
below).
|
||||
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
|
||||
binary — same content-type branching as the TS `createHTTPOperation`),
|
||||
wraps it in a `ResponseEnvelope`, returns.
|
||||
5. For a `Subscription` (`text/event-stream` response): streams
|
||||
`call.responded` events as the SSE chunks arrive (same SSE parsing as
|
||||
the TS `parseSSEFrames`), then `call.completed` on stream end.
|
||||
6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by
|
||||
HTTP status code (see Error Fidelity below), returns a `CallError`.
|
||||
|
||||
The handler is opaque to the `CallAdapter` — it's an `Arc<dyn Handler>`
|
||||
the registry dispatches. `alknet-call` never sees `reqwest`.
|
||||
|
||||
### HTTP client (reqwest)
|
||||
|
||||
`alknet-http` maintains a shared `reqwest::Client` (constructed once,
|
||||
reused across all `from_openapi`/`from_mcp` forwarding handlers). The
|
||||
client handles connection pooling, keep-alive, and TLS. The aisdk
|
||||
`core/client.rs` reference shows the pattern worth referencing: a shared
|
||||
client with `OnceLock<reqwest::Client>`, retry logic (exponential
|
||||
backoff, `Retry-After` header), and separate streaming vs non-streaming
|
||||
clients. `alknet-http` owns its HTTP client; it does not inherit aisdk's.
|
||||
|
||||
The retry/pooling config comes from `StaticConfig` or `DynamicConfig`
|
||||
(hot-reloadable). The credential injection happens per-request (from
|
||||
`OperationContext.capabilities`), not at client construction — the
|
||||
client is shared across all operations, the credentials are per-call.
|
||||
|
||||
The exact pooling/retry config is a two-way-door implementation detail
|
||||
(OQ-40); the one-way constraint is that `alknet-http` owns its `reqwest`
|
||||
client (no env-var-based client config, no shared global client).
|
||||
|
||||
### No-Env-Vars credential injection
|
||||
|
||||
The forwarding handler is the **credential injection point** for the
|
||||
no-env-vars architecture. The handler reads
|
||||
`context.capabilities.get("<service>")` (e.g., `"openai"`, `"vastai"`,
|
||||
`"github"`), extracts the credential, and injects it into the outbound
|
||||
HTTP request:
|
||||
|
||||
- Bearer token → `Authorization: Bearer <token>`.
|
||||
- API key → the header the OpenAPI spec declares (e.g., `X-API-Key:
|
||||
<key>`, or `Authorization: ApiKey <key>` — the `HTTPServiceConfig.auth`
|
||||
in the TS prior art shows the three auth types: `bearer`, `apiKey`,
|
||||
`basic`).
|
||||
- Basic auth → `Authorization: Basic <credentials>`.
|
||||
|
||||
The credential comes from `Capabilities`, which was populated by the
|
||||
dispatch path from the `HandlerRegistration.capabilities` bundle
|
||||
(ADR-022 §6), which was populated by the assembly layer from the vault
|
||||
(ADR-014). The handler never reads `std::env::var`. This is the
|
||||
spec-level invariant: no handler reads outbound credentials from any
|
||||
source other than `OperationContext.capabilities`. See
|
||||
[overview.md](overview.md) and
|
||||
[client-and-adapters.md](../call/client-and-adapters.md).
|
||||
|
||||
### to_openapi
|
||||
|
||||
```rust
|
||||
pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;
|
||||
```
|
||||
|
||||
`to_openapi` generates an OpenAPI document from the local registry's
|
||||
`External` operations:
|
||||
|
||||
1. For each `External` operation in the registry, generate an OpenAPI
|
||||
path entry:
|
||||
- Path: `/{service}/{op}` (the operation path, ADR-036 — the HTTP
|
||||
path IS the operation path).
|
||||
- Method: the operation's `OperationType` → HTTP method (`Query`→GET,
|
||||
`Mutation`→POST by default, `Subscription`→GET with
|
||||
`text/event-stream` response).
|
||||
- `operationId`: the operation name.
|
||||
- `parameters` / `requestBody` / `responses`: built from the
|
||||
operation's `input_schema` / `output_schema` / `error_schemas`.
|
||||
2. The `components.schemas` section holds the reusable schemas
|
||||
referenced by `$ref` from the paths.
|
||||
3. The `info` section carries the API title, version, and description.
|
||||
|
||||
This is a pure projection — it consumes the registry and produces a
|
||||
spec. It does not modify the registry; it does not register operations;
|
||||
it is not an `OperationAdapter`. The HTTP server serves the generated
|
||||
spec at `GET /openapi.json` (or a configured path).
|
||||
|
||||
### Error Fidelity (ADR-023)
|
||||
|
||||
`from_openapi` maps OpenAPI non-2xx response status codes to
|
||||
`ErrorDefinition`s (ADR-023 §5). The normative rule (review #002 W20):
|
||||
`from_openapi` must not produce error codes that collide with the five
|
||||
protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
|
||||
`INTERNAL`, `TIMEOUT`). The adapter prefixes imported error codes with
|
||||
`HTTP_` and the status number:
|
||||
|
||||
```rust
|
||||
// OpenAPI: 404: { schema: NotFoundError }
|
||||
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
|
||||
```
|
||||
|
||||
`to_openapi` projects `error_schemas` back to OpenAPI response
|
||||
definitions:
|
||||
|
||||
```yaml
|
||||
responses:
|
||||
'200': { schema: <output_schema> }
|
||||
'404': { schema: <error_schemas[i].schema> } # where http_status = 404
|
||||
'429': { schema: <error_schemas[j].schema> } # where http_status = 429
|
||||
```
|
||||
|
||||
This makes the adapter contract from ADR-017 faithful on the error axis —
|
||||
no silent dropping of error contracts. See ADR-023.
|
||||
|
||||
## Why
|
||||
|
||||
`from_openapi` is how alknet composes external HTTP APIs (OpenAI,
|
||||
Anthropic, vast.ai, GitHub) into the call protocol. An operation
|
||||
imported via `from_openapi` is a first-class operation: it has a spec,
|
||||
it's discoverable via `services/list`, it can be composed by handlers,
|
||||
its errors are typed. The agent crate's LLM provider calls go through
|
||||
`from_openapi`-imported operations — that's how the no-env-vars
|
||||
invariant makes aisdk's env-var reads unreachable.
|
||||
|
||||
`to_openapi` is how external systems discover the alknet operation
|
||||
surface. An API gateway, a client generator, or a human developer reads
|
||||
the OpenAPI doc to learn what operations exist and how to call them.
|
||||
The generated spec is a compatibility contract (ADR-017 Consequences) —
|
||||
once published, the mapping is one-way.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **`from_openapi`/`from_mcp` handlers read credentials from
|
||||
`OperationContext.capabilities`, not `std::env::var`.** This is the
|
||||
no-env-vars invariant (ADR-014). The handler implementations are
|
||||
verified against this invariant.
|
||||
- **`from_openapi`-registered ops are `Internal` by default.** They are
|
||||
composition material, not directly callable from the wire (ADR-015).
|
||||
The handler that composes them is `External`.
|
||||
- **`from_openapi` error codes are prefixed `HTTP_<status>`.** No
|
||||
collision with protocol-level codes (ADR-023, review #002 W20).
|
||||
- **`to_openapi` is a pure projection.** It consumes the registry, does
|
||||
not produce entries for it. Not an `OperationAdapter`.
|
||||
- **Published `to_openapi` specs are compatibility contracts.** The
|
||||
generated spec's versioning (tied to the registry's `External`
|
||||
operation set version) must be emitted so consumers can detect mapping
|
||||
changes (ADR-017 Consequences, OQ-39).
|
||||
- **`alknet-http` owns its `reqwest::Client`.** Shared across all
|
||||
forwarding handlers, constructed once. No env-var-based client config.
|
||||
Pooling/retry config is a two-way door (OQ-40).
|
||||
- **TLS for outbound calls uses the system trust store by default.**
|
||||
Standard HTTPS to external APIs (OpenAI, Anthropic). Custom CA bundle
|
||||
+ client certs are an optional config for self-hosted API gateways.
|
||||
This is a two-way-door implementation detail; the credential (API
|
||||
key/token) comes from `Capabilities`, the TLS trust comes from the
|
||||
system.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| `from_openapi` is an `OperationAdapter` | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Async trait; produces `HandlerRegistration` bundles |
|
||||
| `to_openapi` is a projection, not an adapter | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Consumes the registry, doesn't produce entries |
|
||||
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_openapi` ops are composition material |
|
||||
| `from_openapi` provenance is a leaf | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | `composition_authority: None`, `scoped_env: None` |
|
||||
| Error fidelity (`HTTP_<status>` codes) | [ADR-023](../../decisions/023-operation-error-schemas.md) | No collision with protocol codes; `to_openapi` projects back |
|
||||
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
|
||||
| HTTP path = operation path | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `to_openapi` paths mirror `/{service}/{op}` |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-39** (open): `to_openapi` published-spec versioning — the
|
||||
versioning strategy for generated OpenAPI specs (tied to the
|
||||
registry's `External` operation set version). One-way after first
|
||||
publication.
|
||||
- **OQ-40** (open): reqwest client config and connection pooling —
|
||||
two-way-door: the exact pooling/retry config shape, hot-reloadable
|
||||
via `DynamicConfig`.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md)
|
||||
— `OperationAdapter` trait, `to_*` are projections
|
||||
- [ADR-023](../../decisions/023-operation-error-schemas.md) — error
|
||||
fidelity, `HTTP_<status>` prefix rule
|
||||
- [overview.md](overview.md) — adapter location map, no-env-vars
|
||||
invariant
|
||||
- [../call/client-and-adapters.md](../call/client-and-adapters.md) —
|
||||
`OperationAdapter` trait, `AdapterError` variants (OQ-26), no-env-vars
|
||||
invariant
|
||||
- `/workspace/@alkdev/operations/src/from_openapi.ts` — TypeScript prior
|
||||
art (parsing, SSE, auth headers, `createHTTPOperation`)
|
||||
- `/workspace/aisdk/src/core/client.rs` — HTTP client reference (pooling,
|
||||
retry, streaming vs non-streaming)
|
||||
245
docs/architecture/crates/http/http-mcp.md
Normal file
245
docs/architecture/crates/http/http-mcp.md
Normal file
@@ -0,0 +1,245 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 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
|
||||
|
||||
```rust
|
||||
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 `CallAdapter` — `Arc<dyn Handler>` the
|
||||
registry dispatches. `alknet-call` never sees rmcp.
|
||||
|
||||
### to_mcp
|
||||
|
||||
```rust
|
||||
pub fn to_mcp_service(
|
||||
registry: Arc<OperationRegistry>,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
) -> StreamableHttpService<...>;
|
||||
```
|
||||
|
||||
`to_mcp` exposes the local registry's `External` operations as MCP tools
|
||||
over streamable HTTP, using rmcp's `StreamableHttpService` (an
|
||||
axum-compatible tower service). The rmcp
|
||||
`simple_auth_streamhttp.rs` server example shows the pattern:
|
||||
|
||||
```rust
|
||||
// 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 pattern: the local operations
|
||||
are exposed as an MCP server (an rmcp `Service` impl that wraps the
|
||||
`OperationRegistry`), the `StreamableHttpService` nests into the axum
|
||||
`Router` at `/mcp`, and a Bearer auth middleware gates access (the
|
||||
`simple_auth_streamhttp.rs` `auth_middleware` + `extract_token` pattern).
|
||||
|
||||
The `to_mcp` service:
|
||||
|
||||
1. On MCP `tools/list`: returns the local registry's `External`
|
||||
operations as MCP tools (name, description, `inputSchema`).
|
||||
2. On MCP `tools/call`: dispatches to the `OperationRegistry::invoke()`
|
||||
— the same dispatch path the HTTP server uses for HTTP requests
|
||||
(ADR-036). The MCP tool call becomes a `call.requested` internally.
|
||||
The result is mapped back to the MCP `tools/call` response shape
|
||||
(`structuredContent` or `content` blocks).
|
||||
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).
|
||||
|
||||
### 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](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.
|
||||
|
||||
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](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built |
|
||||
| `from_mcp` is an `OperationAdapter` | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Async trait; produces `HandlerRegistration` bundles |
|
||||
| `to_mcp` is a projection | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Consumes the registry, doesn't produce entries |
|
||||
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_mcp` ops are composition material |
|
||||
| `from_mcp` provenance is a leaf | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | `composition_authority: None`, `scoped_env: None` |
|
||||
| Error fidelity | [ADR-023](../../decisions/023-operation-error-schemas.md) | MCP tool errors mapped to `ErrorDefinition`s |
|
||||
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
|
||||
| MCP clients are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-40** (open): reqwest client config — the shared `reqwest::Client`
|
||||
used by `from_mcp` (same client as `from_openapi`).
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) — the
|
||||
stdio exclusion this document enforces
|
||||
- [overview.md](overview.md) — adapter location map, feature gates
|
||||
- [../call/client-and-adapters.md](../call/client-and-adapters.md) —
|
||||
`OperationAdapter` 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`)
|
||||
292
docs/architecture/crates/http/http-server.md
Normal file
292
docs/architecture/crates/http/http-server.md
Normal file
@@ -0,0 +1,292 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# HTTP Server
|
||||
|
||||
The `HttpAdapter` — the `ProtocolHandler` for `h2` and `http/1.1` (and
|
||||
`h3`, covered in [webtransport.md](webtransport.md)). This document
|
||||
covers how axum is run over a QUIC bidirectional stream, Bearer auth
|
||||
resolution, the HTTP-to-call dispatch, the `/healthz` raw route, and
|
||||
stealth decoy.
|
||||
|
||||
## What
|
||||
|
||||
The `HttpAdapter` is constructed by the assembly layer with an
|
||||
`Arc<dyn IdentityProvider>` (constructor injection, same pattern as
|
||||
`SshAdapter` — see [auth.md](../core/auth.md)) and an
|
||||
`Arc<OperationRegistry>` (for dispatching HTTP requests to call-protocol
|
||||
operations). It implements `ProtocolHandler` for the standard HTTP ALPNs.
|
||||
|
||||
```rust
|
||||
pub struct HttpAdapter {
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
registry: Arc<OperationRegistry>,
|
||||
/// The default handler for paths that are not registered operations
|
||||
/// (stealth decoy). Configurable: a static site, a fake 404, a
|
||||
/// redirect. Two-way-door default (ADR-010).
|
||||
decoy: DecoyConfig,
|
||||
}
|
||||
|
||||
/// The stealth decoy surface for paths that are not registered
|
||||
/// operations (and not `/healthz`, `/openapi.json`, or the MCP route).
|
||||
/// Set by the assembly layer at `HttpAdapter` construction. The
|
||||
/// existence of the decoy path is fixed by ADR-010; the variant is a
|
||||
/// two-way-door config default.
|
||||
pub enum DecoyConfig {
|
||||
/// Serve a fake `404 Not Found` (the default — matches the reference
|
||||
/// implementation's "fake nginx 404").
|
||||
NotFound,
|
||||
/// Serve a static site from a configured directory (the directory
|
||||
/// path is the payload). For deployments that want a real decoy
|
||||
/// website.
|
||||
StaticSite { root: PathBuf },
|
||||
/// Redirect to a configured URL.
|
||||
Redirect { to: String },
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProtocolHandler for HttpAdapter {
|
||||
fn alpn(&self) -> &'static [u8]; // returns the configured ALPN
|
||||
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
|
||||
}
|
||||
```
|
||||
|
||||
The `HttpAdapter` registers for multiple ALPNs (`http/1.1`, `h2`, `h3`).
|
||||
The endpoint's `HandlerRegistry` maps each ALPN byte string to the same
|
||||
adapter instance; `handle()` branches on `connection.remote_alpn()` to
|
||||
pick the HTTP framing. For `http/1.1` and `h2`, the framing is hyper's
|
||||
HTTP/1.1 or HTTP/2 over a QUIC bidirectional stream; for `h3`, it's the
|
||||
WebTransport/HTTP/3 path (see [webtransport.md](webtransport.md)).
|
||||
|
||||
## Why
|
||||
|
||||
HTTP is the standard external interface. Browsers, curl, axios, API
|
||||
gateways, and load balancers all speak HTTP. Serving HTTP on the standard
|
||||
ALPNs means any HTTP client can connect without knowing about alknet —
|
||||
the TLS handshake negotiates `h2` or `http/1.1` normally. This is the
|
||||
stealth mapping (ADR-010): the HTTP surface is the decoy for clients that
|
||||
don't offer alknet ALPNs, and the real external API surface for clients
|
||||
that do know about alknet.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Running axum over a QUIC stream
|
||||
|
||||
The `HttpAdapter::handle()` method for `h2`/`http/1.1`:
|
||||
|
||||
1. Accepts one bidirectional stream from the QUIC connection
|
||||
(`connection.accept_bi()` → `(SendStream, RecvStream)`).
|
||||
2. Wraps the `(SendStream, RecvStream)` pair as a hyper
|
||||
`TokioIo`-compatible duplex stream — the same byte stream hyper
|
||||
expects for an HTTP connection.
|
||||
3. Constructs the axum `Router` (built once at adapter construction,
|
||||
cloned per connection — axum `Router` is `Clone` and cheap to clone).
|
||||
4. Hands the duplex stream + the axum router to hyper's connection
|
||||
driver (`hyper::server::conn::http1::Builder` or
|
||||
`http2::Builder::serve_connection`), which reads HTTP frames, parses
|
||||
them, dispatches to axum routes, and writes HTTP responses.
|
||||
5. Returns when the HTTP connection closes (the client disconnects or
|
||||
the stream ends).
|
||||
|
||||
The axum `Router` is built once at adapter construction with the
|
||||
`Arc<OperationRegistry>` and `Arc<dyn IdentityProvider>` embedded in its
|
||||
state; cloning the `Router` per connection clones the `Arc`s (cheap,
|
||||
shared state), so every request handler has access to the registry and
|
||||
identity provider through the router's state.
|
||||
|
||||
The axum `Router` is the single routing surface for HTTP requests. It
|
||||
contains:
|
||||
|
||||
- The call-protocol projection routes (`POST /{service}/{op}` →
|
||||
`call.requested` dispatch — ADR-036).
|
||||
- `GET /healthz` (raw route, no auth, no call protocol).
|
||||
- `GET /openapi.json` (serves the `to_openapi` projection).
|
||||
- The stealth decoy fallback (unknown paths).
|
||||
- (Feature-gated) `POST /mcp` (the `to_mcp` streamable HTTP service —
|
||||
[http-mcp.md](http-mcp.md)).
|
||||
|
||||
A single HTTP/2 or HTTP/1.1 connection multiplexes multiple requests
|
||||
over the one bidirectional stream (HTTP/2 multiplexing is native;
|
||||
HTTP/1.1 is sequential). The axum router handles each request on a
|
||||
tokio task; the hyper driver manages the connection lifetime.
|
||||
|
||||
### HTTP-to-call dispatch (ADR-036)
|
||||
|
||||
An HTTP request at `POST /fs/readFile` (or `GET /services/list`, or any
|
||||
`/{service}/{op}` path matching a registered `External` operation) is
|
||||
dispatched to the call protocol:
|
||||
|
||||
1. The axum route handler extracts the operation name from the path
|
||||
(`/fs/readFile` → `fs/readFile`, stripping the leading slash — the
|
||||
registry form).
|
||||
2. It resolves the caller's identity from the `Authorization: Bearer`
|
||||
header via `identity_provider.resolve_from_token(&AuthToken { raw:
|
||||
token_bytes })`.
|
||||
3. It parses the request body as the operation input (JSON).
|
||||
4. It constructs the root `OperationContext` (caller identity, the
|
||||
registration bundle's capabilities, the connection's env composition)
|
||||
and dispatches through the `OperationRegistry::invoke()` — the same
|
||||
dispatch path the `CallAdapter` uses for `alknet/call` wire requests.
|
||||
5. The response (`ResponseEnvelope`) is serialized as the HTTP response
|
||||
body (JSON). Errors map to HTTP status codes (see Error Mapping
|
||||
below).
|
||||
|
||||
`Internal` operations (ADR-015) 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.
|
||||
|
||||
### Streaming projection (SSE)
|
||||
|
||||
A `Subscription` operation served over `h2`/`http/1.1` projects its
|
||||
`call.responded` stream as Server-Sent Events. The axum route handler:
|
||||
|
||||
- Sets `Content-Type: text/event-stream`.
|
||||
- For each `call.responded` event, writes an SSE `data:` frame (the
|
||||
event's `output` serialized as JSON).
|
||||
- On `call.completed`, closes the SSE stream (normal end).
|
||||
- On `call.aborted`, closes the stream with an SSE error event.
|
||||
- On HTTP client disconnect (detected as the response writer closing),
|
||||
sends `call.aborted` for the in-flight subscription, which cascades
|
||||
to descendants per ADR-016.
|
||||
|
||||
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 (see [webtransport.md](webtransport.md)).
|
||||
|
||||
### Auth
|
||||
|
||||
Inbound HTTP auth is `Authorization: Bearer <token>`, resolved via
|
||||
`IdentityProvider::resolve_from_token()` (the auth.md handler table:
|
||||
`HttpAdapter`, Bearer header, `resolve_from_token`). Bearer-only is the
|
||||
auth mechanism for the default surface; other HTTP auth schemes (Basic,
|
||||
API key in query param) are not implemented and would be added as axum
|
||||
middleware (two-way door). This is recorded in
|
||||
[ADR-036](../../decisions/036-http-to-call-operation-mapping.md) §Auth;
|
||||
the resolution mechanism (`resolve_from_token`) is from
|
||||
[ADR-004](../../decisions/004-auth-as-shared-core.md), and the
|
||||
connection-level observability (`set_identity`) is OQ-11 (resolved).
|
||||
|
||||
- Bearer-only is the auth mechanism. 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 axum middleware
|
||||
(two-way door), but the default surface is Bearer-only.
|
||||
- The `HttpAdapter` constructor-injects `Arc<dyn IdentityProvider>`,
|
||||
same pattern as `SshAdapter`.
|
||||
- An unauthenticated request to an operation with `AccessControl`
|
||||
restrictions returns `401` (no token) or `403` (token present but
|
||||
insufficient scopes). The call protocol's `FORBIDDEN` protocol code
|
||||
maps to `403`; `NOT_FOUND` (Internal op) maps to `404`.
|
||||
- The HTTP handler stores the resolved identity on the `Connection` for
|
||||
observability (`connection.set_identity(identity)`), same as the call
|
||||
protocol handler.
|
||||
|
||||
### Error Mapping
|
||||
|
||||
Call-protocol `CallError` codes (ADR-023) map to HTTP status codes:
|
||||
|
||||
| Call `code` | HTTP status | Notes |
|
||||
|-------------|-------------|-------|
|
||||
| `NOT_FOUND` (operation not registered, or Internal op) | `404` | |
|
||||
| `FORBIDDEN` (insufficient scopes, or unauthenticated) | `401` (no token) / `403` (token present) | |
|
||||
| `INVALID_INPUT` (schema mismatch) | `422` | |
|
||||
| `TIMEOUT` | `504` | `retryable: true` |
|
||||
| `INTERNAL` | `500` | |
|
||||
| Operation-level domain code with `http_status` (ADR-023) | the declared `http_status` | `from_openapi`-imported ops carry the original status |
|
||||
| Operation-level domain code without `http_status` | `500` | |
|
||||
|
||||
The `retryable` field from `CallError` maps to an HTTP `Retry-After`
|
||||
hint for `503`/`429`-class errors. The mapping is a two-way-door
|
||||
default (the exact status for ambiguous codes can be refined
|
||||
additively); the one-way constraint is that protocol-level and
|
||||
operation-level codes are distinct (ADR-023) and `from_openapi`-imported
|
||||
codes are prefixed `HTTP_<status>` to avoid collision with protocol
|
||||
codes.
|
||||
|
||||
### `/healthz` (raw route)
|
||||
|
||||
`GET /healthz` is a raw HTTP route outside the call protocol — no auth,
|
||||
no operation registration, no `OperationContext`. It returns `200 OK`
|
||||
with a plain-text body (e.g., `"ok"`) if the endpoint is healthy. This
|
||||
is the infrastructure endpoint load balancers and orchestrators call;
|
||||
it must work before identity is resolvable.
|
||||
|
||||
Other operational endpoints (metrics, dashboard) are call-protocol
|
||||
operations if built (`/metrics/list`, `/dashboard/view`), not raw HTTP
|
||||
routes. `healthz` is the one exception. See ADR-036.
|
||||
|
||||
### Stealth decoy
|
||||
|
||||
For paths that are not registered operations (and not `/healthz`,
|
||||
`/openapi.json`, or the MCP route), the HTTP handler serves a decoy. The
|
||||
decoy is configurable (`DecoyConfig`):
|
||||
|
||||
- A fake `404 Not Found` (the default — matches the reference
|
||||
implementation's "fake nginx 404").
|
||||
- A static site (served from a configured directory).
|
||||
- A redirect (to a configured URL).
|
||||
|
||||
The decoy is the stealth surface: a port scanner or a client that
|
||||
doesn't offer alknet ALPNs connects on `h2`/`http/1.1` and sees the
|
||||
decoy. Real services use `alknet/ssh`, `alknet/call`, etc. The decoy
|
||||
config is a two-way-door default (an operator picks what to serve); the
|
||||
*existence* of the stealth path is fixed by ADR-010.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **The HTTP path IS the operation path.** `POST /fs/readFile` →
|
||||
`call.requested` for `fs/readFile`. No second routing table. See
|
||||
ADR-036.
|
||||
- **`External` operations only.** `Internal` operations return `404`
|
||||
on the HTTP handler.
|
||||
- **Bearer-only auth.** `Authorization: Bearer` →
|
||||
`resolve_from_token`. Other HTTP auth schemes are not implemented.
|
||||
- **No secret material in HTTP responses.** The call protocol carries no
|
||||
secret material (ADR-014); the HTTP handler inherits this constraint.
|
||||
Capabilities are used for outbound calls (`from_openapi`), never
|
||||
serialized into HTTP response bodies.
|
||||
- **`/healthz` is raw.** No auth, no call protocol. The one raw route.
|
||||
- **The `h3` ALPN is a first-class transport.** The `HttpAdapter`
|
||||
registers for `h3` when the `h3` feature is enabled (ADR-038). The
|
||||
`h3` handler is covered in [webtransport.md](webtransport.md); this
|
||||
document covers the `h2`/`http/1.1` path.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Direct path mapping (HTTP path = operation path) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}` → `call.requested` |
|
||||
| SSE projection for subscriptions over h2/http1.1 | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `call.responded` stream → SSE frames |
|
||||
| `/healthz` is a raw route | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | No auth, no call protocol |
|
||||
| Stealth decoy | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | HTTP handler on standard ALPNs serves decoy |
|
||||
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source (settled) |
|
||||
| `h3` is first-class (not deferred) | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | The `h3` ALPN handler lives in this crate |
|
||||
| Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` prefix for imported |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-39** (open): `to_openapi` published-spec versioning — the
|
||||
generated OpenAPI spec is a compatibility contract (ADR-017
|
||||
Consequences); the versioning strategy needs specifying.
|
||||
- **OQ-40** (open): reqwest client config and connection pooling —
|
||||
two-way-door config shape for the outbound HTTP client used by
|
||||
`from_openapi`/`from_mcp`.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) — the
|
||||
HTTP-to-call mapping this server implements
|
||||
- [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md)
|
||||
— the `h3`/WebTransport companion to this server
|
||||
- [overview.md](overview.md) — crate overview, adapter location map
|
||||
- [webtransport.md](webtransport.md) — the `h3` ALPN handler
|
||||
- [http-adapters.md](http-adapters.md) — `from_openapi`/`to_openapi`
|
||||
- [../core/auth.md](../core/auth.md) — `IdentityProvider`, Bearer →
|
||||
`resolve_from_token`
|
||||
- [../core/endpoint.md](../core/endpoint.md) — stealth mode as ALPN
|
||||
dispatch
|
||||
- [../call/operation-registry.md](../call/operation-registry.md) —
|
||||
`OperationRegistry::invoke()`, the dispatch path HTTP requests hit
|
||||
243
docs/architecture/crates/http/overview.md
Normal file
243
docs/architecture/crates/http/overview.md
Normal file
@@ -0,0 +1,243 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# alknet-http — Overview
|
||||
|
||||
The HTTP interface crate: serves inbound HTTP on standard ALPNs and hosts
|
||||
the HTTP-backed call-protocol adapters. This document covers the crate's
|
||||
two roles, its dependency edges, and the adapter location map. Component
|
||||
details are in the sibling documents.
|
||||
|
||||
## What
|
||||
|
||||
`alknet-http` is the HTTP protocol handler for the ALPN-as-service
|
||||
architecture. It serves two roles in one crate:
|
||||
|
||||
1. **HTTP server** — a `ProtocolHandler` (`HttpAdapter`) that accepts
|
||||
HTTP/2, HTTP/1.1, and HTTP/3 (WebTransport) connections on the
|
||||
standard IANA ALPNs (`h2`, `http/1.1`, `h3`). It serves REST APIs, the
|
||||
`to_openapi`/`to_mcp` projections of local call-protocol operations,
|
||||
the `/healthz` operational endpoint, and the decoy surface for
|
||||
stealth mode.
|
||||
2. **HTTP client host** — the home of the HTTP-transport-backed call
|
||||
adapters: `from_openapi` (import external HTTP APIs as call
|
||||
operations, using `reqwest` for outbound calls) and `from_mcp` (import
|
||||
remote MCP tools over streamable HTTP, using `reqwest`). The reverse
|
||||
projections `to_openapi` (generate an OpenAPI doc from the local
|
||||
registry's `External` operations) and `to_mcp` (expose local ops as
|
||||
MCP tools over streamable HTTP, using `axum`) also live here.
|
||||
|
||||
Both directions share the same HTTP dependencies (`axum` for serving,
|
||||
`reqwest` for calling out), which is why they live in one crate rather
|
||||
than being split into a server crate and a client crate. See
|
||||
[ADR-039](../../decisions/039-http-server-and-client-host-colocated.md)
|
||||
for the full rationale.
|
||||
|
||||
## Why
|
||||
|
||||
The crate's purpose is to be the HTTP interface library for downstream
|
||||
crates that need to expose an HTTP interface. A downstream consumer (the
|
||||
CLI binary, a hub deployment, a browser-facing service) wires
|
||||
`HttpAdapter` into the `HandlerRegistry` for the standard HTTP ALPNs and
|
||||
gets a full HTTP surface: REST projection of the call protocol, OpenAPI
|
||||
discovery, MCP tool exposure, and WebTransport for browsers.
|
||||
|
||||
The key architectural insight that shapes the crate: **HTTP is both a
|
||||
server surface and a client transport for adapters.** The server side
|
||||
serves HTTP to external clients (browsers, curl, axios); the client side
|
||||
makes outbound HTTP calls to external APIs (OpenAI, Anthropic, vast.ai)
|
||||
through the `from_openapi`/`from_mcp` forwarding handlers. Both
|
||||
directions share HTTP dependencies and HTTP-specific concerns (TLS,
|
||||
headers, streaming, SSE), so they belong in one crate. See
|
||||
[ADR-039](../../decisions/039-http-server-and-client-host-colocated.md)
|
||||
for the colocation decision.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
alknet-http
|
||||
├── alknet-core (ProtocolHandler, Connection, AuthContext, IdentityProvider, Capabilities)
|
||||
├── alknet-call (OperationAdapter, OperationSpec, Handler, HandlerRegistration,
|
||||
│ OperationRegistry, AdapterError, OperationProvenance)
|
||||
├── axum (HTTP server — Router, extractors, middleware)
|
||||
├── reqwest (HTTP client — from_openapi/from_mcp forwarding)
|
||||
├── hyper (HTTP/1.1 + HTTP/2 framing; axum is built on hyper)
|
||||
├── wtransport (HTTP/3 + WebTransport — feature-gated behind `h3`)
|
||||
└── rmcp (MCP streamable HTTP — feature-gated behind `mcp`)
|
||||
```
|
||||
|
||||
### The `alknet-call` dependency (ADR-003 Amendment 1)
|
||||
|
||||
`alknet-http` depends on `alknet-call`. ADR-003's rule is "no handler
|
||||
crate depends on another handler crate," but `alknet-call` is both a
|
||||
handler (it implements `ProtocolHandler` on `alknet/call`) *and* the
|
||||
protocol-foundation crate that `alknet-agent`, `alknet-napi`, and now
|
||||
`alknet-http` consume. `alknet-http` depending on `alknet-call` is
|
||||
"HTTP uses the call protocol types" (`OperationSpec`, `Handler`,
|
||||
`HandlerRegistration`, `OperationAdapter`), not "HTTP depends on SSH."
|
||||
See [ADR-003 Amendment 1](../../decisions/003-crate-decomposition.md).
|
||||
|
||||
`alknet-call` stays lean — it has no `reqwest`, no `axum`, no HTTP
|
||||
dependencies. The `from_openapi`/`from_mcp` forwarding handlers are
|
||||
opaque `Arc<dyn Handler>` from the registry's perspective: constructed by
|
||||
`alknet_http::from_openapi()` at registration time, stored in
|
||||
`HandlerRegistration`, dispatched by the `CallAdapter` which doesn't
|
||||
know `reqwest` is involved.
|
||||
|
||||
## ALPNs
|
||||
|
||||
| ALPN | Handler | Transport | Browser? |
|
||||
|------|---------|-----------|----------|
|
||||
| `http/1.1` | `HttpAdapter` | HTTP/1.1 over QUIC stream | No |
|
||||
| `h2` | `HttpAdapter` | HTTP/2 over QUIC stream | No |
|
||||
| `h3` | `HttpAdapter` | HTTP/3 / WebTransport | Yes (X.509 required) |
|
||||
|
||||
These are standard IANA ALPN strings, not `alknet/`-prefixed. Any HTTP
|
||||
client connects without knowing about alknet — the TLS handshake
|
||||
negotiates `h2` or `http/1.1` normally, and the `HttpAdapter` serves
|
||||
HTTP. This is the stealth mapping (ADR-010): clients that don't offer
|
||||
alknet ALPNs get the HTTP handler, just like port scanners in stealth
|
||||
mode.
|
||||
|
||||
The `HttpAdapter` registers for all three ALPNs (when the corresponding
|
||||
features are enabled). The endpoint's `HandlerRegistry` maps each ALPN to
|
||||
the same `HttpAdapter` instance; the handler branches on
|
||||
`connection.remote_alpn()` to pick the right framing.
|
||||
|
||||
## Adapter Location Map
|
||||
|
||||
The decomposition principle (settled in
|
||||
[client-and-adapters.md](../call/client-and-adapters.md)): the adapter
|
||||
trait lives where the types live (`alknet-call`); the adapter
|
||||
implementations live where their transport dependencies live.
|
||||
|
||||
```
|
||||
alknet-call (lean — no HTTP client, no HTTP server)
|
||||
├── OperationAdapter trait (the contract — async, ADR-017 §5)
|
||||
├── from_call (QUIC — discovers remote ops via call protocol)
|
||||
├── from_jsonschema (pure parse — caller fetches the doc, passes it in)
|
||||
└── CallClient (outbound connection opener)
|
||||
|
||||
alknet-http (owns HTTP server + HTTP client)
|
||||
├── HttpAdapter (axum server — inbound HTTP on h2/http1.1/h3)
|
||||
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
||||
├── to_openapi (generate OpenAPI doc from local registry)
|
||||
├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest)
|
||||
└── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum)
|
||||
```
|
||||
|
||||
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
|
||||
forwarding handlers are opaque `Arc<dyn Handler>` from the registry's
|
||||
perspective. `alknet-call` stays lean; `alknet-http` owns both HTTP
|
||||
directions.
|
||||
|
||||
## Feature Gates
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["h2", "http1"] # the non-browser HTTP surface
|
||||
h3 = ["dep:wtransport"] # HTTP/3 + WebTransport (browser path; X.509 required)
|
||||
mcp = ["dep:rmcp"] # from_mcp / to_mcp (streamable HTTP only — ADR-037)
|
||||
```
|
||||
|
||||
- `h2` + `http1` (default): the `axum` + `hyper` HTTP/1.1 + HTTP/2
|
||||
server. This is the surface non-browser clients use.
|
||||
- `h3`: the `wtransport` (or quinn HTTP/3 extension) dependency. Adds
|
||||
the `h3` ALPN handler and the WebTransport streaming path. See
|
||||
[webtransport.md](webtransport.md) and
|
||||
[ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md).
|
||||
- `mcp`: the `rmcp` dependency with streamable HTTP transport features
|
||||
only. Adds `from_mcp`/`to_mcp`. See [http-mcp.md](http-mcp.md) and
|
||||
[ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md).
|
||||
|
||||
A deployment that only needs the REST surface (no browsers, no MCP) uses
|
||||
the default features. A browser-facing hub enables `h3`. A deployment
|
||||
that wants MCP tool import/export enables `mcp`.
|
||||
|
||||
## The No-Env-Vars Invariant
|
||||
|
||||
The `from_openapi`/`from_mcp` forwarding handlers are the **credential
|
||||
injection point** for the no-env-vars architecture. The path (from the
|
||||
gap analysis):
|
||||
|
||||
```
|
||||
vault → assembly layer → Capabilities → HandlerRegistration.capabilities
|
||||
→ OperationContext.capabilities → from_openapi handler reads
|
||||
context.capabilities.get("openai") → injects into HTTP Authorization
|
||||
header → reqwest request goes out with vault-derived credential
|
||||
```
|
||||
|
||||
This makes aisdk's `std::env::var("OPENAI_API_KEY")` reads unreachable —
|
||||
the assembly layer never calls `Default::default()` on a provider; it
|
||||
constructs them with vault-derived credentials, or routes HTTP calls
|
||||
through `from_openapi` operations that carry the credential in
|
||||
`Capabilities`.
|
||||
|
||||
**This is a spec-level invariant**: no handler reads outbound
|
||||
credentials from any source other than `OperationContext.capabilities`.
|
||||
The `from_openapi`/`from_mcp` implementations in `alknet-http` are
|
||||
verified against this invariant. See ADR-014 and
|
||||
[client-and-adapters.md](../call/client-and-adapters.md).
|
||||
|
||||
## Architecture (component pointers)
|
||||
|
||||
- **[http-server.md](http-server.md)** — the `HttpAdapter` for `h2`/
|
||||
`http/1.1`: how axum is run over a QUIC bidirectional stream, Bearer
|
||||
auth resolution, the `/healthz` raw route, stealth decoy, and the
|
||||
HTTP-to-call dispatch (ADR-036).
|
||||
- **[http-adapters.md](http-adapters.md)** — `from_openapi` (parse
|
||||
OpenAPI, build forwarding handlers with `reqwest`) and `to_openapi`
|
||||
(generate an OpenAPI doc from the registry's `External` operations).
|
||||
Error fidelity per ADR-023.
|
||||
- **[http-mcp.md](http-mcp.md)** — `from_mcp`/`to_mcp` (feature-gated),
|
||||
streamable HTTP only (ADR-037), the rmcp integration.
|
||||
- **[webtransport.md](webtransport.md)** — the `h3` ALPN handler,
|
||||
WebTransport session/stream handling, the browser streaming path
|
||||
(ADR-038).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| HTTP-to-call operation mapping | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | Direct path mapping; `to_openapi` is projection, not router |
|
||||
| MCP stdio transport exclusion | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built (RCE vector) |
|
||||
| HTTP/3 + WebTransport first-class | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | `h3` in scope, not deferred; browser streaming uses QUIC streams |
|
||||
| HTTP server + client host colocated | [ADR-039](../../decisions/039-http-server-and-client-host-colocated.md) | One crate for server + adapters (shared HTTP deps, shared mapping) |
|
||||
| `alknet-call` is protocol-foundation | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | `alknet-http` depends on `alknet-call` (types, not peer handler) |
|
||||
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source + resolution (settled) |
|
||||
| Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy for unknown paths (settled) |
|
||||
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_openapi`/`from_mcp` produce `Internal` leaves (settled) |
|
||||
| `OperationAdapter` trait is async | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | HTTP adapters implement the async trait (settled) |
|
||||
| `to_*` adapters are projections | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `to_openapi`/`to_mcp` consume the registry, don't produce entries (settled) |
|
||||
| Error schema fidelity | [ADR-023](../../decisions/023-operation-error-schemas.md) | `from_openapi` maps HTTP status → `HTTP_<status>` codes; `to_openapi` projects back (settled) |
|
||||
| Browsers require X.509 | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | `h3`/WebTransport needs X.509 (settled) |
|
||||
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Browser over WebTransport/HTTPS = bearer token, no `PeerId` (settled) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-13** (resolved): Operation path format `/{service}/{op}` — the
|
||||
HTTP path.
|
||||
- **OQ-26** (resolved): `AdapterError` variants — reused by HTTP
|
||||
adapters; `#[non_exhaustive]` allows extension.
|
||||
- **OQ-37** (resolved): Browsers are not peers; `h3` hub is a
|
||||
mixed-fingerprint `PeerEntry`.
|
||||
- **OQ-38** (open, scope): WebTransport relay-as-proxy — does the proxy
|
||||
live in `alknet-http` or a separate relay crate?
|
||||
- **OQ-39** (open): `to_openapi` published-spec versioning — versioning
|
||||
strategy for generated OpenAPI specs.
|
||||
- **OQ-40** (open): reqwest client config and connection pooling —
|
||||
two-way-door config shape.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-http/phase-0-findings.md` — Phase 0 research
|
||||
- `docs/research/alknet-call-completion/gap-analysis.md` — adapter
|
||||
location map, no-env-vars invariant
|
||||
- `/workspace/@alkdev/operations/src/from_openapi.ts`,
|
||||
`/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art
|
||||
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP examples
|
||||
- `/workspace/wtransport/` — pure-Rust WebTransport reference
|
||||
232
docs/architecture/crates/http/webtransport.md
Normal file
232
docs/architecture/crates/http/webtransport.md
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# WebTransport — the h3 ALPN handler
|
||||
|
||||
The `HttpAdapter` registration for the `h3` ALPN: HTTP/3 and
|
||||
WebTransport. This document covers the WebTransport session/stream
|
||||
handling, the browser streaming path, and the relationship to the `h2`/
|
||||
`http/1.1` server. The `h3` support is a first-class transport, not a
|
||||
deferral (ADR-038).
|
||||
|
||||
## What
|
||||
|
||||
The `h3` ALPN handler is the same `HttpAdapter` instance that serves
|
||||
`h2`/`http/1.1`, registered for the `h3` ALPN when the `h3` feature is
|
||||
enabled. It serves two things on a single `h3` connection:
|
||||
|
||||
1. **HTTP/3 requests** — the standard HTTP/3 over QUIC framing. An
|
||||
HTTP/3 request is dispatched through the same axum `Router` as `h2`/
|
||||
`http/1.1` requests (ADR-036 — the HTTP path IS the operation path).
|
||||
From the axum router's perspective, an HTTP/3 request is just
|
||||
another HTTP request; the framing difference is handled below the
|
||||
router.
|
||||
2. **WebTransport sessions** — the browser streaming path. A WebTransport
|
||||
session is a long-lived connection over which the browser opens
|
||||
bidirectional and unidirectional streams. A WebTransport stream that
|
||||
targets the call protocol is handed to the call protocol's dispatch
|
||||
loop directly — a WebTransport bidirectional stream is a QUIC
|
||||
bidirectional stream, the same stream type the call protocol already
|
||||
speaks (ADR-012).
|
||||
|
||||
### Why h3 is a first-class transport
|
||||
|
||||
WebTransport is the browser streaming transport. QUIC streams are cheap
|
||||
(multiplexed over one connection, no head-of-line blocking), and
|
||||
WebTransport is supported in major browsers. The call protocol's
|
||||
subscription/streaming model maps onto WebTransport streams with no
|
||||
translation loss — a `call.responded` stream over a WebTransport
|
||||
bidirectional stream is the native representation, not an SSE
|
||||
translation (which is the projection for `h2`/`http/1.1` clients per
|
||||
ADR-036).
|
||||
|
||||
The Phase 0 research framing ("defer h3/WebTransport past v1") was a
|
||||
residual of the "two-way door as deferral" anti-pattern (ADR-009 §"What
|
||||
this framework is NOT"). WebTransport is in scope, in this crate, as a
|
||||
first-class transport. See ADR-038.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The h3 handler entry
|
||||
|
||||
The `HttpAdapter::handle()` method for the `h3` ALPN 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`. These are the same request model as `h2`/
|
||||
`http/1.1`, just over HTTP/3 framing. Dispatched through the axum
|
||||
`Router` (same router as `h2`/`http/1.1`, ADR-036). An HTTP/3 request
|
||||
is never a WebTransport stream — the stream type is set by the
|
||||
HTTP/3 frame that opens it.
|
||||
2. **WebTransport sessions** — opened by a browser's
|
||||
`new WebTransport(url)` call, which triggers an HTTP/3 extended
|
||||
CONNECT request. The handler accepts the session (the `wtransport`
|
||||
crate's `Endpoint::server(config)?.accept().await.await?.accept()
|
||||
.await?` pattern, or the quinn HTTP/3 endpoint's WebTransport
|
||||
extension — the exact library is a two-way-door implementation
|
||||
detail, ADR-038). Within an established session, the browser opens
|
||||
bidirectional streams via `transport.createBidirectionalStream()`;
|
||||
the handler accepts each via `session.accept_bi()`.
|
||||
|
||||
The two stream types are not disambiguated by "reading the first frame"
|
||||
— they are distinguished by the HTTP/3 frame type that opens them
|
||||
(regular request headers vs. extended CONNECT). The "first frame"
|
||||
routing below applies *within* a WebTransport session, not between an
|
||||
HTTP/3 request and a WebTransport stream.
|
||||
|
||||
### WebTransport session and stream handling
|
||||
|
||||
Once a WebTransport session is established (via extended CONNECT), the
|
||||
browser creates bidirectional streams within it. The handler accepts
|
||||
each stream (`session.accept_bi()`) and reads the first frame to
|
||||
determine the sub-protocol:
|
||||
|
||||
- **Call-protocol `EventEnvelope`** — the stream is a call-protocol
|
||||
stream. The handler hands the `(SendStream, RecvStream)` pair to the
|
||||
call protocol's `Dispatcher` (see [../call/call-protocol.md](../call/call-protocol.md)
|
||||
for `EventEnvelope` and [../call/client-and-adapters.md](../call/client-and-adapters.md)
|
||||
§"Shared Dispatcher" for the `Dispatcher` — the same dispatch loop
|
||||
the `CallAdapter` uses for `alknet/call` connections, ADR-012,
|
||||
stream-agnostic correlation). The browser speaks the EventEnvelope
|
||||
wire format directly over the WebTransport stream.
|
||||
- **Other sub-protocols** — a session may carry other framing
|
||||
conventions (e.g., a future WT-native RPC framing). The session's
|
||||
purpose is declared at CONNECT time (by path/origin), so the handler
|
||||
knows which sub-protocol to expect; the first-frame tag is a
|
||||
belt-and-suspenders disambiguator for sessions that multiplex
|
||||
sub-protocols. For the call-protocol session, the first frame is an
|
||||
`EventEnvelope` JSON object; the handler dispatches accordingly.
|
||||
|
||||
The browser's `WebTransport` JS API is the client side of this:
|
||||
`new WebTransport('https://hub.example.com')` →
|
||||
`transport.createBidirectionalStream()` → write an `EventEnvelope` frame
|
||||
→ read `call.responded` frames. No SSE translation, no HTTP framing —
|
||||
the call protocol speaks directly over the WebTransport stream.
|
||||
|
||||
### Subscription projection (native, not SSE)
|
||||
|
||||
A `Subscription` operation served over WebTransport projects its
|
||||
`call.responded` stream directly onto the WebTransport bidirectional
|
||||
stream — each `call.responded` event is a frame on the stream, no SSE
|
||||
`data:` framing. `call.completed` closes the stream; `call.aborted`
|
||||
closes the stream with an error frame. This is the native streaming
|
||||
projection; SSE (ADR-036) is the projection for `h2`/`http/1.1` clients
|
||||
that don't speak WebTransport.
|
||||
|
||||
### 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 — 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 spec makes. It's
|
||||
recorded so the spec doesn't pretend a raw-key node can serve browsers.
|
||||
A raw-key node serves `h2`/`http/1.1` (for curl, axios, alknet-native
|
||||
clients) but not `h3`/WebTransport (for browsers). A browser-facing hub
|
||||
has a `PeerEntry` with mixed fingerprints (Ed25519 for P2P, X.509 for
|
||||
browsers — ADR-030, ADR-034 §3).
|
||||
|
||||
### Browsers are not alknet peers
|
||||
|
||||
A browser connecting to a hub over WebTransport is served by the `h3`
|
||||
handler. The browser authenticates by bearer token (HTTP `Authorization`
|
||||
header on the WebTransport session request), resolved by the hub's
|
||||
`IdentityProvider::resolve_from_token` against the hub's
|
||||
`PeerEntry.auth_token_hash` or `ApiKeyEntry`. The browser is **not** an
|
||||
alknet peer (ADR-034 §4): it gets no `PeerId`, does not enter
|
||||
`PeerCompositeEnv`, and its "ops" are WebTransport streams served by
|
||||
the `h3` handler, not entries in the call-protocol peer-keyed overlay.
|
||||
|
||||
### Stealth on h3
|
||||
|
||||
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 (the same configurable `DecoyConfig` — fake 404, static site,
|
||||
redirect). 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`. Its API:
|
||||
|
||||
```rust
|
||||
// Server (from the wtransport README):
|
||||
let config = ServerConfig::builder()
|
||||
.with_bind_default(4433)
|
||||
.with_identity(&identity) // X.509 identity
|
||||
.build();
|
||||
let connection = Endpoint::server(config)?
|
||||
.accept().await // await connection
|
||||
.await? // await session request
|
||||
.accept().await?; // await ready session
|
||||
let stream = connection.accept_bi().await?;
|
||||
```
|
||||
|
||||
`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
|
||||
(ADR-038); the one-way constraint is that `h3` is served by this crate
|
||||
as a first-class transport.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **`h3` requires X.509.** Browsers don't support RFC 7250 (ADR-027).
|
||||
A node serving `h3` must have an X.509 identity. Raw-key-only nodes
|
||||
serve `h2`/`http/1.1` but not `h3`.
|
||||
- **`h3` is behind the `h3` feature gate.** The `wtransport` (or
|
||||
quinn HTTP/3 extension) dependency is heavier than `h2`/`http/1.1`;
|
||||
non-browser-facing deployments don't compile it.
|
||||
- **Browsers are not alknet peers.** A browser over WebTransport
|
||||
authenticates by bearer token, gets no `PeerId` (ADR-034 §4).
|
||||
- **WebTransport streams target the call protocol directly.** A
|
||||
WebTransport bidirectional stream carrying an `EventEnvelope` is
|
||||
handed to the call protocol's `Dispatcher` — no SSE translation, no
|
||||
HTTP framing. The browser speaks the call protocol wire format
|
||||
directly.
|
||||
- **The HTTP/3 request path uses the same axum `Router` as `h2`/
|
||||
`http/1.1`.** An HTTP/3 request is just another HTTP request from
|
||||
the router's perspective (ADR-036).
|
||||
- **WebTransport is a draft standard.** The `wtransport` README notes
|
||||
the protocol is not yet standardized; the API may change. The `h3`
|
||||
feature gate isolates the risk.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| `h3`/WebTransport is first-class | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | In scope, not deferred; browser streaming uses QUIC streams |
|
||||
| Browsers require X.509 | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | `h3` needs X.509 (browser limitation) |
|
||||
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
|
||||
| WebTransport streams → call protocol directly | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Stream-agnostic; WebTransport stream = QUIC bidirectional stream |
|
||||
| Stealth on h3 | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Unknown paths get the decoy |
|
||||
| HTTP path = operation path (for HTTP/3 requests) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | Same axum `Router` as h2/http1.1 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-38** (open, scope): WebTransport relay-as-proxy — a proxy that
|
||||
terminates the browser's WebTransport connection and forwards to a
|
||||
P2P hub's Ed25519 endpoint (so the hub need not expose a public
|
||||
X.509 cert). Recorded in ADR-034 §5. Does the proxy live in
|
||||
`alknet-http` or a separate relay crate? This is a genuine scope
|
||||
question (the proxy use case is not yet concrete enough to decide the
|
||||
crate boundary), not a deferral.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md)
|
||||
— the decision that `h3` is in scope
|
||||
- [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) —
|
||||
the HTTP-to-call mapping (the HTTP/3 request path uses the same
|
||||
axum `Router`)
|
||||
- [overview.md](overview.md) — crate overview, feature gates
|
||||
- [http-server.md](http-server.md) — the `h2`/`http/1.1` companion
|
||||
- `/workspace/wtransport/` — pure-Rust WebTransport reference
|
||||
implementation (the `h3` feature's candidate dependency)
|
||||
@@ -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)
|
||||
@@ -736,17 +736,108 @@ is a feature extension, not an unmade architecture decision.
|
||||
all (no CA fallback) and fails closed — same model as iroh.
|
||||
|
||||
**Downstream, not blocking, recorded so they don't get lost:**
|
||||
WebTransport relay-as-proxy (browser → proxy → P2P hub) is deferred
|
||||
with the rest of h3/WebTransport (alknet-http DH-2); ADR-030 §6's
|
||||
fingerprint normalization already keeps the proxied path clean. On-
|
||||
chain / smart-contract peer discovery (relays syncing git repos via
|
||||
iroh gossip) is a *source* of `PeerEntry` records, fits the OQ-36
|
||||
repo/adapter pattern (`alknet-peer-store-onchain` implementing
|
||||
`IdentityProvider`), and does not change the auth model.
|
||||
WebTransport relay-as-proxy (browser → proxy → P2P hub) is the
|
||||
remaining scope question tracked as OQ-38 (h3/WebTransport itself is
|
||||
now in scope, ADR-038); ADR-030 §6's fingerprint normalization already
|
||||
keeps the proxied path clean. On-chain / smart-contract peer
|
||||
discovery (relays syncing git repos via iroh gossip) is a *source* of
|
||||
`PeerEntry` records, fits the OQ-36 repo/adapter pattern
|
||||
(`alknet-peer-store-onchain` implementing `IdentityProvider`), and
|
||||
does not change the auth model.
|
||||
|
||||
Not blocking the ADR-029 migration — the Ed25519 path is the primary
|
||||
use case and was already resolved; this ADR closes the X.509
|
||||
outgoing-only remainder.
|
||||
- **Cross-references**: ADR-027, ADR-029, ADR-030, ADR-033, ADR-034,
|
||||
OQ-29, OQ-36, [client-and-adapters.md](crates/call/client-and-adapters.md),
|
||||
[endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md)
|
||||
[endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md)
|
||||
|
||||
## Theme: alknet-http
|
||||
|
||||
### OQ-38: WebTransport Relay-as-Proxy Scope
|
||||
|
||||
- **Origin**: [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md)
|
||||
§5, [webtransport.md](crates/http/webtransport.md)
|
||||
- **Status**: open (scope, not deferral)
|
||||
- **Door type**: One-way (crate boundary), two-way (mechanism)
|
||||
- **Priority**: low
|
||||
- **Resolution**: A WebTransport 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) is a real feature for the browser-to-P2P-peer case. ADR-034 §5
|
||||
recorded it in the h3/WebTransport bucket; ADR-038 brought h3/
|
||||
WebTransport into scope, so this OQ is now the remaining scope
|
||||
question: does the proxy live in `alknet-http` (as a mode of the `h3`
|
||||
handler) or in a separate relay crate?
|
||||
|
||||
This is a genuine scope question, not a deferral. The proxy use case
|
||||
is not yet concrete enough to decide the crate boundary — no
|
||||
deployment has asked for it yet, and the design (transport-only
|
||||
proxy, no auth-model change per ADR-034 §5) is clear but the home is
|
||||
not. The decision is made when the browser-to-P2P-peer proxy use
|
||||
case becomes concrete; until then it is tracked here, not deferred
|
||||
with "v1/later" language. The proxy does not change the auth model
|
||||
(bearer token + `PeerEntry.auth_token_hash`; proxy is transport-
|
||||
only), so it does not block any other ADR.
|
||||
- **Cross-references**: ADR-027, ADR-030, ADR-034, ADR-038,
|
||||
[webtransport.md](crates/http/webtransport.md)
|
||||
|
||||
### OQ-39: `to_openapi` Published-Spec Versioning
|
||||
|
||||
- **Origin**: [ADR-017](decisions/017-call-protocol-client-and-adapter-contract.md)
|
||||
Consequences, [http-adapters.md](crates/http/http-adapters.md)
|
||||
- **Status**: open
|
||||
- **Door type**: One-way (after first publication), two-way (before)
|
||||
- **Priority**: medium
|
||||
- **Resolution**: ADR-017 Consequences notes that published `to_*`
|
||||
specs are compatibility contracts: once a generated OpenAPI spec is
|
||||
published and external clients build against it, the mapping
|
||||
semantics (e.g., subscriptions → SSE long-poll, error codes → HTTP
|
||||
statuses) become a de facto contract. Changing the mapping later
|
||||
breaks every client. `to_openapi` mapping choices are two-way *before*
|
||||
first publication but one-way *after*.
|
||||
|
||||
The versioning strategy for generated OpenAPI specs needs
|
||||
specifying: version the generated spec (e.g., an OpenAPI `info.version`
|
||||
tied to the registry's `External` operation set version) and emit a
|
||||
spec version marker so consumers can detect mapping changes. The
|
||||
exact versioning scheme (semver tied to operation additions/changes,
|
||||
a content-hash, a monotonically-increasing counter) is a two-way-door
|
||||
implementation detail before first publication; the one-way constraint
|
||||
is that the version marker is emitted and consumers can detect
|
||||
breaking changes. This is the "published artifact is a contract"
|
||||
blind spot in ADR-009's framework (it classifies doors by reversal
|
||||
cost in the codebase, not by compatibility cost for external
|
||||
consumers).
|
||||
- **Cross-references**: ADR-009, ADR-017, ADR-023, ADR-036,
|
||||
[http-adapters.md](crates/http/http-adapters.md)
|
||||
|
||||
### OQ-40: reqwest Client Config and Connection Pooling
|
||||
|
||||
- **Origin**: [http-adapters.md](crates/http/http-adapters.md),
|
||||
[http-mcp.md](crates/http/http-mcp.md), the alknet-http Phase 0
|
||||
findings DH-7
|
||||
- **Status**: open
|
||||
- **Door type**: Two-way
|
||||
- **Priority**: low
|
||||
- **Resolution**: `alknet-http` maintains a shared `reqwest::Client`
|
||||
(constructed once, reused across all `from_openapi`/`from_mcp`
|
||||
forwarding handlers) with connection pooling, keep-alive, and TLS.
|
||||
The aisdk `core/client.rs` reference shows the pattern worth
|
||||
referencing: `OnceLock<reqwest::Client>`, retry logic (exponential
|
||||
backoff, `Retry-After` header), and separate streaming vs
|
||||
non-streaming clients.
|
||||
|
||||
The exact pooling/retry config (pool size, retry policy, timeout
|
||||
defaults, hot-reloadability via `DynamicConfig`) is a two-way-door
|
||||
implementation detail. The one-way constraints are: (1)
|
||||
`alknet-http` owns its `reqwest::Client` (no env-var-based client
|
||||
config, no shared global client), (2) credential injection happens
|
||||
per-request (from `OperationContext.capabilities`), not at client
|
||||
construction (the client is shared across all operations, the
|
||||
credentials are per-call), and (3) TLS for outbound calls uses the
|
||||
system trust store by default (custom CA bundle + client certs are
|
||||
an optional config for self-hosted API gateways). This OQ tracks the
|
||||
two-way-door config shape; the constraints are settled.
|
||||
- **Cross-references**: ADR-014, ADR-017,
|
||||
[http-adapters.md](crates/http/http-adapters.md)
|
||||
@@ -49,7 +49,7 @@ alknet-core
|
||||
├── alknet-git (depends on alknet-core, gix)
|
||||
├── alknet-sftp (depends on alknet-core, russh-sftp)
|
||||
├── alknet-msg (depends on alknet-core)
|
||||
├── alknet-http (depends on alknet-core, axum)
|
||||
├── alknet-http (depends on alknet-core, alknet-call, axum, reqwest, wtransport, rmcp)
|
||||
├── alknet-dns (depends on alknet-core, hickory-proto)
|
||||
│
|
||||
├── alknet-napi (depends on alknet-call, napi-rs)
|
||||
@@ -101,7 +101,7 @@ See [ADR-002](decisions/002-protocol-handler-trait.md) and [ADR-007](decisions/0
|
||||
| `alknet/msg` | MessageAdapter | E2E encrypted messaging, mixnet |
|
||||
| `alknet/http` | HttpAdapter | axum REST API, dashboard, MCP endpoint |
|
||||
| `alknet/dns` | DnsAdapter | DNS over QUIC/TLS, pkrr service discovery |
|
||||
| `h3` | HttpAdapter (WebTransport upgrade) | Browser-compatible WebTransport, then ALPN upgrade |
|
||||
| `h3` | HttpAdapter (HTTP/3 + WebTransport) | Browser-compatible WebTransport + HTTP/3 (first-class, ADR-038) |
|
||||
| `h2` / `http/1.1` | HttpAdapter | Standard HTTP for browsers, curl |
|
||||
|
||||
> **Note**: `alknet/agent` is not in the ALPN registry. The agent service is a future consumer that builds on top of `alknet-call` (it depends on `alknet-call`, not `alknet-core` directly — see ADR-003). It uses the call protocol for tool dispatch and exposes agent operations (e.g., `/agent/chat`) as call-protocol operations in the `OperationRegistry`, not as a separate ALPN. The agent is a mental model that informed the core architecture (capabilities, scoped env, abort cascade) but is not specced yet — its design will change as it's built out against the implemented core crates.
|
||||
|
||||
Reference in New Issue
Block a user