docs(arch): ADR-049 — streaming handler for subscription operations

The call protocol spec describes streaming (call.responded*N +
call.completed, PendingRequestMap::Subscribe, CallConnection::subscribe),
but the server-side Handler type returned a single ResponseEnvelope —
a Subscription op had no way to produce a stream. The TS predecessor
(@alkdev/operations) had separate OperationHandler / SubscriptionHandler
types; the Rust port collapsed them, losing the streaming path. This
restores it end-to-end: StreamingHandler type, HandlerKind on
HandlerRegistration validated against op_type, invoke_streaming() on
OperationRegistry, server-side dispatch branches on op_type, new
INVALID_OPERATION_TYPE protocol code for wrong-dispatch-path misuse,
GatewayDispatch::invoke_streaming() for /subscribe SSE, from_call stream
forwarding via CallConnection::subscribe(), from_openapi SSE forwarding.
OperationEnv::invoke() stays request/response-only (stream composition is
handler-level, not protocol-level). Amends ADR-023's protocol-code list
(five → six). Tracks the stream-operators library as OQ-41 (feature
extension, not an unmade decision).
This commit is contained in:
2026-07-02 07:43:01 +00:00
parent 139c651eaa
commit 7ecc11610a
10 changed files with 602 additions and 76 deletions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-01
last_updated: 2026-07-02
---
# HTTP MCP — from_mcp and to_mcp
@@ -78,10 +78,12 @@ The adapter:
namespace prefix is configured — same local-naming sugar as
`from_call`'s `FromCallConfig::namespace_prefix`, ADR-029 §5).
- `spec.namespace` = the configured `namespace`.
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
spec doesn't have a native streaming/tool-subscription distinction
`tools/call` returns a result. If MCP adds a streaming-tool
extension, a `Subscription` mapping would be added.)
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
spec doesn't have a native streaming/tool-subscription distinction
`tools/call` returns a result. If MCP adds a streaming-tool
extension, a `Subscription` mapping would be added.) All `from_mcp`
handlers are `HandlerKind::Once` (ADR-049); `from_mcp` never
produces a `StreamingHandler`.
- `spec.visibility` = `Internal` (adapter-registered, ADR-015).
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
- `spec.output_schema` = depends on whether the tool declares
@@ -128,8 +130,9 @@ At call time, the `from_mcp` forwarding handler:
registration (the MCP server is a persistent streamable HTTP
endpoint, not a per-call connection).
The handler is opaque to the `CallAdapter``Arc<dyn Handler>` the
registry dispatches. `alknet-call` never sees rmcp.
The handler is opaque to the `CallAdapter`a `HandlerKind::Once`
wrapping an `Arc<dyn Handler>` that the registry dispatches. `alknet-call`
never sees rmcp.
### Output handling (structuredContent vs content blocks)
@@ -222,7 +225,12 @@ The gateway exposes only `Query` and `Mutation` operations
(request/response). `Subscription` operations (streaming) are filtered
out of `search` results and cannot be invoked via `call` — MCP tool
calls are request/response by protocol design; streaming subscriptions
don't fit the LLM tool-call pattern. See ADR-041 §2.
don't fit the LLM tool-call pattern. This is unaffected by ADR-049
(streaming handlers): the `StreamingHandler` type and `invoke_streaming()`
dispatch path exist in `alknet-call` and are used by `to_openapi`'s
`/subscribe` endpoint, but `to_mcp` does not expose them — it filters by
`op_type` and only dispatches `Query`/`Mutation` via `invoke()`. See
ADR-041 §2.
#### `to_mcp` service behavior
@@ -263,19 +271,19 @@ 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.
extracting a **thin shared spine** (the concrete `GatewayDispatch` struct
holding `Arc<OperationRegistry>` + `Arc<dyn IdentityProvider>` with a
`resolve + build_context + invoke` method returning a `ResponseEnvelope`,
named in ADR-049 and extended with `invoke_streaming()` for the streaming
path), **not** a 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
@@ -340,6 +348,7 @@ every other HTTP request.
| Error fidelity | [ADR-023](../../decisions/023-operation-error-schemas.md) | MCP tool errors mapped to `ErrorDefinition`s |
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
| MCP clients are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_mcp` handlers are always `HandlerKind::Once` (MCP tools are request/response); `to_mcp` excludes `Subscription` ops (unchanged by the streaming handler) |
## Open Questions