use crate::durable::DurableSession;
use crate::mcp::server::{McpServerHealth, McpServerRegistry};
use crate::mcp::McpConnectionManager;
use crate::plugin::effective_tools::compute_tool_availability;
use crate::plugin::effective_tools::PluginToolSummary;
use crate::plugin::effective_tools::UnavailableReason;
use crate::plugin::registry::PluginRegistry;
use crate::plugin::wasm_host::WasmHost;
use crate::tool::{ToolDefinition, ToolFuture, ToolRegistry};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Clone)]
enum ToolInfo {
Local {
name: String,
},
Mcp {
server_id: String,
tool_name: String,
definition: ToolDefinition,
},
Plugin {
plugin_id: String,
tool_name: String,
definition: ToolDefinition,
},
}
#[derive(Clone)]
pub struct SessionToolCatalog {
local_registry: Arc<ToolRegistry>,
mcp_registry: Arc<McpServerRegistry>,
plugin_registry: Arc<PluginRegistry>,
wasm_host: Arc<WasmHost>,
connection_manager: Arc<McpConnectionManager>,
definitions: Vec<ToolDefinition>,
tool_map: Arc<HashMap<String, ToolInfo>>,
effective_tool_filter: Option<crate::profile::ToolFilter>,
}
#[derive(Debug)]
pub struct McpServerSummary {
pub id: String,
pub label: String,
pub enabled: bool,
pub health: McpServerHealth,
pub usable: bool,
pub tool_count: usize,
}
impl SessionToolCatalog {
fn activate_skill_definition(session: &DurableSession) -> Option<ToolDefinition> {
let mut skills: Vec<_> = session
.list_available_skills()
.iter()
.map(|skill| {
(
skill.metadata.id.clone(),
skill.metadata.description.clone(),
)
})
.collect();
if skills.is_empty() {
return None;
}
skills.sort_by(|a, b| a.0.cmp(&b.0));
let catalog_lines = skills
.iter()
.map(|(name, description)| format!("- {}: {}", name, description))
.collect::<Vec<_>>()
.join("\n");
let enum_values = skills.into_iter().map(|(name, _)| name).collect::<Vec<_>>();
Some(ToolDefinition::new(
"activate_skill",
format!(
"Activate a skill by name to receive its instructions. Available skills:\n{}",
catalog_lines
),
serde_json::json!({
"type": "object",
"properties": {
"skill_name": {
"type": "string",
"description": "The name of the skill to activate",
"enum": enum_values,
}
},
"required": ["skill_name"]
}),
))
}
fn is_mcp_server_enabled_for_session(session: &DurableSession, server_id: &str) -> bool {
session.is_mcp_server_enabled(server_id).unwrap_or(false)
}
pub(crate) fn resolve_mcp_tool_name(
name: &str,
registry: &McpServerRegistry,
) -> Option<(String, String)> {
let without_prefix = name.strip_prefix("mcp_")?;
registry
.list_servers()
.into_iter()
.filter_map(|server| {
let prefix = format!("{}_", server.config.id);
without_prefix
.strip_prefix(&prefix)
.map(|tool_name| (server.config.id, tool_name.to_string()))
})
.max_by_key(|(server_id, _)| server_id.len())
}
pub(crate) fn resolve_plugin_tool_name(
name: &str,
registry: &PluginRegistry,
) -> Option<(String, String)> {
let without_prefix = name.strip_prefix("plugin_")?;
registry
.list()
.into_iter()
.filter_map(|plugin| {
let prefix = format!("{}_", plugin.config.id);
without_prefix
.strip_prefix(&prefix)
.map(|tool_name| (plugin.config.id, tool_name.to_string()))
})
.max_by_key(|(plugin_id, _)| plugin_id.len())
}
pub fn new(
local_registry: Arc<ToolRegistry>,
mcp_registry: Arc<McpServerRegistry>,
plugin_registry: Arc<PluginRegistry>,
wasm_host: Arc<WasmHost>,
connection_manager: Arc<McpConnectionManager>,
session: &DurableSession,
) -> Self {
let mut definitions = Vec::new();
let mut tool_map = HashMap::new();
for def in local_registry.definitions() {
if def.name == "activate_skill" {
let Some(definition) = Self::activate_skill_definition(session) else {
continue;
};
let name = definition.name.clone();
tool_map.insert(name.clone(), ToolInfo::Local { name: name.clone() });
definitions.push(definition);
continue;
}
let name = def.name.clone();
tool_map.insert(name.clone(), ToolInfo::Local { name: name.clone() });
definitions.push(def);
}
for server in mcp_registry.list_servers() {
let server_id = server.config.id.clone();
let enabled = Self::is_mcp_server_enabled_for_session(session, &server_id);
if !enabled {
continue;
}
if !server.health.is_usable() {
continue;
}
for tool_info in &server.discovered_tools {
let namespaced_name = format!("mcp_{}_{}", server_id, tool_info.name);
let description = format!("[MCP:{}] {}", server_id, tool_info.description);
let definition = ToolDefinition::new(
&namespaced_name,
&description,
tool_info.input_schema.clone(),
)
.with_approval(true);
tool_map.insert(
namespaced_name.clone(),
ToolInfo::Mcp {
server_id: server_id.clone(),
tool_name: tool_info.name.clone(),
definition: definition.clone(),
},
);
definitions.push(definition);
}
}
for plugin in plugin_registry.list() {
let plugin_id = plugin.config.id.clone();
let enabled = session.is_plugin_enabled(&plugin_id).unwrap_or(false);
if !enabled {
continue;
}
if !plugin.health.is_healthy() {
continue;
}
let manifest = match &plugin.manifest {
Some(m) => m,
None => continue,
};
for tool in &manifest.tools {
if !compute_tool_availability(&plugin, tool).available {
continue;
}
let namespaced_name = format!("plugin_{}_{}", plugin_id, tool.name);
let description = format!("[Plugin:{}] {}", plugin_id, tool.description);
let definition =
ToolDefinition::new(&namespaced_name, &description, tool.input_schema.clone())
.with_approval(tool.requires_approval);
tool_map.insert(
namespaced_name.clone(),
ToolInfo::Plugin {
plugin_id: plugin_id.clone(),
tool_name: tool.name.clone(),
definition: definition.clone(),
},
);
definitions.push(definition);
}
}
let hidden_tools: std::collections::HashSet<String> =
session.hidden_tools.iter().cloned().collect();
definitions.retain(|d| !hidden_tools.contains(&d.name));
tool_map.retain(|name, _| !hidden_tools.contains(name));
if let Some(ref filter) = session.effective_tool_filter {
match filter {
crate::profile::ToolFilter::Inherit => {}
crate::profile::ToolFilter::Allow(allowed) => {
let allowed_set: std::collections::HashSet<String> =
allowed.iter().cloned().collect();
definitions.retain(|d| allowed_set.contains(&d.name));
tool_map.retain(|name, _| allowed_set.contains(name));
}
crate::profile::ToolFilter::Deny(denied) => {
let denied_set: std::collections::HashSet<String> =
denied.iter().cloned().collect();
definitions.retain(|d| !denied_set.contains(&d.name));
tool_map.retain(|name, _| !denied_set.contains(name));
}
}
}
Self {
local_registry,
mcp_registry,
plugin_registry,
wasm_host,
connection_manager,
definitions,
tool_map: Arc::new(tool_map),
effective_tool_filter: session.effective_tool_filter.clone(),
}
}
pub fn definitions(&self) -> &[ToolDefinition] {
&self.definitions
}
pub fn get_definition(&self, name: &str) -> Option<&ToolDefinition> {
self.definitions.iter().find(|d| d.name == name)
}
pub fn contains(&self, name: &str) -> bool {
self.tool_map.contains_key(name)
}
pub fn len(&self) -> usize {
self.definitions.len()
}
pub fn is_empty(&self) -> bool {
self.definitions.is_empty()
}
pub fn requires_approval(&self, name: &str) -> bool {
match self.tool_map.get(name) {
Some(ToolInfo::Local { .. }) => {
self.local_registry
.get(name)
.map(|t| t.requires_approval())
.unwrap_or(false)
}
Some(ToolInfo::Mcp { .. }) => true, Some(ToolInfo::Plugin { definition, .. }) => definition.requires_approval,
None => false,
}
}
pub fn execute(
&self,
call_id: &str,
name: &str,
arguments: Value,
session: &DurableSession,
) -> ToolFuture {
self.execute_with_builtin_config(call_id, name, arguments, session, None)
}
pub fn execute_with_builtin_config(
&self,
call_id: &str,
name: &str,
arguments: Value,
session: &DurableSession,
builtin_config: Option<crate::builtin::BuiltinToolConfig>,
) -> ToolFuture {
let tool_info = self.tool_map.get(name).cloned();
let local_registry = Arc::clone(&self.local_registry);
let connection_manager = Arc::clone(&self.connection_manager);
let mcp_registry = Arc::clone(&self.mcp_registry);
let plugin_registry = Arc::clone(&self.plugin_registry);
let wasm_host = Arc::clone(&self.wasm_host);
let call_id_owned = call_id.to_owned();
match tool_info {
Some(ToolInfo::Local { name: tool_name }) => {
Box::pin(async move {
if let Some(ref config) = builtin_config {
if let Some(tool) = crate::builtin::instantiate_builtin(&tool_name, config)
{
return tool.execute(&call_id_owned, arguments).await;
}
if crate::builtin::is_builtin_name(&tool_name) {
return Err(crate::error::RuntimeError::tool_execution(format!(
"Builtin tool '{}' is configured for this session but could not be instantiated.",
tool_name
)));
}
}
if let Some(tool) = local_registry.get(&tool_name) {
tool.execute(&call_id_owned, arguments).await
} else {
Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' is registered in the session catalog but could not be found in the tool registry. This may indicate an internal error.",
tool_name
)))
}
})
}
Some(ToolInfo::Mcp {
server_id,
tool_name,
..
}) => {
Box::pin(async move {
connection_manager
.call_tool(&server_id, &tool_name, arguments)
.await
.map_err(|e| {
crate::error::RuntimeError::tool_execution(format!(
"MCP tool call failed: {}",
e
))
})
})
}
Some(ToolInfo::Plugin {
plugin_id,
tool_name,
..
}) => {
Box::pin(async move {
if let Some(plugin) = plugin_registry.get(&plugin_id) {
if !plugin.health.is_healthy() {
return Err(crate::error::RuntimeError::tool_execution(format!(
"Plugin '{}' is not healthy (status: {:?}). Cannot execute tool '{}'.",
plugin_id, plugin.health, tool_name
)));
}
if let Some(manifest) = &plugin.manifest {
if let Some(tool) = manifest.tools.iter().find(|t| t.name == tool_name)
{
if let Some(auth_reqs) = &tool.auth_requirements {
if !auth_reqs.available_unauthenticated
&& !plugin.auth_state.is_authenticated()
{
return Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' from plugin '{}' requires authentication. Please authenticate first.",
tool_name, plugin_id
)));
}
}
}
}
wasm_host
.execute_tool(&plugin_id, &tool_name, arguments)
.await
.map_err(|e| {
crate::error::RuntimeError::tool_execution(format!(
"Plugin tool call failed: {}",
e
))
})
} else {
Err(crate::error::RuntimeError::tool_execution(format!(
"Plugin '{}' not found in registry.",
plugin_id
)))
}
})
}
None => {
let name_owned = name.to_owned();
let session_mcp_enablement: HashMap<String, bool> = session
.mcp_server_enablement
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
let session_plugin_enablement: HashMap<String, bool> = session
.plugin_enablement
.list_all()
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
Box::pin(async move {
if let Some((server_id, tool_name)) =
Self::resolve_mcp_tool_name(&name_owned, &mcp_registry)
{
if let Some(server) = mcp_registry.get_server(&server_id) {
let is_enabled = session_mcp_enablement
.get(&server_id)
.copied()
.unwrap_or(false);
if !is_enabled {
return Err(crate::error::RuntimeError::tool_execution(format!(
"MCP server '{}' is disabled for this session. Enable it to use tool '{}'.",
server_id, tool_name
)));
}
if !server.health.is_usable() {
return Err(crate::error::RuntimeError::tool_execution(format!(
"MCP server '{}' is not healthy (status: {:?}). Cannot execute tool '{}'.",
server_id, server.health, tool_name
)));
}
let tool_exists =
server.discovered_tools.iter().any(|t| t.name == tool_name);
if !tool_exists {
let available_tools: Vec<_> = server
.discovered_tools
.iter()
.map(|t| t.name.clone())
.collect();
return Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' not found on MCP server '{}'. Available tools: {}",
tool_name,
server_id,
if available_tools.is_empty() {
"(none)".to_string()
} else {
available_tools.join(", ")
}
)));
}
Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' from MCP server '{}' is unexpectedly unavailable. This may indicate an internal consistency error.",
tool_name, server_id
)))
} else {
Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' references MCP server '{}' which is not configured.",
name_owned, server_id
)))
}
} else if name_owned.starts_with("mcp_") {
Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' references an unknown or ambiguous MCP server identifier.",
name_owned
)))
} else if let Some((plugin_id, tool_name)) =
Self::resolve_plugin_tool_name(&name_owned, &plugin_registry)
{
if let Some(plugin) = plugin_registry.get(&plugin_id) {
let is_enabled = session_plugin_enablement
.get(&plugin_id)
.copied()
.unwrap_or(false);
if !is_enabled {
return Err(crate::error::RuntimeError::tool_execution(format!(
"Plugin '{}' is disabled for this session. Enable it to use tool '{}'.",
plugin_id, tool_name
)));
}
if !plugin.health.is_healthy() {
return Err(crate::error::RuntimeError::tool_execution(format!(
"Plugin '{}' is not healthy (status: {:?}). Cannot execute tool '{}'.",
plugin_id, plugin.health, tool_name
)));
}
if let Some(manifest) = &plugin.manifest {
if let Some(tool) =
manifest.tools.iter().find(|t| t.name == tool_name)
{
if let Some(auth_reqs) = &tool.auth_requirements {
if !auth_reqs.available_unauthenticated
&& !plugin.auth_state.is_authenticated()
{
return Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' from plugin '{}' requires authentication. Please authenticate first.",
tool_name, plugin_id
)));
}
}
} else {
let available_tools: Vec<_> =
manifest.tools.iter().map(|t| t.name.clone()).collect();
return Err(crate::error::RuntimeError::tool_execution(
format!(
"Tool '{}' not found in plugin '{}'. Available tools: {}",
tool_name,
plugin_id,
if available_tools.is_empty() {
"(none)".to_string()
} else {
available_tools.join(", ")
}
),
));
}
} else {
return Err(crate::error::RuntimeError::tool_execution(format!(
"Plugin '{}' has no manifest loaded. Cannot execute tool '{}'.",
plugin_id, tool_name
)));
}
Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' from plugin '{}' is unexpectedly unavailable. This may indicate an internal consistency error.",
tool_name, plugin_id
)))
} else {
Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' references plugin '{}' which is not installed.",
name_owned, plugin_id
)))
}
} else if name_owned.starts_with("plugin_") {
Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' references an unknown or ambiguous plugin identifier.",
name_owned
)))
} else {
Err(crate::error::RuntimeError::tool_execution(format!(
"Tool '{}' is not available. It may not exist or may not be enabled for this session.",
name_owned
)))
}
})
}
}
}
pub fn provider_definitions(&self) -> Vec<iron_providers::ToolDefinition> {
self.definitions
.iter()
.map(|d| d.to_provider_definition())
.collect()
}
pub fn mcp_server_summary(&self, session: &DurableSession) -> Vec<McpServerSummary> {
let mut summaries = Vec::new();
for server in self.mcp_registry.list_servers() {
let server_id = server.config.id.clone();
let enabled = Self::is_mcp_server_enabled_for_session(session, &server_id);
let usable = server.health.is_usable();
summaries.push(McpServerSummary {
id: server_id.clone(),
label: server.config.label.clone(),
enabled,
health: server.health,
usable: enabled && usable,
tool_count: if enabled && usable {
server.discovered_tools.len()
} else {
0
},
});
}
summaries
}
pub fn plugin_summary(&self, session: &DurableSession) -> Vec<PluginToolSummary> {
let mut summaries = Vec::new();
for plugin in self.plugin_registry.list() {
let plugin_id = plugin.config.id.clone();
let enabled = session.is_plugin_enabled(&plugin_id).unwrap_or(false);
let healthy = plugin.health.is_healthy();
let usable = enabled && healthy;
let available_tool_count = if usable {
plugin
.manifest
.as_ref()
.map(|m| {
m.tools
.iter()
.filter(|t| compute_tool_availability(&plugin, t).available)
.count()
})
.unwrap_or(0)
} else {
0
};
summaries.push(PluginToolSummary {
id: plugin_id,
enabled,
healthy,
usable,
tool_count: available_tool_count,
requires_auth: plugin.requires_auth(),
authenticated: plugin.auth_state.is_authenticated(),
});
}
summaries
}
pub fn inspect_tools(&self, session: &DurableSession) -> Vec<ToolDiagnostic> {
let mut diagnostics = Vec::new();
for (name, info) in self.tool_map.iter() {
let (source, description) = match info {
ToolInfo::Local { .. } => (ToolSource::Local, String::new()),
ToolInfo::Mcp {
server_id,
definition,
..
} => (
ToolSource::Mcp {
server_id: server_id.clone(),
},
definition.description.clone(),
),
ToolInfo::Plugin {
plugin_id,
definition,
..
} => (
ToolSource::Plugin {
plugin_id: plugin_id.clone(),
},
definition.description.clone(),
),
};
let description = if matches!(info, ToolInfo::Local { .. }) {
self.definitions
.iter()
.find(|d| d.name == *name)
.map(|d| d.description.clone())
.unwrap_or_default()
} else {
description
};
diagnostics.push(ToolDiagnostic {
name: name.clone(),
source,
available: true,
unavailable_reason: None,
requires_approval: self.requires_approval(name),
description,
});
}
let mut emitted: std::collections::HashSet<String> =
diagnostics.iter().map(|d| d.name.clone()).collect();
for server in self.mcp_registry.list_servers() {
let server_id = server.config.id.clone();
let enabled = Self::is_mcp_server_enabled_for_session(session, &server_id);
let namespaced_prefix = format!("mcp_{}_", server_id);
for tool_info in &server.discovered_tools {
let namespaced = format!("{}{}", namespaced_prefix, tool_info.name);
if emitted.contains(namespaced.as_str()) {
continue;
}
let unavailable_reason = if !enabled {
Some(UnavailableReason::McpServerNotEnabled)
} else if !server.health.is_usable() {
Some(UnavailableReason::McpServerNotHealthy(server.health))
} else {
None
};
diagnostics.push(ToolDiagnostic {
name: namespaced,
source: ToolSource::Mcp {
server_id: server_id.clone(),
},
available: unavailable_reason.is_none(),
unavailable_reason,
requires_approval: true,
description: tool_info.description.clone(),
});
emitted.insert(format!("mcp_{}_{}", server_id, tool_info.name));
}
}
for plugin in self.plugin_registry.list() {
let plugin_id = &plugin.config.id;
let enabled = session.is_plugin_enabled(plugin_id).unwrap_or(false);
if !enabled {
if let Some(manifest) = &plugin.manifest {
for tool in &manifest.tools {
let namespaced = format!("plugin_{}_{}", plugin_id, tool.name);
if !emitted.contains(namespaced.as_str()) {
diagnostics.push(ToolDiagnostic {
name: namespaced.clone(),
source: ToolSource::Plugin {
plugin_id: plugin_id.clone(),
},
available: false,
unavailable_reason: Some(UnavailableReason::PluginNotEnabled),
requires_approval: tool.requires_approval,
description: tool.description.clone(),
});
emitted.insert(namespaced);
}
}
}
continue;
}
if !plugin.health.is_healthy() {
if let Some(manifest) = &plugin.manifest {
for tool in &manifest.tools {
let namespaced = format!("plugin_{}_{}", plugin_id, tool.name);
if !emitted.contains(namespaced.as_str()) {
diagnostics.push(ToolDiagnostic {
name: namespaced.clone(),
source: ToolSource::Plugin {
plugin_id: plugin_id.clone(),
},
available: false,
unavailable_reason: Some(UnavailableReason::PluginNotHealthy(
plugin.health,
)),
requires_approval: tool.requires_approval,
description: tool.description.clone(),
});
emitted.insert(namespaced);
}
}
}
continue;
}
if let Some(manifest) = &plugin.manifest {
for tool in &manifest.tools {
let namespaced = format!("plugin_{}_{}", plugin_id, tool.name);
if emitted.contains(namespaced.as_str()) {
continue;
}
let result = compute_tool_availability(&plugin, tool);
diagnostics.push(ToolDiagnostic {
name: namespaced.clone(),
source: ToolSource::Plugin {
plugin_id: plugin_id.clone(),
},
available: result.available,
unavailable_reason: result.reason,
requires_approval: tool.requires_approval,
description: tool.description.clone(),
});
emitted.insert(namespaced);
}
}
}
let hidden_tools: std::collections::HashSet<&str> =
session.hidden_tools.iter().map(|s| s.as_str()).collect();
diagnostics.retain(|d| !hidden_tools.contains(d.name.as_str()));
if let Some(ref filter) = self.effective_tool_filter {
let is_excluded = |name: &str| -> bool {
match filter {
crate::profile::ToolFilter::Allow(allow_names) => {
!allow_names.iter().any(|a| a == name)
}
crate::profile::ToolFilter::Deny(deny_names) => {
deny_names.iter().any(|d| d == name)
}
crate::profile::ToolFilter::Inherit => false,
}
};
for def in self.local_registry.definitions() {
if is_excluded(&def.name) && !emitted.contains(&def.name) {
diagnostics.push(ToolDiagnostic {
name: def.name.clone(),
source: ToolSource::Local,
available: false,
unavailable_reason: Some(UnavailableReason::DeniedByProfileFilter),
requires_approval: self
.local_registry
.get(&def.name)
.map(|t| t.requires_approval())
.unwrap_or(false),
description: def.description.clone(),
});
emitted.insert(def.name.clone());
}
}
for server in self.mcp_registry.list_servers() {
for tool_info in &server.discovered_tools {
let namespaced = format!("mcp_{}_{}", server.config.id, tool_info.name);
if is_excluded(&namespaced) && !emitted.contains(&namespaced) {
diagnostics.push(ToolDiagnostic {
name: namespaced.clone(),
source: ToolSource::Mcp {
server_id: server.config.id.clone(),
},
available: false,
unavailable_reason: Some(UnavailableReason::DeniedByProfileFilter),
requires_approval: true,
description: tool_info.description.clone(),
});
emitted.insert(namespaced);
}
}
}
for plugin in self.plugin_registry.list() {
if let Some(manifest) = &plugin.manifest {
for tool in &manifest.tools {
let namespaced = format!("plugin_{}_{}", plugin.config.id, tool.name);
if is_excluded(&namespaced) && !emitted.contains(&namespaced) {
diagnostics.push(ToolDiagnostic {
name: namespaced.clone(),
source: ToolSource::Plugin {
plugin_id: plugin.config.id.clone(),
},
available: false,
unavailable_reason: Some(UnavailableReason::DeniedByProfileFilter),
requires_approval: tool.requires_approval,
description: tool.description.clone(),
});
emitted.insert(namespaced);
}
}
}
}
}
if let Some(ref filter) = session.effective_tool_filter {
let filter_names: Vec<String> = match filter {
crate::profile::ToolFilter::Allow(names) => names.clone(),
crate::profile::ToolFilter::Deny(names) => names.clone(),
crate::profile::ToolFilter::Inherit => vec![],
};
let known_names: std::collections::HashSet<String> =
diagnostics.iter().map(|d| d.name.clone()).collect();
for name in filter_names {
if !known_names.contains(&name) {
diagnostics.push(ToolDiagnostic {
name: name.clone(),
source: ToolSource::Local,
available: false,
unavailable_reason: Some(UnavailableReason::UnknownToolName),
requires_approval: false,
description: format!(
"Tool '{}' is referenced by the profile filter but is not available in the current catalog.",
name
),
});
}
}
}
diagnostics
}
pub fn inspect_tool(&self, session: &DurableSession, name: &str) -> Option<ToolDiagnostic> {
self.inspect_tools(session)
.into_iter()
.find(|diagnostic| diagnostic.name == name)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSource {
Local,
Mcp {
server_id: String,
},
Plugin {
plugin_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDiagnostic {
pub name: String,
pub source: ToolSource,
pub available: bool,
pub unavailable_reason: Option<UnavailableReason>,
pub requires_approval: bool,
pub description: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::durable::SessionId;
use crate::plugin::auth::CredentialBinding;
use crate::plugin::config::{PluginConfig, PluginSource};
use crate::plugin::manifest::{
ExportedTool, PluginIdentity, PluginManifest, PluginPublisher, PresentationMetadata,
ToolAuthRequirements,
};
use crate::plugin::network::NetworkPolicy;
use crate::plugin::status::PluginHealth;
use crate::skill::{LoadedSkill, SkillMetadata, SkillOrigin};
use std::path::PathBuf;
use std::sync::Arc;
fn make_plugin_config(id: &str) -> PluginConfig {
PluginConfig {
id: id.to_string(),
source: PluginSource::LocalPath {
path: PathBuf::from("/dev/null"),
},
enabled_by_default: true,
}
}
fn make_manifest(id: &str, tools: Vec<ExportedTool>) -> PluginManifest {
PluginManifest {
identity: PluginIdentity {
id: id.to_string(),
name: format!("Plugin {}", id),
version: "1.0.0".to_string(),
},
publisher: PluginPublisher {
name: "Test".to_string(),
url: None,
contact: None,
},
presentation: PresentationMetadata {
description: "Test plugin".to_string(),
long_description: None,
icon: None,
category: None,
keywords: vec![],
},
network_policy: NetworkPolicy::Wildcard,
auth: None,
tools,
max_memory_bytes: None,
api_version: "1.0".to_string(),
}
}
fn make_tool(name: &str, auth: Option<ToolAuthRequirements>) -> ExportedTool {
ExportedTool {
name: name.to_string(),
description: format!("Tool {}", name),
input_schema: serde_json::json!({"type": "object"}),
requires_approval: false,
auth_requirements: auth,
}
}
fn make_approval_tool(name: &str) -> ExportedTool {
ExportedTool {
name: name.to_string(),
description: format!("Approval tool {}", name),
input_schema: serde_json::json!({"type": "object"}),
requires_approval: true,
auth_requirements: None,
}
}
fn make_skill(name: &str, description: &str) -> LoadedSkill {
LoadedSkill {
metadata: SkillMetadata {
id: name.to_string(),
display_name: name.to_string(),
description: description.to_string(),
origin: SkillOrigin::ClientProvided,
auto_activate: false,
tags: vec![],
requires_tools: vec![],
requires_capabilities: vec![],
requires_trust: false,
},
location: None,
body: format!("# {}\nInstructions", name),
resources: vec![],
}
}
fn build_catalog(session: &DurableSession) -> SessionToolCatalog {
let mut local_registry = crate::tool::ToolRegistry::new();
local_registry.register(crate::tool::FunctionTool::new(
crate::context::CompressTool::definition(),
|_args| {
Err(crate::error::RuntimeError::tool_execution(
"test stub".to_string(),
))
},
));
let local = Arc::new(local_registry);
let mcp = Arc::new(crate::mcp::server::McpServerRegistry::new());
let plugin = Arc::new(crate::plugin::registry::PluginRegistry::new());
let wasm = Arc::new(crate::plugin::wasm_host::WasmHost::new());
let conn = Arc::new(crate::mcp::McpConnectionManager::new(
crate::mcp::server::McpServerRegistry::new(),
));
SessionToolCatalog::new(local, mcp, plugin, wasm, conn, session)
}
fn build_catalog_with_local_tools(
session: &DurableSession,
tools: impl FnOnce(&mut crate::tool::ToolRegistry),
) -> SessionToolCatalog {
let mut local_registry = crate::tool::ToolRegistry::new();
tools(&mut local_registry);
let local = Arc::new(local_registry);
let mcp = Arc::new(crate::mcp::server::McpServerRegistry::new());
let plugin = Arc::new(crate::plugin::registry::PluginRegistry::new());
let wasm = Arc::new(crate::plugin::wasm_host::WasmHost::new());
let conn = Arc::new(crate::mcp::McpConnectionManager::new(
crate::mcp::server::McpServerRegistry::new(),
));
SessionToolCatalog::new(local, mcp, plugin, wasm, conn, session)
}
fn build_catalog_with_plugins(
session: &DurableSession,
plugin_registry: Arc<crate::plugin::registry::PluginRegistry>,
) -> SessionToolCatalog {
let local = Arc::new(crate::tool::ToolRegistry::new());
let mcp = Arc::new(crate::mcp::server::McpServerRegistry::new());
let wasm = Arc::new(crate::plugin::wasm_host::WasmHost::new());
let conn = Arc::new(crate::mcp::McpConnectionManager::new(
crate::mcp::server::McpServerRegistry::new(),
));
SessionToolCatalog::new(local, mcp, plugin_registry, wasm, conn, session)
}
fn register_healthy_plugin(
registry: &crate::plugin::registry::PluginRegistry,
plugin_id: &str,
tools: Vec<ExportedTool>,
) {
registry.register(make_plugin_config(plugin_id));
registry.update_health(plugin_id, PluginHealth::Healthy);
registry.set_manifest(plugin_id, make_manifest(plugin_id, tools));
}
#[test]
fn catalog_has_only_compress_when_no_plugins_registered() {
let session = DurableSession::new(SessionId::new());
let catalog = build_catalog(&session);
assert_eq!(catalog.len(), 1);
assert!(catalog.contains(crate::context::compaction::COMPRESS_TOOL_NAME));
}
#[test]
fn catalog_exposes_compress_when_session_has_context() {
let mut session = DurableSession::new(SessionId::new());
session.add_user_text("context that can eventually be compressed");
let catalog = build_catalog(&session);
assert!(catalog.contains(crate::context::compaction::COMPRESS_TOOL_NAME));
assert!(catalog
.get_definition(crate::context::compaction::COMPRESS_TOOL_NAME)
.is_some());
assert!(!catalog.requires_approval(crate::context::compaction::COMPRESS_TOOL_NAME));
}
#[test]
fn catalog_exposes_compress_for_empty_session() {
let session = DurableSession::new(SessionId::new());
let catalog = build_catalog(&session);
assert!(catalog.contains(crate::context::compaction::COMPRESS_TOOL_NAME));
assert!(catalog
.get_definition(crate::context::compaction::COMPRESS_TOOL_NAME)
.is_some());
}
#[test]
fn catalog_omits_activate_skill_when_session_has_no_available_skills() {
let session = DurableSession::new(SessionId::new());
let catalog = build_catalog_with_local_tools(&session, |registry| {
registry.register(crate::tool::FunctionTool::simple(
"activate_skill",
"activate skill",
|_| Ok(serde_json::json!({})),
));
});
assert!(!catalog.contains("activate_skill"));
}
#[test]
fn catalog_exposes_dynamic_activate_skill_definition() {
let mut session = DurableSession::new(SessionId::new());
session.set_available_skills(vec![
make_skill("review", "Review code changes"),
make_skill("docs", "Write technical docs"),
]);
let catalog = build_catalog_with_local_tools(&session, |registry| {
registry.register(crate::tool::FunctionTool::simple(
"activate_skill",
"activate skill",
|_| Ok(serde_json::json!({})),
));
});
let definition = catalog
.get_definition("activate_skill")
.expect("activate_skill should be present");
assert!(definition
.description
.contains("review: Review code changes"));
assert!(definition
.description
.contains("docs: Write technical docs"));
let enum_values = definition.input_schema["properties"]["skill_name"]["enum"]
.as_array()
.expect("enum values");
assert_eq!(enum_values.len(), 2);
assert!(enum_values.iter().any(|value| value == "review"));
assert!(enum_values.iter().any(|value| value == "docs"));
}
#[test]
fn catalogs_use_session_local_skill_snapshots() {
let mut session_a = DurableSession::new(SessionId::new());
session_a.set_available_skills(vec![make_skill("review", "Review code changes")]);
let mut session_b = DurableSession::new(SessionId::new());
session_b.set_available_skills(vec![make_skill("docs", "Write technical docs")]);
let catalog_a = build_catalog_with_local_tools(&session_a, |registry| {
registry.register(crate::tool::FunctionTool::simple(
"activate_skill",
"activate skill",
|_| Ok(serde_json::json!({})),
));
});
let catalog_b = build_catalog_with_local_tools(&session_b, |registry| {
registry.register(crate::tool::FunctionTool::simple(
"activate_skill",
"activate skill",
|_| Ok(serde_json::json!({})),
));
});
let definition_a = catalog_a.get_definition("activate_skill").unwrap();
let definition_b = catalog_b.get_definition("activate_skill").unwrap();
assert!(definition_a
.description
.contains("review: Review code changes"));
assert!(!definition_a
.description
.contains("docs: Write technical docs"));
assert!(definition_b
.description
.contains("docs: Write technical docs"));
assert!(!definition_b
.description
.contains("review: Review code changes"));
}
#[test]
fn catalog_includes_enabled_healthy_plugin_tools() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(
®istry,
"myplug",
vec![make_tool("read", None), make_tool("write", None)],
);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("myplug", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert_eq!(catalog.len(), 2);
let names: Vec<&str> = catalog
.definitions()
.iter()
.map(|d| d.name.as_str())
.collect();
assert!(names.contains(&"plugin_myplug_read"));
assert!(names.contains(&"plugin_myplug_write"));
}
#[test]
fn catalog_excludes_disabled_plugin_tools() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(®istry, "offplug", vec![make_tool("x", None)]);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("offplug", false);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert!(
catalog.is_empty(),
"disabled plugin should contribute no tools"
);
}
#[test]
fn catalog_excludes_unhealthy_plugin_tools_even_when_enabled() {
let registry = crate::plugin::registry::PluginRegistry::new();
registry.register(make_plugin_config("sick"));
registry.set_manifest("sick", make_manifest("sick", vec![make_tool("a", None)]));
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("sick", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert!(
catalog.is_empty(),
"unhealthy plugin should contribute no tools"
);
}
#[test]
fn catalog_excludes_plugin_without_manifest() {
let registry = crate::plugin::registry::PluginRegistry::new();
registry.register(make_plugin_config("nomanifest"));
registry.update_health("nomanifest", PluginHealth::Healthy);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("nomanifest", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert!(catalog.is_empty());
}
#[test]
fn catalog_excludes_auth_gated_tools_when_not_authenticated() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(
®istry,
"authplug",
vec![
make_tool(
"free",
Some(ToolAuthRequirements {
scopes: vec![],
available_unauthenticated: true,
}),
),
make_tool(
"gated",
Some(ToolAuthRequirements {
scopes: vec![],
available_unauthenticated: false,
}),
),
],
);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("authplug", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert_eq!(catalog.len(), 1, "only the free tool should appear");
let name = &catalog.definitions()[0].name;
assert_eq!(name, "plugin_authplug_free");
}
#[test]
fn catalog_includes_auth_gated_tools_when_authenticated() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(
®istry,
"authplug2",
vec![
make_tool(
"free",
Some(ToolAuthRequirements {
scopes: vec![],
available_unauthenticated: true,
}),
),
make_tool(
"gated",
Some(ToolAuthRequirements {
scopes: vec![],
available_unauthenticated: false,
}),
),
],
);
registry.set_credentials(
"authplug2",
CredentialBinding {
plugin_id: "authplug2".to_string(),
provider: "test".to_string(),
access_token: "tok".to_string(),
refresh_token: None,
expires_at: None,
scopes: vec![],
},
);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("authplug2", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert_eq!(catalog.len(), 2, "both tools should appear after auth");
}
#[test]
fn catalog_reflects_approval_from_manifest() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(
®istry,
"approval-plug",
vec![
make_tool("no_approval", None),
make_approval_tool("needs_approval"),
],
);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("approval-plug", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert!(!catalog.requires_approval("plugin_approval-plug_no_approval"));
assert!(catalog.requires_approval("plugin_approval-plug_needs_approval"));
}
#[test]
fn catalog_contains_and_get_definition() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(®istry, "lookup", vec![make_tool("find", None)]);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("lookup", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert!(catalog.contains("plugin_lookup_find"));
assert!(!catalog.contains("plugin_lookup_missing"));
let def = catalog.get_definition("plugin_lookup_find").unwrap();
assert_eq!(def.name, "plugin_lookup_find");
}
#[test]
fn plugin_summary_reports_enabled_healthy_usable() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(®istry, "sum-plug", vec![make_tool("a", None)]);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("sum-plug", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
let summaries = catalog.plugin_summary(&session);
let s = summaries.iter().find(|s| s.id == "sum-plug").unwrap();
assert!(s.enabled);
assert!(s.healthy);
assert!(s.usable);
assert_eq!(s.tool_count, 1);
}
#[test]
fn plugin_summary_reports_disabled_as_not_usable() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(®istry, "dis-plug", vec![make_tool("a", None)]);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("dis-plug", false);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
let summaries = catalog.plugin_summary(&session);
let s = summaries.iter().find(|s| s.id == "dis-plug").unwrap();
assert!(!s.enabled);
assert!(s.healthy);
assert!(!s.usable);
assert_eq!(s.tool_count, 0);
}
#[test]
fn inspect_tools_reports_available_and_unavailable() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(
®istry,
"diag",
vec![
make_tool("visible", None),
make_tool(
"hidden",
Some(ToolAuthRequirements {
scopes: vec![],
available_unauthenticated: false,
}),
),
],
);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("diag", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
let diags = catalog.inspect_tools(&session);
let visible = diags
.iter()
.find(|d| d.name == "plugin_diag_visible")
.unwrap();
assert!(visible.available);
assert_eq!(
visible.source,
ToolSource::Plugin {
plugin_id: "diag".to_string()
}
);
let hidden = diags
.iter()
.find(|d| d.name == "plugin_diag_hidden")
.unwrap();
assert!(!hidden.available);
assert_eq!(
hidden.unavailable_reason,
Some(UnavailableReason::AuthRequired)
);
}
#[test]
fn inspect_tools_reports_disabled_plugin_as_not_enabled() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(®istry, "off-diag", vec![make_tool("a", None)]);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("off-diag", false);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
let diags = catalog.inspect_tools(&session);
let tool = diags
.iter()
.find(|d| d.name == "plugin_off-diag_a")
.unwrap();
assert!(!tool.available);
assert_eq!(
tool.unavailable_reason,
Some(UnavailableReason::PluginNotEnabled)
);
}
#[test]
fn inspect_tools_reports_unhealthy_plugin() {
let registry = crate::plugin::registry::PluginRegistry::new();
registry.register(make_plugin_config("sick-diag"));
registry.update_health("sick-diag", PluginHealth::Error);
registry.set_manifest(
"sick-diag",
make_manifest("sick-diag", vec![make_tool("a", None)]),
);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("sick-diag", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
let diags = catalog.inspect_tools(&session);
let tool = diags
.iter()
.find(|d| d.name == "plugin_sick-diag_a")
.unwrap();
assert!(!tool.available);
assert_eq!(
tool.unavailable_reason,
Some(UnavailableReason::PluginNotHealthy(PluginHealth::Error))
);
}
#[test]
fn resolve_plugin_tool_name_basic() {
let registry = crate::plugin::registry::PluginRegistry::new();
registry.register(make_plugin_config("myplug"));
let result = SessionToolCatalog::resolve_plugin_tool_name("plugin_myplug_read", ®istry);
assert_eq!(result, Some(("myplug".to_string(), "read".to_string())));
}
#[test]
fn resolve_plugin_tool_name_longest_match() {
let registry = crate::plugin::registry::PluginRegistry::new();
registry.register(make_plugin_config("my"));
registry.register(make_plugin_config("my_plugin"));
let result =
SessionToolCatalog::resolve_plugin_tool_name("plugin_my_plugin_read", ®istry);
assert_eq!(result, Some(("my_plugin".to_string(), "read".to_string())));
}
#[test]
fn resolve_plugin_tool_name_no_match() {
let registry = crate::plugin::registry::PluginRegistry::new();
assert!(
SessionToolCatalog::resolve_plugin_tool_name("plugin_nonexistent_tool", ®istry)
.is_none()
);
}
#[tokio::test]
async fn execute_plugin_tool_not_enabled_returns_error() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(®istry, "exec-off", vec![make_tool("x", None)]);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("exec-off", false);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
let result = catalog
.execute(
"call-1",
"plugin_exec-off_x",
serde_json::json!({}),
&session,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("disabled"),
"error should mention disabled: {}",
msg
);
}
#[tokio::test]
async fn execute_plugin_tool_unhealthy_returns_error() {
let registry = crate::plugin::registry::PluginRegistry::new();
registry.register(make_plugin_config("exec-sick"));
registry.update_health("exec-sick", PluginHealth::Error);
registry.set_manifest(
"exec-sick",
make_manifest("exec-sick", vec![make_tool("x", None)]),
);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("exec-sick", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
let result = catalog
.execute(
"call-2",
"plugin_exec-sick_x",
serde_json::json!({}),
&session,
)
.await;
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("not healthy"),
"error should mention unhealthy: {}",
msg
);
}
#[tokio::test]
async fn execute_plugin_tool_requires_auth_returns_error() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(
®istry,
"exec-auth",
vec![make_tool(
"gated",
Some(ToolAuthRequirements {
scopes: vec![],
available_unauthenticated: false,
}),
)],
);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("exec-auth", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
let result = catalog
.execute(
"call-3",
"plugin_exec-auth_gated",
serde_json::json!({}),
&session,
)
.await;
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("requires authentication"),
"error should mention authentication: {}",
msg
);
}
#[tokio::test]
async fn execute_unknown_plugin_tool_returns_error() {
let registry = crate::plugin::registry::PluginRegistry::new();
let session = DurableSession::new(SessionId::new());
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
let result = catalog
.execute(
"call-4",
"plugin_nonexistent_x",
serde_json::json!({}),
&session,
)
.await;
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("not installed") || msg.contains("unknown"),
"error should mention plugin status: {}",
msg
);
}
#[tokio::test]
async fn execute_completely_unknown_tool_returns_error() {
let session = DurableSession::new(SessionId::new());
let catalog = build_catalog(&session);
let result = catalog
.execute(
"call-5",
"some_random_tool",
serde_json::json!({}),
&session,
)
.await;
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("not available"));
}
#[test]
fn multiple_plugins_contribute_tools_independently() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(®istry, "plug-a", vec![make_tool("alpha", None)]);
register_healthy_plugin(®istry, "plug-b", vec![make_tool("beta", None)]);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("plug-a", true);
session.set_plugin_enabled("plug-b", true);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert_eq!(catalog.len(), 2);
assert!(catalog.contains("plugin_plug-a_alpha"));
assert!(catalog.contains("plugin_plug-b_beta"));
}
#[test]
fn enabling_one_plugin_does_not_affect_another() {
let registry = crate::plugin::registry::PluginRegistry::new();
register_healthy_plugin(®istry, "plug-a", vec![make_tool("alpha", None)]);
register_healthy_plugin(®istry, "plug-b", vec![make_tool("beta", None)]);
let mut session = DurableSession::new(SessionId::new());
session.set_plugin_enabled("plug-a", true);
session.set_plugin_enabled("plug-b", false);
let catalog = build_catalog_with_plugins(&session, Arc::new(registry));
assert_eq!(catalog.len(), 1);
assert!(catalog.contains("plugin_plug-a_alpha"));
assert!(!catalog.contains("plugin_plug-b_beta"));
}
}