Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/openhuman/mcp/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub use types::{ConnStatus, InstalledServer, McpTool};
/// completes should see.
#[cfg(feature = "mcp")]
pub mod connections {
use crate::openhuman::config::Config;
pub use tinymcp_bus::ConnectedServerOverview;

use crate::openhuman::mcp::host;
Expand All @@ -88,6 +89,35 @@ pub mod connections {
}
}

/// Every tool on every connected server in `config`'s workspace.
///
/// The counterpart to [`all_connected_tools`] for a caller that holds a
/// `Config`. It resolves through [`host::for_config`], which is keyed by
/// workspace, rather than through the process-wide default — so it answers
/// about the workspace the caller named instead of whichever one
/// `mcp::init` happened to claim first.
///
/// That distinction is invisible in the shipped app, which opens one
/// workspace, and decisive in a test binary: `resolve` hands back a lone
/// host but returns `None` once a second one exists, so an ambient lookup
/// silently reports nothing connected as soon as two tests each open their
/// own temporary workspace in one process.
///
/// A host that cannot be opened yields an empty list rather than an error:
/// the callers fold this into a tool list, and MCP being unavailable must
/// not fail the listing.
pub async fn all_connected_tools_for_config(
config: &Config,
) -> Vec<(String, String, tinymcp_bus::McpTool)> {
match host::for_config(config) {
Ok(service) => service.dynamic().connections().all_connected_tools().await,
Err(error) => {
tracing::debug!(?error, "[mcp] no host for workspace; reporting no tools");
Vec::new()
}
}
}

/// Every tool on every connected server, paired with its server.
pub async fn all_connected_tools() -> Vec<(String, String, tinymcp_bus::McpTool)> {
match host::try_service() {
Expand Down Expand Up @@ -171,6 +201,24 @@ pub mod connections {
}
}

/// Drop a connection held in `config`'s workspace.
///
/// The counterpart to [`disconnect`] for a caller that holds a `Config`,
/// for the same reason [`all_connected_tools_for_config`] exists: the
/// by-server-id form resolves through the process default, which stops
/// answering once a second workspace is open. A caller that connected
/// through [`connect`] already named a workspace and should close over the
/// same one.
pub async fn disconnect_for_config(config: &Config, server_id: &str) -> bool {
match host::for_config(config) {
Ok(service) => service.dynamic().connections().disconnect(server_id).await,
Err(error) => {
tracing::debug!(?error, "[mcp] no host for workspace; nothing to disconnect");
false
}
}
}

/// The most recent failure message for a server.
pub async fn last_error_for(server_id: &str) -> Option<String> {
host::try_service()?
Expand Down
10 changes: 10 additions & 0 deletions src/openhuman/mcp/registry/stub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ pub mod oauth {

/// Global in-process registry of connected MCP servers.
pub mod connections {
use crate::openhuman::config::Config;

/// Re-exported from the ungated `types` module — the SAME type the enabled
/// build uses, not a mirrored copy, so the orchestrator prompt builder's
/// field access can never drift between builds.
Expand All @@ -124,4 +126,12 @@ pub mod connections {
pub async fn all_connected_tools() -> Vec<(String, String, McpTool)> {
Vec::new()
}

/// Empty, for the same reason as [`all_connected_tools`]. The workspace the
/// caller names changes nothing when the registry is compiled out.
pub async fn all_connected_tools_for_config(
_config: &Config,
) -> Vec<(String, String, McpTool)> {
Vec::new()
}
}
2 changes: 1 addition & 1 deletion src/openhuman/tools/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ mod providers;
mod schemas;
mod types;

pub use ops::{get_tool, list_tools, registry_entries};
pub use ops::{get_tool, list_tools, registry_entries, registry_entries_for_config};
pub use providers::{
capability_provider_by_id, capability_provider_diagnostics, capability_provider_registry,
is_capability_provider_trusted_enabled, list_capability_providers,
Expand Down
34 changes: 32 additions & 2 deletions src/openhuman/tools/registry/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub async fn diagnostics() -> Result<RpcOutcome<ToolPolicyDiagnostics>, String>
pub fn diagnostics_for_config(config: &Config) -> RpcOutcome<ToolPolicyDiagnostics> {
log::debug!("[tool_registry] diagnostics_for_config start");

let tools = registry_entries();
let tools = registry_entries_for_config(config);
let total_tools = tools.len();
let enabled_tools = tools.iter().filter(|entry| entry.enabled).count();
let mcp_stdio_tools = tools
Expand Down Expand Up @@ -220,7 +220,30 @@ pub fn get_tool(tool_id: &str) -> Result<RpcOutcome<ToolRegistryEntry>, String>
/// 1. MCP stdio server tools (existing `mcp::server` surface)
/// 2. Controller-backed tools (existing `tools` namespace)
/// 3. Connected MCP client server tools (new `mcp_clients` domain)
///
/// The connected-client tools come from whichever workspace `mcp::init` claimed
/// as the process default. That is right for the shipped app, which opens one;
/// a caller that holds a `Config` should prefer
/// [`registry_entries_for_config`], which names the workspace it means.
pub fn registry_entries() -> Vec<ToolRegistryEntry> {
build_registry_entries(None)
}

/// The same snapshot, with connected-client tools read from `config`'s
/// workspace rather than the process default.
///
/// The two differ only when more than one workspace is open in a process.
/// `mcp::host::resolve` returns a lone host but `None` once a second exists, so
/// the ambient form silently reports nothing connected in a test binary where
/// several cases each open their own temporary workspace — which is how
/// `tool_registry_entries_include_connected_mcp_client_tools` came to pass
/// alone and fail beside its neighbours.
pub fn registry_entries_for_config(config: &Config) -> Vec<ToolRegistryEntry> {
build_registry_entries(Some(config))
}

/// The shared body. `config` selects the MCP host; everything else is identical.
fn build_registry_entries(config: Option<&Config>) -> Vec<ToolRegistryEntry> {
let mut entries = BTreeMap::new();

for spec in crate::openhuman::mcp::server::tool_specs() {
Expand All @@ -245,7 +268,14 @@ pub fn registry_entries() -> Vec<ToolRegistryEntry> {
// (kind = CurrentThread) panics on block_in_place.
if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
tokio::task::block_in_place(|| {
handle.block_on(connections::all_connected_tools())
handle.block_on(async {
match config {
Some(config) => {
connections::all_connected_tools_for_config(config).await
}
None => connections::all_connected_tools().await,
}
})
})
} else {
Vec::new()
Expand Down
47 changes: 44 additions & 3 deletions tests/raw_coverage/tool_registry_approval_raw_coverage_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use openhuman_core::openhuman::tools::registry::{
all_tool_registry_controller_schemas, all_tool_registry_registered_controllers,
capability_provider_by_id, capability_provider_diagnostics, capability_provider_registry,
denials, get_tool, is_capability_provider_trusted_enabled, list_capability_providers,
list_tools, normalize_capability_provider_id, registry_entries,
list_tools, normalize_capability_provider_id, registry_entries, registry_entries_for_config,
CapabilityProviderRegistryError,
};

Expand Down Expand Up @@ -654,7 +654,26 @@ async fn tool_registry_entries_include_connected_mcp_client_tools() {
.expect("connect test mcp server");
assert_eq!(tools.first().map(|tool| tool.name.as_str()), Some("echo"));

let entries = registry_entries();
// A second workspace, so that scoping is what the assertions below actually
// test. With one workspace open, `registry_entries()` and the config-scoped
// form agree, and this case would keep passing if the forwarding regressed.
let other_tmp = tempdir().expect("second tempdir");
let other_config = Config {
workspace_dir: other_tmp.path().to_path_buf(),
..Config::default()
};
let other_server = test_mcp_server();
connections::connect(&other_config, &other_server)
.await
.expect("connect second test mcp server");

// Config-scoped, not ambient: this case connects through
// `host::for_config(&config)`, keyed by its own tempdir. `registry_entries()`
// resolves through the process default instead, which returns a lone host
// but `None` once another case in this binary has opened a second one — so
// the ambient form reports nothing connected here purely because of who
// else ran first.
let entries = registry_entries_for_config(&config);
let client_entry = entries
.iter()
.find(|entry| entry.tool_id == format!("mcp-client::{}::echo", server.server_id))
Expand All @@ -664,7 +683,29 @@ async fn tool_registry_entries_include_connected_mcp_client_tools() {
assert_eq!(client_entry.route["server_id"], json!(server.server_id));
assert!(client_entry.tags.iter().any(|tag| tag == "mcp_client"));

assert!(connections::disconnect(&server.server_id).await);
// The other workspace's server must NOT leak in. This is the assertion that
// fails if a config-scoped lookup falls back to the process default.
assert!(
!entries.iter().any(
|entry| entry.tool_id == format!("mcp-client::{}::echo", other_server.server_id)
),
"entries for one workspace must not include another workspace's server"
);

// Symmetrically, from the second workspace's side.
let other_entries = registry_entries_for_config(&other_config);
assert!(other_entries
.iter()
.any(|entry| entry.tool_id == format!("mcp-client::{}::echo", other_server.server_id)));
assert!(!other_entries
.iter()
.any(|entry| entry.tool_id == format!("mcp-client::{}::echo", server.server_id)));

// Config-scoped for the same reason as the lookup above: this connection
// lives in the host keyed by `config`'s workspace, and the by-id form
// resolves through the process default.
assert!(connections::disconnect_for_config(&config, &server.server_id).await);
assert!(connections::disconnect_for_config(&other_config, &other_server.server_id).await);
}

#[tokio::test]
Expand Down
Loading