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:
2025-10-25 06:45:18 +02:00
parent 6849d5ef12
commit 9024e2b914
9 changed files with 46 additions and 30 deletions

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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"));

View File

@@ -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
}
} }

View File

@@ -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
}
} }

View File

@@ -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

View File

@@ -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,

View File

@@ -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;