feat(http): implement to_mcp 4-tool gateway projection (rmcp ServerHandler, StreamableHttpService at /mcp)

to_mcp is the MCP-direction gateway projection (ADR-041): exposes 4 fixed
gateway tools (search, schema, call, batch) over rmcp StreamableHttpService
nested into the axum Router at /mcp, not one MCP tool per registry operation.
The LLM discovers operations on demand via search+schema.

- ToMcpGateway implements rmcp ServerHandler (call_tool, list_tools, get_info)
- tools/list returns the 4 fixed gateway tools, never the registry's ops
- search dispatches services/list via GatewayDispatch::invoke, excludes
  Subscription ops (ADR-041 §2), returns names + descriptions
- schema dispatches services/schema, returns the full OperationSpec
- call dispatches via GatewayDispatch::invoke (shared spine), maps
  ResponseEnvelope -> CallToolResult::structured (Ok) /
  CallToolResult::structured_error (Err(CallError))
- batch loops over invoke, returns an array of results
- Bearer auth via shared bearer_auth_middleware applied around nest_service
  (rmcp simple_auth_streamhttp pattern); Identity read from
  RequestContext.extensions -> http::request::Parts.extensions
  (research §6 #2 identity-survives-framing assumption, confirmed via test)
- to_mcp is a pure projection (consumes registry, produces no entries)
- Feature-gated behind mcp; stdio NOT built (ADR-037)
- /mcp route wired in adapter.rs replacing the placeholder 501

cargo test -p alknet-http --features mcp: 172 passed
cargo clippy -p alknet-http --features mcp --all-targets: clean
cargo check -p alknet-http (no mcp): clean
This commit is contained in:
2026-07-01 19:18:19 +00:00
parent 539a812c12
commit 64696fec97
3 changed files with 873 additions and 3 deletions

View File

@@ -17,7 +17,7 @@ use async_trait::async_trait;
use axum::http::StatusCode;
use axum::middleware::from_fn_with_state;
use axum::response::IntoResponse;
use axum::routing::{any, get, post};
use axum::routing::{any, get};
use axum::Router;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder as HyperBuilder;
@@ -32,6 +32,10 @@ use alknet_core::types::{Connection, HandlerError, ProtocolHandler, StreamError}
use super::auth::bearer_auth_middleware;
use crate::server::decoy::decoy_fallback;
use crate::server::healthz::healthz;
#[cfg(feature = "mcp")]
use crate::adapters::to_mcp_service;
#[cfg(feature = "mcp")]
use crate::gateway::GatewayDispatch;
const ALPN_HTTP1: &[u8] = b"http/1.1";
const ALPN_H2: &[u8] = b"h2";
@@ -135,6 +139,20 @@ impl HttpAdapter {
fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
let auth_state = Arc::clone(&state.identity_provider);
#[cfg(feature = "mcp")]
let mcp_router: Router<RouterState> = {
let dispatch = Arc::new(GatewayDispatch::new(
Arc::clone(&state.registry),
Arc::clone(&state.identity_provider),
));
Router::new()
.nest_service("/mcp", to_mcp_service(dispatch))
.layer(from_fn_with_state(auth_state.clone(), bearer_auth_middleware))
};
#[cfg(not(feature = "mcp"))]
let mcp_router: Router<RouterState> = Router::new();
let default: Router<RouterState> = Router::new()
.route("/search", any(not_implemented))
.route("/schema", any(not_implemented))
@@ -142,10 +160,10 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
.route("/batch", any(not_implemented))
.route("/subscribe", any(not_implemented))
.route("/openapi.json", get(not_implemented))
.route("/mcp", post(not_implemented))
.route_layer(from_fn_with_state(auth_state.clone(), bearer_auth_middleware))
.route("/healthz", get(healthz))
.fallback(decoy_fallback);
.fallback(decoy_fallback)
.merge(mcp_router);
let with_extras = match extra_routes {
Some(extra) => {
@@ -257,6 +275,7 @@ impl AsyncWrite for QuicStream {
#[cfg(test)]
mod tests {
use super::*;
use axum::routing::post;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
struct NoopProvider;