docs(http): decompose alknet-http spec into 19 implementation tasks

Break the alknet-http architecture spec into atomic, dependency-ordered
tasks in tasks/http/, following the taskgraph frontmatter conventions
used by the call/core/vault crates.

Tasks span 7 phases across 5 module subdirectories (server/, gateway/,
client/, adapters/, websocket/):
- Phase 0: crate-init (foundation)
- Phase 1: gateway-dispatch-spine, error-mapping, shared-http-client
  (shared infrastructure)
- Phase 2: http-adapter, bearer-auth-middleware, gateway-endpoints,
  healthz-decoy (HTTP server surface)
- Phase 3: to-openapi (OpenAPI gateway projection)
- Phase 4: from-openapi (OpenAPI adapter, reqwest forwarding)
- Phase 5: dispatcher-transport-abstraction, upgrade-handler,
  connection-overlay (WebSocket browser bidirectional path)
- Phase 6: from-mcp, to-mcp (MCP adapters, feature-gated)
- Phase 7: review-http, review-websocket, review-mcp, review-http-final
  (quality checkpoints)

The gateway-dispatch-spine task implements the thin shared core
recommended by the gateway-factoring research (concrete struct, not a
trait). The dispatcher-transport-abstraction task is a cross-crate
change to alknet-call (exposes EventEnvelope-level dispatch API for
non-QUIC transports) — the highest-risk task. WebTransport/h3 is
deferred per ADR-044 and has no tasks; from_wss is out of scope.

Validated: 19 tasks, no cycles, 8 parallel generations, critical path
length 8 (through the WebSocket strand).
This commit is contained in:
2026-07-01 07:11:17 +00:00
parent e0c6f61e6a
commit e855c8c7eb
19 changed files with 3493 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
---
id: http/adapters/from-mcp
name: Implement from_mcp adapter (rmcp streamable HTTP client, tools/list discovery, structuredContent handling)
status: pending
depends_on: [http/client/shared-http-client, http/gateway/error-mapping]
scope: broad
risk: medium
impact: component
level: implementation
---
## Description
Implement `from_mcp` in `src/adapters/from_mcp.rs` (feature-gated behind
`mcp`). This is the MCP-direction adapter: it discovers remote MCP tools
via the MCP `tools/list` call over streamable HTTP, and registers each
as a `HandlerRegistration` bundle with a forwarding handler that calls
the remote tool via `tools/call`. Uses rmcp's
`StreamableHttpClientTransport` (reqwest-based). Implements
`OperationAdapter` (ADR-017 §5).
### Streamable HTTP only (ADR-037)
MCP defines two transports: streamable HTTP and stdio. **alknet-http
supports only streamable HTTP.** Stdio is not built — it is the spawn-
arbitrary-executable RCE vector that the rest of the architecture is
designed to avoid (ADR-037). The `mcp` feature gate pulls in rmcp with
the streamable HTTP transport features only; the stdio transport
(`transport-child-process`) is not a dependency, not optional, not
behind a separate feature.
### The adapter (http-mcp.md §"from_mcp")
```rust
pub struct FromMCP {
/// The MCP server's streamable HTTP endpoint URL.
endpoint: String,
/// Bearer token for the MCP server (from Capabilities at registration).
auth_token: Option<String>,
/// The importing deployment's name for this MCP server (becomes the
/// operation namespace).
namespace: String,
}
#[async_trait]
impl OperationAdapter for FromMCP {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
```
The adapter:
1. Connects to the MCP server's streamable HTTP endpoint using rmcp's
`StreamableHttpClientTransport::from_uri(endpoint)` (the rmcp
`streamable_http.rs` client example shows the pattern: `client_info
.serve(transport).await`, then `client.list_tools()`,
`client.call_tool()`). On connection failure, returns
`AdapterError::DiscoveryFailed`; on 401, `AdapterError::Unauthorized`.
2. Calls `tools/list` → the list of MCP tools (name, description,
`inputSchema`, optional `outputSchema`).
3. For each tool, constructs a `HandlerRegistration`:
- `spec.name` = the tool name (or `namespace/tool_name` if a
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.visibility` = `Internal` (adapter-registered, ADR-015).
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
- `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.
- **`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>`. 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).
- `spec.access_control` = `AccessControl::default()`.
- `handler` = a forwarding handler (see Forwarding Handler below).
- `provenance` = `FromMCP`, `composition_authority: None`,
`scoped_env: None` (leaf — ADR-022).
- `capabilities` = the bearer token for the MCP server (injected by
the assembly layer at registration — see No-Env-Vars below).
4. Returns the bundles. The caller (the assembly layer) registers them
in the `OperationRegistry`.
### Forwarding handler (http-mcp.md §"Forwarding handler")
At call time, the `from_mcp` forwarding handler:
1. Reads the call input (`serde_json::Value` — the tool arguments).
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 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
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.
### Output handling: structuredContent vs content blocks (http-mcp.md §"Output handling")
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.
### No-Env-Vars (http-mcp.md §"No-Env-Vars")
The `from_mcp` forwarding handler reads the MCP server's bearer token
from `context.capabilities` (the same injection path as `from_openapi`),
not from `std::env::var`. The assembly layer injects the token at
registration; the handler reads it per-call. This is the no-env-vars
invariant (ADR-014).
## Acceptance Criteria
- [ ] `FromMCP` struct with `endpoint`, `auth_token`, `namespace`
- [ ] `OperationAdapter` impl: `async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>`
- [ ] Connects via rmcp `StreamableHttpClientTransport::from_uri(endpoint)`
- [ ] Connection failure → `AdapterError::DiscoveryFailed`
- [ ] 401 → `AdapterError::Unauthorized`
- [ ] Calls `tools/list` → MCP tools (name, description, inputSchema, outputSchema)
- [ ] For each tool: constructs `HandlerRegistration`
- [ ] `spec.name` = tool name (or `namespace/tool_name` with prefix)
- [ ] `spec.namespace` = configured `namespace`
- [ ] `spec.op_type` = `Mutation` (MCP tools are call/response)
- [ ] `spec.visibility` = `Internal` (ADR-015)
- [ ] `spec.input_schema` = tool's `inputSchema`
- [ ] `spec.output_schema` = declared `outputSchema` if present, else `ContentBlock` union
- [ ] `spec.error_schemas` from MCP tool error descriptions (ADR-023)
- [ ] `spec.access_control` = `AccessControl::default()`
- [ ] `provenance` = `FromMCP`, `composition_authority: None`, `scoped_env: None` (ADR-022)
- [ ] `capabilities` = bearer token for MCP server (injected at registration)
- [ ] Forwarding handler calls `client.call_tool({ name, arguments })`
- [ ] `structured_content` present → use as result (validated against `output_schema`)
- [ ] `structured_content` absent → map `content: Vec<ContentBlock>` to `ContentBlock` union
- [ ] No heuristic `JSON.parse` of text blocks (carry as `ContentBlock`)
- [ ] `isError: true``CallError` with MCP error content
- [ ] rmcp client connection maintained for registration lifetime
- [ ] No-env-vars: handler reads `context.capabilities`, never `std::env::var` (ADR-014)
- [ ] Feature-gated behind `mcp` (no compile without `mcp` feature)
- [ ] stdio transport NOT built (ADR-037 — streamable HTTP only)
- [ ] Unit test: `import()` with mock MCP server → `HandlerRegistration` bundles
- [ ] Unit test: `outputSchema` present → `output_schema` = declared schema
- [ ] Unit test: `outputSchema` absent → `output_schema` = `ContentBlock` union
- [ ] Unit test: `structured_content` present → used as result
- [ ] Unit test: `structured_content` absent → `content` blocks mapped to union
- [ ] Unit test: `isError: true``CallError`
- [ ] Integration test: forwarding handler calls remote MCP tool via rmcp
- [ ] Integration test: no `std::env::var` reads in the forwarding handler
- [ ] `cargo test -p alknet-http --features mcp` succeeds
- [ ] `cargo clippy -p alknet-http --features mcp --all-targets` succeeds with no warnings
- [ ] `cargo check -p alknet-http` (no `mcp` feature) succeeds — from_mcp not compiled
## References
- docs/architecture/crates/http/http-mcp.md — from_mcp (full spec)
- docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md — ADR-037 (streamable HTTP only)
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (OperationAdapter)
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (Internal)
- docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md — ADR-022 (leaf)
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (error fidelity)
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014 (no env vars)
- /workspace/rust-sdk/crates/rmcp/src/model.rs — CallToolResult (content + structured_content)
- /workspace/rust-sdk/crates/rmcp/src/model/content.rs — ContentBlock enum
- /workspace/rust-sdk/examples/clients/src/streamable_http.rs — streamable HTTP MCP client pattern
- /workspace/@alkdev/operations/src/from_mcp.ts — TypeScript prior art (mapMCPContentBlocks, structuredContent logic)
## Notes
> from_mcp is feature-gated behind mcp (rmcp dependency). Streamable HTTP
> only — stdio is NOT built (ADR-037). The output handling follows the
> structuredContent-preferred-over-content-blocks rule (same as the TS
> adapter and rmcp's into_typed). The adapter does NOT heuristically
> JSON.parse text blocks — it carries the ContentBlock union as the
> typed result; a downstream consumer that knows the text is JSON can
> parse it. The no-env-vars invariant applies (handler reads
> context.capabilities, not std::env::var). The rmcp client connection
> is maintained for the registration lifetime (persistent streamable HTTP
> endpoint, not per-call). The handler is opaque to CallAdapter
> (Arc<dyn Handler>); alknet-call never sees rmcp.
## Summary
> To be filled on completion

View File

@@ -0,0 +1,242 @@
---
id: http/adapters/from-openapi
name: Implement from_openapi adapter (parse OpenAPI, reqwest forwarding handlers, no-env-vars injection)
status: pending
depends_on: [http/client/shared-http-client, http/gateway/error-mapping]
scope: broad
risk: medium
impact: component
level: implementation
---
## Description
Implement `from_openapi` in `src/adapters/from_openapi.rs`. This is the
OpenAPI-direction adapter: it parses an OpenAPI document, constructs a
`HandlerRegistration` bundle per OpenAPI operation with a forwarding
handler that calls the external HTTP endpoint via `reqwest`, and returns
the bundles for registration in the `OperationRegistry`. The adapter
implements `OperationAdapter` (the async trait from `alknet-call`,
ADR-017 §5).
### The adapter (http-adapters.md §"from_openapi")
```rust
pub struct FromOpenAPI {
spec: OpenAPISpec,
config: HttpServiceConfig,
}
#[async_trait]
impl OperationAdapter for FromOpenAPI {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
```
### Type definitions (http-adapters.md §"Type definitions")
```rust
/// A parsed OpenAPI document. The concrete type is a two-way-door
/// 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 Rust type, but not the
/// same document shape. Coordinate with the to-openapi task on the type.
pub struct OpenAPISpec {
pub info: OpenAPIInfo,
pub paths: BTreeMap<String, PathItem>,
pub components: Option<Components>,
// ... OpenAPI 3.x fields as needed
}
/// Configuration for an HTTP-backed adapter (from_openapi). Carries the
/// base URL, auth scheme (from Capabilities at registration, not env vars),
/// and optional headers. The auth field is the scheme the external API
/// expects; the credential itself is read from
/// OperationContext.capabilities at call time, not stored here.
pub struct HttpServiceConfig {
pub namespace: String,
pub base_url: String,
pub auth: Option<HttpAuthScheme>,
pub default_headers: HashMap<String, String>,
}
pub enum HttpAuthScheme {
Bearer, // Authorization: Bearer <token>
ApiKey { header_name: String }, // e.g., X-API-Key: <key>
Basic, // Authorization: Basic <credentials>
}
```
### The import flow (http-adapters.md §"from_openapi")
The adapter:
1. Parses the OpenAPI document (`OpenAPISpec``paths`, `components`,
`$ref` resolution). On parse failure, returns
`AdapterError::SchemaParse`. The TS prior art
(`@alkdev/operations/src/from_openapi.ts`) shows the parsing patterns:
`resolveRef` for `$ref`, `resolveRefsRecursive` for nested refs,
`buildInputSchema` (parameters + request body → input JSON Schema),
`buildOutputSchema` (200/201 response → output JSON Schema),
`detectOperationType` (SSE response → `Subscription`, GET → `Query`,
else `Mutation`).
2. For each `(path, method, operation)` in `spec.paths`, constructs a
`HandlerRegistration`:
- `spec.name` = the `operationId` (or a generated
`${method}_${path_parts}` name if `operationId` is absent — same
normalization as the TS `normalizeOperationId`).
- `spec.namespace` = the `config.namespace` (the importing
deployment's name for the service, not the OpenAPI doc's `info.title`).
- `spec.op_type` = `Query` / `Mutation` / `Subscription` (detected
from the method + response content type, same as TS).
- `spec.visibility` = `Internal` (adapter-registered ops are
composition material, not directly callable from the wire — ADR-015).
- `spec.input_schema` / `output_schema` = the JSON Schemas built
from the OpenAPI parameters/responses.
- `spec.error_schemas` = the `ErrorDefinition`s built from the
non-2xx OpenAPI responses (ADR-023 §5 — see Error Fidelity below).
- `spec.access_control` = `AccessControl::default()` (the adapter
doesn't declare scopes; the composing handler that reaches the
imported op gates access).
- `handler` = a forwarding handler (see Forwarding Handler below).
- `provenance` = `FromOpenAPI`, `composition_authority: None`,
`scoped_env: None` (leaf — ADR-022).
- `capabilities` = the credentials the forwarding handler needs (the
bearer token / API key for the external HTTP endpoint, injected by
the assembly layer at registration — see No-Env-Vars below).
3. Returns the bundles. The caller (the assembly layer) registers them
in the `OperationRegistry`.
### Forwarding handler (http-adapters.md §"Forwarding handler")
The forwarding handler is the `Arc<dyn Handler>` stored in the
`HandlerRegistration`. At call time, it:
1. Reads the call input (`serde_json::Value`).
2. Builds the outbound HTTP request:
- URL path: substitutes path parameters (`{id}` → input value),
appends query parameters from input fields not in the path.
- Method: the OpenAPI operation's method.
- Headers: `Content-Type: application/json` + the auth header built
from `context.capabilities` (see No-Env-Vars below).
- Body: the `body` field of the input (for `Mutation`/`Subscription`).
3. Sends the request via the shared HTTP client (`SharedHttpClient`
the `shared-http-client` task).
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
binary — same content-type branching as the TS `createHTTPOperation`),
wraps it in a `ResponseEnvelope`, returns.
5. For a `Subscription` (`text/event-stream` response): streams
`call.responded` events as the SSE chunks arrive (same SSE parsing as
the TS `parseSSEFrames`), then `call.completed` on stream end.
6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by
HTTP status code (see Error Fidelity below), returns a `CallError`.
The handler is opaque to the `CallAdapter` — it's an `Arc<dyn Handler>`
the registry dispatches. `alknet-call` never sees `reqwest`.
### No-Env-Vars credential injection (http-adapters.md §"No-Env-Vars credential injection")
The forwarding handler is the **credential injection point** for the
no-env-vars architecture. The handler reads
`context.capabilities.get("<service>")` (e.g., `"openai"`, `"vastai"`,
`"github"`), extracts the credential, and injects it into the outbound
HTTP request:
- Bearer token → `Authorization: Bearer <token>`.
- API key → the header the OpenAPI spec declares (e.g., `X-API-Key:
<key>`, or `Authorization: ApiKey <key>` — the `HTTPServiceConfig.auth`
in the TS prior art shows the three auth types: `bearer`, `apiKey`,
`basic`).
- Basic auth → `Authorization: Basic <credentials>`.
The credential comes from `Capabilities`, which was populated by the
dispatch path from the `HandlerRegistration.capabilities` bundle
(ADR-022 §6), which was populated by the assembly layer from the vault
(ADR-014). The handler never reads `std::env::var`. This is the
spec-level invariant: no handler reads outbound credentials from any
source other than `OperationContext.capabilities`.
### Error Fidelity (ADR-023, http-adapters.md §"Error Fidelity")
`from_openapi` maps OpenAPI non-2xx response status codes to
`ErrorDefinition`s (ADR-023 §5). The normative rule (review #002 W20):
`from_openapi` must not produce error codes that collide with the five
protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
`INTERNAL`, `TIMEOUT`). The adapter prefixes imported error codes with
`HTTP_` and the status number:
```rust
// OpenAPI: 404: { schema: NotFoundError }
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
```
## Acceptance Criteria
- [ ] `FromOpenAPI` struct with `spec: OpenAPISpec`, `config: HttpServiceConfig`
- [ ] `OperationAdapter` impl: `async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>`
- [ ] Parses OpenAPI doc (`paths`, `components`, `$ref` resolution)
- [ ] Parse failure → `AdapterError::SchemaParse`
- [ ] For each `(path, method, operation)`: constructs `HandlerRegistration`
- [ ] `spec.name` = `operationId` (or generated `${method}_${path_parts}`)
- [ ] `spec.namespace` = `config.namespace`
- [ ] `spec.op_type` = `Query`/`Mutation`/`Subscription` (detected from method + response content type)
- [ ] `spec.visibility` = `Internal` (ADR-015)
- [ ] `spec.input_schema` / `output_schema` from OpenAPI parameters/responses
- [ ] `spec.error_schemas` from non-2xx OpenAPI responses with `HTTP_<status>` prefix (ADR-023)
- [ ] `spec.access_control` = `AccessControl::default()`
- [ ] `provenance` = `FromOpenAPI`, `composition_authority: None`, `scoped_env: None` (ADR-022)
- [ ] `capabilities` = credentials for the external endpoint (injected at registration)
- [ ] Forwarding handler builds outbound HTTP request (path params, query, headers, body)
- [ ] Forwarding handler sends via `SharedHttpClient` (the shared client)
- [ ] `Query`/`Mutation`: parses response body (JSON/text/binary), wraps in `ResponseEnvelope`
- [ ] `Subscription` (`text/event-stream`): streams `call.responded` from SSE chunks, then `call.completed`
- [ ] HTTP error (non-2xx): maps to declared `ErrorDefinition` by status code, returns `CallError`
- [ ] No-env-vars: handler reads `context.capabilities.get("<service>")`, never `std::env::var` (ADR-014)
- [ ] Bearer/ApiKey/Basic auth injection from `Capabilities`
- [ ] `HttpAuthScheme` enum with `Bearer`, `ApiKey { header_name }`, `Basic`
- [ ] `HttpServiceConfig` with `namespace`, `base_url`, `auth`, `default_headers`
- [ ] Unit test: parse a minimal OpenAPI doc → one `HandlerRegistration`
- [ ] Unit test: parse failure → `AdapterError::SchemaParse`
- [ ] Unit test: `operationId` absent → generated name `${method}_${path_parts}`
- [ ] Unit test: GET → `Query`, POST → `Mutation`, SSE response → `Subscription`
- [ ] Unit test: error response 404 → `ErrorDefinition { code: "HTTP_404", http_status: Some(404) }`
- [ ] Unit test: forwarding handler injects Bearer token from `context.capabilities`
- [ ] Integration test: forwarding handler calls external endpoint via `SharedHttpClient`
- [ ] Integration test: SSE response streams `call.responded` events
- [ ] Integration test: no `std::env::var` reads in the forwarding handler
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/http/http-adapters.md — from_openapi (full spec)
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (OperationAdapter trait)
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (Internal visibility)
- docs/architecture/decisions/022-handler-registration-provenance-and-composition-authority.md — ADR-022 (leaf provenance)
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (HTTP_<status> prefix, error fidelity)
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014 (no env vars)
- /workspace/@alkdev/operations/src/from_openapi.ts — TypeScript prior art (parsing, SSE, auth headers, createHTTPOperation, parseSSEFrames)
## Notes
> from_openapi is the no-env-vars credential injection point. The
> forwarding handler reads context.capabilities, not std::env::var —
> this is the spec-level invariant (ADR-014). The handler is opaque to
> CallAdapter (Arc<dyn Handler>); alknet-call never sees reqwest. The
> error codes are prefixed HTTP_<status> to avoid collision with
> protocol-level codes (ADR-023, review #002 W20). The OpenAPISpec type
> is shared with to_openapi (coordinate on the type); the shape is not
> (from_openapi consumes per-operation-paths, to_openapi produces the
> 5-endpoint gateway doc). The TS prior art (@alkdev/operations/src/
> from_openapi.ts) shows the parsing patterns (resolveRef,
> buildInputSchema, buildOutputSchema, detectOperationType,
> createHTTPOperation, parseSSEFrames) — the SSE normalization patterns
> stay referenced, the client construction anti-patterns (env-var
> config, hand-rolled retry) are discarded.
## Summary
> To be filled on completion

View File

@@ -0,0 +1,208 @@
---
id: http/adapters/to-mcp
name: Implement to_mcp gateway projection (4-tool gateway, rmcp StreamableHttpService, ADR-041)
status: pending
depends_on: [http/gateway/gateway-dispatch-spine, http/server/bearer-auth-middleware]
scope: broad
risk: medium
impact: component
level: implementation
---
## Description
Implement `to_mcp` in `src/adapters/to_mcp.rs` (feature-gated behind
`mcp`). This is the MCP-direction gateway projection: it exposes the
local registry's `External` operations as a **fixed gateway tool set**
over streamable HTTP — not one MCP tool per operation. This is the
tool-gateway pattern (ADR-041): the LLM has a few tools in context
(search, schema, call, batch), not hundreds, and discovers operations
on demand through the gateway.
### Pure projection (ADR-017 §5)
`to_mcp` is a pure projection — it consumes the registry and does not
produce entries for it. It is not an `OperationAdapter`. An external MCP
client (an editor, an AI tool) discovers and calls alknet operations
through the MCP protocol.
### The gateway tool set (http-mcp.md §"The gateway tool set")
`to_mcp` exposes four MCP tools that gate access to the full operation
registry:
| MCP tool | Call protocol operation | Purpose |
|----------|------------------------|---------|
| `search` | `services/list` | List/search available operations (filtered by the caller's `AccessControl`). Returns names + descriptions, not full schemas. |
| `schema` | `services/schema` | Get an operation's full `OperationSpec` (input/output JSON Schemas, error schemas). |
| `call` | `call.requested` (Query/Mutation) | Invoke an operation by name with a JSON input. Returns the output or a typed error (ADR-023). |
| `batch` | multiple `call.requested` | Invoke multiple operations in one tool call (correlated request IDs, OQ-14). |
The LLM calls `search` to discover operations, `schema` to learn an
operation's input shape, `call` to invoke. Same pattern as
`man <command>` — discover on demand, don't preload. See ADR-041 for
the rationale (the tool-bloat problem).
### `Subscription` exclusion (http-mcp.md §"Subscription exclusion")
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.
### `to_mcp` service behavior (http-mcp.md §"to_mcp service behavior")
1. On MCP `tools/list`: returns the fixed gateway tool set (4 tools:
`search`, `schema`, `call`, `batch`), not the registry's
operations. The gateway tools have stable names and schemas; the
registry's operations are discovered through `search`.
2. On MCP `tools/call`:
- `search` → dispatches `services/list` (filtered by the caller's
`AccessControl`), returns operation names + descriptions.
- `schema` → dispatches `services/schema`, returns the
`OperationSpec`.
- `call` → dispatches `OperationRegistry::invoke()` (via the shared
`GatewayDispatch::invoke()` — the dispatch spine). The result is
mapped to an MCP `CallToolResult` (`structuredContent` for the
output, or `isError: true` for a `CallError` with typed
`details` per ADR-023).
- `batch` → dispatches multiple `call.requested` events, returns
an array of results.
3. Auth: the Bearer middleware resolves the token via
`IdentityProvider::resolve_from_token()`, same as the HTTP server's
auth (ADR-004). The MCP client authenticates by bearer token; no
`PeerId` (browsers and MCP clients are not alknet peers — ADR-034 §4).
`AccessControl` gates `search` results and `call` dispatch — the
LLM sees only what it's authorized to call.
### rmcp integration (http-mcp.md §"to_mcp", research §4)
The rmcp `simple_auth_streamhttp.rs` server example shows the
streamable-HTTP-service-into-axum-`Router` pattern:
```rust
// From the rmcp example:
let mcp_service: StreamableHttpService<Counter, LocalSessionManager> =
StreamableHttpService::new(
|| Ok(Counter::new()),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
);
let protected_mcp_router = Router::new()
.nest_service("/mcp", mcp_service)
.layer(middleware::from_fn_with_state(token_store, auth_middleware));
```
`alknet-http`'s `to_mcp` follows the same axum integration pattern, but
the rmcp `Service` impl is a gateway service (4 fixed tools) rather than
a per-operation tool registry. The `to_mcp` gateway implements rmcp's
`ServerHandler` trait (`call_tool` / `list_tools`) and is wrapped by
`StreamableHttpService` (a `tower::Service<Request<RequestBody>>`).
### Shared dispatch spine with `to_openapi` (http-mcp.md §"Shared dispatch spine")
`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.
The shared spine is the `GatewayDispatch` struct (the
`gateway-dispatch-spine` task). `to_mcp` holds an `Arc<GatewayDispatch>`
(or it lives in the rmcp service state) and calls
`GatewayDispatch::invoke()` for the `call` tool. The
`ResponseEnvelope``CallToolResult` mapping is `to_mcp`-specific.
### Auth: shared middleware (research §4.4)
The Bearer auth middleware (the `bearer-auth-middleware` task) is applied
as an axum layer *around* the nested `StreamableHttpService` (the rmcp
example shows the pattern: `middleware::from_fn_with_state` around
`Router::nest_service`). 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`).
### `CallToolResult` mapping
The `ResponseEnvelope``CallToolResult` mapping uses rmcp's
`IntoCallToolResult` trait (`tool.rs:78-113`):
- `Ok(value)``CallToolResult::structured(value)` (`model.rs:3006`).
- `Err(call_error)``CallToolResult::structured_error(error.details)`
(`model.rs:3032`) or `CallToolResult::error(error_data)`.
## Acceptance Criteria
- [ ] `to_mcp` implements rmcp `ServerHandler` trait (`call_tool`, `list_tools`)
- [ ] `tools/list` returns 4 fixed gateway tools (`search`, `schema`, `call`, `batch`)
- [ ] `tools/list` does NOT return the registry's operations (discovered via `search`)
- [ ] `search` tool → dispatches `services/list` via `GatewayDispatch::invoke`
- [ ] `search` results are `AccessControl::check(identity)`-filtered
- [ ] `search` results are names + descriptions (not full schemas)
- [ ] `Subscription` ops filtered out of `search` results (ADR-041 §2)
- [ ] `schema` tool → dispatches `services/schema` via `GatewayDispatch::invoke`
- [ ] `call` tool → dispatches via `GatewayDispatch::invoke` (shared spine)
- [ ] `call` result → `CallToolResult::structured(value)` for `Ok`
- [ ] `call` error → `CallToolResult::structured_error(details)` for `Err(CallError)`
- [ ] `batch` tool → loop over `GatewayDispatch::invoke`, returns array
- [ ] Bearer auth via shared `bearer_auth_middleware` (applied around `nest_service`)
- [ ] `Identity` read from `RequestContext.extensions` inside `call_tool`
- [ ] MCP client has no `PeerId` (not an alknet peer, ADR-034 §4)
- [ ] `AccessControl` gates `search` results and `call` dispatch
- [ ] `to_mcp` is a pure projection (consumes registry, does not produce entries)
- [ ] `StreamableHttpService` nested into axum `Router` at `/mcp`
- [ ] Feature-gated behind `mcp` (no compile without `mcp` feature)
- [ ] stdio transport NOT built (ADR-037)
- [ ] Unit test: `tools/list` returns exactly 4 gateway tools
- [ ] Unit test: `search` returns AccessControl-filtered ops (no Subscriptions)
- [ ] Unit test: `schema` returns full OperationSpec
- [ ] Unit test: `call``CallToolResult::structured` for success
- [ ] Unit test: `call``CallToolResult::structured_error` for CallError
- [ ] Unit test: `batch` returns array of results
- [ ] Integration test: MCP client calls `search``schema``call` round-trip
- [ ] Integration test: Bearer auth middleware gates `to_mcp` service
- [ ] Integration test: `Identity` survives rmcp framing (research §6 #2)
- [ ] `cargo test -p alknet-http --features mcp` succeeds
- [ ] `cargo clippy -p alknet-http --features mcp --all-targets` succeeds with no warnings
- [ ] `cargo check -p alknet-http` (no `mcp` feature) succeeds — to_mcp not compiled
## References
- docs/architecture/crates/http/http-mcp.md — to_mcp (full spec)
- docs/research/alknet-http-gateway-factoring/findings.md — §4 (rmcp StreamableHttpService constraints), §4.4 (auth middleware sharing)
- docs/architecture/decisions/041-mcp-tool-gateway-pattern.md — ADR-041 (4-tool gateway, Subscription exclusion)
- docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md — ADR-037 (streamable HTTP only)
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (to_* are projections)
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (error fidelity, CallToolResult mapping)
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 §4 (MCP clients are not peers)
- /workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs — axum middleware around nested StreamableHttpService
- /workspace/rust-sdk/crates/rmcp/src/handler/server.rs — ServerHandler trait (call_tool, list_tools)
- /workspace/rust-sdk/crates/rmcp/src/handler/server/tool.rs — IntoCallToolResult trait
- /workspace/rust-sdk/crates/rmcp/src/model.rs — CallToolResult (structured, structured_error)
## Notes
> to_mcp is the 4-tool gateway (ADR-041): search/schema/call/batch, not
> one tool per operation. The LLM discovers operations on demand through
> search + schema, same as man <command>. Subscription ops are excluded
> (MCP tool calls are request/response). The shared dispatch spine
> (GatewayDispatch) is used for the call tool; the ResponseEnvelope →
> CallToolResult mapping is to_mcp-specific. The Bearer auth middleware
> is shared with the HTTP routes (research §4.4 — applied around
> nest_service). The load-bearing assumption is that Identity survives
> the rmcp framing (research §6 #2 — confirm with a spike that
> ctx.extensions.get::<Identity>() works inside call_tool). to_mcp is a
> pure projection (consumes registry, does not produce entries). The mcp
> feature gate is optional; stdio is NOT built (ADR-037).
## Summary
> To be filled on completion

View File

@@ -0,0 +1,188 @@
---
id: http/adapters/to-openapi
name: Implement to_openapi gateway projection (5-endpoint OpenAPI doc, info.version semver, ADR-042/045)
status: pending
depends_on: [http/server/gateway-endpoints, http/gateway/gateway-dispatch-spine]
scope: moderate
risk: medium
impact: component
level: implementation
---
## Description
Implement `to_openapi` in `src/adapters/to_openapi.rs`. This is the
OpenAPI gateway projection: it generates an OpenAPI document with a
**fixed 5-endpoint gateway set** that gates access to the full operation
registry — not one path per operation (ADR-042). The external client (a
code generator, a human developer, a `fetch`-based client) calls
`/search` to discover operations, `/schema` to learn an operation's
input shape, `/call` to invoke. Served at `GET /openapi.json` by the HTTP
server.
### Pure projection (ADR-017 §5)
`to_openapi` is a pure projection — it consumes the registry and produces
a spec. It does not modify the registry; it does not register
operations; it is not an `OperationAdapter`. The HTTP server serves the
generated spec at `GET /openapi.json`.
```rust
/// Generate an OpenAPI document describing the 5 gateway endpoints.
/// Pure projection: consumes the registry, does not produce entries.
/// The per-caller operation surface is discovered via /search, not
/// preloaded into the doc (ADR-042 §3).
pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;
```
### The gateway endpoint set (http-adapters.md §"The gateway endpoint set")
`to_openapi` generates 5 fixed endpoints:
| OpenAPI path | Call protocol | HTTP method | Purpose |
|--------------|--------------|-------------|---------|
| `/search` | `services/list` | `GET` | List/search operations (AccessControl-filtered). Names + descriptions. |
| `/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) | `POST` (SSE) | Invoke a streaming operation. Body `{ operation, input }`; response `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
`OperationSpec`; the gateway wraps it in OpenAPI's schema format without
splitting parameters.
`/subscribe` is the one endpoint the MCP gateway excludes (ADR-041 —
MCP tool calls are request/response). OpenAPI/SSE supports streaming;
the gateway's `/subscribe` uses the SSE projection — `call.responded`
SSE `data:` frames, `call.completed` → stream close.
### Per-caller API surface (http-adapters.md §"Per-caller API surface")
The `/search` endpoint's results are `AccessControl::check(identity)`-
filtered — the client sees only the operations it is authorized to call.
The generated OpenAPI doc describes the 5 gateway endpoints (stable,
same for every caller); the per-caller operation surface is discovered
through `/search`, not preloaded into the doc. This is the key
advantage over a traditional per-operation-paths OpenAPI doc: the
per-caller API surface is the default (the Gitea failure mode — dumping
admin ops to every caller — is structurally impossible).
### `info.version` semver (ADR-045, OQ-39 resolved)
The generated gateway doc carries `info.version` (semver) tracking the
**gateway endpoint contract**, not the operation set — per-caller
operation changes (add/remove/modify, schema changes) do not bump the
version (the operation set is discovered via `/search`, not preloaded
into the doc). Consumers detect breaking changes via the major version.
- **Major** = breaking gateway change (an endpoint removed, a request
field removed, a status code changed meaning).
- **Minor** = additive (a new endpoint, a new optional request field).
- **Patch** = wording (doc clarifications, description tweaks).
The version is a constant in `to_openapi` (bumped manually when the
gateway contract changes), not derived from the registry's operation
set. The initial version is `1.0.0`.
### Error fidelity (ADR-023, http-adapters.md §"Error Fidelity")
`to_openapi` projects `error_schemas` to the gateway endpoint's response
definitions. The `/call` endpoint's responses include the
operation-level errors (mapped by `http_status`), plus the protocol-
level errors:
```yaml
# /call endpoint responses
responses:
'200': { schema: <output_schema for the called operation> }
'400': { schema: <INVALID_INPUT error> }
'401': { schema: <no bearer token> }
'403': { schema: <FORBIDDEN — insufficient scopes> }
'404': { schema: <NOT_FOUND — operation not registered or Internal> }
'422': { schema: <operation-level error with http_status=422> }
'429': { schema: <operation-level error with http_status=429> }
'500': { schema: <INTERNAL> }
'504': { schema: <TIMEOUT> }
```
The operation-level errors (with `http_status`) are surfaced on the
`/call` endpoint's response — the gateway propagates the called
operation's `error_schemas` as response definitions. This makes the
adapter contract from ADR-017 faithful on the error axis — no silent
dropping of error contracts.
### The `OpenAPISpec` type
The concrete type is a two-way-door 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 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. Coordinate with the `from-openapi` task on the shared type.
### Traditional per-operation-paths projection (additive, out of scope)
A deployment that wants a traditional REST OpenAPI doc (per-operation
paths with split parameters) can build it as a separate projection with
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 (ADR-042 §5). This task
implements the gateway projection only; the traditional projection is
out of scope.
## Acceptance Criteria
- [ ] `to_openapi(registry: &OperationRegistry) -> OpenAPISpec` implemented
- [ ] Generates 5 fixed gateway endpoints (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`)
- [ ] No per-operation paths (the gateway is the surface, ADR-042)
- [ ] `/call` request body is flat JSON `{ operation, input }` (no path/query/body split)
- [ ] `/subscribe` response is `text/event-stream`
- [ ] `info.version` is semver tracking the gateway contract (initial `1.0.0`, ADR-045)
- [ ] Per-caller operation surface NOT preloaded into the doc (discovered via `/search`)
- [ ] `/call` responses include protocol-level errors (400, 401, 403, 404, 500, 504)
- [ ] `/call` responses include operation-level errors (mapped by `http_status`, ADR-023)
- [ ] `HTTP_<status>`-prefixed error codes projected correctly (no collision with protocol codes)
- [ ] `to_openapi` is a pure projection (does not modify registry, not an OperationAdapter)
- [ ] `GET /openapi.json` route serves the generated spec (wired by http-adapter task)
- [ ] Unit test: generated doc has exactly 5 paths (the gateway endpoints)
- [ ] Unit test: `/call` request schema is `{ operation: string, input: object }`
- [ ] Unit test: `/subscribe` response content type is `text/event-stream`
- [ ] Unit test: `info.version` is `1.0.0`
- [ ] Unit test: `/call` responses include all protocol-level error statuses
- [ ] Unit test: operation with `error_schemas` → those errors projected on `/call`
- [ ] Unit test: operation with `HTTP_404` error code → projected as 404 response
- [ ] `cargo test -p alknet-http` succeeds
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
## References
- docs/architecture/crates/http/http-adapters.md — to_openapi (§"to_openapi", §"The gateway endpoint set", §"Per-caller API surface", §"Error Fidelity")
- docs/architecture/decisions/042-openapi-gateway-pattern.md — ADR-042 (5 fixed gateway endpoints)
- docs/architecture/decisions/045-to-openapi-gateway-spec-versioning.md — ADR-045 (info.version semver)
- docs/architecture/decisions/047-remove-direct-call-http-surface.md — ADR-047 (gateway is sole invoke path)
- docs/architecture/decisions/023-operation-error-schemas.md — ADR-023 (error fidelity, HTTP_<status> prefix)
- docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md — ADR-017 §5 (to_* are projections)
## Notes
> to_openapi is a pure projection — it consumes the registry, does not
> produce entries. The generated doc describes the 5 fixed gateway
> endpoints (stable, same for every caller); the per-caller operation
> surface is discovered via /search, not preloaded. The info.version
> semver tracks the gateway endpoint contract, not the operation set
> (ADR-045) — per-caller operation changes do not bump the version. The
> error fidelity (ADR-023) projects operation-level errors (with
> http_status) onto /call's responses, plus the protocol-level errors.
> The OpenAPISpec type is shared with from_openapi (coordinate on the
> type); the shape is not (from_openapi consumes per-operation-paths,
> to_openapi produces the 5-endpoint gateway doc). The traditional
> per-operation-paths projection is additive (ADR-042 §5) and out of
> scope.
## Summary
> To be filled on completion