Three issues found in the http crate spec sanity check that would have
caused problems during task decomposition, now fixed:
C1 — /subscribe GET→POST: the gateway's /subscribe is an invoke endpoint
carrying { operation, input } in the body, but was listed as GET (which
has no body). Flipped to POST with Accept: text/event-stream negotiating
the SSE response, consistent with /call's flat-JSON-body invariant.
Browsers using EventSource can't POST but use WebSocket for the
bidirectional path; the HTTP gateway's /subscribe is for non-browser
HTTP clients (fetch + ReadableStream). Touches ADR-042, ADR-047,
ADR-048, http-adapters.md, http-server.md.
C2 — stale direct-call references: three spots contradicted ADR-047
(which removed the POST /{service}/{op} direct-call surface) and
ADR-046 §3 (which states /{service}/{op} is no longer reserved).
Cleaned up in http-server.md (custom-routes intro + collision list) and
ADR-046 §6 (default-surface list).
W2 — from_mcp output handling: the spec's fallback for tools without
outputSchema was Type.Unknown(), but the correct fallback is the MCP
ContentBlock union (text|image|audio|resource|resource_link) — a
well-defined MCP type, not Unknown. Fixed http-mcp.md with the full
structuredContent-preferred-over-content-blocks logic (matching the TS
adapter and rmcp SDK), enriched references with specific rmcp source
files. Also added shared-dispatch-spine notes to http-mcp.md and
http-adapters.md cross-referencing the new research findings.
Research (docs/research/alknet-http-gateway-factoring/findings.md):
to_mcp and to_openapi share a dispatch spine (resolve → invoke → map).
Recommendation: extract a thin shared struct now, not a GatewayDispatch
trait — the server-integration layers (axum routes vs rmcp
StreamableHttpService) and wire-framing stay per-gateway. A third
gateway is not on the horizon; if one appears its server-integration
needs its own shape anyway.
Minor: WS route precedence note (websocket.md), OpenAPISpec
shared-type-not-shape clarification (http-adapters.md), date bumps.
32 KiB
Research: alknet-http Gateway Factoring — Shared Dispatch Core vs Copy-Until-Third
Status: Complete
Date: 2026-07-01
Scope: Deep dive — architecture factoring decision for to_mcp / to_openapi
Question: Should the two to_* gateway projections share a common dispatch
core now, or remain separate implementations until a third gateway appears?
1. Summary
Recommendation: conditional — extract a thin shared core now, but do not
build a GatewayDispatch trait or a gateway abstraction.
The two gateways genuinely share a dispatch spine: resolve caller identity
(Bearer → IdentityProvider::resolve_from_token) → build a root
OperationContext → OperationRegistry::invoke() → return a
ResponseEnvelope. That spine is ~15–30 lines per gateway endpoint, and it is
already mostly shared through OperationRegistry::invoke() and the
services/list operation (which owns AccessControl-filtered listing for both
gateways). What is left to factor is a small resolve_identity + build_context + invoke helper — a free async fn or a tiny struct holding
Arc<OperationRegistry> + Arc<dyn IdentityProvider>, returning a
ResponseEnvelope. Both gateways call it; each gateway then maps the
ResponseEnvelope to its own wire shape.
What is not worth sharing — and what a premature GatewayDispatch trait
would wrongly collapse — is the server-integration layer. to_openapi is five
axum route handlers (POST /call, GET /search, …); to_mcp is one rmcp
ServerHandler impl (call_tool / list_tools) wrapped by rmcp's
StreamableHttpService, a tower::Service<Request<RequestBody>> that owns
JSON-RPC framing, session management, SSE priming, and MCP-protocol-version
validation. These two shapes do not share a common trait surface, and forcing
them under one GatewayDispatch trait would either leak rmcp's
CallToolResult/RequestContext types into the shared core (wrong direction —
the core should be neutral) or require an adapter trait so abstract it has no
real methods. The wire-framing, discovery listing, streaming, and versioning
differences are all genuine and all live outside the dispatch spine.
The honest read: this is a "copy-until-third" situation for the
server-integration and wire-framing layers, and a "share now" situation for
the dispatch spine. The dispatch spine is small enough that the duplication
cost of not sharing it is also small — but the spine is also the one place
where a divergence bug (one gateway resolving identity differently, or building
OperationContext with a different internal flag, or mapping CallError
inconsistently) would be a real security/correctness issue. That asymmetry —
small to share, costly to diverge — is the case for extracting the thin helper
now and leaving the rest alone.
A third gateway (GraphQL, gRPC) is not on the horizon. If one appears, the
server-integration layer will need its own shape anyway (a GraphQL schema +
resolver tree, a gRPC service impl), and the thin shared spine will absorb
cleanly. Building a GatewayDispatch trait now, before a third shape exists to
validate the abstraction, is the classic premature-generalization failure mode.
2. The Shared Core Hypothesis — What the Two Gateways Genuinely Share
2.1 The dispatch spine, traced through both specs
to_openapi /call (http-server.md:156-184, http-adapters.md:256-275):
- axum route handler for
POST /callreads JSON body{ "operation": "/fs/readFile", "input": {...} }. - Resolves caller identity from
Authorization: Bearerheader viaidentity_provider.resolve_from_token(&AuthToken { raw: token_bytes })(http-server.md:163-164;auth.md:211-214defines the trait). - Constructs the root
OperationContext(caller identity, registration bundle's capabilities, connection's env composition) and dispatches throughOperationRegistry::invoke()— "the same dispatch path theCallAdapteruses foralknet/callwire requests" (http-server.md:166-168). - The response (
ResponseEnvelope) is serialized as the HTTP response body (JSON). Errors map to HTTP status codes (http-server.md:286-306).
to_mcp call tool (http-mcp.md:187-210, ADR-041 §4 lines 105-113):
- rmcp
ServerHandler::call_toolreceivesCallToolRequestParams { name, arguments, .. }(server.rs:303-309;model.rs:3098-3110). - Auth: the Bearer middleware resolves the token via
IdentityProvider::resolve_from_token(), "same as the HTTP server's auth (ADR-004)" (http-mcp.md:205-207). The rmcp example (simple_auth_streamhttp.rs:73-89, 147-153) confirms this is axum middleware layered around the nestedStreamableHttpService, not inside it. call→ "dispatchesOperationRegistry::invoke()(the same dispatch path the HTTP server uses, ADR-036)" (http-mcp.md:199-200; ADR-041 §4).- The result is mapped to an MCP
CallToolResult(structuredContentfor the output, orisError: truefor aCallErrorwith typeddetailsper ADR-023) (http-mcp.md:200-202; ADR-041 §4 lines 110-113).
The shared spine is explicit in both specs. Both resolve identity the same
way (resolve_from_token), both build a root OperationContext, both dispatch
through OperationRegistry::invoke(), both get back a ResponseEnvelope
(call-protocol.md:491-501: ResponseEnvelope { request_id, result: Result<Value, CallError> }). The only divergence in the spine itself is the
output mapping: ResponseEnvelope → HTTP Response (JSON body + status
code) vs ResponseEnvelope → CallToolResult (structured_content /
is_error / content).
2.2 AccessControl-filtered listing is already shared
The hypothesis in the research brief asks whether AccessControl-filtered
listing belongs in the shared core or the gateway. The specs answer: it is
already in the shared core — it is the services/list operation.
OperationRegistryhas built-inservices/listandservices/schemaoperations (operation-registry.md:610-642).services/list"only returnsExternaloperations to remote callers" and isAccessControl::check- filtered (operation-registry.md:621,client-and-adapters.md:187-196).to_openapi/searchdispatchesservices/list(http-adapters.md:260).to_mcpsearchtool dispatchesservices/list(http-mcp.md:194-195, ADR-041 §1 lines 70-71).
Both gateways invoke the same operation for listing. The filtering logic lives
in the services_list_handler, not in either gateway. A GatewayDispatch
abstraction would not centralize listing — it is already centralized in the
registry. The gateway's only listing-specific job is to frame the
services/list result (OpenAPI JSON array vs MCP ListToolsResult-shaped
tool-list entries), which is wire framing, not dispatch.
2.3 The OperationContext construction is shared in shape, divergent in one field
The root OperationContext for a wire-ingress call is built by the dispatch
path with internal: false (operation-registry.md:148-152:
internal is "Set by OperationEnv::invoke() (true) or the CallAdapter
dispatch path (false) — never by handlers"). Both gateways build a root context
for a wire-ingress call, so both set internal: false. There is no
gateway-specific authority switch — the caller's identity is the resolved
bearer identity, handler_identity comes from the registration bundle,
forwarded_for: None (wire-ingress only, operation-registry.md:180).
The one field that differs: request_id. For to_openapi it is generated by
the HTTP handler (or the wire call.requested id, if the gateway is framed as
a call); for to_mcp it is the rmcp RequestId from the JSON-RPC request
(tool.rs:36, tool.rs:206-213 passes name/arguments but the request id
lives on the RequestContext). This is a trivial divergence — a UUID v4 from
generate_request_id() (operation-registry.md:204-223) works for both. It is
not a factoring blocker.
2.4 Error mapping: shared input, divergent output
Both gateways consume the same CallError { code, message, retryable, details }
(call-protocol.md:496-501) and map it to their wire shape:
to_openapi:CallError.code→ HTTP status (http-server.md:288-306:NOT_FOUND→404,FORBIDDEN→401/403,INVALID_INPUT→422,TIMEOUT→504,INTERNAL→500, operation-levelhttp_status→ declared status).to_mcp:CallError→CallToolResultwithis_error: Some(true)and typeddetailsasstructured_content(ADR-041 §4 lines 110-113;model.rs:3014-3039showsCallToolResult::structured_error).
The input (CallError) is shared; the output (HTTP status table vs
CallToolResult builder) is gateway-specific. The error-mapping code is ~15
lines per gateway and is genuinely different (an HTTP status is not a
CallToolResult). This belongs in each gateway, not in a shared core.
3. The Divergences That Resist Sharing
Five genuine divergences, each tied to a specific spec/SDK location:
3.1 Wire framing (HTTP JSON vs MCP CallToolResult)
to_openapi/callreturns an HTTPResponsewith a JSON body (http-server.md:169-171: "The response (ResponseEnvelope) is serialized as the HTTP response body (JSON)").to_mcpcallreturnsResult<CallToolResult, McpError>(server.rs:303-309).CallToolResultis{ content: Vec<ContentBlock>, structured_content: Option<Value>, is_error: Option<bool>, meta: Option<Meta> }(model.rs:2868-2881). The success path usesCallToolResult::structured(value)(model.rs:3006-3013); the error path usesCallToolResult::structured_errororCallToolResult::error(model.rs:2984-3039).
These are different types with different serialization. A shared core that
produced CallToolResult would leak rmcp into to_openapi; a shared core that
produced HTTP Response would be useless to to_mcp. The neutral type is
ResponseEnvelope, and the ResponseEnvelope → wire-shape mapping is the
gateway's job.
3.2 Discovery shape (OpenAPI /search endpoint vs MCP tools/list)
to_openapiexposes aGET /searchHTTP endpoint (http-adapters.md:258-260) that returns operation names + descriptions as JSON. The OpenAPI doc describes the 5 gateway endpoints (http-adapters.md:277-286); the per-caller operation surface is discovered via/search.to_mcpexposes asearchMCP tool (http-mcp.md:167-172) and relies on rmcp'stools/list(server.rs:310-316, 541-547) to advertise the 4 fixed gateway tools (http-mcp.md:189-192: "On MCPtools/list: returns the fixed gateway tool set (4 tools:search,schema,call,batch), not the registry's operations").
The discovery models are structurally different: OpenAPI's is "one doc + one
/search endpoint"; MCP's is "tools/list returns the 4 meta-tools, and the
search meta-tool returns the registry's operations." A shared discovery
abstraction would have to model both, which is more complexity than the two
separate implementations. The services/list operation is the shared backend;
the discovery framing is gateway-specific.
3.3 Streaming (/subscribe SSE vs excluded)
to_openapiincludes/subscribe(SSE):GET /subscribewithtext/event-stream,call.responded→ SSEdata:frame,call.completed→ stream close (http-adapters.md:264,http-server.md:186-206, ADR-042 §2).to_mcpexcludes streaming: "MCP tool calls are request/response by protocol design; streaming subscriptions don't fit the LLM tool-call pattern" (ADR-041 §2 lines 79-93).Subscriptionoperations are filtered out ofsearchresults and cannot be invoked viacall(http-mcp.md:179-185).
This is a one-sided divergence — to_openapi has a streaming endpoint,
to_mcp does not. A shared core that included streaming would force to_mcp
to carry dead code; a shared core that excluded it would force to_openapi to
own streaming entirely outside the core. The latter is correct: streaming is
to_openapi-specific.
3.4 Versioning (info.version semver vs none)
to_openapicarriesinfo.version(semver) tracking the gateway endpoint contract (ADR-045 §1: major = breaking gateway change, minor = additive, patch = wording). Per-caller operation changes do not bump the version (ADR-045 §1 lines 80-85).to_mcphas no versioning. The MCPtools/listreturns the 4 fixed tools; there is no published-spec version field. (MCP'sprotocolVersionis the MCP-protocol version, negotiated viainitialize, not an alknet gateway contract version —tower.rs:183-212validates theMCP-Protocol-Versionheader.)
Versioning is purely a to_openapi concern. It does not belong in a shared
core.
3.5 Server integration (axum routes vs rmcp StreamableHttpService)
This is the divergence that most constrains the factoring. See §4.
4. rmcp StreamableHttpService Constraints
4.1 The tower-service shape
StreamableHttpService<S, M> implements
tower_service::Service<Request<RequestBody>> (tower.rs:570-594):
impl<RequestBody, S, M> tower_service::Service<Request<RequestBody>> for StreamableHttpService<S, M>
where
RequestBody: Body + Send + 'static,
S: crate::Service<RoleServer> + Send + 'static,
M: SessionManager,
...
{
type Response = BoxResponse;
type Error = Infallible;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn call(&mut self, req: http::Request<RequestBody>) -> Self::Future { ... }
}
It is nested into an axum Router via Router::nest_service("/mcp", mcp_service) (simple_auth_streamhttp.rs:147-153). The service owns, inside
its call method:
- DNS-rebinding / Host / Origin validation (
tower.rs:411-461, 867-871). MCP-Protocol-Versionheader validation (tower.rs:183-212, 954, 1084).- JSON-RPC message deserialization (
tower.rs:1048-1053). - Session management (stateful mode:
create_session,has_session,restore_session,accept_message—tower.rs:1055-1220; stateless mode:serve_directly—tower.rs:1221-1302). - SSE priming events and keep-alive (
tower.rs:995-1003, 1196-1212). - The
initializehandshake replay for cross-instance session restore (tower.rs:703-861). - Response framing as SSE or JSON-direct (
tower.rs:1254-1292,json_responseconfig).
The to_mcp gateway does not write axum route handlers for
search/schema/call/batch. It implements rmcp's ServerHandler trait
(server.rs:424-432) — specifically call_tool (server.rs:303-309, 533-539)
and list_tools (server.rs:310-316, 541-547) — and StreamableHttpService
frames the wire. The gateway tools are MCP tools, not HTTP endpoints.
4.2 What this means for a shared core
The server-integration shapes are different abstraction levels:
to_openapi: the gateway is the axum route layer. Fiveasync fnhandlers, each with axum extractors, each calling the dispatch spine and mapping toResponse.to_mcp: the gateway is an rmcpServerHandlerimpl. The axum route layer isStreamableHttpService(rmcp's code), and the gateway'scall_tool/list_toolsmethods are called by rmcp'sserve_directly/serve_servermachinery (tower.rs:1249, 671).
A GatewayDispatch trait that abstracted over both would need to either:
- Be a tower
Service— butto_openapi's five route handlers are not oneService<Request>; they are five separateasync fns composed by axum'sRouter. Forcing them into oneServicewould reimplement routing inside the service, duplicating axum. - Be an async
fn-shaped trait (e.g.,async fn dispatch(...) -> ResponseEnvelope) — butto_mcp'scall_toolreturnsResult<CallToolResult, McpError>, notResponseEnvelope. The trait would need an associated output type, and each gateway would provide a different one, at which point the trait has no shared methods and is not an abstraction. - Produce a neutral
ResponseEnvelopeand let each gateway wrap it — this works, but it is not a trait; it is a free function (or a struct holdingArc<OperationRegistry>+Arc<dyn IdentityProvider>with a method likeasync fn invoke_as(&self, identity, op, input) -> ResponseEnvelope). Both gateways call it as a library function, not through a polymorphic trait.
Option 3 is the viable one, and it is exactly the "thin shared core" the
recommendation endorses. The shared core is a library, not a trait. It
produces ResponseEnvelope (the neutral type both gateways already consume),
and each gateway owns the ResponseEnvelope → wire-shape mapping.
4.3 Can a neutral result type feed both axum routes and a tower Service?
Yes. The neutral type is ResponseEnvelope, which already exists
(call-protocol.md:491-501). The flow:
- Shared core (a
GatewayDispatchstruct or freefn):async fn invoke(&self, identity: Option<Identity>, op: &str, input: Value) -> ResponseEnvelope. Internally: build rootOperationContext(internal: false,identityfrom the resolved bearer,handler_identityfrom the registration,forwarded_for: None, freshrequest_id), callOperationRegistry::invoke(), return theResponseEnvelope. to_openapi/callhandler:async fnwith axum extractors → call shared core → matchResponseEnvelope.result:Ok(v)→Json(v)with 200;Err(e)→ mapCallError.codeto HTTP status (http-server.md:288-306), body =e.detailsor error JSON.to_mcpcalltool:ServerHandler::call_tool→ dispatch onparams.name("call" → call shared core; "search" → invokeservices/list; "schema" → invokeservices/schema; "batch" → loop) → matchResponseEnvelope.result:Ok(v)→CallToolResult::structured(v).into_call_tool_result()(model.rs:3006,tool.rs:82-86);Err(e)→CallToolResult::structured_error(e.details.unwrap_or(json!({}))). into_call_tool_result()(model.rs:3032,tool.rs:100-113).
The IntoCallToolResult trait (tool.rs:78-113) is the bridge on the to_mcp
side — it converts CallToolResult / ErrorData / Result<T,E> into
Result<CallToolResult, ErrorData>. The shared core does not need to know
about it; the to_mcp gateway calls .into_call_tool_result() on the
CallToolResult it builds from the ResponseEnvelope.
4.4 The auth-extraction convergence (a point for sharing, not against)
Both gateways resolve the bearer token via axum middleware, not inside the dispatch logic:
to_openapi: axum middleware extractsAuthorization: Bearer, callsresolve_from_token, stashesIdentityin request state for the route handlers.to_mcp: the rmcp example (simple_auth_streamhttp.rs:73-89, 147-153) applies axummiddleware::from_fn_with_statearound the nestedStreamableHttpService. Theto_mcpspec confirms: "Auth: the Bearer middleware resolves the token viaIdentityProvider::resolve_from_token(), same as the HTTP server's auth (ADR-004)" (http-mcp.md:205-207).
This means the auth middleware is shareable now — one axum layer that
resolves the bearer and stashes Option<Identity> in request extensions. The
to_mcp call_tool handler reads the Identity from
RequestContext<RoleServer>.extensions (rmcp injects http::request::Parts
into extensions — tower.rs:487-521, 1086-1097); the to_openapi handler
reads it from axum state/extractors. The extraction differs, but the
resolution is the same and can be one middleware. This is a second small
shared piece (alongside the dispatch spine).
5. Recommendation
5.1 Share the thin dispatch spine now; do not build a GatewayDispatch trait
Extract a small, concrete struct (not a trait) in alknet-http:
/// Shared dispatch spine for the `to_*` gateway projections.
/// Resolves identity, builds a root OperationContext, invokes the registry,
/// returns the neutral ResponseEnvelope. Each gateway maps the envelope to
/// its own wire shape.
pub struct GatewayDispatch {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl GatewayDispatch {
/// Resolve a bearer token to an Identity (shared by both gateways'
/// axum auth middleware).
pub fn resolve_bearer(&self, token: &AuthToken) -> Option<Identity> {
self.identity_provider.resolve_from_token(token)
}
/// Invoke an operation as a wire-ingress caller. `internal: false`,
/// `forwarded_for: None`, fresh request_id. Returns the neutral
/// ResponseEnvelope; the gateway maps it to its wire shape.
pub async fn invoke(
&self,
identity: Option<Identity>,
op: &str,
input: Value,
) -> ResponseEnvelope {
// build root OperationContext, call self.registry.invoke(op, input, ctx)
// ...
}
}
Both to_openapi and to_mcp hold an Arc<GatewayDispatch> (or it lives in
axum state / rmcp service state). Each gateway owns:
- Wire framing:
ResponseEnvelope→axum::Response(JSON + status) vsResponseEnvelope→CallToolResult(structured/structured_error). - Discovery framing:
/searchHTTP endpoint vstools/list+searchtool. - Streaming:
/subscribeSSE (to_openapionly). - Versioning:
info.versionsemver (to_openapionly). - Server integration: 5 axum route handlers vs 1 rmcp
ServerHandlerimpl wrapped byStreamableHttpService.
The shared core owns:
- Identity resolution (
resolve_bearer— used by both gateways' axum middleware). - Root
OperationContextconstruction (internal: false,forwarded_for: None, freshrequest_id,identityfrom the resolved bearer,handler_identityfrom the registration bundle). OperationRegistry::invoke()call.- Returning the neutral
ResponseEnvelope.
AccessControl-filtered listing stays in the services/list operation (where
it already is — operation-registry.md:610-642). The gateways invoke
services/list through the shared core; they do not reimplement filtering.
5.2 Why not a GatewayDispatch trait
A trait would need an associated output type (HTTP Response vs
CallToolResult), at which point it has no shared method bodies. Or it would
produce ResponseEnvelope — but then it is a struct, not a trait (there is one
implementation). The third gateway, if it ever appears, will have its own
output type (GraphQL response, gRPC message); a trait generalized now over two
output types will almost certainly not generalize over the third without
redesign. A concrete struct with a ResponseEnvelope-returning method absorbs
the third gateway cleanly (it calls the same method, maps to its own shape).
The rule of three applies: two gateways do not justify a trait abstraction for the wire-framing layer. The dispatch spine is shared because it is the same code (not the same shape, different code) — that is a struct, not a trait.
5.3 Why share the spine now rather than copy-until-third
The spine is small (~15–30 lines per endpoint), so the duplication cost of copying is also small. The case for sharing now rests on the asymmetry of divergence cost:
- A divergence in identity resolution (one gateway resolves the bearer differently) is a security bug — the two gateways would enforce different auth.
- A divergence in
OperationContextconstruction (one gateway setsinternal: trueby mistake, or populatesforwarded_forfrom an untrusted source) is a privilege-escalation or metadata-leak bug. - A divergence in
invoke()call shape (one gateway bypassesAccessControl, or skips the reachability check) is an authorization bug.
These are exactly the bugs that are easy to introduce by copy-paste and hard to catch in review. A shared spine makes the two gateways provably identical on the security-relevant axis (auth, authority, ACL), and lets them diverge only on the wire-framing axis (where divergence is correct). That is worth the small extraction cost now.
The wire-framing, discovery, streaming, and versioning layers are not security-relevant in the same way — they are presentation, and copying them is fine. A divergence in HTTP status mapping is a compatibility bug, not a security bug.
5.4 What this rules out
- No
GatewayDispatchtrait. A concrete struct, not a polymorphic trait. - No shared
into_wire()method. TheResponseEnvelope→ wire mapping is per-gateway; do not parameterize the core over it. - No shared streaming abstraction.
/subscribeSSE isto_openapi-only; do not build aGatewayStreamtrait for one implementation. - No shared discovery abstraction.
services/listis the shared backend; the discovery framing (OpenAPI/searchvs MCPtools/list+searchtool) is per-gateway. - No shared versioning.
info.versionisto_openapi-only.
6. Open Questions (Spike-Needed)
These need a concrete implementation spike to confirm, not just spec reading:
-
OperationContextconstruction ergonomics. The root-context construction is currently specced as living in theCallAdapterdispatch path (operation-registry.md:148-152,call-protocol.mdbuild_root_ context). Extracting it intoGatewayDispatch::invokerequires either (a) calling a sharedbuild_root_contexthelper from bothCallAdapterandGatewayDispatch, or (b) duplicating the construction logic. A spike should confirmbuild_root_contextis reusable as a free function (it should be — it takesidentity,capabilities,env,deadlineand returns anOperationContext), and thatGatewayDispatchcan call it without re-implementing theinternal: false/forwarded_for: Noneinvariants. Ifbuild_root_contextis tangled withCallAdapter-specific state (PendingRequestMap, theDispatcher), the extraction is larger than this research assumes. -
rmcp
RequestContext→Identityextraction. Theto_mcpcall_toolhandler receivesRequestContext<RoleServer>(server.rs:305). The resolvedIdentityneeds to flow from the axum auth middleware (which stashes it in request extensions) into the rmcp handler. rmcp injectshttp::request::Partsinto extensions (tower.rs:487-521, 1086-1097), so theIdentity(stashed by the axum middleware intoParts.extensions) should be retrievable viactx.extensions.get::<Identity>()insidecall_tool. A spike should confirm this extension-survives-the-rmcp-framing path works end-to-end — it is the load-bearing assumption for sharing the auth middleware. -
batchsemantics. Both gateways have abatchendpoint/tool (http-adapters.md:263,http-mcp.md:172, ADR-041 §1). The spec notes "correlated request IDs, OQ-14" — OQ-14 is open. The shared core'sinvoke()is per-operation;batchis a loop overinvoke()in both gateways. A spike should confirmbatchis genuinely just a loop (no shared batch-specific state, no transactional semantics) — if it is,batchstays in each gateway as a thin loop over the sharedinvoke. If OQ-14 resolves to something more structured (atomic batch, partial-failure semantics), the shared core may need ainvoke_batchmethod. -
to_mcpsearch/schematool dispatch. Theto_mcpcall_toolhandler dispatches onparams.name(search/schema/call/batch).searchandschemainvokeservices/list/services/schema— through the sharedGatewayDispatch::invoke, or directly? The shared core'sinvoke()callsOperationRegistry::invoke(), which works forservices/listandservices/schema(they are registered operations). A spike should confirm theservices/listhandler'sAccessControl- filtering works when invoked throughGatewayDispatch::invokewith the resolved bearerIdentity— i.e., that the filtering sees the caller's identity, not a synthetic one. (It should —services/listisAccessControl::check(identity)-filtered, andGatewayDispatchpasses the resolved identity as the caller.) -
to_openapi/subscribeand the shared core./subscribeis a streamingSubscriptioninvocation — it produces a stream ofcall.respondedevents, not a singleResponseEnvelope. The sharedGatewayDispatch::invoke()returns oneResponseEnvelope(the request/response shape). A spike should confirm/subscribeeither (a) calls a different shared method (invoke_subscribe→ returns a stream), or (b) is entirelyto_openapi-specific and does not touch the shared core. Hypothesis: (b) is cleaner —/subscribeis SSE framing over aSubscriptioninvoke, and theSubscriptioninvoke path is already inOperationRegistry(the handler returns a stream via theHandlertype,operation-registry.md:94-96). The shared core stays request/response;/subscribeisto_openapi-owned. Confirm with a spike.
References
alknet specs
docs/architecture/crates/http/http-adapters.md—to_openapispec (gateway endpoints §254-301, error fidelity §303-340, versioning §378-384)docs/architecture/crates/http/http-mcp.md—to_mcpspec (gateway tools §162-210, auth §205-207, subscription exclusion §179-185)docs/architecture/crates/http/http-server.md—HttpAdapter, axum router §86-149,/calldispatch §156-184, SSE §186-206, error mapping §286-306docs/architecture/decisions/041-mcp-tool-gateway-pattern.md— MCP gateway ADR (4 tools §1, subscription exclusion §2, AccessControl §5)docs/architecture/decisions/042-openapi-gateway-pattern.md— OpenAPI gateway ADR (5 endpoints §1, subscribe §2, per-caller §3)docs/architecture/decisions/045-to-openapi-gateway-spec-versioning.md—info.versionsemver §1docs/architecture/crates/call/operation-registry.md—OperationRegistry::invoke()(line 201),OperationContext(lines 110-174),services/list/services/schema(lines 610-642),AccessControl(lines 72-90)docs/architecture/crates/call/client-and-adapters.md—OperationAdaptertrait (lines 397-409), adapter location map (lines 432-455),to_*are projections (lines 427-429)docs/architecture/crates/call/call-protocol.md—ResponseEnvelope/CallError(lines 491-501)docs/architecture/crates/core/auth.md—IdentityProvidertrait (lines 211-214),resolve_from_tokenused by HTTP + call (line 218)
rmcp SDK
rust-sdk/crates/rmcp/src/transport/streamable_http_server/tower.rs—StreamableHttpService(lines 546-594),Service<Request<RequestBody>>impl (lines 570-594), DNS-rebinding/Host/Origin validation (lines 411-461), MCP-Protocol-Version validation (lines 183-212), session management (lines 1055-1220), stateless mode (lines 1221-1302),http::request::Partsinjection into extensions (lines 487-521, 1086-1097)rust-sdk/crates/rmcp/src/handler/server.rs—ServerHandlertrait (lines 424-432),call_tool(lines 303-309, 533-539),list_tools(lines 310-316, 541-547)rust-sdk/crates/rmcp/src/handler/server/tool.rs—IntoCallToolResulttrait (lines 78-113),ToolCallContext(lines 33-66),CallToolHandler(lines 151-156)rust-sdk/crates/rmcp/src/model.rs—CallToolResult(lines 2868-2881, 2925-3039:success/error/structured/structured_error),CallToolRequestParams(lines 3098-3110)rust-sdk/crates/rmcp/src/service/tower.rs—TowerHandler(lines 8-54), the rmcp-internal tower-Service-to-Service<RoleServer>adapterrust-sdk/examples/servers/src/simple_auth_streamhttp.rs— axum middleware around nestedStreamableHttpService(lines 73-89, 147-153)
Prior art
@alkdev/operations/docs/architecture/adapters.md— TypeScript prior art (from_openapi,from_mcp,FromSchema, scanner)