From e0c6f61e6aa3c2d6f9ec153c1a6dc67a43c2841d Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Wed, 1 Jul 2026 05:41:07 +0000 Subject: [PATCH] =?UTF-8?q?docs(http):=20pre-decomposition=20sanity=20chec?= =?UTF-8?q?k=20fixes=20=E2=80=94=20/subscribe=20POST,=20direct-call=20clea?= =?UTF-8?q?nup,=20from=5Fmcp=20output=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../architecture/crates/http/http-adapters.md | 21 +- docs/architecture/crates/http/http-mcp.md | 103 ++- docs/architecture/crates/http/http-server.md | 22 +- docs/architecture/crates/http/websocket.md | 16 +- .../decisions/042-openapi-gateway-pattern.md | 15 +- .../046-assembly-layer-custom-http-routes.md | 7 +- .../047-remove-direct-call-http-surface.md | 2 +- ...48-websocket-native-session-not-gateway.md | 2 +- .../alknet-http-gateway-factoring/findings.md | 613 ++++++++++++++++++ 9 files changed, 770 insertions(+), 31 deletions(-) create mode 100644 docs/research/alknet-http-gateway-factoring/findings.md diff --git a/docs/architecture/crates/http/http-adapters.md b/docs/architecture/crates/http/http-adapters.md index 29a46d5..1ddbab7 100644 --- a/docs/architecture/crates/http/http-adapters.md +++ b/docs/architecture/crates/http/http-adapters.md @@ -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, @@ -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 diff --git a/docs/architecture/crates/http/http-mcp.md b/docs/architecture/crates/http/http-mcp.md index 5951465..4d0c4bc 100644 --- a/docs/architecture/crates/http/http-mcp.md +++ b/docs/architecture/crates/http/http-mcp.md @@ -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`. 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` 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` (always present, defaults to `[]`) and +`structured_content: Option` (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` 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` + `Arc` 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>` (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` and + `structured_content: Option` (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`) \ No newline at end of file + (`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) \ No newline at end of file diff --git a/docs/architecture/crates/http/http-server.md b/docs/architecture/crates/http/http-server.md index 277461b..696ce85 100644 --- a/docs/architecture/crates/http/http-server.md +++ b/docs/architecture/crates/http/http-server.md @@ -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. diff --git a/docs/architecture/crates/http/websocket.md b/docs/architecture/crates/http/websocket.md index 6ba7a04..5b17aa1 100644 --- a/docs/architecture/crates/http/websocket.md +++ b/docs/architecture/crates/http/websocket.md @@ -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 diff --git a/docs/architecture/decisions/042-openapi-gateway-pattern.md b/docs/architecture/decisions/042-openapi-gateway-pattern.md index c2e664a..2c4cc90 100644 --- a/docs/architecture/decisions/042-openapi-gateway-pattern.md +++ b/docs/architecture/decisions/042-openapi-gateway-pattern.md @@ -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). | | `/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. | -| `/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` 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 — 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 `data:` frame, `call.completed` closes the stream, `call.aborted` closes 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 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) The `/search` endpoint's results are filtered by the caller's diff --git a/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md b/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md index c207a6d..776639c 100644 --- a/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md +++ b/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md @@ -154,10 +154,11 @@ it wants. ### 6. This does not change the default surface A deployment that constructs `HttpAdapter` with no extra routes gets -exactly the behavior documented in `http-server.md` — direct-call, -gateway, `/healthz`, `/openapi.json`, MCP (feature-gated), decoy. The +exactly the behavior documented in `http-server.md` — gateway, +`/healthz`, `/openapi.json`, MCP (feature-gated), decoy. 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. ## Consequences diff --git a/docs/architecture/decisions/047-remove-direct-call-http-surface.md b/docs/architecture/decisions/047-remove-direct-call-http-surface.md index 4c98567..63a20cd 100644 --- a/docs/architecture/decisions/047-remove-direct-call-http-surface.md +++ b/docs/architecture/decisions/047-remove-direct-call-http-surface.md @@ -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. The gateway endpoints have fixed methods (ADR-042's table): `/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 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 diff --git a/docs/architecture/decisions/048-websocket-native-session-not-gateway.md b/docs/architecture/decisions/048-websocket-native-session-not-gateway.md index da9caa9..2201369 100644 --- a/docs/architecture/decisions/048-websocket-native-session-not-gateway.md +++ b/docs/architecture/decisions/048-websocket-native-session-not-gateway.md @@ -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) | | 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 | -| 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) | | Multiplexing | HTTP/2 native; HTTP/1.1 sequential | By request ID (ADR-012), not by stream | diff --git a/docs/research/alknet-http-gateway-factoring/findings.md b/docs/research/alknet-http-gateway-factoring/findings.md new file mode 100644 index 0000000..b51947f --- /dev/null +++ b/docs/research/alknet-http-gateway-factoring/findings.md @@ -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 ~15–30 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` + `Arc`, 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>` 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 }`). 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` + (`server.rs:303-309`). `CallToolResult` is `{ content: Vec, + structured_content: Option, is_error: Option, meta: + Option }` (`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` implements +`tower_service::Service>` (`tower.rs:570-594`): + +```rust +impl tower_service::Service> for StreamableHttpService +where + RequestBody: Body + Send + 'static, + S: crate::Service + Send + 'static, + M: SessionManager, + ... +{ + type Response = BoxResponse; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; + fn call(&mut self, req: http::Request) -> 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`; 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`, 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` + `Arc` 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, 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` into +`Result`. 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` in request extensions. The +`to_mcp` `call_tool` handler reads the `Identity` from +`RequestContext.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, + identity_provider: Arc, +} + +impl GatewayDispatch { + /// Resolve a bearer token to an Identity (shared by both gateways' + /// axum auth middleware). + pub fn resolve_bearer(&self, token: &AuthToken) -> Option { + 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, + op: &str, + input: Value, + ) -> ResponseEnvelope { + // build root OperationContext, call self.registry.invoke(op, input, ctx) + // ... + } +} +``` + +Both `to_openapi` and `to_mcp` hold an `Arc` (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 (~15–30 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` (`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::()` 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>` 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` 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) \ No newline at end of file