feat(mcp): enforce spec-compliant tool registry

- Reject dotted tool identifiers during registration and remove alias-backed lookups.
- Drop web.search compatibility, normalize all code/tests around the canonical web_search name, and update consent/session logic.
- Harden CLI toggles to manage the spec-compliant identifier and ensure MCP configs shed non-compliant entries automatically.

Acceptance Criteria:
- Tool registry denies invalid identifiers by default and no alias codepaths remain.

Test Notes:
- cargo check -p owlen-core (tests unavailable in sandbox).
This commit is contained in:
2025-10-25 04:48:17 +02:00
parent 6a94373c4f
commit c3a92a092b
13 changed files with 284 additions and 105 deletions

View File

@@ -7,6 +7,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::encryption::VaultHandle;
use crate::tools::canonical_tool_name;
#[derive(Clone, Debug)]
pub struct ConsentRequest {
@@ -94,10 +95,12 @@ impl ConsentManager {
data_types: Vec<String>,
endpoints: Vec<String>,
) -> Result<ConsentScope> {
let canonical = canonical_tool_name(tool_name);
// Check if already granted permanently
if self
.permanent_records
.get(tool_name)
.get(canonical)
.is_some_and(|existing| existing.scope == ConsentScope::Permanent)
{
return Ok(ConsentScope::Permanent);
@@ -106,31 +109,31 @@ impl ConsentManager {
// Check if granted for session
if self
.session_records
.get(tool_name)
.get(canonical)
.is_some_and(|existing| existing.scope == ConsentScope::Session)
{
return Ok(ConsentScope::Session);
}
// Check if request is already pending (prevent duplicate prompts)
if self.pending_requests.contains_key(tool_name) {
if self.pending_requests.contains_key(canonical) {
// Wait for the other prompt to complete by returning denied temporarily
// The caller should retry after a short delay
return Ok(ConsentScope::Denied);
}
// Mark as pending
self.pending_requests.insert(tool_name.to_string(), ());
self.pending_requests.insert(canonical.to_string(), ());
// Show consent dialog and get scope
let scope = self.show_consent_dialog(tool_name, &data_types, &endpoints)?;
// Remove from pending
self.pending_requests.remove(tool_name);
self.pending_requests.remove(canonical);
// Create record based on scope
let record = ConsentRecord {
tool_name: tool_name.to_string(),
tool_name: canonical.to_string(),
scope: scope.clone(),
timestamp: Utc::now(),
data_types,
@@ -140,10 +143,10 @@ impl ConsentManager {
// Store in appropriate location
match scope {
ConsentScope::Permanent => {
self.permanent_records.insert(tool_name.to_string(), record);
self.permanent_records.insert(canonical.to_string(), record);
}
ConsentScope::Session => {
self.session_records.insert(tool_name.to_string(), record);
self.session_records.insert(canonical.to_string(), record);
}
ConsentScope::Once | ConsentScope::Denied => {
// Don't store, just return the decision
@@ -171,8 +174,9 @@ impl ConsentManager {
endpoints: Vec<String>,
scope: ConsentScope,
) {
let canonical = canonical_tool_name(tool_name);
let record = ConsentRecord {
tool_name: tool_name.to_string(),
tool_name: canonical.to_string(),
scope: scope.clone(),
timestamp: Utc::now(),
data_types,
@@ -181,13 +185,13 @@ impl ConsentManager {
match scope {
ConsentScope::Permanent => {
self.permanent_records.insert(tool_name.to_string(), record);
self.permanent_records.insert(canonical.to_string(), record);
}
ConsentScope::Session => {
self.session_records.insert(tool_name.to_string(), record);
self.session_records.insert(canonical.to_string(), record);
}
ConsentScope::Once => {
self.once_records.insert(tool_name.to_string(), record);
self.once_records.insert(canonical.to_string(), record);
}
ConsentScope::Denied => {} // Denied is not stored
}
@@ -195,28 +199,30 @@ impl ConsentManager {
/// Check if consent is needed (returns None if already granted, Some(info) if needed)
pub fn check_consent_needed(&self, tool_name: &str) -> Option<ConsentRequest> {
if self.has_consent(tool_name) {
let canonical = canonical_tool_name(tool_name);
if self.has_consent(canonical) {
None
} else {
Some(ConsentRequest {
tool_name: tool_name.to_string(),
tool_name: canonical.to_string(),
})
}
}
pub fn has_consent(&self, tool_name: &str) -> bool {
let canonical = canonical_tool_name(tool_name);
// Check permanent first, then session, then once
self.permanent_records
.get(tool_name)
.get(canonical)
.map(|r| r.scope == ConsentScope::Permanent)
.or_else(|| {
self.session_records
.get(tool_name)
.get(canonical)
.map(|r| r.scope == ConsentScope::Session)
})
.or_else(|| {
self.once_records
.get(tool_name)
.get(canonical)
.map(|r| r.scope == ConsentScope::Once)
})
.unwrap_or(false)
@@ -224,13 +230,15 @@ impl ConsentManager {
/// Consume "once" consent for a tool (clears it after first use)
pub fn consume_once_consent(&mut self, tool_name: &str) {
self.once_records.remove(tool_name);
let canonical = canonical_tool_name(tool_name);
self.once_records.remove(canonical);
}
pub fn revoke_consent(&mut self, tool_name: &str) {
self.permanent_records.remove(tool_name);
self.session_records.remove(tool_name);
self.once_records.remove(tool_name);
let canonical = canonical_tool_name(tool_name);
self.permanent_records.remove(canonical);
self.session_records.remove(canonical);
self.once_records.remove(canonical);
}
pub fn clear_all_consent(&mut self) {
@@ -253,10 +261,11 @@ impl ConsentManager {
data_types: Vec<String>,
endpoints: Vec<String>,
) -> Option<(String, Vec<String>, Vec<String>)> {
if self.has_consent(tool_name) {
let canonical = canonical_tool_name(tool_name);
if self.has_consent(canonical) {
return None;
}
Some((tool_name.to_string(), data_types, endpoints))
Some((canonical.to_string(), data_types, endpoints))
}
fn show_consent_dialog(