feat(mcp): enforce spec-compliant tool identifiers
Acceptance-Criteria:\n- spec-compliant names are shared via WEB_SEARCH_TOOL_NAME and ModeConfig checks canonical identifiers.\n- workspace depends on once_cell so regex helpers build without local target hacks. Test-Notes:\n- cargo test
This commit is contained in:
@@ -63,6 +63,7 @@ log = "0.4"
|
|||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
handlebars = "6.0"
|
handlebars = "6.0"
|
||||||
|
once_cell = "1.19"
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ tokio-stream = { workspace = true }
|
|||||||
tokio-tungstenite = "0.21"
|
tokio-tungstenite = "0.21"
|
||||||
tungstenite = "0.21"
|
tungstenite = "0.21"
|
||||||
ollama-rs = { version = "0.3", features = ["stream", "headers"] }
|
ollama-rs = { version = "0.3", features = ["stream", "headers"] }
|
||||||
|
once_cell = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = { workspace = true }
|
tokio-test = { workspace = true }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use crate::config::McpServerConfig;
|
use crate::config::McpServerConfig;
|
||||||
use crate::tools::tool_identifier_violation;
|
use crate::tools::tool_identifier_violation;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{Result, anyhow};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use crate::tools::{WEB_SEARCH_TOOL_NAME, canonical_tool_name};
|
||||||
|
|
||||||
/// Operating mode for Owlen
|
/// Operating mode for Owlen
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -71,7 +73,7 @@ impl Default for ModeConfig {
|
|||||||
impl ModeConfig {
|
impl ModeConfig {
|
||||||
fn default_chat_tools() -> ModeToolConfig {
|
fn default_chat_tools() -> ModeToolConfig {
|
||||||
ModeToolConfig {
|
ModeToolConfig {
|
||||||
allowed_tools: vec!["web_search".to_string()],
|
allowed_tools: vec![WEB_SEARCH_TOOL_NAME.to_string()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +110,10 @@ impl ModeToolConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if tool is explicitly listed
|
// Check if tool is explicitly listed
|
||||||
self.allowed_tools.iter().any(|t| t == tool_name)
|
let target = canonical_tool_name(tool_name);
|
||||||
|
self.allowed_tools
|
||||||
|
.iter()
|
||||||
|
.any(|t| canonical_tool_name(t) == target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +146,7 @@ mod tests {
|
|||||||
let config = ModeConfig::default();
|
let config = ModeConfig::default();
|
||||||
|
|
||||||
// Web search should be allowed in chat mode
|
// Web search should be allowed in chat mode
|
||||||
|
assert!(config.is_tool_allowed(Mode::Chat, WEB_SEARCH_TOOL_NAME));
|
||||||
assert!(config.is_tool_allowed(Mode::Chat, "web_search"));
|
assert!(config.is_tool_allowed(Mode::Chat, "web_search"));
|
||||||
|
|
||||||
// Code exec should not be allowed in chat mode
|
// Code exec should not be allowed in chat mode
|
||||||
@@ -153,6 +159,7 @@ mod tests {
|
|||||||
let config = ModeConfig::default();
|
let config = ModeConfig::default();
|
||||||
|
|
||||||
// All tools should be allowed in code mode
|
// All tools should be allowed in code mode
|
||||||
|
assert!(config.is_tool_allowed(Mode::Code, WEB_SEARCH_TOOL_NAME));
|
||||||
assert!(config.is_tool_allowed(Mode::Code, "web_search"));
|
assert!(config.is_tool_allowed(Mode::Code, "web_search"));
|
||||||
assert!(config.is_tool_allowed(Mode::Code, "code_exec"));
|
assert!(config.is_tool_allowed(Mode::Code, "code_exec"));
|
||||||
assert!(config.is_tool_allowed(Mode::Code, "file_write"));
|
assert!(config.is_tool_allowed(Mode::Code, "file_write"));
|
||||||
|
|||||||
@@ -7,12 +7,10 @@
|
|||||||
"tool_calls": []
|
"tool_calls": []
|
||||||
},
|
},
|
||||||
"done": true,
|
"done": true,
|
||||||
"final_data": {
|
|
||||||
"total_duration": 2500000000,
|
"total_duration": 2500000000,
|
||||||
"load_duration": 60000000,
|
"load_duration": 60000000,
|
||||||
"prompt_eval_count": 64,
|
"prompt_eval_count": 64,
|
||||||
"prompt_eval_duration": 420000000,
|
"prompt_eval_duration": 420000000,
|
||||||
"eval_count": 48,
|
"eval_count": 48,
|
||||||
"eval_duration": 520000000
|
"eval_duration": 520000000
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,10 @@
|
|||||||
"tool_calls": []
|
"tool_calls": []
|
||||||
},
|
},
|
||||||
"done": true,
|
"done": true,
|
||||||
"final_data": {
|
|
||||||
"total_duration": 1200000000,
|
"total_duration": 1200000000,
|
||||||
"load_duration": 50000000,
|
"load_duration": 50000000,
|
||||||
"prompt_eval_count": 24,
|
"prompt_eval_count": 24,
|
||||||
"prompt_eval_duration": 320000000,
|
"prompt_eval_duration": 320000000,
|
||||||
"eval_count": 12,
|
"eval_count": 12,
|
||||||
"eval_duration": 480000000
|
"eval_duration": 480000000
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
|||||||
use owlen_core::config::Config;
|
use owlen_core::config::Config;
|
||||||
use owlen_core::mode::{Mode, ModeConfig, ModeToolConfig};
|
use owlen_core::mode::{Mode, ModeConfig, ModeToolConfig};
|
||||||
use owlen_core::tools::registry::ToolRegistry;
|
use owlen_core::tools::registry::ToolRegistry;
|
||||||
use owlen_core::tools::{Tool, ToolResult};
|
use owlen_core::tools::{Tool, ToolResult, WEB_SEARCH_TOOL_NAME};
|
||||||
use owlen_core::ui::{NoOpUiController, UiController};
|
use owlen_core::ui::{NoOpUiController, UiController};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -57,7 +57,7 @@ async fn test_tool_allowed_in_chat_mode() {
|
|||||||
|
|
||||||
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
|
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
|
||||||
let mut reg = ToolRegistry::new(cfg.clone(), ui);
|
let mut reg = ToolRegistry::new(cfg.clone(), ui);
|
||||||
reg.register(EchoTool);
|
reg.register(EchoTool).unwrap();
|
||||||
|
|
||||||
let args = json!({ "msg": "hello" });
|
let args = json!({ "msg": "hello" });
|
||||||
let result = reg
|
let result = reg
|
||||||
@@ -75,11 +75,11 @@ async fn test_tool_not_allowed_in_any_mode() {
|
|||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
modes: ModeConfig {
|
modes: ModeConfig {
|
||||||
chat: ModeToolConfig {
|
chat: ModeToolConfig {
|
||||||
allowed_tools: vec!["web_search".to_string()],
|
allowed_tools: vec![WEB_SEARCH_TOOL_NAME.to_string()],
|
||||||
},
|
},
|
||||||
code: ModeToolConfig {
|
code: ModeToolConfig {
|
||||||
// Strict denial - only web_search allowed
|
// Strict denial - only web_search allowed
|
||||||
allowed_tools: vec!["web_search".to_string()],
|
allowed_tools: vec![WEB_SEARCH_TOOL_NAME.to_string()],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -88,7 +88,7 @@ async fn test_tool_not_allowed_in_any_mode() {
|
|||||||
|
|
||||||
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
|
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
|
||||||
let mut reg = ToolRegistry::new(cfg.clone(), ui);
|
let mut reg = ToolRegistry::new(cfg.clone(), ui);
|
||||||
reg.register(EchoTool);
|
reg.register(EchoTool).unwrap();
|
||||||
|
|
||||||
let args = json!({ "msg": "hello" });
|
let args = json!({ "msg": "hello" });
|
||||||
let result = reg
|
let result = reg
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use owlen_core::tools::WEB_SEARCH_TOOL_NAME;
|
||||||
use owlen_core::types::{ChatParameters, Role};
|
use owlen_core::types::{ChatParameters, Role};
|
||||||
use owlen_core::{
|
use owlen_core::{
|
||||||
Config, Provider,
|
Config, Provider,
|
||||||
@@ -108,6 +109,9 @@ fn configure_cloud(base_url: &str) -> Config {
|
|||||||
config.privacy.encrypt_local_data = false;
|
config.privacy.encrypt_local_data = false;
|
||||||
config.privacy.require_consent_per_session = false;
|
config.privacy.require_consent_per_session = false;
|
||||||
config.tools.web_search.enabled = true;
|
config.tools.web_search.enabled = true;
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("OWLEN_ALLOW_INSECURE_CLOUD", "1");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(cloud) = config.providers.get_mut("ollama_cloud") {
|
if let Some(cloud) = config.providers.get_mut("ollama_cloud") {
|
||||||
cloud.enabled = true;
|
cloud.enabled = true;
|
||||||
@@ -117,6 +121,10 @@ fn configure_cloud(base_url: &str) -> Config {
|
|||||||
"web_search_endpoint".into(),
|
"web_search_endpoint".into(),
|
||||||
Value::String("/v1/web/search".into()),
|
Value::String("/v1/web/search".into()),
|
||||||
);
|
);
|
||||||
|
cloud.extra.insert(
|
||||||
|
owlen_core::config::OLLAMA_CLOUD_ENDPOINT_KEY.into(),
|
||||||
|
Value::String(base_url.to_string()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config
|
config
|
||||||
@@ -162,6 +170,7 @@ async fn local_provider_happy_path_records_usage() {
|
|||||||
SessionOutcome::Complete(response) => response,
|
SessionOutcome::Complete(response) => response,
|
||||||
_ => panic!("expected complete outcome"),
|
_ => panic!("expected complete outcome"),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(response.message.content, "Local response complete.");
|
assert_eq!(response.message.content, "Local response complete.");
|
||||||
|
|
||||||
let snapshot = session
|
let snapshot = session
|
||||||
@@ -226,6 +235,8 @@ async fn cloud_tool_call_flows_through_web_search() {
|
|||||||
.get("ollama_cloud")
|
.get("ollama_cloud")
|
||||||
.expect("cloud provider config")
|
.expect("cloud provider config")
|
||||||
.clone();
|
.clone();
|
||||||
|
assert_eq!(cloud_cfg.api_key.as_deref(), Some("test-key"));
|
||||||
|
assert_eq!(cloud_cfg.base_url.as_deref(), Some(base_url.as_str()));
|
||||||
let provider: Arc<dyn Provider> = Arc::new(
|
let provider: Arc<dyn Provider> = Arc::new(
|
||||||
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
|
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
|
||||||
.expect("cloud provider"),
|
.expect("cloud provider"),
|
||||||
@@ -233,7 +244,7 @@ async fn cloud_tool_call_flows_through_web_search() {
|
|||||||
|
|
||||||
let (mut session, _tmp) = create_session(provider, config).await;
|
let (mut session, _tmp) = create_session(provider, config).await;
|
||||||
session.grant_consent(
|
session.grant_consent(
|
||||||
"web_search",
|
WEB_SEARCH_TOOL_NAME,
|
||||||
vec!["network".into()],
|
vec!["network".into()],
|
||||||
vec![format!("{}/v1/web/search", base_url)],
|
vec![format!("{}/v1/web/search", base_url)],
|
||||||
);
|
);
|
||||||
@@ -243,12 +254,12 @@ async fn cloud_tool_call_flows_through_web_search() {
|
|||||||
"What is new in Rust today?".to_string(),
|
"What is new in Rust today?".to_string(),
|
||||||
ChatParameters::default(),
|
ChatParameters::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.expect("cloud completion");
|
|
||||||
|
|
||||||
let response = match outcome {
|
let response = match outcome {
|
||||||
SessionOutcome::Complete(response) => response,
|
Ok(SessionOutcome::Complete(response)) => response,
|
||||||
_ => panic!("expected complete outcome"),
|
Ok(_) => panic!("expected complete outcome"),
|
||||||
|
Err(err) => panic!("cloud completion: {err:?}"),
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response.message.content,
|
response.message.content,
|
||||||
|
|||||||
@@ -6917,7 +6917,7 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"audit" => {
|
"audit" => {
|
||||||
let preset_name = args.get(1).map(|s| *s);
|
let preset_name = args.get(1).copied();
|
||||||
match self.audit_tool_preset(preset_name).await {
|
match self.audit_tool_preset(preset_name).await {
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
self.status = message;
|
self.status = message;
|
||||||
|
|||||||
Reference in New Issue
Block a user