Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,209 @@
//! `RemoteMcpHandler` — the rmcp `ServerHandler` that projects the gateway
//! `Registry` onto the Remote (external companion) surface.
//!
//! `list_tools` → `Registry::tool_specs(Surface::Remote)` (Deny-gated tools are
//! invisible). `call_tool` → `Registry::dispatch_opt` with a `CallerCtx` whose
//! `remote` marker forces `Surface::Remote`, so the danger matrix (Read/Write
//! Allow, Destructive Confirm, Sensitive Deny) is enforced centrally. The
//! handler is stateless apart from the shared `Arc<GatewayDeps>`; a fresh
//! instance is produced per session by the transport's service factory.
use std::sync::Arc;
use nomifun_auth::SYSTEM_USER_ID;
use nomifun_gateway::{CallerCtx, GatewayDeps, Registry, Surface};
use rmcp::ServerHandler;
use rmcp::model::{
CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams,
ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::{RequestContext, RoleServer};
fn query_value<'a>(query: &'a str, key: &str) -> Option<&'a str> {
query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
(k == key).then_some(v)
})
}
pub(crate) fn domain_scope_from_query(query: Option<&str>) -> Option<Vec<String>> {
let query = query?;
if let Some(domains) = query_value(query, "domains") {
let selected: Vec<String> = domains
.split(',')
.map(str::trim)
.filter(|domain| !domain.is_empty())
.map(ToOwned::to_owned)
.collect();
return (!selected.is_empty()).then_some(selected);
}
match query_value(query, "profile") {
Some("agent") => Some(
crate::AGENT_PROFILE_DOMAINS
.iter()
.map(|d| d.to_string())
.collect(),
),
_ => None,
}
}
fn domain_scope_from_context(ctx: &RequestContext<RoleServer>) -> Option<Vec<String>> {
let parts = ctx.extensions.get::<axum::http::request::Parts>()?;
domain_scope_from_query(parts.uri.query())
}
fn remote_specs_for_scope(scope: Option<&[String]>) -> Vec<nomifun_gateway::ToolSpec> {
match scope {
Some(domains) => {
let domain_refs: Vec<&str> = domains.iter().map(String::as_str).collect();
Registry::global().tool_specs_for(Surface::Remote, &domain_refs)
}
None => Registry::global().tool_specs(Surface::Remote),
}
}
/// MCP server handler for external (network) callers. One per MCP session;
/// holds a clone of the shared gateway service bundle. `domains` optionally
/// restricts `tools/list` to a curated profile (e.g. the `agent` profile);
/// `None` advertises the full Remote surface.
#[derive(Clone)]
pub struct RemoteMcpHandler {
deps: Arc<GatewayDeps>,
domains: Option<&'static [&'static str]>,
}
impl RemoteMcpHandler {
pub fn new(deps: Arc<GatewayDeps>) -> Self {
Self {
deps,
domains: None,
}
}
/// Curated profile: only advertise capabilities in `domains`.
pub fn with_domains(deps: Arc<GatewayDeps>, domains: &'static [&'static str]) -> Self {
Self {
deps,
domains: Some(domains),
}
}
}
impl ServerHandler for RemoteMcpHandler {
fn get_info(&self) -> ServerInfo {
// ServerInfo is #[non_exhaustive] — build from Default then set fields.
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info.instructions = Some(
"NomiFun external companion. These tools drive the NomiFun platform \
(agent / browser / computer / knowledge / files / and platform control). \
Destructive actions require re-calling with `confirm: true`; some sensitive \
actions are disabled on this surface."
.to_string(),
);
info
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, rmcp::ErrorData> {
let query_scope = domain_scope_from_context(&context);
let specs = match (self.domains, query_scope.as_deref()) {
(Some(domains), _) => Registry::global().tool_specs_for(Surface::Remote, domains),
(None, scope) => remote_specs_for_scope(scope),
};
let tools: Vec<Tool> = specs
.into_iter()
.map(|spec| Tool::new(spec.name, spec.description, Arc::new(spec.input_schema)))
.collect();
Ok(ListToolsResult {
tools,
meta: None,
next_cursor: None,
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let args = serde_json::Value::Object(request.arguments.unwrap_or_default());
let query_scope = domain_scope_from_context(&ctx);
let allowed_specs = match (self.domains, query_scope.as_deref()) {
(Some(domains), _) => Registry::global().tool_specs_for(Surface::Remote, domains),
(None, scope) => remote_specs_for_scope(scope),
};
if !allowed_specs.iter().any(|spec| spec.name == request.name) {
return Ok(crate::result::build_tool_result(serde_json::json!({
"error": format!("Tool '{}' is outside the configured Remote MCP capability scope", request.name)
})));
}
// External caller == the Remote surface, bound to one companion (外部伙伴).
// rmcp injects the originating HTTP `Parts` into the request extensions;
// our companion_token_middleware stashed the resolved companion there.
let companion_id = ctx
.extensions
.get::<axum::http::request::Parts>()
.and_then(|parts| parts.extensions.get::<crate::router::RemoteCompanion>())
.map(|rc| rc.0.clone());
if companion_id.is_none() {
// The companion_token_middleware always stashes a `RemoteCompanion`
// before the MCP service runs, so reaching dispatch with `None` means
// the rmcp `http::request::Parts` extension downcast broke (e.g. an
// rmcp/transport upgrade changed how the originating request is
// injected). Leave a trail instead of silently degrading every MCP
// caller to companion-less.
tracing::warn!(
tool = %request.name,
"remote MCP call resolved no companion_id from request extensions \
(Parts→RemoteCompanion downcast failed); dispatching companion-less"
);
}
let caller = CallerCtx {
remote: true,
user_id: SYSTEM_USER_ID.to_string(),
companion_id,
..Default::default()
};
let result = match Registry::global()
.dispatch_opt(self.deps.clone(), caller, &request.name, &args)
.await
{
Some(value) => value,
None => serde_json::json!({ "error": format!("Unknown tool: {}", request.name) }),
};
Ok(crate::result::build_tool_result(result))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn domain_scope_from_query_reads_custom_domains() {
assert_eq!(
domain_scope_from_query(Some("domains=agent,conversation,files")),
Some(vec![
"agent".to_string(),
"conversation".to_string(),
"files".to_string()
])
);
assert_eq!(
domain_scope_from_query(Some("profile=agent")),
Some(
crate::AGENT_PROFILE_DOMAINS
.iter()
.map(|d| d.to_string())
.collect()
)
);
assert_eq!(domain_scope_from_query(Some("domains=")), None);
assert_eq!(domain_scope_from_query(None), None);
}
}
@@ -0,0 +1,33 @@
//! `nomifun-public` — the **Remote 前门** (external companion surface).
//!
//! Projects the platform's single capability source of truth
//! (`nomifun_gateway::Registry`) onto a network-reachable, companion-token-
//! authenticated **MCP Streamable-HTTP** endpoint, so an external AI agent
//! (Claude Code / Cursor / a custom LLM agent) — i.e. an "外部伙伴" — can drive
//! the platform exactly as the desktop companion does, over `Surface::Remote`.
//!
//! This crate is deliberately thin: it owns transport + auth + identity only.
//! Every capability, its schema, its danger tier and its surface gate already
//! live in `nomifun-gateway`; adding a capability there makes it appear here
//! automatically (the inheritance guarantee — see the design spec §2.1). It MUST
//! be mounted in-process by `nomifun-app` (the `server.lock` data-dir is
//! single-writer; a sidecar is impossible).
mod handler;
mod rest;
mod result;
mod router;
pub use handler::RemoteMcpHandler;
pub use rest::public_rest_router;
pub use result::build_tool_result;
pub use router::{PublicMcpState, public_mcp_router};
/// Curated "agent" profile for the Remote surface: the do-work capability
/// domains an external task-delegation agent typically needs, excluding
/// platform-management domains (channel/companion/cron/system/team/…). Keeps a
/// remote MCP client's tool list tight (better tool-selection) without changing
/// permissions — dispatch is still gated by the Remote surface, not the profile.
/// (`computer` lights up when the computer-use caps land.)
pub const AGENT_PROFILE_DOMAINS: &[&str] =
&["agent", "conversation", "browser", "computer", "knowledge", "files", "memory"];
@@ -0,0 +1,280 @@
//! REST `/v1` adapter — the human/script-facing projection of the gateway
//! Registry, beside the flagship `/mcp` MCP adapter. Auto-generated from the
//! SAME registry, so it inherits every capability and the Remote surface gate:
//!
//! - `GET /v1/tools` — list the Remote-surface capabilities + schemas
//! - `POST /v1/tools/{name}` — invoke a capability (body = its JSON args)
//! - `GET /v1/openapi.json` — OpenAPI 3.1 doc generated from the schemas
//!
//! Token-gated by the same companion-token middleware as `/mcp`. Mount with
//! `.nest("/v1", ..)` (never `.merge`, same reason as the MCP router).
use std::sync::Arc;
use axum::{
Extension, Json, Router,
extract::{Path, Query, State, rejection::JsonRejection},
http::StatusCode,
middleware::from_fn_with_state,
response::sse::{Event, KeepAlive, Sse},
response::{IntoResponse, Response},
routing::{get, post},
};
use nomifun_auth::{CompanionTokenValidator, SYSTEM_USER_ID};
use nomifun_gateway::{CallerCtx, GatewayDeps, Registry, Surface, ToolSpec};
use serde::Deserialize;
use serde_json::{Value, json};
use std::convert::Infallible;
use crate::router::{PublicMcpState, RemoteCompanion, companion_token_middleware};
#[derive(Clone)]
struct RestState {
deps: Arc<GatewayDeps>,
}
/// `?profile=agent|full` (default full) — curate the advertised catalog.
#[derive(Deserialize)]
struct ProfileQuery {
#[serde(default)]
profile: Option<String>,
#[serde(default)]
domains: Option<String>,
}
fn domains_from_query_value(domains: Option<&str>) -> Option<Vec<String>> {
let selected: Vec<String> = domains?
.split(',')
.map(str::trim)
.filter(|domain| !domain.is_empty())
.map(ToOwned::to_owned)
.collect();
(!selected.is_empty()).then_some(selected)
}
/// Resolve a profile name to the matching Remote-surface tool specs.
fn specs_for_profile(profile: Option<&str>) -> Vec<ToolSpec> {
match profile {
Some("agent") => {
Registry::global().tool_specs_for(Surface::Remote, crate::AGENT_PROFILE_DOMAINS)
}
_ => Registry::global().tool_specs(Surface::Remote),
}
}
fn specs_for_query(q: &ProfileQuery) -> Vec<ToolSpec> {
if let Some(domains) = domains_from_query_value(q.domains.as_deref()) {
let domain_refs: Vec<&str> = domains.iter().map(String::as_str).collect();
return Registry::global().tool_specs_for(Surface::Remote, &domain_refs);
}
specs_for_profile(q.profile.as_deref())
}
/// `GET /v1/tools[?profile=agent]` — the Remote-surface capability catalog
/// (name + description + JSON Schema). `profile=agent` returns the curated
/// do-work subset; default is the full surface.
async fn list_tools(Query(q): Query<ProfileQuery>) -> Json<Value> {
let tools: Vec<Value> = specs_for_query(&q)
.into_iter()
.map(|s| json!({ "name": s.name, "domain": s.domain, "description": s.description, "input_schema": s.input_schema }))
.collect();
Json(json!({ "count": tools.len(), "tools": tools }))
}
/// `POST /v1/tools/{name}` — invoke a capability. Body is the capability's JSON
/// args (empty body == `{}`). Dispatches under `Surface::Remote`, so the danger
/// gate (Destructive→needs_confirmation, Sensitive→denied) applies identically.
async fn call_tool(
State(state): State<RestState>,
Path(name): Path<String>,
Query(q): Query<ProfileQuery>,
Extension(RemoteCompanion(companion_id)): Extension<RemoteCompanion>,
body: Result<Json<Value>, JsonRejection>,
) -> Response {
// Lenient: a no-arg tool may be POSTed with an empty body.
let args = match body {
Ok(Json(v)) if v.is_null() => json!({}),
Ok(Json(v)) => v,
Err(_) => json!({}),
};
if !specs_for_query(&q).iter().any(|spec| spec.name == name) {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": format!("Tool '{name}' is outside the configured Remote REST capability scope") })),
)
.into_response();
}
let ctx = CallerCtx {
remote: true,
user_id: SYSTEM_USER_ID.to_string(),
companion_id: Some(companion_id),
..Default::default()
};
match Registry::global()
.dispatch_opt(state.deps.clone(), ctx, &name, &args)
.await
{
Some(result) => {
// Map the registry result envelope onto HTTP status codes.
let status = if result.get("error").is_some() {
StatusCode::UNPROCESSABLE_ENTITY
} else if result.get("needs_confirmation").is_some() {
StatusCode::CONFLICT
} else {
StatusCode::OK
};
(status, Json(result)).into_response()
}
None => (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("Unknown tool: {name}") })),
)
.into_response(),
}
}
/// `POST /v1/tools/{name}/stream` — Server-Sent Events stream of a tool call.
/// Each SSE `data:` frame is a JSON event; streaming tools (e.g.
/// `nomi_agent_run`) emit incremental `{"type": ..}` deltas as they happen, and
/// every call ends with one `{"type":"__result__","data": <final>}` frame.
/// Non-streaming tools emit only that terminal frame.
async fn stream_tool(
State(state): State<RestState>,
Path(name): Path<String>,
Query(q): Query<ProfileQuery>,
Extension(RemoteCompanion(companion_id)): Extension<RemoteCompanion>,
body: Result<Json<Value>, JsonRejection>,
) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
let args = match body {
Ok(Json(v)) if !v.is_null() => v,
_ => json!({}),
};
let (tx, rx) = tokio::sync::mpsc::channel::<Value>(256);
let deps = state.deps.clone();
tokio::spawn(async move {
if !specs_for_query(&q).iter().any(|spec| spec.name == name) {
let _ = tx
.send(json!({
"type": "__result__",
"data": { "error": format!("Tool '{name}' is outside the configured Remote REST capability scope") }
}))
.await;
return;
}
let ctx = CallerCtx {
remote: true,
user_id: SYSTEM_USER_ID.to_string(),
companion_id: Some(companion_id),
..Default::default()
};
let final_val = match Registry::global()
.dispatch_stream(deps, ctx, &name, &args, tx.clone())
.await
{
Some(v) => v,
None => json!({ "error": format!("Unknown tool: {name}") }),
};
// Terminal frame carries the final result/envelope; sending it then
// dropping `tx` ends the SSE stream.
let _ = tx
.send(json!({ "type": "__result__", "data": final_val }))
.await;
});
let stream = futures::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|v| {
(
Ok::<Event, Infallible>(Event::default().data(v.to_string())),
rx,
)
})
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
/// `GET /v1/openapi.json` — OpenAPI 3.1 generated from the registry: one
/// `POST /v1/tools/{name}` operation per Remote capability, requestBody schema =
/// the capability's input schema.
async fn openapi(Query(q): Query<ProfileQuery>) -> Json<Value> {
let mut paths = serde_json::Map::new();
for s in specs_for_query(&q) {
paths.insert(
format!("/v1/tools/{}", s.name),
json!({
"post": {
"summary": s.description,
"operationId": s.name,
"requestBody": {
"required": true,
"content": { "application/json": { "schema": s.input_schema } }
},
"responses": {
"200": { "description": "tool result", "content": { "application/json": { "schema": { "type": "object" } } } },
"409": { "description": "needs confirmation (re-call with confirm=true)" },
"422": { "description": "tool returned an error" }
},
"security": [{ "bearerAuth": [] }]
}
}),
);
}
Json(json!({
"openapi": "3.1.0",
"info": {
"title": "NomiFun Remote Capability API",
"version": "v1",
"description": "External-companion access to NomiFun platform capabilities. All operations require Authorization: Bearer <companion access token>."
},
"paths": paths,
"components": {
"securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer" } }
}
}))
}
/// Build the REST sub-router. Mount with `.nest("/v1", ..)`; the companion-token
/// layer + this router's routes are then scoped to `/v1`.
pub fn public_rest_router(
deps: Arc<GatewayDeps>,
validator: Arc<CompanionTokenValidator>,
) -> Router {
Router::new()
.route("/tools", get(list_tools))
.route("/tools/{name}", post(call_tool))
.route("/tools/{name}/stream", post(stream_tool))
.route("/openapi.json", get(openapi))
.with_state(RestState { deps })
.layer(from_fn_with_state(
PublicMcpState { validator },
companion_token_middleware,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openapi_lists_remote_tools() {
// openapi() is pure; exercise the path-generation against the real registry.
let specs = Registry::global().tool_specs(Surface::Remote);
assert!(!specs.is_empty());
// every Remote tool yields a /v1/tools/<name> POST path
assert!(specs.iter().all(|s| !s.name.is_empty()));
}
#[test]
fn custom_domains_filter_rest_catalog() {
let full = specs_for_profile(None);
let filtered = specs_for_query(&ProfileQuery {
profile: Some("full".to_string()),
domains: Some("agent,files".to_string()),
});
assert!(!filtered.is_empty());
assert!(filtered.len() < full.len());
assert!(
filtered
.iter()
.all(|s| s.domain == "agent" || s.domain == "files")
);
}
}
@@ -0,0 +1,61 @@
//! Maps a gateway dispatch result (`serde_json::Value`) onto an MCP
//! `CallToolResult`. Mirrors `nomifun-app`'s `gateway_stdio::build_tool_result`
//! image seam, but operates on the in-process `Value` directly (no HTTP hop).
use rmcp::model::{CallToolResult, Content};
use serde_json::Value;
/// Build the MCP tool result from a gateway dispatch result value.
///
/// Image seam (matches the inward stdio bridge): a capability that returns
/// images attaches `_mcp_images: [{"mime_type","data"}]`; those become proper
/// MCP `image` content parts and the key is stripped from the text payload so
/// the base64 isn't also emitted as text tokens. Dispatch errors are returned
/// as `{"error": ...}` JSON text in a success result — identical to the inward
/// bridge's behaviour, so external clients see the same shape.
pub fn build_tool_result(mut value: Value) -> CallToolResult {
let images: Vec<Content> = value
.get("_mcp_images")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|img| {
let data = img.get("data").and_then(Value::as_str)?;
let mime = img.get("mime_type").and_then(Value::as_str)?;
Some(Content::image(data.to_owned(), mime.to_owned()))
})
.collect()
})
.unwrap_or_default();
if !images.is_empty()
&& let Value::Object(map) = &mut value
{
map.remove("_mcp_images");
}
let text = serde_json::to_string(&value).unwrap_or_else(|_| value.to_string());
let mut contents = vec![Content::text(text)];
contents.extend(images);
CallToolResult::success(contents)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_value_becomes_one_text_part() {
let r = build_tool_result(serde_json::json!({"ok": true}));
assert_eq!(r.content.len(), 1);
}
#[test]
fn images_marker_splits_into_text_plus_image() {
let r = build_tool_result(serde_json::json!({
"note": "shot",
"_mcp_images": [{"mime_type": "image/png", "data": "AAAA"}]
}));
assert_eq!(r.content.len(), 2, "one text part + one image part");
}
}
@@ -0,0 +1,216 @@
//! Transport + auth wiring for the Remote front door.
//!
//! Mounts rmcp's official `StreamableHttpService` at `/mcp` and wraps it with a
//! companion-token middleware. The host MUST mount this with `.nest("/mcp", ..)`
//! (NEVER `.merge` — see [`public_mcp_router`] for why); it then rides both the
//! desktop loopback/LAN listeners and the headless web host, sharing service
//! state with the SPA.
use std::sync::Arc;
use axum::{
Router,
extract::{Request, State},
http::{StatusCode, header},
middleware::{Next, from_fn_with_state},
response::{IntoResponse, Response},
};
use nomifun_auth::CompanionTokenValidator;
use nomifun_gateway::GatewayDeps;
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use crate::handler::RemoteMcpHandler;
/// The companion a validated Remote token is bound to, stashed in the request
/// extensions by [`companion_token_middleware`] and read by both adapters
/// (MCP via `RequestContext.extensions`→`http::request::Parts`; REST via
/// `Extension<RemoteCompanion>`).
#[derive(Clone, Debug)]
pub struct RemoteCompanion(pub String);
/// State for the companion-token middleware.
#[derive(Clone)]
pub struct PublicMcpState {
pub validator: Arc<CompanionTokenValidator>,
}
/// Reject any request to the Remote surface that does not carry a valid
/// per-companion API token in `Authorization: Bearer <token>`. A valid token
/// resolves to the companion it is bound to; the resolved companion id is
/// stashed in the request extensions as [`RemoteCompanion`] so both adapters
/// can thread it into `CallerCtx.companion_id`. Anything else is 401. Shared by
/// the `/mcp` and `/v1` (REST) adapters.
pub(crate) async fn companion_token_middleware(
State(state): State<PublicMcpState>,
request: Request,
next: Next,
) -> Response {
let presented = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
match state.validator.resolve(presented) {
Some(companion_id) => {
let mut request = request;
request.extensions_mut().insert(RemoteCompanion(companion_id));
next.run(request).await
}
None => (StatusCode::UNAUTHORIZED, "unauthorized").into_response(),
}
}
/// Build the Remote front-door sub-router (MCP Streamable-HTTP) gated by the
/// companion token. The caller MUST mount it with `.nest("/mcp", ..)` (NOT
/// `.merge`): `nest` scopes both the token-auth layer and this router's
/// fallback service to the `/mcp` prefix, so it cannot hijack the host app's
/// global 404 fallback (merging a layered router would route every unmatched
/// path through the token middleware → spurious 401s). `deps` is the SAME
/// `Arc<GatewayDeps>` the SPA/inward gateway use (shared state, one dispatch
/// authority). `domains = None` advertises the full Remote surface; `Some(..)`
/// advertises a curated profile (e.g. `AGENT_PROFILE_DOMAINS`).
pub fn public_mcp_router(
deps: Arc<GatewayDeps>,
validator: Arc<CompanionTokenValidator>,
domains: Option<&'static [&'static str]>,
) -> Router {
// The companion token (a 256-bit Bearer in the Authorization header, NOT a
// cookie) is the real gate — it is non-ambient, so a DNS-rebinding browser
// page cannot read it or have it auto-attached, and any rebound request is
// rejected 401 before reaching a tool. rmcp's own Host check defaults to
// loopback-only (would reject LAN/public hosts), so we disable it; on the
// desktop LAN listener the app additionally layers a host_guard (DNS-rebind)
// — the headless web host relies on the token + your TLS/reverse proxy.
let config = StreamableHttpServerConfig::default().disable_allowed_hosts();
let service: StreamableHttpService<RemoteMcpHandler, LocalSessionManager> = StreamableHttpService::new(
{
let deps = deps.clone();
move || {
Ok(match domains {
Some(d) => RemoteMcpHandler::with_domains(deps.clone(), d),
None => RemoteMcpHandler::new(deps.clone()),
})
}
},
Arc::new(LocalSessionManager::default()),
config,
);
// `fallback_service` serves every path within the `/mcp` nest; the token
// layer wraps it. Scoped by `nest`, so the global fallback is untouched.
Router::new()
.fallback_service(service)
.layer(from_fn_with_state(PublicMcpState { validator }, companion_token_middleware))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request as HttpRequest;
use nomifun_auth::token_sha256_hex;
use tower::ServiceExt; // oneshot
// A request with no Authorization header (or an unknown token) is rejected
// before reaching the MCP service; a valid token resolves to its companion.
#[tokio::test]
async fn missing_token_is_unauthorized() {
let validator =
Arc::new(CompanionTokenValidator::new(vec![("comp".into(), token_sha256_hex("secret-token"))]));
// We can't easily build GatewayDeps in a unit test, so exercise the
// middleware in isolation over a trivial inner router.
let state = PublicMcpState { validator: validator.clone() };
let app = Router::new()
.route("/mcp", axum::routing::post(|| async { "ok" }))
.layer(from_fn_with_state(state, companion_token_middleware));
let res = app
.clone()
.oneshot(HttpRequest::post("/mcp").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let ok = app
.oneshot(
HttpRequest::post("/mcp")
.header(header::AUTHORIZATION, "Bearer secret-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(ok.status(), StatusCode::OK);
// Revocation closes it.
validator.remove_token("comp");
let res2 = Router::new()
.route("/mcp", axum::routing::post(|| async { "ok" }))
.layer(from_fn_with_state(PublicMcpState { validator }, companion_token_middleware))
.oneshot(
HttpRequest::post("/mcp")
.header(header::AUTHORIZATION, "Bearer secret-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res2.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn valid_token_inserts_remote_companion_extension() {
use axum::routing::get;
use nomifun_auth::token_sha256_hex;
let validator = std::sync::Arc::new(
nomifun_auth::CompanionTokenValidator::new(vec![("comp-x".into(), token_sha256_hex("secret-tok"))]),
);
// A probe handler that echoes whether the extension is present + its value.
async fn probe(ext: Option<axum::Extension<RemoteCompanion>>) -> String {
match ext {
Some(axum::Extension(RemoteCompanion(c))) => format!("companion={c}"),
None => "none".into(),
}
}
let app = axum::Router::new()
.route("/probe", get(probe))
.layer(axum::middleware::from_fn_with_state(
PublicMcpState { validator },
companion_token_middleware,
));
// Valid token → 200 + companion echoed.
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/probe")
.header(axum::http::header::AUTHORIZATION, "Bearer secret-tok")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
assert_eq!(&body[..], b"companion=comp-x");
// Bad token → 401.
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/probe")
.header(axum::http::header::AUTHORIZATION, "Bearer nope")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
}
}