Add auth-aware MCP client transport layer (#1972)

This commit is contained in:
Steven Enamakel
2026-05-16 17:23:48 -07:00
committed by GitHub
parent 18ca68a56c
commit f0b5fdb9d2
17 changed files with 2721 additions and 266 deletions
+2 -1
View File
@@ -29,7 +29,8 @@ pub use schema::{
CurlConfig, DelegateAgentConfig, DictationActivationMode, DictationConfig, DiscordConfig,
DockerRuntimeConfig, EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig, HttpRequestConfig,
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend,
LocalAiConfig, MatrixConfig, MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
LocalAiConfig, MatrixConfig, McpAuthConfig, McpClientConfig, McpClientIdentityConfig,
McpServerConfig, MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
MultimodalConfig, ObservabilityConfig, ProxyConfig, ProxyScope, ReflectionSource,
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig,
+3 -2
View File
@@ -68,8 +68,9 @@ pub use storage_memory::{
};
pub use tools::{
BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig,
GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, MultimodalConfig,
SecretsConfig, SeltzConfig, WebSearchConfig, COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT,
GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, McpAuthConfig,
McpClientConfig, McpClientIdentityConfig, McpServerConfig, MultimodalConfig, SecretsConfig,
SeltzConfig, WebSearchConfig, COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT,
};
pub use update::{UpdateConfig, UpdateRestartStrategy};
mod voice_server;
+142
View File
@@ -3,6 +3,7 @@
use super::defaults;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
@@ -223,6 +224,147 @@ impl Default for GitbooksConfig {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct McpServerConfig {
/// Stable server slug used by the agent-facing bridge tools.
#[serde(default)]
pub name: String,
/// MCP endpoint URL. Current implementation supports stateless
/// Streamable HTTP / JSON responses.
#[serde(default)]
pub endpoint: String,
/// Optional stdio command for local MCP servers. When set, the
/// client launches this command as a subprocess and speaks newline-
/// delimited JSON-RPC over stdin/stdout per the MCP stdio transport.
#[serde(default)]
pub command: String,
/// Command-line arguments for stdio MCP servers.
#[serde(default)]
pub args: Vec<String>,
/// Extra environment variables for stdio MCP servers. MCP stdio auth
/// is typically passed this way.
#[serde(default)]
pub env: HashMap<String, String>,
/// Optional working directory for stdio MCP servers.
#[serde(default)]
pub cwd: Option<String>,
/// Optional human-readable description shown in bridge tool output.
#[serde(default)]
pub description: Option<String>,
/// Whether this server should be exposed to the MCP bridge tools.
#[serde(default = "defaults::default_true")]
pub enabled: bool,
/// Per-request timeout in seconds.
#[serde(default = "default_mcp_timeout_secs")]
pub timeout_secs: u64,
/// Optional auth strategy applied to outbound requests for this
/// server. Useful for API-key and pre-provisioned bearer-token
/// flows; interactive OAuth discovery is handled by the client
/// transport separately when a server returns an auth challenge.
#[serde(default)]
pub auth: McpAuthConfig,
}
fn default_mcp_timeout_secs() -> u64 {
30
}
impl Default for McpServerConfig {
fn default() -> Self {
Self {
name: String::new(),
endpoint: String::new(),
command: String::new(),
args: Vec::new(),
env: HashMap::new(),
cwd: None,
description: None,
enabled: defaults::default_true(),
timeout_secs: default_mcp_timeout_secs(),
auth: McpAuthConfig::None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum McpAuthConfig {
None,
BearerToken { token: String },
Basic { username: String, password: String },
Header { name: String, value: String },
QueryParam { name: String, value: String },
}
impl Default for McpAuthConfig {
fn default() -> Self {
Self::None
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct McpClientIdentityConfig {
/// Client name sent during `initialize.clientInfo.name`.
#[serde(default = "default_mcp_client_name")]
pub name: String,
/// Client title sent during `initialize.clientInfo.title`.
#[serde(default = "default_mcp_client_title")]
pub title: String,
/// Client version sent during `initialize.clientInfo.version`.
#[serde(default = "default_mcp_client_version")]
pub version: String,
}
fn default_mcp_client_name() -> String {
"openhuman-core".into()
}
fn default_mcp_client_title() -> String {
"OpenHuman Core MCP Client".into()
}
fn default_mcp_client_version() -> String {
env!("CARGO_PKG_VERSION").into()
}
impl Default for McpClientIdentityConfig {
fn default() -> Self {
Self {
name: default_mcp_client_name(),
title: default_mcp_client_title(),
version: default_mcp_client_version(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct McpClientConfig {
/// When `true`, register the generic MCP bridge tools and expose
/// configured remote MCP servers to the agent runtime.
#[serde(default = "defaults::default_true")]
pub enabled: bool,
/// Named remote MCP servers accessible via `mcp_list_*` /
/// `mcp_call_tool`.
#[serde(default)]
pub servers: Vec<McpServerConfig>,
/// Identity block sent during initialize.
#[serde(default)]
pub client_identity: McpClientIdentityConfig,
}
impl Default for McpClientConfig {
fn default() -> Self {
Self {
enabled: defaults::default_true(),
servers: Vec::new(),
client_identity: McpClientIdentityConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct SeltzConfig {
+4
View File
@@ -140,6 +140,9 @@ pub struct Config {
#[serde(default)]
pub gitbooks: GitbooksConfig,
#[serde(default)]
pub mcp_client: McpClientConfig,
#[serde(default)]
pub multimodal: MultimodalConfig,
@@ -419,6 +422,7 @@ impl Default for Config {
http_request: HttpRequestConfig::default(),
curl: CurlConfig::default(),
gitbooks: GitbooksConfig::default(),
mcp_client: McpClientConfig::default(),
multimodal: MultimodalConfig::default(),
seltz: SeltzConfig::default(),
web_search: WebSearchConfig::default(),
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
//! Shared MCP client + registry for remote MCP servers exposed to agents.
//!
//! Supports Streamable HTTP and stdio transports. HTTP transport carries
//! session + auth lifecycle; stdio launches a subprocess and exchanges
//! newline-delimited JSON-RPC messages over stdin/stdout per the MCP spec.
mod client;
mod registry;
mod stdio;
pub use client::{
redact_endpoint, AuthorizationServerMetadata, McpAuthChallenge, McpAuthorizationContext,
McpHttpClient, McpInitializeResult, McpRemoteTool, McpServerToolResult, McpSseEvent,
ProtectedResourceMetadata,
};
pub use registry::{McpRegistrySource, McpServerDefinition, McpServerRegistry, McpTransportClient};
pub use stdio::McpStdioClient;
+281
View File
@@ -0,0 +1,281 @@
use super::client::{
McpAuthorizationContext, McpHttpClient, McpInitializeResult, McpRemoteTool, McpServerToolResult,
};
use super::stdio::McpStdioClient;
use crate::openhuman::config::{Config, McpAuthConfig, McpClientIdentityConfig, McpServerConfig};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum McpRegistrySource {
Config,
LegacyGitbooks,
}
#[derive(Debug, Clone)]
pub struct McpServerDefinition {
pub name: String,
pub endpoint: String,
pub command: Option<String>,
pub description: Option<String>,
pub timeout_secs: u64,
pub auth: McpAuthConfig,
pub source: McpRegistrySource,
client: Arc<McpTransportClient>,
}
#[derive(Debug)]
pub enum McpTransportClient {
Http(McpHttpClient),
Stdio(McpStdioClient),
}
#[derive(Debug, Default, Clone)]
pub struct McpServerRegistry {
by_name: HashMap<String, McpServerDefinition>,
order: Vec<String>,
}
impl McpServerRegistry {
pub fn from_config(config: &Config) -> Self {
let mut registry = Self::default();
if !config.mcp_client.enabled {
return registry;
}
for server in &config.mcp_client.servers {
registry.register_config_server(
server,
&config.mcp_client.client_identity,
McpRegistrySource::Config,
);
}
if config.gitbooks.enabled && registry.get("gitbooks").is_none() {
registry.insert(McpServerDefinition {
name: "gitbooks".into(),
endpoint: config.gitbooks.endpoint.clone(),
command: None,
description: Some("OpenHuman GitBook documentation MCP server.".into()),
timeout_secs: config.gitbooks.timeout_secs,
auth: McpAuthConfig::None,
source: McpRegistrySource::LegacyGitbooks,
client: Arc::new(McpTransportClient::Http(McpHttpClient::with_options(
config.gitbooks.endpoint.clone(),
config.gitbooks.timeout_secs,
McpAuthConfig::None,
config.mcp_client.client_identity.clone(),
))),
});
}
registry
}
pub fn is_empty(&self) -> bool {
self.order.is_empty()
}
pub fn list(&self) -> Vec<&McpServerDefinition> {
self.order
.iter()
.filter_map(|name| self.by_name.get(name))
.collect()
}
pub fn get(&self, name: &str) -> Option<&McpServerDefinition> {
self.by_name.get(name)
}
pub async fn list_tools(&self, server: &str) -> anyhow::Result<Vec<McpRemoteTool>> {
let server = self
.get(server)
.ok_or_else(|| anyhow::anyhow!("unknown MCP server `{server}`"))?;
server.client.list_tools().await
}
pub async fn call_tool(
&self,
server: &str,
tool: &str,
arguments: Value,
) -> anyhow::Result<McpServerToolResult> {
let server = self
.get(server)
.ok_or_else(|| anyhow::anyhow!("unknown MCP server `{server}`"))?;
server.client.call_tool(tool, arguments).await
}
pub async fn initialize(&self, server: &str) -> anyhow::Result<McpInitializeResult> {
let server = self
.get(server)
.ok_or_else(|| anyhow::anyhow!("unknown MCP server `{server}`"))?;
server.client.initialize().await
}
pub async fn discover_authorization(
&self,
server: &str,
) -> anyhow::Result<Option<McpAuthorizationContext>> {
let server = self
.get(server)
.ok_or_else(|| anyhow::anyhow!("unknown MCP server `{server}`"))?;
server.client.discover_authorization().await
}
fn register_config_server(
&mut self,
server: &McpServerConfig,
identity: &McpClientIdentityConfig,
source: McpRegistrySource,
) {
if !server.enabled {
return;
}
let name = server.name.trim();
let endpoint = server.endpoint.trim();
let command = server.command.trim();
if name.is_empty() || (endpoint.is_empty() && command.is_empty()) {
tracing::warn!(
name = server.name,
endpoint = server.endpoint,
command = server.command,
"[mcp_client] skipping malformed MCP server config entry"
);
return;
}
self.insert(McpServerDefinition {
name: name.to_string(),
endpoint: endpoint.to_string(),
command: transport_command(server),
description: server.description.clone(),
timeout_secs: server.timeout_secs,
auth: server.auth.clone(),
source,
client: Arc::new(build_transport_client(server, identity)),
});
}
fn insert(&mut self, def: McpServerDefinition) {
let name = def.name.clone();
if self.by_name.insert(name.clone(), def).is_none() {
self.order.push(name);
}
}
}
impl McpTransportClient {
pub async fn initialize(&self) -> anyhow::Result<McpInitializeResult> {
match self {
Self::Http(client) => client.initialize().await,
Self::Stdio(client) => client.initialize().await,
}
}
pub async fn list_tools(&self) -> anyhow::Result<Vec<McpRemoteTool>> {
match self {
Self::Http(client) => client.list_tools().await,
Self::Stdio(client) => client.list_tools().await,
}
}
pub async fn call_tool(
&self,
tool: &str,
arguments: Value,
) -> anyhow::Result<McpServerToolResult> {
match self {
Self::Http(client) => client.call_tool(tool, arguments).await,
Self::Stdio(client) => client.call_tool(tool, arguments).await,
}
}
pub async fn discover_authorization(&self) -> anyhow::Result<Option<McpAuthorizationContext>> {
match self {
Self::Http(client) => client.discover_authorization().await,
Self::Stdio(_) => Ok(None),
}
}
}
fn build_transport_client(
server: &McpServerConfig,
identity: &McpClientIdentityConfig,
) -> McpTransportClient {
if !server.command.trim().is_empty() {
let env = server
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<Vec<_>>();
McpTransportClient::Stdio(McpStdioClient::new(
server.command.trim().to_string(),
server.args.clone(),
env,
server.cwd.as_ref().map(PathBuf::from),
identity.clone(),
))
} else {
McpTransportClient::Http(McpHttpClient::with_options(
server.endpoint.trim().to_string(),
server.timeout_secs,
server.auth.clone(),
identity.clone(),
))
}
}
fn transport_command(server: &McpServerConfig) -> Option<String> {
let command = server.command.trim();
if command.is_empty() {
None
} else {
Some(command.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_config_seeds_legacy_gitbooks_when_enabled() {
let config = Config::default();
let registry = McpServerRegistry::from_config(&config);
let gitbooks = registry.get("gitbooks").expect("gitbooks");
assert_eq!(gitbooks.source, McpRegistrySource::LegacyGitbooks);
}
#[test]
fn explicit_server_overrides_legacy_name() {
let mut config = Config::default();
config.mcp_client.servers.push(McpServerConfig {
name: "gitbooks".into(),
endpoint: "https://example.com/mcp".into(),
command: String::new(),
args: Vec::new(),
env: HashMap::new(),
cwd: None,
description: Some("Custom docs".into()),
enabled: true,
timeout_secs: 9,
auth: crate::openhuman::config::McpAuthConfig::None,
});
let registry = McpServerRegistry::from_config(&config);
let gitbooks = registry.get("gitbooks").expect("gitbooks");
assert_eq!(gitbooks.source, McpRegistrySource::Config);
assert_eq!(gitbooks.endpoint, "https://example.com/mcp");
}
#[test]
fn disabled_config_short_circuits_registry() {
let mut config = Config::default();
config.mcp_client.enabled = false;
let registry = McpServerRegistry::from_config(&config);
assert!(registry.is_empty());
}
}
+279
View File
@@ -0,0 +1,279 @@
use super::client::{render_tool_result, McpInitializeResult, McpRemoteTool, McpServerToolResult};
use crate::openhuman::config::McpClientIdentityConfig;
use anyhow::Context;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicI64, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::Mutex;
const LATEST_PROTOCOL_VERSION: &str = "2025-11-25";
#[derive(Debug)]
pub struct McpStdioClient {
command: String,
args: Vec<String>,
env: Vec<(String, String)>,
cwd: Option<PathBuf>,
next_id: AtomicI64,
client_name: String,
client_title: String,
client_version: String,
state: Mutex<Option<StdioSession>>,
}
#[derive(Debug)]
struct StdioSession {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
initialize: McpInitializeResult,
}
impl McpStdioClient {
pub fn new(
command: String,
args: Vec<String>,
env: Vec<(String, String)>,
cwd: Option<PathBuf>,
identity: McpClientIdentityConfig,
) -> Self {
Self {
command,
args,
env,
cwd,
next_id: AtomicI64::new(1),
client_name: identity.name,
client_title: identity.title,
client_version: identity.version,
state: Mutex::new(None),
}
}
pub async fn initialize(&self) -> anyhow::Result<McpInitializeResult> {
let mut state = self.state.lock().await;
if let Some(session) = state.as_ref() {
return Ok(session.initialize.clone());
}
let mut command = Command::new(&self.command);
command
.args(&self.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
if let Some(cwd) = self.cwd.as_ref() {
command.current_dir(cwd);
}
for (key, value) in &self.env {
command.env(key, value);
}
let mut child = command
.spawn()
.with_context(|| format!("spawning MCP stdio server `{}`", self.command))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("stdio server missing stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("stdio server missing stdout"))?;
let mut session = StdioSession {
child,
stdin,
stdout: BufReader::new(stdout),
initialize: McpInitializeResult {
protocol_version: LATEST_PROTOCOL_VERSION.into(),
capabilities: json!({}),
server_info: json!({}),
instructions: None,
},
};
let response = self
.send_request_on_session(
&mut session,
"initialize",
json!({
"protocolVersion": LATEST_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {
"name": self.client_name,
"title": self.client_title,
"version": self.client_version,
}
}),
)
.await?;
let init: McpInitializeResult =
serde_json::from_value(response).context("parsing stdio initialize result")?;
self.send_notification_on_session(&mut session, "notifications/initialized", json!({}))
.await?;
session.initialize = init.clone();
*state = Some(session);
Ok(init)
}
pub async fn list_tools(&self) -> anyhow::Result<Vec<McpRemoteTool>> {
self.initialize().await?;
let mut state = self.state.lock().await;
let session = state
.as_mut()
.ok_or_else(|| anyhow::anyhow!("stdio MCP session not initialized"))?;
let response = self
.send_request_on_session(session, "tools/list", json!({}))
.await?;
serde_json::from_value(
response
.get("tools")
.cloned()
.ok_or_else(|| anyhow::anyhow!("stdio tools/list response missing `tools`"))?,
)
.context("parsing stdio tools/list payload")
}
pub async fn call_tool(
&self,
name: &str,
arguments: Value,
) -> anyhow::Result<McpServerToolResult> {
self.initialize().await?;
let mut state = self.state.lock().await;
let session = state
.as_mut()
.ok_or_else(|| anyhow::anyhow!("stdio MCP session not initialized"))?;
let result = self
.send_request_on_session(
session,
"tools/call",
json!({
"name": name,
"arguments": arguments,
}),
)
.await?;
let rendered = render_tool_result(&result);
Ok(McpServerToolResult {
raw_result: result,
rendered,
})
}
pub async fn close_session(&self) -> anyhow::Result<()> {
let mut state = self.state.lock().await;
if let Some(mut session) = state.take() {
let _ = session.child.start_kill();
let _ = session.child.wait().await;
}
Ok(())
}
async fn send_request_on_session(
&self,
session: &mut StdioSession,
method: &str,
params: Value,
) -> anyhow::Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let line = serde_json::to_string(&json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}))?;
session.stdin.write_all(line.as_bytes()).await?;
session.stdin.write_all(b"\n").await?;
session.stdin.flush().await?;
loop {
let mut response = String::new();
let read = session.stdout.read_line(&mut response).await?;
if read == 0 {
anyhow::bail!("stdio MCP server closed stdout while waiting for `{method}`");
}
let trimmed = response.trim();
if trimmed.is_empty() {
continue;
}
if !trimmed.starts_with('{') && !trimmed.starts_with('[') {
tracing::debug!(
target: "[mcp_client::stdio]",
command = %self.command,
line = %trimmed,
"ignoring non-JSON stdout line from stdio MCP server"
);
continue;
}
let payload: Value = serde_json::from_str(trimmed)
.with_context(|| format!("parsing stdio MCP response: {trimmed}"))?;
if let Some(err) = payload.get("error") {
anyhow::bail!("MCP stdio error: {err}");
}
return payload
.get("result")
.cloned()
.ok_or_else(|| anyhow::anyhow!("stdio MCP response missing `result`: {payload}"));
}
}
async fn send_notification_on_session(
&self,
session: &mut StdioSession,
method: &str,
params: Value,
) -> anyhow::Result<()> {
let line = serde_json::to_string(&json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
}))?;
session.stdin.write_all(line.as_bytes()).await?;
session.stdin.write_all(b"\n").await?;
session.stdin.flush().await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn openhuman_core_bin() -> PathBuf {
let status = std::process::Command::new("cargo")
.args(["build", "--quiet", "--bin", "openhuman-core"])
.status()
.expect("spawn cargo build");
assert!(status.success(), "cargo build --bin openhuman-core failed");
let exe = std::env::current_exe().expect("current_exe");
let debug_dir = exe
.parent()
.and_then(|p| p.parent())
.expect("target/debug dir");
let bin = debug_dir.join("openhuman-core");
assert!(bin.exists(), "expected openhuman-core at {}", bin.display());
bin
}
#[tokio::test]
async fn stdio_client_talks_to_openhuman_mcp_server() {
let client = McpStdioClient::new(
openhuman_core_bin().display().to_string(),
vec!["mcp".into()],
Vec::new(),
Some(std::env::current_dir().unwrap()),
McpClientIdentityConfig::default(),
);
let init = client.initialize().await.expect("initialize");
assert_eq!(init.protocol_version, LATEST_PROTOCOL_VERSION);
let tools = client.list_tools().await.expect("list_tools");
assert!(tools.iter().any(|tool| tool.name == "memory.search"));
client.close_session().await.expect("close");
}
}
+11 -3
View File
@@ -227,7 +227,7 @@ fn initialize_result(params: Value) -> Value {
"name": "openhuman-core",
"version": env!("CARGO_PKG_VERSION")
},
"instructions": "OpenHuman MCP exposes a small read-only memory surface. Use memory.search or memory.recall first, then tree.read_chunk for source text."
"instructions": "OpenHuman MCP exposes first-level core integration: inspect the live tool catalog with core.list_tools or core.tool_instructions, inspect subagents with agent.list_subagents, run a standalone subagent with agent.run_subagent, and use memory.search or memory.recall plus tree.read_chunk for local memory reads."
})
}
@@ -324,7 +324,7 @@ mod tests {
}
#[tokio::test]
async fn tools_list_returns_curated_tools() {
async fn tools_list_returns_first_level_core_tools() {
let response = request(json!({
"jsonrpc": "2.0",
"id": 2,
@@ -340,7 +340,15 @@ mod tests {
.collect::<Vec<_>>();
assert_eq!(
names,
vec!["memory.search", "memory.recall", "tree.read_chunk"]
vec![
"core.list_tools",
"core.tool_instructions",
"agent.list_subagents",
"agent.run_subagent",
"memory.search",
"memory.recall",
"tree.read_chunk"
]
);
}
+6 -1
View File
@@ -76,7 +76,12 @@ fn print_help() {
eprintln!("Usage: openhuman-core mcp [-v|--verbose]");
eprintln!();
eprintln!("Start an opt-in stdio Model Context Protocol server.");
eprintln!("The server exposes a curated read-only memory surface:");
eprintln!("The server exposes first-level core MCP tools:");
eprintln!(" core.list_tools");
eprintln!(" core.tool_instructions");
eprintln!(" agent.list_subagents");
eprintln!(" agent.run_subagent");
eprintln!("And the read-only memory surface:");
eprintln!(" memory.search");
eprintln!(" memory.recall");
eprintln!(" tree.read_chunk");
+328 -18
View File
@@ -1,20 +1,24 @@
use serde_json::{json, Map, Value};
use crate::core::all;
use crate::openhuman::agent::harness::AgentDefinitionRegistry;
use crate::openhuman::agent::Agent;
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::providers::traits::build_tool_instructions_text;
use crate::openhuman::security::{SecurityPolicy, ToolOperation};
const DEFAULT_LIMIT: u64 = 10;
const MAX_LIMIT: u64 = 50;
const QUERY_ARGUMENTS: &[&str] = &["query", "k"];
const TREE_READ_CHUNK_ARGUMENTS: &[&str] = &["chunk_id"];
const SUBAGENT_RUN_ARGUMENTS: &[&str] = &["agent_id", "prompt"];
#[derive(Debug, Clone)]
pub struct McpToolSpec {
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub rpc_method: &'static str,
pub rpc_method: Option<&'static str>,
pub input_schema: Value,
}
@@ -57,25 +61,67 @@ impl ToolCallError {
pub fn tool_specs() -> Vec<McpToolSpec> {
vec![
McpToolSpec {
name: "core.list_tools",
title: "List Core Tools",
description: "List the live core agent tool catalog that OpenHuman exposes to its orchestrator session.",
rpc_method: None,
input_schema: no_args_schema(),
},
McpToolSpec {
name: "core.tool_instructions",
title: "Get Tool Instructions",
description: "Emit the markdown tool-use instructions block that OpenHuman injects into prompt-guided agents.",
rpc_method: None,
input_schema: no_args_schema(),
},
McpToolSpec {
name: "agent.list_subagents",
title: "List Subagents",
description: "List registered sub-agent definitions that the core can dispatch for specialized work.",
rpc_method: None,
input_schema: no_args_schema(),
},
McpToolSpec {
name: "agent.run_subagent",
title: "Run Subagent",
description: "Run a registered OpenHuman sub-agent directly from the core and return its final response.",
rpc_method: None,
input_schema: json!({
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "Registered sub-agent id (for example `researcher`, `planner`, `code_executor`)."
},
"prompt": {
"type": "string",
"description": "Task prompt for the sub-agent. Include the context it needs because this is a fresh session."
}
},
"required": ["agent_id", "prompt"],
"additionalProperties": false
}),
},
McpToolSpec {
name: "memory.search",
title: "Search Memory",
description: "Keyword-search OpenHuman's local memory tree and return matching chunks ordered by recency.",
rpc_method: "openhuman.memory_tree_search",
rpc_method: Some("openhuman.memory_tree_search"),
input_schema: query_schema("Substring to match against stored memory chunks."),
},
McpToolSpec {
name: "memory.recall",
title: "Recall Memory",
description: "Semantically recall local memory-tree chunks relevant to a natural-language query.",
rpc_method: "openhuman.memory_tree_recall",
rpc_method: Some("openhuman.memory_tree_recall"),
input_schema: query_schema("Natural-language query to embed and rerank against memory summaries."),
},
McpToolSpec {
name: "tree.read_chunk",
title: "Read Memory Chunk",
description: "Read one memory-tree chunk by id. Use this to inspect the source text behind search or recall results.",
rpc_method: "openhuman.memory_tree_get_chunk",
rpc_method: Some("openhuman.memory_tree_get_chunk"),
input_schema: json!({
"type": "object",
"properties": {
@@ -113,17 +159,47 @@ pub async fn call_tool(name: &str, arguments: Value) -> Result<Value, ToolCallEr
.ok_or_else(|| ToolCallError::InvalidParams(format!("unknown MCP tool `{name}`")))?;
let params = build_rpc_params(spec.name, arguments)?;
match spec.name {
"core.list_tools" => {
reject_unexpected_arguments(&params, &[])?;
enforce_read_policy(spec.name).await?;
return list_core_tools().await;
}
"core.tool_instructions" => {
reject_unexpected_arguments(&params, &[])?;
enforce_read_policy(spec.name).await?;
return core_tool_instructions().await;
}
"agent.list_subagents" => {
reject_unexpected_arguments(&params, &[])?;
enforce_read_policy(spec.name).await?;
return list_subagents().await;
}
"agent.run_subagent" => {
enforce_act_policy(spec.name).await?;
return run_subagent_tool(&params).await;
}
_ => {}
}
validate_controller_params(&spec, &params)?;
enforce_read_policy(spec.name).await?;
let rpc_method = spec.rpc_method.ok_or_else(|| {
ToolCallError::Internal(format!(
"MCP tool `{}` is missing its RPC mapping",
spec.name
))
})?;
log::debug!(
"[mcp_server] tools/call dispatch tool={} rpc_method={} arg_keys={:?}",
spec.name,
spec.rpc_method,
rpc_method,
params.keys().collect::<Vec<_>>()
);
match all::try_invoke_registered_rpc(spec.rpc_method, params).await {
match all::try_invoke_registered_rpc(rpc_method, params).await {
Some(Ok(value)) => {
log::debug!("[mcp_server] tools/call success tool={}", spec.name);
Ok(tool_success(value))
@@ -140,16 +216,24 @@ pub async fn call_tool(name: &str, arguments: Value) -> Result<Value, ToolCallEr
log::error!(
"[mcp_server] tools/call mapping missing registered RPC method tool={} rpc_method={}",
spec.name,
spec.rpc_method
rpc_method
);
Ok(tool_error(format!(
"{} is unavailable: mapped RPC method `{}` is not registered",
spec.name, spec.rpc_method
spec.name, rpc_method
)))
}
}
}
fn no_args_schema() -> Value {
json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
fn query_schema(query_description: &str) -> Value {
json!({
"type": "object",
@@ -177,6 +261,19 @@ fn build_rpc_params(
) -> Result<Map<String, Value>, ToolCallError> {
let args = object_arguments(arguments)?;
match tool_name {
"core.list_tools" | "core.tool_instructions" | "agent.list_subagents" => {
reject_unexpected_arguments(&args, &[])?;
Ok(Map::new())
}
"agent.run_subagent" => {
reject_unexpected_arguments(&args, SUBAGENT_RUN_ARGUMENTS)?;
let agent_id = required_non_empty_string(&args, "agent_id")?;
let prompt = required_non_empty_string(&args, "prompt")?;
Ok(Map::from_iter([
("agent_id".to_string(), Value::String(agent_id)),
("prompt".to_string(), Value::String(prompt)),
]))
}
"memory.search" | "memory.recall" => {
reject_unexpected_arguments(&args, QUERY_ARGUMENTS)?;
let query = required_non_empty_string(&args, "query")?;
@@ -276,10 +373,16 @@ fn validate_controller_params(
spec: &McpToolSpec,
params: &Map<String, Value>,
) -> Result<(), ToolCallError> {
let schema = all::schema_for_rpc_method(spec.rpc_method).ok_or_else(|| {
let rpc_method = spec.rpc_method.ok_or_else(|| {
ToolCallError::Internal(format!(
"MCP tool `{}` does not dispatch through RPC validation",
spec.name
))
})?;
let schema = all::schema_for_rpc_method(rpc_method).ok_or_else(|| {
ToolCallError::InvalidParams(format!(
"mapped RPC method `{}` is not registered",
spec.rpc_method
rpc_method
))
})?;
all::validate_params(&schema, params).map_err(ToolCallError::InvalidParams)
@@ -309,6 +412,156 @@ async fn enforce_read_policy(tool_name: &str) -> Result<(), ToolCallError> {
.map_err(ToolCallError::InvalidParams)
}
async fn enforce_act_policy(tool_name: &str) -> Result<(), ToolCallError> {
let config = match config_rpc::load_config_with_timeout().await {
Ok(config) => config,
Err(err) => {
log::warn!(
"[mcp_server] enforce_act_policy config load failed tool={tool_name} error={err}"
);
return Err(ToolCallError::Internal(format!(
"failed to load config: {err}"
)));
}
};
let policy = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
policy
.enforce_tool_operation(ToolOperation::Act, tool_name)
.map_err(ToolCallError::InvalidParams)
}
async fn load_config_and_init_registry() -> Result<crate::openhuman::config::Config, ToolCallError>
{
let config = config_rpc::load_config_with_timeout()
.await
.map_err(|err| ToolCallError::Internal(format!("failed to load config: {err}")))?;
AgentDefinitionRegistry::init_global(&config.workspace_dir).map_err(|err| {
ToolCallError::Internal(format!(
"failed to initialise AgentDefinitionRegistry: {err}"
))
})?;
Ok(config)
}
async fn build_orchestrator_agent() -> Result<Agent, ToolCallError> {
let config = load_config_and_init_registry().await?;
let mut agent = Agent::from_config_for_agent(&config, "orchestrator").map_err(|err| {
ToolCallError::Internal(format!("failed to build orchestrator agent: {err}"))
})?;
agent.fetch_connected_integrations().await;
let _ = agent.refresh_delegation_tools();
Ok(agent)
}
async fn list_core_tools() -> Result<Value, ToolCallError> {
let agent = build_orchestrator_agent().await?;
let tools = agent
.tool_specs()
.iter()
.map(|spec| {
json!({
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters,
})
})
.collect::<Vec<_>>();
Ok(tool_success(json!({ "tools": tools })))
}
async fn core_tool_instructions() -> Result<Value, ToolCallError> {
let agent = build_orchestrator_agent().await?;
Ok(tool_text_success(build_tool_instructions_text(
agent.tool_specs(),
)))
}
async fn list_subagents() -> Result<Value, ToolCallError> {
let config = load_config_and_init_registry().await?;
let registry = AgentDefinitionRegistry::global().ok_or_else(|| {
ToolCallError::Internal("AgentDefinitionRegistry missing after init".to_string())
})?;
let definitions = registry
.list()
.into_iter()
.map(|def| {
json!({
"id": def.id,
"display_name": def.display_name(),
"when_to_use": def.when_to_use,
"temperature": def.temperature,
"max_iterations": def.max_iterations,
"sandbox_mode": def.sandbox_mode,
"tool_scope": def.tools,
"subagents": def.subagents,
"source": def.source,
})
})
.collect::<Vec<_>>();
let summary = format!(
"# OpenHuman Subagents\n\nWorkspace: `{}`\n\n{}",
config.workspace_dir.display(),
definitions
.iter()
.map(|def| {
let id = def.get("id").and_then(Value::as_str).unwrap_or("<unknown>");
let when = def.get("when_to_use").and_then(Value::as_str).unwrap_or("");
format!("- **{id}**: {when}")
})
.collect::<Vec<_>>()
.join("\n")
);
Ok(json!({
"content": [{
"type": "text",
"text": summary,
}],
"structuredContent": {
"definitions": definitions,
}
}))
}
async fn run_subagent_tool(params: &Map<String, Value>) -> Result<Value, ToolCallError> {
let agent_id = required_non_empty_string(params, "agent_id")?;
let prompt = required_non_empty_string(params, "prompt")?;
if agent_id == "integrations_agent" {
return Err(ToolCallError::InvalidParams(
"agent.run_subagent does not yet support `integrations_agent`; first-level MCP support is currently limited to standalone agents that do not require toolkit binding".to_string(),
));
}
let config = load_config_and_init_registry().await?;
let mut agent = Agent::from_config_for_agent(&config, &agent_id).map_err(|err| {
ToolCallError::InvalidParams(format!("failed to build agent `{agent_id}`: {err}"))
})?;
agent.set_event_context(
format!("mcp:{}:{}", agent_id, uuid::Uuid::new_v4()),
"mcp_server",
);
agent.fetch_connected_integrations().await;
let _ = agent.refresh_delegation_tools();
let response = agent
.run_single(&prompt)
.await
.map_err(|err| ToolCallError::Internal(format!("subagent `{agent_id}` failed: {err}")))?;
Ok(json!({
"content": [{
"type": "text",
"text": response,
}],
"structuredContent": {
"agent_id": agent_id,
"response": response,
}
}))
}
fn tool_success(value: Value) -> Value {
json!({
"content": [{
@@ -318,6 +571,15 @@ fn tool_success(value: Value) -> Value {
})
}
fn tool_text_success(text: String) -> Value {
json!({
"content": [{
"type": "text",
"text": text,
}]
})
}
fn tool_error(message: String) -> Value {
json!({
"content": [{
@@ -344,7 +606,7 @@ mod tests {
use super::*;
#[test]
fn list_tools_exposes_curated_read_only_surface() {
fn list_tools_exposes_first_level_mcp_surface() {
let result = list_tools_result();
let names = result["tools"]
.as_array()
@@ -355,22 +617,70 @@ mod tests {
assert_eq!(
names,
vec!["memory.search", "memory.recall", "tree.read_chunk"]
vec![
"core.list_tools",
"core.tool_instructions",
"agent.list_subagents",
"agent.run_subagent",
"memory.search",
"memory.recall",
"tree.read_chunk"
]
);
}
#[test]
fn mapped_rpc_methods_are_registered() {
for spec in tool_specs() {
assert!(
all::schema_for_rpc_method(spec.rpc_method).is_some(),
"missing registered RPC method for {} -> {}",
spec.name,
spec.rpc_method
);
if let Some(rpc_method) = spec.rpc_method {
assert!(
all::schema_for_rpc_method(rpc_method).is_some(),
"missing registered RPC method for {} -> {}",
spec.name,
rpc_method
);
}
}
}
#[test]
fn build_rpc_params_parses_run_subagent_arguments() {
let params = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause."
}),
)
.expect("params should parse");
assert_eq!(
params.get("agent_id").and_then(Value::as_str),
Some("researcher")
);
assert_eq!(
params.get("prompt").and_then(Value::as_str),
Some("Find the root cause.")
);
}
#[test]
fn build_rpc_params_rejects_extra_run_subagent_fields() {
let err = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause.",
"toolkit": "gmail"
}),
)
.expect_err("unexpected field should be rejected");
assert!(
matches!(err, ToolCallError::InvalidParams(message) if message.contains("unexpected argument"))
);
}
#[test]
fn memory_search_params_trim_query_and_use_default_k() {
let params = build_rpc_params(
+1
View File
@@ -41,6 +41,7 @@ pub mod integrations;
pub mod javascript;
pub mod learning;
pub mod local_ai;
pub mod mcp_client;
pub mod mcp_server;
pub mod meet;
pub mod meet_agent;
+18 -241
View File
@@ -1,176 +1,11 @@
//! `gitbooks` — answer questions about OpenHuman by talking to the
//! GitBook MCP server.
//!
//! GitBook hosts a stateless Streamable-HTTP MCP server that exposes
//! exactly two tools:
//!
//! - `searchDocumentation { query }` — returns excerpts + page links
//! - `getPage { url }` — returns the full markdown of a page
//!
//! We mirror them as `gitbooks_search` and `gitbooks_get_page`. The
//! server returns each JSON-RPC response in a single
//! `event: message\ndata: {…}` SSE frame, so we do a tiny inline
//! parse — no need to pull in a full SSE/MCP client crate yet.
//! GitBook MCP server through the shared `openhuman::mcp_client` path.
use crate::openhuman::mcp_client::{redact_endpoint, McpHttpClient};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// Minimal MCP-over-HTTP client for stateless servers (no session id).
struct McpHttpClient {
endpoint: String,
timeout_secs: u64,
next_id: AtomicI64,
}
impl McpHttpClient {
fn new(endpoint: String, timeout_secs: u64) -> Self {
Self {
endpoint,
timeout_secs,
next_id: AtomicI64::new(1),
}
}
async fn call_tool(&self, name: &str, arguments: Value) -> anyhow::Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let body = json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": { "name": name, "arguments": arguments }
});
let builder = reqwest::Client::builder()
.timeout(Duration::from_secs(self.timeout_secs))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none());
let builder =
crate::openhuman::config::apply_runtime_proxy_to_builder(builder, "tool.gitbooks");
let client = builder.build()?;
// Log only the redacted host so query strings / path params /
// any future bearer-token-bearing endpoints don't leak into
// logs aggregated for triage.
tracing::debug!(
target: "[gitbooks]",
endpoint = %redact_endpoint(&self.endpoint),
tool = %name,
"MCP tools/call"
);
let resp = client
.post(&self.endpoint)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(serde_json::to_vec(&body)?)
.send()
.await?;
let status = resp.status();
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let text = resp.text().await?;
if !status.is_success() {
anyhow::bail!("MCP HTTP {} — {}", status.as_u16(), text);
}
let payload: Value = if content_type.starts_with("text/event-stream") {
parse_sse_message(&text)?
} else {
serde_json::from_str(&text).map_err(|e| {
anyhow::anyhow!("Failed to parse MCP JSON response: {e} — body: {text}")
})?
};
if let Some(err) = payload.get("error") {
anyhow::bail!("MCP error: {err}");
}
let result = payload
.get("result")
.ok_or_else(|| anyhow::anyhow!("MCP response missing 'result': {payload}"))?
.clone();
Ok(result)
}
}
/// Reduce a configured endpoint URL to `scheme://host[:port]` for
/// safe logging. Anything that doesn't parse as a recognisable
/// http(s) URL is reported as `<redacted>` rather than echoed.
fn redact_endpoint(raw: &str) -> String {
let trimmed = raw.trim();
let (scheme, rest) = if let Some(r) = trimmed.strip_prefix("https://") {
("https", r)
} else if let Some(r) = trimmed.strip_prefix("http://") {
("http", r)
} else {
return "<redacted>".into();
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
if authority.is_empty() || authority.contains('@') {
return "<redacted>".into();
}
format!("{scheme}://{authority}")
}
/// Parse the first `data: {…}` line from a Streamable-HTTP SSE
/// response. The GitBook server emits exactly one frame per JSON-RPC
/// request, so we do not need a full SSE state machine.
fn parse_sse_message(body: &str) -> anyhow::Result<Value> {
for line in body.lines() {
let line = line.trim_end_matches('\r');
if let Some(data) = line.strip_prefix("data:") {
let data = data.trim_start();
if data.is_empty() {
continue;
}
return serde_json::from_str(data).map_err(|e| {
anyhow::anyhow!("Failed to parse SSE data frame: {e} — line: {data}")
});
}
}
anyhow::bail!("No SSE data frame found in MCP response: {body}")
}
/// Render an MCP `tools/call` result into a single string for the
/// agent. MCP returns `{ content: [{ type, text }, …], isError? }`.
fn render_tool_result(result: &Value) -> ToolResult {
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
let mut out = String::new();
if let Some(content) = result.get("content").and_then(Value::as_array) {
for block in content {
if let Some(t) = block.get("text").and_then(Value::as_str) {
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(t);
}
}
}
if out.is_empty() {
out = result.to_string();
}
if is_error {
ToolResult::error(out)
} else {
ToolResult::success(out)
}
}
// ── Search ─────────────────────────────────────────────────────────
pub struct GitbooksSearchTool {
client: Arc<McpHttpClient>,
@@ -223,19 +58,24 @@ impl Tool for GitbooksSearchTool {
return Ok(ToolResult::error("query cannot be empty"));
}
tracing::debug!(
target: "[gitbooks]",
endpoint = %redact_endpoint(self.client.endpoint()),
tool = "searchDocumentation",
"dispatching via shared MCP client"
);
match self
.client
.call_tool("searchDocumentation", json!({ "query": query }))
.await
{
Ok(result) => Ok(render_tool_result(&result)),
Ok(result) => Ok(result.rendered),
Err(e) => Ok(ToolResult::error(format!("gitbooks_search failed: {e}"))),
}
}
}
// ── Get page ──────────────────────────────────────────────────────
pub struct GitbooksGetPageTool {
client: Arc<McpHttpClient>,
}
@@ -286,12 +126,19 @@ impl Tool for GitbooksGetPageTool {
return Ok(ToolResult::error("url cannot be empty"));
}
tracing::debug!(
target: "[gitbooks]",
endpoint = %redact_endpoint(self.client.endpoint()),
tool = "getPage",
"dispatching via shared MCP client"
);
match self
.client
.call_tool("getPage", json!({ "url": url }))
.await
{
Ok(result) => Ok(render_tool_result(&result)),
Ok(result) => Ok(result.rendered),
Err(e) => Ok(ToolResult::error(format!("gitbooks_get_page failed: {e}"))),
}
}
@@ -313,73 +160,6 @@ mod tests {
);
}
#[test]
fn redact_endpoint_rejects_userinfo_and_unknown_schemes() {
assert_eq!(
redact_endpoint("https://user:pass@example.com/x"),
"<redacted>"
);
assert_eq!(redact_endpoint("ftp://example.com"), "<redacted>");
assert_eq!(redact_endpoint(""), "<redacted>");
}
#[test]
fn parse_sse_extracts_data_frame() {
let body = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"x\":1}}\n\n";
let v = parse_sse_message(body).unwrap();
assert_eq!(v["result"]["x"], 1);
}
#[test]
fn parse_sse_handles_crlf() {
let body = "event: message\r\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\r\n\r\n";
assert!(parse_sse_message(body).is_ok());
}
#[test]
fn parse_sse_errors_when_no_data_line() {
let body = "event: ping\n\n";
assert!(parse_sse_message(body).is_err());
}
#[test]
fn parse_sse_errors_on_invalid_json() {
let body = "data: not-json\n\n";
assert!(parse_sse_message(body).is_err());
}
#[test]
fn render_tool_result_concatenates_text_blocks() {
let r = json!({
"content": [
{"type": "text", "text": "first"},
{"type": "text", "text": "second"}
]
});
let out = render_tool_result(&r);
assert!(!out.is_error);
assert!(out.output().contains("first"));
assert!(out.output().contains("second"));
}
#[test]
fn render_tool_result_marks_errors() {
let r = json!({
"content": [{"type": "text", "text": "boom"}],
"isError": true
});
let out = render_tool_result(&r);
assert!(out.is_error);
assert!(out.output().contains("boom"));
}
#[test]
fn render_tool_result_falls_back_to_raw_json() {
let r = json!({"weird": "shape"});
let out = render_tool_result(&r);
assert!(out.output().contains("weird"));
}
#[tokio::test]
async fn search_rejects_empty_query() {
let t = GitbooksSearchTool::new("https://example.com/mcp".into(), 5);
@@ -395,9 +175,6 @@ mod tests {
assert!(result.is_error);
}
/// Live integration test against the real GitBook MCP endpoint.
/// Gated behind `OPENHUMAN_GITBOOKS_LIVE_TEST=1` so CI / offline
/// builds don't depend on the public network.
#[tokio::test]
async fn live_search_smoke() {
if std::env::var("OPENHUMAN_GITBOOKS_LIVE_TEST")
+313
View File
@@ -0,0 +1,313 @@
use crate::openhuman::mcp_client::{McpRegistrySource, McpServerRegistry};
use crate::openhuman::security::{SecurityPolicy, ToolOperation};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;
pub struct McpListServersTool {
registry: Arc<McpServerRegistry>,
}
impl McpListServersTool {
pub fn new(registry: Arc<McpServerRegistry>) -> Self {
Self { registry }
}
}
#[async_trait]
impl Tool for McpListServersTool {
fn name(&self) -> &str {
"mcp_list_servers"
}
fn description(&self) -> &str {
"List named remote MCP servers registered in OpenHuman core. Use this before browsing tools on a specific MCP server."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::ReadOnly
}
fn supports_markdown(&self) -> bool {
true
}
async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
let servers = self
.registry
.list()
.into_iter()
.map(|server| {
json!({
"name": server.name,
"endpoint": server.endpoint,
"description": server.description,
"timeout_secs": server.timeout_secs,
"auth": server.auth,
"source": server.source,
})
})
.collect::<Vec<_>>();
let markdown = if servers.is_empty() {
"# MCP Servers\n\nNo remote MCP servers are registered.".to_string()
} else {
let mut md = String::from("# MCP Servers\n");
for server in self.registry.list() {
let source = match server.source {
McpRegistrySource::Config => "config",
McpRegistrySource::LegacyGitbooks => "legacy_gitbooks",
};
md.push_str(&format!(
"\n- **{}** ({source})\n - endpoint: `{}`\n - auth: `{}`",
server.name,
server.endpoint,
match &server.auth {
crate::openhuman::config::McpAuthConfig::None => "none",
crate::openhuman::config::McpAuthConfig::BearerToken { .. } =>
"bearer_token",
crate::openhuman::config::McpAuthConfig::Basic { .. } => "basic",
crate::openhuman::config::McpAuthConfig::Header { .. } => "header",
crate::openhuman::config::McpAuthConfig::QueryParam { .. } => "query_param",
}
));
if let Some(description) = server.description.as_deref() {
md.push_str(&format!("\n - {description}"));
}
}
md
};
Ok(ToolResult::success_with_markdown(
json!({ "servers": servers }),
markdown,
))
}
}
pub struct McpListToolsTool {
registry: Arc<McpServerRegistry>,
}
impl McpListToolsTool {
pub fn new(registry: Arc<McpServerRegistry>) -> Self {
Self { registry }
}
}
#[async_trait]
impl Tool for McpListToolsTool {
fn name(&self) -> &str {
"mcp_list_tools"
}
fn description(&self) -> &str {
"List tools exposed by a named remote MCP server. Use this before calling `mcp_call_tool`."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "Registered MCP server name from `mcp_list_servers`."
}
},
"required": ["server"],
"additionalProperties": false
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::ReadOnly
}
fn supports_markdown(&self) -> bool {
true
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let server = required_string_arg(&args, "server")?;
let tools = match self.registry.list_tools(&server).await {
Ok(tools) => tools,
Err(err) => return Ok(ToolResult::error(format!("mcp_list_tools failed: {err}"))),
};
let payload = tools
.iter()
.map(|tool| {
json!({
"name": tool.name,
"title": tool.title,
"description": tool.description,
"input_schema": tool.input_schema,
})
})
.collect::<Vec<_>>();
let mut markdown = format!("# MCP Tools: `{server}`\n");
if tools.is_empty() {
markdown.push_str("\nNo tools were returned by the remote server.");
} else {
for tool in &tools {
markdown.push_str(&format!(
"\n- **{}**: {}\n - schema: `{}`",
tool.name,
tool.description.as_deref().unwrap_or("No description."),
serde_json::to_string(&tool.input_schema).unwrap_or_else(|_| "{}".into())
));
}
}
Ok(ToolResult::success_with_markdown(
json!({ "server": server, "tools": payload }),
markdown,
))
}
}
pub struct McpCallTool {
registry: Arc<McpServerRegistry>,
security: Arc<SecurityPolicy>,
}
impl McpCallTool {
pub fn new(registry: Arc<McpServerRegistry>, security: Arc<SecurityPolicy>) -> Self {
Self { registry, security }
}
}
#[async_trait]
impl Tool for McpCallTool {
fn name(&self) -> &str {
"mcp_call_tool"
}
fn description(&self) -> &str {
"Call a tool on a named remote MCP server. First inspect available tools with `mcp_list_tools`, then pass the remote tool name and its JSON arguments here."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "Registered MCP server name from `mcp_list_servers`."
},
"tool": {
"type": "string",
"description": "Remote MCP tool name from `mcp_list_tools`."
},
"arguments": {
"type": "object",
"description": "Arguments object passed through to the remote MCP tool."
}
},
"required": ["server", "tool", "arguments"],
"additionalProperties": false
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Execute
}
fn supports_markdown(&self) -> bool {
true
}
async fn execute_with_options(
&self,
args: Value,
options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Act, self.name())
.map_err(|err| anyhow::anyhow!(err))?;
let server = required_string_arg(&args, "server")?;
let tool = required_string_arg(&args, "tool")?;
let arguments = args
.get("arguments")
.cloned()
.ok_or_else(|| anyhow::anyhow!("missing required `arguments` object"))?;
if !arguments.is_object() {
return Ok(ToolResult::error("`arguments` must be an object"));
}
let mut result = match self.registry.call_tool(&server, &tool, arguments).await {
Ok(result) => result.rendered,
Err(err) => return Ok(ToolResult::error(format!("mcp_call_tool failed: {err}"))),
};
if options.prefer_markdown && result.markdown_formatted.is_none() {
result.markdown_formatted = Some(result.output());
}
Ok(result)
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
}
fn required_string_arg(args: &Value, key: &str) -> anyhow::Result<String> {
let value = args
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing required `{key}`"))?;
Ok(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::config::{Config, McpServerConfig};
fn test_registry() -> Arc<McpServerRegistry> {
let mut config = Config::default();
config.gitbooks.enabled = false;
config.mcp_client.servers.push(McpServerConfig {
name: "docs".into(),
endpoint: "https://example.com/mcp".into(),
command: String::new(),
args: Vec::new(),
env: std::collections::HashMap::new(),
cwd: None,
description: Some("Docs MCP".into()),
enabled: true,
timeout_secs: 30,
auth: crate::openhuman::config::McpAuthConfig::None,
});
Arc::new(McpServerRegistry::from_config(&config))
}
#[tokio::test]
async fn list_servers_renders_registry_entries() {
let tool = McpListServersTool::new(test_registry());
let result = tool.execute(json!({})).await.expect("execute");
assert!(result.output().contains("docs"));
assert!(result.markdown_formatted.is_some());
}
#[tokio::test]
async fn list_tools_requires_server() {
let tool = McpListToolsTool::new(test_registry());
let result = tool.execute(json!({})).await;
assert!(result.is_err());
}
}
+2
View File
@@ -3,6 +3,7 @@ mod curl;
mod gitbooks;
mod gmail_unsubscribe;
mod http_request;
mod mcp;
mod url_guard;
mod web_fetch;
mod web_search;
@@ -12,5 +13,6 @@ pub use curl::CurlTool;
pub use gitbooks::{GitbooksGetPageTool, GitbooksSearchTool};
pub use gmail_unsubscribe::GmailUnsubscribeTool;
pub use http_request::HttpRequestTool;
pub use mcp::{McpCallTool, McpListServersTool, McpListToolsTool};
pub use web_fetch::WebFetchTool;
pub use web_search::WebSearchTool;
+20
View File
@@ -242,6 +242,26 @@ pub fn all_tools_with_runtime(
tracing::debug!("[gitbooks] registered gitbooks_search + gitbooks_get_page");
}
// Generic remote MCP bridge tools. These let the agent enumerate
// named MCP servers and forward `tools/call` through the core
// instead of hardcoding one bespoke MCP integration per server.
let mcp_registry =
Arc::new(crate::openhuman::mcp_client::McpServerRegistry::from_config(root_config));
if !mcp_registry.is_empty() {
tools.push(Box::new(McpListServersTool::new(Arc::clone(&mcp_registry))));
tools.push(Box::new(McpListToolsTool::new(Arc::clone(&mcp_registry))));
tools.push(Box::new(McpCallTool::new(
Arc::clone(&mcp_registry),
security.clone(),
)));
tracing::debug!(
count = mcp_registry.list().len(),
"[mcp_client] registered generic MCP bridge tools"
);
} else {
tracing::debug!("[mcp_client] no MCP servers registered — bridge tools skipped");
}
// Web search — always registered. Result/timeout budget
// knobs still come from `config.web_search`, but there is no
// enable flag: every session needs research as a baseline
+28
View File
@@ -239,6 +239,34 @@ fn all_tools_registers_gitbooks_when_enabled() {
);
}
#[test]
fn all_tools_registers_generic_mcp_bridge_tools_when_servers_exist() {
let tmp = TempDir::new().unwrap();
let mut cfg = test_config(&tmp);
cfg.gitbooks.enabled = false;
cfg.mcp_client
.servers
.push(crate::openhuman::config::McpServerConfig {
name: "docs".into(),
endpoint: "https://example.com/mcp".into(),
command: String::new(),
args: Vec::new(),
env: std::collections::HashMap::new(),
cwd: None,
description: Some("Example docs MCP".into()),
enabled: true,
timeout_secs: 30,
auth: crate::openhuman::config::McpAuthConfig::None,
});
let tools = integration_tools_for_config(&tmp, &cfg);
let names = tool_names(&tools);
assert_contains_all(
&names,
&["mcp_list_servers", "mcp_list_tools", "mcp_call_tool"],
);
}
#[test]
fn all_tools_skips_gitbooks_when_disabled() {
let tmp = TempDir::new().unwrap();