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:
@@ -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
|
||||
|
||||
@@ -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 (~15–30 lines per endpoint), but it is
|
||||
the one place where a divergence bug (identity resolved differently,
|
||||
`OperationContext.internal` set inconsistently, `CallError` mapped
|
||||
asymmetrically) would be a security/correctness issue. The
|
||||
server-integration and wire-framing layers stay per-gateway; a third
|
||||
gateway (GraphQL, gRPC) is not on the horizon, and if one appears its
|
||||
server-integration layer needs its own shape anyway. This is an
|
||||
implementation factoring note, not an ADR — the decision is internal to
|
||||
`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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user