docs(http): pre-decomposition sanity check fixes — /subscribe POST, direct-call cleanup, from_mcp output handling

Three issues found in the http crate spec sanity check that would have
caused problems during task decomposition, now fixed:

C1 — /subscribe GET→POST: the gateway's /subscribe is an invoke endpoint
carrying { operation, input } in the body, but was listed as GET (which
has no body). Flipped to POST with Accept: text/event-stream negotiating
the SSE response, consistent with /call's flat-JSON-body invariant.
Browsers using EventSource can't POST but use WebSocket for the
bidirectional path; the HTTP gateway's /subscribe is for non-browser
HTTP clients (fetch + ReadableStream). Touches ADR-042, ADR-047,
ADR-048, http-adapters.md, http-server.md.

C2 — stale direct-call references: three spots contradicted ADR-047
(which removed the POST /{service}/{op} direct-call surface) and
ADR-046 §3 (which states /{service}/{op} is no longer reserved).
Cleaned up in http-server.md (custom-routes intro + collision list) and
ADR-046 §6 (default-surface list).

W2 — from_mcp output handling: the spec's fallback for tools without
outputSchema was Type.Unknown(), but the correct fallback is the MCP
ContentBlock union (text|image|audio|resource|resource_link) — a
well-defined MCP type, not Unknown. Fixed http-mcp.md with the full
structuredContent-preferred-over-content-blocks logic (matching the TS
adapter and rmcp SDK), enriched references with specific rmcp source
files. Also added shared-dispatch-spine notes to http-mcp.md and
http-adapters.md cross-referencing the new research findings.

Research (docs/research/alknet-http-gateway-factoring/findings.md):
to_mcp and to_openapi share a dispatch spine (resolve → invoke → map).
Recommendation: extract a thin shared struct now, not a GatewayDispatch
trait — the server-integration layers (axum routes vs rmcp
StreamableHttpService) and wire-framing stay per-gateway. A third
gateway is not on the horizon; if one appears its server-integration
needs its own shape anyway.

Minor: WS route precedence note (websocket.md), OpenAPISpec
shared-type-not-shape clarification (http-adapters.md), date bumps.
This commit is contained in:
2026-07-01 05:41:07 +00:00
parent 3edc42e3b4
commit e0c6f61e6a
9 changed files with 770 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-30
last_updated: 2026-07-01
---
# HTTP Adapters — from_openapi and to_openapi
@@ -50,7 +50,11 @@ impl OperationAdapter for FromOpenAPI {
/// 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.
/// `to_openapi` produces one. Both directions share the same Rust type,
/// but not the same document shape: `from_openapi` consumes traditional
/// per-operation-paths docs (one path per operation), while `to_openapi`
/// produces the 5-endpoint gateway doc (ADR-042). The type is shared;
/// the shape is not.
pub struct OpenAPISpec {
pub info: OpenAPIInfo,
pub paths: BTreeMap<String, PathItem>,
@@ -261,7 +265,7 @@ surface problem).
| `/schema` | `services/schema` | `GET` | Get an operation's full `OperationSpec`. |
| `/call` | `call.requested` (Query/Mutation) | `POST` | Invoke an operation. Flat JSON body `{ operation, input }`. |
| `/batch` | multiple `call.requested` | `POST` | Invoke multiple operations. Array of `{ operation, input }`. |
| `/subscribe` | `call.requested` (Subscription) | `GET` (SSE) | Invoke a streaming operation. `text/event-stream`. |
| `/subscribe` | `call.requested` (Subscription) | `POST` (SSE) | Invoke a streaming operation. Body `{ operation, input }` (same shape as `/call`); response is `text/event-stream`. |
The input is always a flat JSON body — no path/query/body split to
reverse-engineer. JSON Schema for the input/output is already in the
@@ -300,6 +304,17 @@ HTTP-specific metadata (which fields are path params, etc.). The
gateway pattern is the default `to_openapi` projection; the traditional
projection is additive, not a replacement. See ADR-042 §5.
#### Shared dispatch spine with `to_mcp`
`to_openapi`'s `/call` endpoint and `to_mcp`'s `call` tool share the
same dispatch spine (resolve identity → build `OperationContext` →
`OperationRegistry::invoke()` → map `ResponseEnvelope`). The
wire-framing, discovery, streaming, and server-integration layers are
per-gateway. See [http-mcp.md](http-mcp.md) §"Shared dispatch spine
with `to_openapi`" and
`docs/research/alknet-http-gateway-factoring/findings.md` for the
factoring recommendation (thin shared struct, not a trait).
### Error Fidelity (ADR-023)
`from_openapi` maps OpenAPI non-2xx response status codes to

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-29
last_updated: 2026-07-01
---
# HTTP MCP — from_mcp and to_mcp
@@ -84,9 +84,21 @@ The adapter:
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.output_schema` = depends on whether the tool declares
`outputSchema` (MCP 2025-06-18+):
- **`outputSchema` present** → `output_schema` = the declared
schema (converted from JSON Schema). The result arrives in
`CallToolResult.structured_content` and is composable with
local operations (the data matches the declared type).
- **`outputSchema` absent** (older MCP servers) → `output_schema`
= the MCP `ContentBlock` union (`text | image | audio |
resource | resource_link` — a well-defined MCP type, *not*
`Type.Unknown()`). The result arrives in
`CallToolResult.content` as a `Vec<ContentBlock>`. The common
sub-case is a single `Text` block (which older servers often
fill with JSON-stringified data), but the *type* is the
`ContentBlock` union regardless of what the text contains.
See "Output handling" below.
- `spec.error_schemas` = the MCP tool's error description mapped to
`ErrorDefinition` (ADR-023 — MCP tool definitions carry error
descriptions; the adapter maps them).
@@ -107,10 +119,9 @@ At call time, the `from_mcp` forwarding handler:
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.
3. On success: extracts the result from the `CallToolResult`, following
the `structuredContent`-preferred-over-content-blocks rule (see
"Output handling" below), 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
@@ -120,6 +131,35 @@ At call time, the `from_mcp` forwarding handler:
The handler is opaque to the `CallAdapter``Arc<dyn Handler>` the
registry dispatches. `alknet-call` never sees rmcp.
### Output handling (structuredContent vs content blocks)
MCP `CallToolResult` (rmcp `model.rs`) carries two result fields:
`content: Vec<ContentBlock>` (always present, defaults to `[]`) and
`structured_content: Option<Value>` (present when the tool declared
`outputSchema`). The `from_mcp` handler follows the same rule the TS
adapter (`@alkdev/operations/src/from_mcp.ts`) and the rmcp SDK
(`CallToolResult::into_typed`) use:
- **`structured_content` present** (tool declared `outputSchema`): the
handler uses `structured_content` as the result, validated/cast
against the declared `output_schema`. This is the composable case —
the data matches the declared type, so a composing handler can use it
as a typed value.
- **`structured_content` absent** (older server, no `outputSchema`):
the handler maps `content: Vec<ContentBlock>` to the
`ContentBlock`-union `output_schema` (text/image/audio/resource/
resource_link). The TS `mapMCPContentBlocks` shows the mapping; the
Rust `ContentBlock` enum (`rmcp/src/model/content.rs`) is the same
shape. The common sub-case is a single `Text` block — older servers
often JSON-stringify structured data into the `text` field. The
adapter does *not* attempt to `JSON.parse` the text heuristically
(fragile, not the adapter's concern); it carries the `ContentBlock`
union as the typed result. A consumer that knows the text is JSON can
parse it downstream.
The `isError: true` case is handled separately (step 4 above) — it
maps to a `CallError`, not to the output handling path.
### to_mcp
```rust
@@ -209,6 +249,34 @@ don't fit the LLM tool-call pattern. See ADR-041 §2.
`AccessControl` gates `search` results and `call` dispatch — the
LLM sees only what it's authorized to call.
#### Shared dispatch spine with `to_openapi`
`to_mcp`'s `call` tool and `to_openapi`'s `/call` endpoint share the
same dispatch spine: resolve caller identity (Bearer →
`IdentityProvider::resolve_from_token`) → build a root
`OperationContext``OperationRegistry::invoke()` → map the
`ResponseEnvelope` to the gateway's wire shape (`CallToolResult` for
MCP, HTTP JSON for OpenAPI). The wire framing, discovery listing
(`tools/list` vs `/search`), streaming (excluded vs `/subscribe` SSE),
and server integration (rmcp `StreamableHttpService` tower service vs
axum route handlers) are genuinely per-gateway and are not shared.
Research findings
(`docs/research/alknet-http-gateway-factoring/findings.md`) recommend
extracting a **thin shared spine** (a concrete struct holding
`Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
`resolve + build_context + invoke` method returning a
`ResponseEnvelope`), **not** a `GatewayDispatch` trait or gateway
abstraction. The spine is small (~1530 lines per endpoint), but it is
the one place where a divergence bug (identity resolved differently,
`OperationContext.internal` set inconsistently, `CallError` mapped
asymmetrically) would be a security/correctness issue. The
server-integration and wire-framing layers stay per-gateway; a third
gateway (GraphQL, gRPC) is not on the horizon, and if one appears its
server-integration layer needs its own shape anyway. This is an
implementation factoring note, not an ADR — the decision is internal to
`alknet-http` and does not cross crate boundaries.
### No-Env-Vars
The `from_mcp` forwarding handler reads the MCP server's bearer token
@@ -290,9 +358,26 @@ See [open-questions.md](../../open-questions.md) for full details.
`OperationAdapter` trait, `AdapterError` variants
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP
transport
- `/workspace/rust-sdk/crates/rmcp/src/model/tool.rs``Tool` with
`output_schema: Option<Arc<JsonObject>>` (the `outputSchema` field)
- `/workspace/rust-sdk/crates/rmcp/src/model/content.rs``ContentBlock`
enum (text/image/audio/resource/resource_link — the fallback
`output_schema` type when `outputSchema` is absent)
- `/workspace/rust-sdk/crates/rmcp/src/model.rs` (~line 2868) —
`CallToolResult` with `content: Vec<ContentBlock>` and
`structured_content: Option<Value>` (the two result fields); see also
`into_typed` (~line 3057) for the SDK's own
structured-content-preferred-over-text-block fallback logic
- `/workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs`
— streamable HTTP MCP server with Bearer auth (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`)
(`createMCPClient`, `mapMCPContentBlocks`, the `MCPClientLoader`; the
`structuredContent`-preferred-over-content-blocks logic)
- `/workspace/@alkdev/operations/docs/architecture/adapters.md`
TypeScript adapter architecture doc (the `from_mcp` `outputSchema`/
`structuredContent` handling, the `MUTATION` tool-type decision)
- `docs/research/alknet-http-gateway-factoring/findings.md` — research
on the shared dispatch spine between `to_mcp` and `to_openapi`
(recommendation: thin shared struct, not a trait)

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-30
last_updated: 2026-07-01
---
# HTTP Server
@@ -117,7 +117,9 @@ contains:
`POST /call` with `{ "operation": "/{service}/{op}", "input": {...} }`,
discovers available operations via `GET /search`
(`AccessControl`-filtered), and learns an operation's shape via `GET
/schema`. `/subscribe` is the SSE streaming invoke path. There is no
/ schema`. `POST /subscribe` is the SSE streaming invoke path (body
`{ operation, input }`, same shape as `/call`, response
`text/event-stream`). There is no
per-operation `POST /{service}/{op}` direct-call surface — the
gateway is the invoke path (ADR-047 supersedes ADR-036's direct-call
surface; the simplified contract is a few fixed endpoints, not a
@@ -185,9 +187,11 @@ SSE streaming projection (below).
### Streaming projection (SSE — the gateway's `/subscribe`)
A `Subscription` operation invoked via the gateway's `/subscribe`
A `Subscription` operation invoked via the gateway's `POST /subscribe`
endpoint projects its `call.responded` stream as Server-Sent Events.
The axum route handler:
The request body is `{ operation, input }` (the same flat JSON shape as
`/call`); the response is `text/event-stream` (negotiated via
`Accept: text/event-stream` on the `POST`). The axum route handler:
- Sets `Content-Type: text/event-stream`.
- For each `call.responded` event, writes an SSE `data:` frame (the
@@ -341,7 +345,7 @@ paths matched by neither the default surface nor a custom route.
### Custom routes (ADR-046)
A deployment that needs HTTP endpoints outside the default surface
(direct-call + gateway + `/healthz` + `/openapi.json` + MCP) injects
(gateway + `/healthz` + `/openapi.json` + MCP) injects
them as an `axum::Router` at `HttpAdapter` construction. The classic use
case: an OpenAI-compatible proxy at `/v1/chat/completions` that wraps a
call-protocol operation (the deployment parses the OAI request, invokes
@@ -363,10 +367,14 @@ Custom routes:
different auth applies its own axum middleware (the deployment owns
its custom routes' middleware stack).
- **Do not collide** with the reserved default-surface paths
(`/{service}/{op}`, `/search`, `/schema`, `/call`, `/batch`,
(`/search`, `/schema`, `/call`, `/batch`,
`/subscribe`, `/healthz`, `/openapi.json`, the MCP route) — the
default surface wins on collision; custom routes namespace away
naturally (`/v1/...`).
naturally (`/v1/...`). (ADR-047 removed the direct-call
`POST /{service}/{op}` surface, so `/{service}/{op}` is no longer a
reserved path; a deployment that builds a per-operation projection as
a custom route is the one case where `/{service}/{op}` patterns
appear, subject to the same collision rule.)
- Are **not versioned** by `to_openapi` (ADR-045 versions the gateway
contract, not custom routes). The deployment versions its own custom
routes however it wants.

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-30
last_updated: 2026-07-01
---
# WebSocket — the Browser Bidirectional Path
@@ -122,10 +122,16 @@ mechanism of ADR-046, but a deployment that passes no custom routes gets
`/alknet/call`). The path must not collide with the reserved
gateway/`/healthz`/`/openapi.json`/MCP/custom-route paths per ADR-046's
collision rule; `/alknet/call` namespaces away from the reserved set
naturally. The upgrade runs over HTTP/1.1 (the standard `Upgrade: websocket`
header, RFC 6455) or HTTP/2 (the extended CONNECT protocol, RFC 8441);
axum/hyper supports both, and the handler does not branch on which — the
WS frame stream is the same once the upgrade completes.
naturally. A deployment that builds a custom REST projection with
`POST /{service}/{op}` routes (ADR-047 §4) coexists with the WS upgrade
at `/alknet/call` — axum's `Router::merge` prioritizes specific routes
over wildcards, so the WS upgrade's exact `/alknet/call` path wins over
any `/{service}/{op}` wildcard a custom route projection might
register, and the two do not collide. The upgrade runs over HTTP/1.1
(the standard `Upgrade: websocket` header, RFC 6455) or HTTP/2 (the
extended CONNECT protocol, RFC 8441); axum/hyper supports both, and
the handler does not branch on which — the WS frame stream is the same
once the upgrade completes.
### Framing: `EventEnvelope` over binary WS messages