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 status: draft
last_updated: 2026-06-30 last_updated: 2026-07-01
--- ---
# HTTP Adapters — from_openapi and to_openapi # HTTP Adapters — from_openapi and to_openapi
@@ -50,7 +50,11 @@ impl OperationAdapter for FromOpenAPI {
/// implementation detail (openapiv3::OpenApi, a local alknet-http /// implementation detail (openapiv3::OpenApi, a local alknet-http
/// type, or a serde_json::Value-based parse); the one-way constraint is /// 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 /// 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 struct OpenAPISpec {
pub info: OpenAPIInfo, pub info: OpenAPIInfo,
pub paths: BTreeMap<String, PathItem>, pub paths: BTreeMap<String, PathItem>,
@@ -261,7 +265,7 @@ surface problem).
| `/schema` | `services/schema` | `GET` | Get an operation's full `OperationSpec`. | | `/schema` | `services/schema` | `GET` | Get an operation's full `OperationSpec`. |
| `/call` | `call.requested` (Query/Mutation) | `POST` | Invoke an operation. Flat JSON body `{ operation, input }`. | | `/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 }`. | | `/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 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 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 gateway pattern is the default `to_openapi` projection; the traditional
projection is additive, not a replacement. See ADR-042 §5. 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) ### Error Fidelity (ADR-023)
`from_openapi` maps OpenAPI non-2xx response status codes to `from_openapi` maps OpenAPI non-2xx response status codes to

View File

@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-06-29 last_updated: 2026-07-01
--- ---
# HTTP MCP — from_mcp and to_mcp # HTTP MCP — from_mcp and to_mcp
@@ -84,9 +84,21 @@ The adapter:
extension, a `Subscription` mapping would be added.) extension, a `Subscription` mapping would be added.)
- `spec.visibility` = `Internal` (adapter-registered, ADR-015). - `spec.visibility` = `Internal` (adapter-registered, ADR-015).
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema). - `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
- `spec.output_schema` = the tool's `outputSchema`, or - `spec.output_schema` = depends on whether the tool declares
`Type.Unknown()` if absent (the TS `from_mcp.ts` shows this `outputSchema` (MCP 2025-06-18+):
fallback). - **`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 - `spec.error_schemas` = the MCP tool's error description mapped to
`ErrorDefinition` (ADR-023 — MCP tool definitions carry error `ErrorDefinition` (ADR-023 — MCP tool definitions carry error
descriptions; the adapter maps them). 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 2. Calls `client.call_tool({ name: tool_name, arguments: input })` via
the rmcp client (the `streamable_http.rs` example shows the rmcp client (the `streamable_http.rs` example shows
`client.call_tool(CallToolRequestParams::new(name).with_arguments(...))`). `client.call_tool(CallToolRequestParams::new(name).with_arguments(...))`).
3. On success: extracts `structuredContent` (if present) or maps the 3. On success: extracts the result from the `CallToolResult`, following
`content` blocks (the TS `mapMCPContentBlocks` shows the mapping: the `structuredContent`-preferred-over-content-blocks rule (see
text/image/audio/resource/resource_link → `MCPContentBlock`), "Output handling" below), wraps in a `ResponseEnvelope`, returns.
wraps in a `ResponseEnvelope`, returns.
4. On `result.isError`: maps to a `CallError` with the MCP error content 4. On `result.isError`: maps to a `CallError` with the MCP error content
(the TS `from_mcp.ts` handler shows the error mapping), returns. (the TS `from_mcp.ts` handler shows the error mapping), returns.
5. The rmcp client connection is maintained for the lifetime of the 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 The handler is opaque to the `CallAdapter``Arc<dyn Handler>` the
registry dispatches. `alknet-call` never sees rmcp. 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 ### to_mcp
```rust ```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 `AccessControl` gates `search` results and `call` dispatch — the
LLM sees only what it's authorized to call. 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 ### No-Env-Vars
The `from_mcp` forwarding handler reads the MCP server's bearer token 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 `OperationAdapter` trait, `AdapterError` variants
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP - `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP
transport 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` - `/workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs`
— streamable HTTP MCP server with Bearer auth (the `to_mcp` pattern) — streamable HTTP MCP server with Bearer auth (the `to_mcp` pattern)
- `/workspace/rust-sdk/examples/clients/src/streamable_http.rs` - `/workspace/rust-sdk/examples/clients/src/streamable_http.rs`
streamable HTTP MCP client (the `from_mcp` pattern) streamable HTTP MCP client (the `from_mcp` pattern)
- `/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art - `/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 status: draft
last_updated: 2026-06-30 last_updated: 2026-07-01
--- ---
# HTTP Server # HTTP Server
@@ -117,7 +117,9 @@ contains:
`POST /call` with `{ "operation": "/{service}/{op}", "input": {...} }`, `POST /call` with `{ "operation": "/{service}/{op}", "input": {...} }`,
discovers available operations via `GET /search` discovers available operations via `GET /search`
(`AccessControl`-filtered), and learns an operation's shape via `GET (`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 per-operation `POST /{service}/{op}` direct-call surface — the
gateway is the invoke path (ADR-047 supersedes ADR-036's direct-call gateway is the invoke path (ADR-047 supersedes ADR-036's direct-call
surface; the simplified contract is a few fixed endpoints, not a 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`) ### 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. 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`. - Sets `Content-Type: text/event-stream`.
- For each `call.responded` event, writes an SSE `data:` frame (the - 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) ### Custom routes (ADR-046)
A deployment that needs HTTP endpoints outside the default surface 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 them as an `axum::Router` at `HttpAdapter` construction. The classic use
case: an OpenAI-compatible proxy at `/v1/chat/completions` that wraps a case: an OpenAI-compatible proxy at `/v1/chat/completions` that wraps a
call-protocol operation (the deployment parses the OAI request, invokes 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 different auth applies its own axum middleware (the deployment owns
its custom routes' middleware stack). its custom routes' middleware stack).
- **Do not collide** with the reserved default-surface paths - **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 `/subscribe`, `/healthz`, `/openapi.json`, the MCP route) — the
default surface wins on collision; custom routes namespace away 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 - Are **not versioned** by `to_openapi` (ADR-045 versions the gateway
contract, not custom routes). The deployment versions its own custom contract, not custom routes). The deployment versions its own custom
routes however it wants. routes however it wants.

View File

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

View File

@@ -87,7 +87,7 @@ The gateway endpoint set (initial, two-way-door extensible):
| `/schema` | `services/schema` | `GET` | Get an operation's full `OperationSpec` (input/output JSON Schemas, error schemas). | | `/schema` | `services/schema` | `GET` | Get an operation's full `OperationSpec` (input/output JSON Schemas, error schemas). |
| `/call` | `call.requested` (Query/Mutation) | `POST` | Invoke an operation by name with a JSON input. Returns the output or a typed error (ADR-023). | | `/call` | `call.requested` (Query/Mutation) | `POST` | Invoke an operation by name with a JSON input. Returns the output or a typed error (ADR-023). |
| `/batch` | multiple `call.requested` | `POST` | Invoke multiple operations in one request (correlated request IDs, OQ-14). Returns an array of results. | | `/batch` | multiple `call.requested` | `POST` | Invoke multiple operations in one request (correlated request IDs, OQ-14). Returns an array of results. |
| `/subscribe` | `call.requested` (Subscription) | `GET` (SSE) | Invoke a streaming operation. Returns `text/event-stream` — each `call.responded` is an SSE frame, `call.completed` closes the stream. | | `/subscribe` | `call.requested` (Subscription) | `POST` (SSE) | Invoke a streaming operation. Body `{ operation, input }` (same shape as `/call`); response is `text/event-stream` — each `call.responded` is an SSE frame, `call.completed` closes the stream. |
Five endpoints. The client calls `/search` to find operations, `/schema` Five endpoints. The client calls `/search` to find operations, `/schema`
to learn the input shape, `/call` (or `/subscribe` for streaming) to to learn the input shape, `/call` (or `/subscribe` for streaming) to
@@ -99,13 +99,24 @@ as JSON. No path/query/body split to reverse-engineer.
The OpenAPI gateway includes `subscribe` (which the MCP gateway excludes The OpenAPI gateway includes `subscribe` (which the MCP gateway excludes
— ADR-041, MCP tool calls are request/response). The `subscribe` — ADR-041, MCP tool calls are request/response). The `subscribe`
endpoint maps `Subscription` operations onto SSE: `GET /subscribe` with endpoint maps `Subscription` operations onto SSE: `POST /subscribe` with
a `{ operation, input }` JSON body (same shape as `/call`) and
`Accept: text/event-stream`, each `call.responded` event is an SSE `Accept: text/event-stream`, each `call.responded` event is an SSE
`data:` frame, `call.completed` closes the stream, `call.aborted` closes `data:` frame, `call.completed` closes the stream, `call.aborted` closes
with an error frame. This is the same SSE projection ADR-036 describes with an error frame. This is the same SSE projection ADR-036 describes
for `h2`/`http/1.1` clients — the gateway's `subscribe` endpoint is the for `h2`/`http/1.1` clients — the gateway's `subscribe` endpoint is the
single SSE entry point instead of per-operation SSE streams. single SSE entry point instead of per-operation SSE streams.
`POST` (not `GET`) is used because `/subscribe` is an invoke endpoint
that carries `{ operation, input }` in the request body, the same flat
JSON body shape the rest of the gateway uses. A `GET` request has no
body, so it cannot carry the operation name and input. The SSE response
is negotiated via `Accept: text/event-stream` on the `POST`, not via the
method. (Browsers using `EventSource` cannot `POST`, but browsers use
WebSocket for the bidirectional path — ADR-044; the HTTP gateway's
`/subscribe` is for non-browser HTTP clients, and `fetch` +
`ReadableStream` handles POST-SSE cleanly.)
### 3. The generated OpenAPI doc is per-caller (AccessControl-filtered) ### 3. The generated OpenAPI doc is per-caller (AccessControl-filtered)
The `/search` endpoint's results are filtered by the caller's The `/search` endpoint's results are filtered by the caller's

View File

@@ -154,10 +154,11 @@ it wants.
### 6. This does not change the default surface ### 6. This does not change the default surface
A deployment that constructs `HttpAdapter` with no extra routes gets A deployment that constructs `HttpAdapter` with no extra routes gets
exactly the behavior documented in `http-server.md`direct-call, exactly the behavior documented in `http-server.md`gateway,
gateway, `/healthz`, `/openapi.json`, MCP (feature-gated), decoy. The `/healthz`, `/openapi.json`, MCP (feature-gated), decoy. The
extension point is purely additive. The default surface remains the extension point is purely additive. The default surface remains the
published contract (ADR-036, ADR-042, ADR-045); custom routes are a published contract (ADR-042, ADR-045; ADR-036's routing decision is
superseded by ADR-047); custom routes are a
deployment-specific addition on top, not a modification of it. deployment-specific addition on top, not a modification of it.
## Consequences ## Consequences

View File

@@ -112,7 +112,7 @@ ADR-036's `OperationType` → HTTP method mapping (`Query`→`GET`,
at the HTTP path level, because there are no per-operation HTTP paths. at the HTTP path level, because there are no per-operation HTTP paths.
The gateway endpoints have fixed methods (ADR-042's table): The gateway endpoints have fixed methods (ADR-042's table):
`/search` `GET`, `/schema` `GET`, `/call` `POST`, `/batch` `POST`, `/search` `GET`, `/schema` `GET`, `/call` `POST`, `/batch` `POST`,
`/subscribe` `GET` (SSE). The `OperationType` of the *called operation* `/subscribe` `POST` (SSE). The `OperationType` of the *called operation*
is carried in the request/result, not expressed in the HTTP verb — the is carried in the request/result, not expressed in the HTTP verb — the
client calls `/call` with the operation name; the operation's type is client calls `/call` with the operation name; the operation's type is
the registry's concern, not the HTTP method's. A `Query` operation and a the registry's concern, not the HTTP method's. A `Query` operation and a

View File

@@ -51,7 +51,7 @@ own explicit decision record:
| Invoke shape | `POST /call` with `{ "operation": "/fs/readFile", "input": {...} }` | `call.requested` event with `{ operation, input }` payload (the call protocol's native shape) | | Invoke shape | `POST /call` with `{ "operation": "/fs/readFile", "input": {...} }` | `call.requested` event with `{ operation, input }` payload (the call protocol's native shape) |
| Discovery | `GET /search` (gateway endpoint) | `services/list` as an ordinary call-protocol op | | Discovery | `GET /search` (gateway endpoint) | `services/list` as an ordinary call-protocol op |
| Schema | `GET /schema` (gateway endpoint) | `services/schema` as an ordinary call-protocol op | | Schema | `GET /schema` (gateway endpoint) | `services/schema` as an ordinary call-protocol op |
| Streaming | `GET /subscribe` (SSE frames) | `call.responded` events as binary WS messages (no SSE) | | Streaming | `POST /subscribe` (SSE frames) | `call.responded` events as binary WS messages (no SSE) |
| Dispatcher | axum route handler → `OperationRegistry::invoke()` | shared `Dispatcher` (ADR-012, stream-agnostic) | | Dispatcher | axum route handler → `OperationRegistry::invoke()` | shared `Dispatcher` (ADR-012, stream-agnostic) |
| Multiplexing | HTTP/2 native; HTTP/1.1 sequential | By request ID (ADR-012), not by stream | | Multiplexing | HTTP/2 native; HTTP/1.1 sequential | By request ID (ADR-012), not by stream |

View File

@@ -0,0 +1,613 @@
# Research: alknet-http Gateway Factoring — Shared Dispatch Core vs Copy-Until-Third
**Status**: Complete
**Date**: 2026-07-01
**Scope**: Deep dive — architecture factoring decision for `to_mcp` / `to_openapi`
**Question**: Should the two `to_*` gateway projections share a common dispatch
core now, or remain separate implementations until a third gateway appears?
---
## 1. Summary
**Recommendation: conditional — extract a *thin* shared core now, but do not
build a `GatewayDispatch` trait or a gateway abstraction.**
The two gateways genuinely share a dispatch spine: resolve caller identity
(Bearer → `IdentityProvider::resolve_from_token`) → build a root
`OperationContext``OperationRegistry::invoke()` → return a
`ResponseEnvelope`. That spine is ~1530 lines per gateway endpoint, and it is
*already mostly shared* through `OperationRegistry::invoke()` and the
`services/list` operation (which owns `AccessControl`-filtered listing for both
gateways). What is left to factor is a small `resolve_identity + build_context +
invoke` helper — a free `async fn` or a tiny struct holding
`Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>`, returning a
`ResponseEnvelope`. Both gateways call it; each gateway then maps the
`ResponseEnvelope` to its own wire shape.
What is **not** worth sharing — and what a premature `GatewayDispatch` trait
would wrongly collapse — is the server-integration layer. `to_openapi` is five
axum route handlers (`POST /call`, `GET /search`, …); `to_mcp` is one rmcp
`ServerHandler` impl (`call_tool` / `list_tools`) wrapped by rmcp's
`StreamableHttpService`, a `tower::Service<Request<RequestBody>>` that owns
JSON-RPC framing, session management, SSE priming, and MCP-protocol-version
validation. These two shapes do not share a common trait surface, and forcing
them under one `GatewayDispatch` trait would either leak rmcp's
`CallToolResult`/`RequestContext` types into the shared core (wrong direction —
the core should be neutral) or require an adapter trait so abstract it has no
real methods. The wire-framing, discovery listing, streaming, and versioning
differences are all genuine and all live *outside* the dispatch spine.
The honest read: this is a "copy-until-third" situation *for the
server-integration and wire-framing layers*, and a "share now" situation *for
the dispatch spine*. The dispatch spine is small enough that the duplication
cost of *not* sharing it is also small — but the spine is also the one place
where a divergence bug (one gateway resolving identity differently, or building
`OperationContext` with a different `internal` flag, or mapping `CallError`
inconsistently) would be a real security/correctness issue. That asymmetry —
small to share, costly to diverge — is the case for extracting the thin helper
now and leaving the rest alone.
A third gateway (GraphQL, gRPC) is not on the horizon. If one appears, the
server-integration layer will need its own shape anyway (a GraphQL schema +
resolver tree, a gRPC service impl), and the thin shared spine will absorb
cleanly. Building a `GatewayDispatch` trait now, before a third shape exists to
validate the abstraction, is the classic premature-generalization failure mode.
---
## 2. The Shared Core Hypothesis — What the Two Gateways Genuinely Share
### 2.1 The dispatch spine, traced through both specs
**`to_openapi` `/call`** (`http-server.md:156-184`, `http-adapters.md:256-275`):
1. axum route handler for `POST /call` reads JSON body
`{ "operation": "/fs/readFile", "input": {...} }`.
2. Resolves caller identity from `Authorization: Bearer` header via
`identity_provider.resolve_from_token(&AuthToken { raw: token_bytes })`
(`http-server.md:163-164`; `auth.md:211-214` defines the trait).
3. Constructs the root `OperationContext` (caller identity, registration
bundle's capabilities, connection's env composition) and dispatches through
`OperationRegistry::invoke()` — "the same dispatch path the `CallAdapter`
uses for `alknet/call` wire requests" (`http-server.md:166-168`).
4. The response (`ResponseEnvelope`) is serialized as the HTTP response body
(JSON). Errors map to HTTP status codes (`http-server.md:286-306`).
**`to_mcp` `call` tool** (`http-mcp.md:187-210`, ADR-041 §4 lines 105-113):
1. rmcp `ServerHandler::call_tool` receives `CallToolRequestParams { name,
arguments, .. }` (`server.rs:303-309`; `model.rs:3098-3110`).
2. Auth: the Bearer middleware resolves the token via
`IdentityProvider::resolve_from_token()`, "same as the HTTP server's auth
(ADR-004)" (`http-mcp.md:205-207`). The rmcp example
(`simple_auth_streamhttp.rs:73-89, 147-153`) confirms this is axum
middleware layered *around* the nested `StreamableHttpService`, not inside
it.
3. `call` → "dispatches `OperationRegistry::invoke()` (the same dispatch path
the HTTP server uses, ADR-036)" (`http-mcp.md:199-200`; ADR-041 §4).
4. The result is mapped to an MCP `CallToolResult` (`structuredContent` for the
output, or `isError: true` for a `CallError` with typed `details` per
ADR-023) (`http-mcp.md:200-202`; ADR-041 §4 lines 110-113).
**The shared spine is explicit in both specs.** Both resolve identity the same
way (`resolve_from_token`), both build a root `OperationContext`, both dispatch
through `OperationRegistry::invoke()`, both get back a `ResponseEnvelope`
(`call-protocol.md:491-501`: `ResponseEnvelope { request_id, result:
Result<Value, CallError> }`). The only divergence in the spine itself is the
*output mapping*: `ResponseEnvelope` → HTTP `Response` (JSON body + status
code) vs `ResponseEnvelope` → `CallToolResult` (`structured_content` /
`is_error` / `content`).
### 2.2 `AccessControl`-filtered listing is *already* shared
The hypothesis in the research brief asks whether `AccessControl`-filtered
listing belongs in the shared core or the gateway. The specs answer: it is
already in the shared core — it is the `services/list` operation.
- `OperationRegistry` has built-in `services/list` and `services/schema`
operations (`operation-registry.md:610-642`). `services/list` "only returns
`External` operations to remote callers" and is `AccessControl::check`-
filtered (`operation-registry.md:621`, `client-and-adapters.md:187-196`).
- `to_openapi` `/search` dispatches `services/list` (`http-adapters.md:260`).
- `to_mcp` `search` tool dispatches `services/list` (`http-mcp.md:194-195`,
ADR-041 §1 lines 70-71).
Both gateways invoke the *same operation* for listing. The filtering logic lives
in the `services_list_handler`, not in either gateway. A `GatewayDispatch`
abstraction would not centralize listing — it is already centralized in the
registry. The gateway's only listing-specific job is to frame the
`services/list` result (OpenAPI JSON array vs MCP `ListToolsResult`-shaped
tool-list entries), which is wire framing, not dispatch.
### 2.3 The `OperationContext` construction is shared in *shape*, divergent in *one field*
The root `OperationContext` for a wire-ingress call is built by the dispatch
path with `internal: false` (`operation-registry.md:148-152`:
`internal` is "Set by `OperationEnv::invoke()` (true) or the `CallAdapter`
dispatch path (false) — never by handlers"). Both gateways build a root context
for a wire-ingress call, so both set `internal: false`. There is no
gateway-specific authority switch — the caller's `identity` is the resolved
bearer identity, `handler_identity` comes from the registration bundle,
`forwarded_for: None` (wire-ingress only, `operation-registry.md:180`).
The one field that differs: `request_id`. For `to_openapi` it is generated by
the HTTP handler (or the wire `call.requested` id, if the gateway is framed as
a call); for `to_mcp` it is the rmcp `RequestId` from the JSON-RPC request
(`tool.rs:36`, `tool.rs:206-213` passes `name`/`arguments` but the request id
lives on the `RequestContext`). This is a trivial divergence — a UUID v4 from
`generate_request_id()` (`operation-registry.md:204-223`) works for both. It is
not a factoring blocker.
### 2.4 Error mapping: shared *input*, divergent *output*
Both gateways consume the same `CallError { code, message, retryable, details }`
(`call-protocol.md:496-501`) and map it to their wire shape:
- `to_openapi`: `CallError.code` → HTTP status (`http-server.md:288-306`:
`NOT_FOUND`→404, `FORBIDDEN`→401/403, `INVALID_INPUT`→422, `TIMEOUT`→504,
`INTERNAL`→500, operation-level `http_status` → declared status).
- `to_mcp`: `CallError` → `CallToolResult` with `is_error: Some(true)` and
typed `details` as `structured_content` (ADR-041 §4 lines 110-113;
`model.rs:3014-3039` shows `CallToolResult::structured_error`).
The *input* (`CallError`) is shared; the *output* (HTTP status table vs
`CallToolResult` builder) is gateway-specific. The error-mapping code is ~15
lines per gateway and is genuinely different (an HTTP status is not a
`CallToolResult`). This belongs in each gateway, not in a shared core.
---
## 3. The Divergences That Resist Sharing
Five genuine divergences, each tied to a specific spec/SDK location:
### 3.1 Wire framing (HTTP JSON vs MCP `CallToolResult`)
- `to_openapi` `/call` returns an HTTP `Response` with a JSON body
(`http-server.md:169-171`: "The response (`ResponseEnvelope`) is serialized
as the HTTP response body (JSON)").
- `to_mcp` `call` returns `Result<CallToolResult, McpError>`
(`server.rs:303-309`). `CallToolResult` is `{ content: Vec<ContentBlock>,
structured_content: Option<Value>, is_error: Option<bool>, meta:
Option<Meta> }` (`model.rs:2868-2881`). The success path uses
`CallToolResult::structured(value)` (`model.rs:3006-3013`); the error path
uses `CallToolResult::structured_error` or `CallToolResult::error`
(`model.rs:2984-3039`).
These are different types with different serialization. A shared core that
produced `CallToolResult` would leak rmcp into `to_openapi`; a shared core that
produced HTTP `Response` would be useless to `to_mcp`. The neutral type is
`ResponseEnvelope`, and the `ResponseEnvelope` → wire-shape mapping is the
gateway's job.
### 3.2 Discovery shape (OpenAPI `/search` endpoint vs MCP `tools/list`)
- `to_openapi` exposes a `GET /search` HTTP endpoint
(`http-adapters.md:258-260`) that returns operation names + descriptions as
JSON. The OpenAPI doc *describes* the 5 gateway endpoints
(`http-adapters.md:277-286`); the per-caller operation surface is discovered
via `/search`.
- `to_mcp` exposes a `search` *MCP tool* (`http-mcp.md:167-172`) and relies on
rmcp's `tools/list` (`server.rs:310-316, 541-547`) to advertise the *4 fixed
gateway tools* (`http-mcp.md:189-192`: "On MCP `tools/list`: returns the
fixed gateway tool set (4 tools: `search`, `schema`, `call`, `batch`), not
the registry's operations").
The discovery models are structurally different: OpenAPI's is "one doc + one
`/search` endpoint"; MCP's is "`tools/list` returns the 4 meta-tools, and the
`search` meta-tool returns the registry's operations." A shared discovery
abstraction would have to model both, which is more complexity than the two
separate implementations. The `services/list` operation is the shared backend;
the discovery *framing* is gateway-specific.
### 3.3 Streaming (`/subscribe` SSE vs excluded)
- `to_openapi` includes `/subscribe` (SSE): `GET /subscribe` with
`text/event-stream`, `call.responded` → SSE `data:` frame, `call.completed`
→ stream close (`http-adapters.md:264`, `http-server.md:186-206`, ADR-042 §2).
- `to_mcp` excludes streaming: "MCP tool calls are request/response by
protocol design; streaming subscriptions don't fit the LLM tool-call
pattern" (ADR-041 §2 lines 79-93). `Subscription` operations are filtered
out of `search` results and cannot be invoked via `call`
(`http-mcp.md:179-185`).
This is a one-sided divergence — `to_openapi` has a streaming endpoint,
`to_mcp` does not. A shared core that included streaming would force `to_mcp`
to carry dead code; a shared core that excluded it would force `to_openapi` to
own streaming entirely outside the core. The latter is correct: streaming is
`to_openapi`-specific.
### 3.4 Versioning (`info.version` semver vs none)
- `to_openapi` carries `info.version` (semver) tracking the gateway endpoint
contract (ADR-045 §1: major = breaking gateway change, minor = additive,
patch = wording). Per-caller operation changes do not bump the version
(ADR-045 §1 lines 80-85).
- `to_mcp` has no versioning. The MCP `tools/list` returns the 4 fixed tools;
there is no published-spec version field. (MCP's `protocolVersion` is the
MCP-protocol version, negotiated via `initialize`, not an alknet gateway
contract version — `tower.rs:183-212` validates the
`MCP-Protocol-Version` header.)
Versioning is purely a `to_openapi` concern. It does not belong in a shared
core.
### 3.5 Server integration (axum routes vs rmcp `StreamableHttpService`)
This is the divergence that most constrains the factoring. See §4.
---
## 4. rmcp `StreamableHttpService` Constraints
### 4.1 The tower-service shape
`StreamableHttpService<S, M>` implements
`tower_service::Service<Request<RequestBody>>` (`tower.rs:570-594`):
```rust
impl<RequestBody, S, M> tower_service::Service<Request<RequestBody>> for StreamableHttpService<S, M>
where
RequestBody: Body + Send + 'static,
S: crate::Service<RoleServer> + Send + 'static,
M: SessionManager,
...
{
type Response = BoxResponse;
type Error = Infallible;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn call(&mut self, req: http::Request<RequestBody>) -> Self::Future { ... }
}
```
It is nested into an axum `Router` via `Router::nest_service("/mcp",
mcp_service)` (`simple_auth_streamhttp.rs:147-153`). The service owns, *inside*
its `call` method:
- DNS-rebinding / Host / Origin validation (`tower.rs:411-461, 867-871`).
- `MCP-Protocol-Version` header validation (`tower.rs:183-212, 954, 1084`).
- JSON-RPC message deserialization (`tower.rs:1048-1053`).
- Session management (stateful mode: `create_session`, `has_session`,
`restore_session`, `accept_message` — `tower.rs:1055-1220`; stateless mode:
`serve_directly` — `tower.rs:1221-1302`).
- SSE priming events and keep-alive (`tower.rs:995-1003, 1196-1212`).
- The `initialize` handshake replay for cross-instance session restore
(`tower.rs:703-861`).
- Response framing as SSE or JSON-direct (`tower.rs:1254-1292`,
`json_response` config).
The `to_mcp` gateway does **not** write axum route handlers for
`search`/`schema`/`call`/`batch`. It implements rmcp's `ServerHandler` trait
(`server.rs:424-432`) — specifically `call_tool` (`server.rs:303-309, 533-539`)
and `list_tools` (`server.rs:310-316, 541-547`) — and `StreamableHttpService`
frames the wire. The gateway tools are MCP tools, not HTTP endpoints.
### 4.2 What this means for a shared core
The server-integration shapes are *different abstraction levels*:
- **`to_openapi`**: the gateway *is* the axum route layer. Five `async fn`
handlers, each with axum extractors, each calling the dispatch spine and
mapping to `Response`.
- **`to_mcp`**: the gateway *is* an rmcp `ServerHandler` impl. The axum route
layer is `StreamableHttpService` (rmcp's code), and the gateway's `call_tool`
/ `list_tools` methods are called by rmcp's `serve_directly` /
`serve_server` machinery (`tower.rs:1249, 671`).
A `GatewayDispatch` trait that abstracted over both would need to either:
1. **Be a tower `Service`** — but `to_openapi`'s five route handlers are not
one `Service<Request>`; they are five separate `async fn`s composed by
axum's `Router`. Forcing them into one `Service` would reimplement routing
inside the service, duplicating axum.
2. **Be an async `fn`-shaped trait** (e.g., `async fn dispatch(...) ->
ResponseEnvelope`) — but `to_mcp`'s `call_tool` returns
`Result<CallToolResult, McpError>`, not `ResponseEnvelope`. The trait would
need an associated output type, and each gateway would provide a different
one, at which point the trait has no shared methods and is not an
abstraction.
3. **Produce a neutral `ResponseEnvelope` and let each gateway wrap it** —
this works, but it is not a *trait*; it is a *free function* (or a struct
holding `Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
method like `async fn invoke_as(&self, identity, op, input) ->
ResponseEnvelope`). Both gateways call it as a library function, not through
a polymorphic trait.
Option 3 is the viable one, and it is exactly the "thin shared core" the
recommendation endorses. The shared core is a *library*, not a *trait*. It
produces `ResponseEnvelope` (the neutral type both gateways already consume),
and each gateway owns the `ResponseEnvelope` → wire-shape mapping.
### 4.3 Can a neutral result type feed both axum routes and a tower `Service`?
Yes. The neutral type is `ResponseEnvelope`, which already exists
(`call-protocol.md:491-501`). The flow:
- **Shared core** (a `GatewayDispatch` struct or free `fn`):
`async fn invoke(&self, identity: Option<Identity>, op: &str, input: Value)
-> ResponseEnvelope`. Internally: build root `OperationContext` (`internal:
false`, `identity` from the resolved bearer, `handler_identity` from the
registration, `forwarded_for: None`, fresh `request_id`), call
`OperationRegistry::invoke()`, return the `ResponseEnvelope`.
- **`to_openapi` `/call` handler**: `async fn` with axum extractors → call
shared core → match `ResponseEnvelope.result`: `Ok(v)` → `Json(v)` with 200;
`Err(e)` → map `CallError.code` to HTTP status (`http-server.md:288-306`),
body = `e.details` or error JSON.
- **`to_mcp` `call` tool**: `ServerHandler::call_tool` → dispatch on
`params.name` ("call" → call shared core; "search" → invoke `services/list`;
"schema" → invoke `services/schema`; "batch" → loop) → match
`ResponseEnvelope.result`: `Ok(v)` →
`CallToolResult::structured(v).into_call_tool_result()` (`model.rs:3006`,
`tool.rs:82-86`); `Err(e)` →
`CallToolResult::structured_error(e.details.unwrap_or(json!({}))).
into_call_tool_result()` (`model.rs:3032`, `tool.rs:100-113`).
The `IntoCallToolResult` trait (`tool.rs:78-113`) is the bridge on the `to_mcp`
side — it converts `CallToolResult` / `ErrorData` / `Result<T,E>` into
`Result<CallToolResult, ErrorData>`. The shared core does not need to know
about it; the `to_mcp` gateway calls `.into_call_tool_result()` on the
`CallToolResult` it builds from the `ResponseEnvelope`.
### 4.4 The auth-extraction convergence (a point *for* sharing, not against)
Both gateways resolve the bearer token via axum middleware, not inside the
dispatch logic:
- `to_openapi`: axum middleware extracts `Authorization: Bearer`, calls
`resolve_from_token`, stashes `Identity` in request state for the route
handlers.
- `to_mcp`: the rmcp example (`simple_auth_streamhttp.rs:73-89, 147-153`)
applies axum `middleware::from_fn_with_state` *around* the nested
`StreamableHttpService`. The `to_mcp` spec confirms: "Auth: the Bearer
middleware resolves the token via `IdentityProvider::resolve_from_token()`,
same as the HTTP server's auth (ADR-004)" (`http-mcp.md:205-207`).
This means the *auth middleware* is shareable now — one axum layer that
resolves the bearer and stashes `Option<Identity>` in request extensions. The
`to_mcp` `call_tool` handler reads the `Identity` from
`RequestContext<RoleServer>.extensions` (rmcp injects `http::request::Parts`
into extensions — `tower.rs:487-521, 1086-1097`); the `to_openapi` handler
reads it from axum state/extractors. The *extraction* differs, but the
*resolution* is the same and can be one middleware. This is a second small
shared piece (alongside the dispatch spine).
---
## 5. Recommendation
### 5.1 Share the thin dispatch spine now; do not build a `GatewayDispatch` trait
Extract a small, concrete struct (not a trait) in `alknet-http`:
```rust
/// Shared dispatch spine for the `to_*` gateway projections.
/// Resolves identity, builds a root OperationContext, invokes the registry,
/// returns the neutral ResponseEnvelope. Each gateway maps the envelope to
/// its own wire shape.
pub struct GatewayDispatch {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl GatewayDispatch {
/// Resolve a bearer token to an Identity (shared by both gateways'
/// axum auth middleware).
pub fn resolve_bearer(&self, token: &AuthToken) -> Option<Identity> {
self.identity_provider.resolve_from_token(token)
}
/// Invoke an operation as a wire-ingress caller. `internal: false`,
/// `forwarded_for: None`, fresh request_id. Returns the neutral
/// ResponseEnvelope; the gateway maps it to its wire shape.
pub async fn invoke(
&self,
identity: Option<Identity>,
op: &str,
input: Value,
) -> ResponseEnvelope {
// build root OperationContext, call self.registry.invoke(op, input, ctx)
// ...
}
}
```
Both `to_openapi` and `to_mcp` hold an `Arc<GatewayDispatch>` (or it lives in
axum state / rmcp service state). Each gateway owns:
- **Wire framing**: `ResponseEnvelope` → `axum::Response` (JSON + status) vs
`ResponseEnvelope` → `CallToolResult` (`structured` / `structured_error`).
- **Discovery framing**: `/search` HTTP endpoint vs `tools/list` + `search`
tool.
- **Streaming**: `/subscribe` SSE (`to_openapi` only).
- **Versioning**: `info.version` semver (`to_openapi` only).
- **Server integration**: 5 axum route handlers vs 1 rmcp `ServerHandler` impl
wrapped by `StreamableHttpService`.
The shared core owns:
- Identity resolution (`resolve_bearer` — used by both gateways' axum
middleware).
- Root `OperationContext` construction (`internal: false`, `forwarded_for:
None`, fresh `request_id`, `identity` from the resolved bearer,
`handler_identity` from the registration bundle).
- `OperationRegistry::invoke()` call.
- Returning the neutral `ResponseEnvelope`.
`AccessControl`-filtered listing stays in the `services/list` operation (where
it already is — `operation-registry.md:610-642`). The gateways invoke
`services/list` through the shared core; they do not reimplement filtering.
### 5.2 Why not a `GatewayDispatch` trait
A trait would need an associated output type (HTTP `Response` vs
`CallToolResult`), at which point it has no shared method bodies. Or it would
produce `ResponseEnvelope` — but then it is a struct, not a trait (there is one
implementation). The third gateway, if it ever appears, will have its own
output type (GraphQL response, gRPC message); a trait generalized now over two
output types will almost certainly not generalize over the third without
redesign. A concrete struct with a `ResponseEnvelope`-returning method absorbs
the third gateway cleanly (it calls the same method, maps to its own shape).
The rule of three applies: two gateways do not justify a trait abstraction for
the wire-framing layer. The dispatch spine is shared because it is *the same
code* (not the same shape, different code) — that is a struct, not a trait.
### 5.3 Why share the spine now rather than copy-until-third
The spine is small (~1530 lines per endpoint), so the duplication cost of
copying is also small. The case for sharing now rests on the *asymmetry of
divergence cost*:
- A divergence in identity resolution (one gateway resolves the bearer
differently) is a security bug — the two gateways would enforce different
auth.
- A divergence in `OperationContext` construction (one gateway sets `internal:
true` by mistake, or populates `forwarded_for` from an untrusted source) is
a privilege-escalation or metadata-leak bug.
- A divergence in `invoke()` call shape (one gateway bypasses
`AccessControl`, or skips the reachability check) is an authorization bug.
These are exactly the bugs that are easy to introduce by copy-paste and hard to
catch in review. A shared spine makes the two gateways *provably* identical on
the security-relevant axis (auth, authority, ACL), and lets them diverge only
on the wire-framing axis (where divergence is correct). That is worth the small
extraction cost now.
The wire-framing, discovery, streaming, and versioning layers are *not*
security-relevant in the same way — they are presentation, and copying them
is fine. A divergence in HTTP status mapping is a compatibility bug, not a
security bug.
### 5.4 What this rules out
- **No `GatewayDispatch` trait.** A concrete struct, not a polymorphic trait.
- **No shared `into_wire()` method.** The `ResponseEnvelope` → wire mapping is
per-gateway; do not parameterize the core over it.
- **No shared streaming abstraction.** `/subscribe` SSE is `to_openapi`-only;
do not build a `GatewayStream` trait for one implementation.
- **No shared discovery abstraction.** `services/list` is the shared backend;
the discovery *framing* (OpenAPI `/search` vs MCP `tools/list` + `search`
tool) is per-gateway.
- **No shared versioning.** `info.version` is `to_openapi`-only.
---
## 6. Open Questions (Spike-Needed)
These need a concrete implementation spike to confirm, not just spec reading:
1. **`OperationContext` construction ergonomics.** The root-context
construction is currently specced as living in the `CallAdapter` dispatch
path (`operation-registry.md:148-152`, `call-protocol.md` `build_root_
context`). Extracting it into `GatewayDispatch::invoke` requires either
(a) calling a shared `build_root_context` helper from both `CallAdapter`
and `GatewayDispatch`, or (b) duplicating the construction logic. A spike
should confirm `build_root_context` is reusable as a free function (it
should be — it takes `identity`, `capabilities`, `env`, `deadline` and
returns an `OperationContext`), and that `GatewayDispatch` can call it
without re-implementing the `internal: false` / `forwarded_for: None`
invariants. If `build_root_context` is tangled with `CallAdapter`-specific
state (`PendingRequestMap`, the `Dispatcher`), the extraction is larger
than this research assumes.
2. **rmcp `RequestContext` → `Identity` extraction.** The `to_mcp` `call_tool`
handler receives `RequestContext<RoleServer>` (`server.rs:305`). The
resolved `Identity` needs to flow from the axum auth middleware (which
stashes it in request extensions) into the rmcp handler. rmcp injects
`http::request::Parts` into extensions (`tower.rs:487-521, 1086-1097`), so
the `Identity` (stashed by the axum middleware into `Parts.extensions`)
should be retrievable via `ctx.extensions.get::<Identity>()` inside
`call_tool`. A spike should confirm this extension-survives-the-rmcp-framing
path works end-to-end — it is the load-bearing assumption for sharing the
auth middleware.
3. **`batch` semantics.** Both gateways have a `batch` endpoint/tool
(`http-adapters.md:263`, `http-mcp.md:172`, ADR-041 §1). The spec notes
"correlated request IDs, OQ-14" — OQ-14 is open. The shared core's
`invoke()` is per-operation; `batch` is a loop over `invoke()` in both
gateways. A spike should confirm `batch` is genuinely just a loop (no
shared batch-specific state, no transactional semantics) — if it is, `batch`
stays in each gateway as a thin loop over the shared `invoke`. If OQ-14
resolves to something more structured (atomic batch, partial-failure
semantics), the shared core may need a `invoke_batch` method.
4. **`to_mcp` `search`/`schema` tool dispatch.** The `to_mcp` `call_tool`
handler dispatches on `params.name` (`search`/`schema`/`call`/`batch`).
`search` and `schema` invoke `services/list` / `services/schema` — through
the shared `GatewayDispatch::invoke`, or directly? The shared core's
`invoke()` calls `OperationRegistry::invoke()`, which works for
`services/list` and `services/schema` (they are registered operations). A
spike should confirm the `services/list` handler's `AccessControl`-
filtering works when invoked through `GatewayDispatch::invoke` with the
resolved bearer `Identity` — i.e., that the filtering sees the *caller's*
identity, not a synthetic one. (It should — `services/list` is
`AccessControl::check(identity)`-filtered, and `GatewayDispatch` passes the
resolved identity as the caller.)
5. **`to_openapi` `/subscribe` and the shared core.** `/subscribe` is a
streaming `Subscription` invocation — it produces a *stream* of
`call.responded` events, not a single `ResponseEnvelope`. The shared
`GatewayDispatch::invoke()` returns one `ResponseEnvelope` (the
request/response shape). A spike should confirm `/subscribe` either (a)
calls a different shared method (`invoke_subscribe` → returns a stream), or
(b) is entirely `to_openapi`-specific and does not touch the shared core.
Hypothesis: (b) is cleaner — `/subscribe` is SSE framing over a
`Subscription` invoke, and the `Subscription` invoke path is already in
`OperationRegistry` (the handler returns a stream via the `Handler` type,
`operation-registry.md:94-96`). The shared core stays request/response;
`/subscribe` is `to_openapi`-owned. Confirm with a spike.
---
## References
### alknet specs
- `docs/architecture/crates/http/http-adapters.md` — `to_openapi` spec
(gateway endpoints §254-301, error fidelity §303-340, versioning §378-384)
- `docs/architecture/crates/http/http-mcp.md` — `to_mcp` spec (gateway tools
§162-210, auth §205-207, subscription exclusion §179-185)
- `docs/architecture/crates/http/http-server.md` — `HttpAdapter`, axum router
§86-149, `/call` dispatch §156-184, SSE §186-206, error mapping §286-306
- `docs/architecture/decisions/041-mcp-tool-gateway-pattern.md` — MCP gateway
ADR (4 tools §1, subscription exclusion §2, AccessControl §5)
- `docs/architecture/decisions/042-openapi-gateway-pattern.md` — OpenAPI
gateway ADR (5 endpoints §1, subscribe §2, per-caller §3)
- `docs/architecture/decisions/045-to-openapi-gateway-spec-versioning.md` —
`info.version` semver §1
- `docs/architecture/crates/call/operation-registry.md` —
`OperationRegistry::invoke()` (line 201), `OperationContext` (lines 110-174),
`services/list`/`services/schema` (lines 610-642), `AccessControl` (lines
72-90)
- `docs/architecture/crates/call/client-and-adapters.md` — `OperationAdapter`
trait (lines 397-409), adapter location map (lines 432-455), `to_*` are
projections (lines 427-429)
- `docs/architecture/crates/call/call-protocol.md` — `ResponseEnvelope` /
`CallError` (lines 491-501)
- `docs/architecture/crates/core/auth.md` — `IdentityProvider` trait (lines
211-214), `resolve_from_token` used by HTTP + call (line 218)
### rmcp SDK
- `rust-sdk/crates/rmcp/src/transport/streamable_http_server/tower.rs` —
`StreamableHttpService` (lines 546-594), `Service<Request<RequestBody>>` impl
(lines 570-594), DNS-rebinding/Host/Origin validation (lines 411-461),
MCP-Protocol-Version validation (lines 183-212), session management (lines
1055-1220), stateless mode (lines 1221-1302), `http::request::Parts`
injection into extensions (lines 487-521, 1086-1097)
- `rust-sdk/crates/rmcp/src/handler/server.rs` — `ServerHandler` trait (lines
424-432), `call_tool` (lines 303-309, 533-539), `list_tools` (lines 310-316,
541-547)
- `rust-sdk/crates/rmcp/src/handler/server/tool.rs` — `IntoCallToolResult`
trait (lines 78-113), `ToolCallContext` (lines 33-66), `CallToolHandler`
(lines 151-156)
- `rust-sdk/crates/rmcp/src/model.rs` — `CallToolResult` (lines 2868-2881,
2925-3039: `success`/`error`/`structured`/`structured_error`),
`CallToolRequestParams` (lines 3098-3110)
- `rust-sdk/crates/rmcp/src/service/tower.rs` — `TowerHandler` (lines 8-54),
the rmcp-internal tower-Service-to-`Service<RoleServer>` adapter
- `rust-sdk/examples/servers/src/simple_auth_streamhttp.rs` — axum middleware
around nested `StreamableHttpService` (lines 73-89, 147-153)
### Prior art
- `@alkdev/operations/docs/architecture/adapters.md` — TypeScript prior art
(`from_openapi`, `from_mcp`, `FromSchema`, scanner)