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"
serde_yaml = "0.9"
handlebars = "6.0"
once_cell = "1.19"
# Configuration
toml = "0.8"

View File

@@ -45,6 +45,7 @@ tokio-stream = { workspace = true }
tokio-tungstenite = "0.21"
tungstenite = "0.21"
ollama-rs = { version = "0.3", features = ["stream", "headers"] }
once_cell = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }

View File

@@ -6,7 +6,7 @@
use crate::config::McpServerConfig;
use crate::tools::tool_identifier_violation;
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use std::collections::{HashMap, HashSet};
use std::str::FromStr;

View File

@@ -6,6 +6,8 @@
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use crate::tools::{WEB_SEARCH_TOOL_NAME, canonical_tool_name};
/// Operating mode for Owlen
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
@@ -71,7 +73,7 @@ impl Default for ModeConfig {
impl ModeConfig {
fn default_chat_tools() -> 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
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();
// 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"));
// Code exec should not be allowed in chat mode
@@ -153,6 +159,7 @@ mod tests {
let config = ModeConfig::default();
// 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, "code_exec"));
assert!(config.is_tool_allowed(Mode::Code, "file_write"));

View File

@@ -7,12 +7,10 @@
"tool_calls": []
},
"done": true,
"final_data": {
"total_duration": 2500000000,
"load_duration": 60000000,
"prompt_eval_count": 64,
"prompt_eval_duration": 420000000,
"eval_count": 48,
"eval_duration": 520000000
}
"total_duration": 2500000000,
"load_duration": 60000000,
"prompt_eval_count": 64,
"prompt_eval_duration": 420000000,
"eval_count": 48,
"eval_duration": 520000000
}

View File

@@ -7,12 +7,10 @@
"tool_calls": []
},
"done": true,
"final_data": {
"total_duration": 1200000000,
"load_duration": 50000000,
"prompt_eval_count": 24,
"prompt_eval_duration": 320000000,
"eval_count": 12,
"eval_duration": 480000000
}
"total_duration": 1200000000,
"load_duration": 50000000,
"prompt_eval_count": 24,
"prompt_eval_duration": 320000000,
"eval_count": 12,
"eval_duration": 480000000
}

View File

@@ -13,7 +13,7 @@ use std::sync::Arc;
use owlen_core::config::Config;
use owlen_core::mode::{Mode, ModeConfig, ModeToolConfig};
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 serde_json::json;
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 mut reg = ToolRegistry::new(cfg.clone(), ui);
reg.register(EchoTool);
reg.register(EchoTool).unwrap();
let args = json!({ "msg": "hello" });
let result = reg
@@ -75,11 +75,11 @@ async fn test_tool_not_allowed_in_any_mode() {
let cfg = Config {
modes: ModeConfig {
chat: ModeToolConfig {
allowed_tools: vec!["web_search".to_string()],
allowed_tools: vec![WEB_SEARCH_TOOL_NAME.to_string()],
},
code: ModeToolConfig {
// Strict denial - only web_search allowed
allowed_tools: vec!["web_search".to_string()],
allowed_tools: vec![WEB_SEARCH_TOOL_NAME.to_string()],
},
},
..Default::default()
@@ -88,7 +88,7 @@ async fn test_tool_not_allowed_in_any_mode() {
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
let mut reg = ToolRegistry::new(cfg.clone(), ui);
reg.register(EchoTool);
reg.register(EchoTool).unwrap();
let args = json!({ "msg": "hello" });
let result = reg

View File

@@ -1,5 +1,6 @@
use std::sync::Arc;
use owlen_core::tools::WEB_SEARCH_TOOL_NAME;
use owlen_core::types::{ChatParameters, Role};
use owlen_core::{
Config, Provider,
@@ -108,6 +109,9 @@ fn configure_cloud(base_url: &str) -> Config {
config.privacy.encrypt_local_data = false;
config.privacy.require_consent_per_session = false;
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") {
cloud.enabled = true;
@@ -117,6 +121,10 @@ fn configure_cloud(base_url: &str) -> Config {
"web_search_endpoint".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
@@ -162,6 +170,7 @@ async fn local_provider_happy_path_records_usage() {
SessionOutcome::Complete(response) => response,
_ => panic!("expected complete outcome"),
};
assert_eq!(response.message.content, "Local response complete.");
let snapshot = session
@@ -226,6 +235,8 @@ async fn cloud_tool_call_flows_through_web_search() {
.get("ollama_cloud")
.expect("cloud provider config")
.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(
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
.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;
session.grant_consent(
"web_search",
WEB_SEARCH_TOOL_NAME,
vec!["network".into()],
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(),
ChatParameters::default(),
)
.await
.expect("cloud completion");
.await;
let response = match outcome {
SessionOutcome::Complete(response) => response,
_ => panic!("expected complete outcome"),
Ok(SessionOutcome::Complete(response)) => response,
Ok(_) => panic!("expected complete outcome"),
Err(err) => panic!("cloud completion: {err:?}"),
};
assert_eq!(
response.message.content,

View File

@@ -6917,7 +6917,7 @@ impl ChatApp {
}
}
"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 {
Ok(message) => {
self.status = message;