Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user