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:
226
tasks/http/adapters/from-mcp.md
Normal file
226
tasks/http/adapters/from-mcp.md
Normal 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
|
||||
242
tasks/http/adapters/from-openapi.md
Normal file
242
tasks/http/adapters/from-openapi.md
Normal 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
|
||||
208
tasks/http/adapters/to-mcp.md
Normal file
208
tasks/http/adapters/to-mcp.md
Normal 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
|
||||
188
tasks/http/adapters/to-openapi.md
Normal file
188
tasks/http/adapters/to-openapi.md
Normal 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
|
||||
Reference in New Issue
Block a user