1use serde_json::{json, Value};
28
29use super::activation::{activate_model_initiated, ActivationAuthority, SharedSessionToolSet};
30use super::catalog::{DiscoveryIndex, DiscoveryQuery, SearchLimits, ToolCatalog, ToolId};
31use super::{Tool, ToolContext, ToolOrigin, ToolOutput};
32use crate::{Result, RuntimeError};
33use agent_core::BoundedText;
34
35pub const SEARCH_TOOLS_MAX_RESULTS: usize = 16;
37pub const SEARCH_TOOLS_MAX_RESULT_BYTES: usize = 8 * 1024;
39pub const ACTIVATE_TOOLS_MAX_BATCH: usize = 16;
41const ID_ECHO_MAX_BYTES: usize = 128;
43
44#[derive(Clone)]
48pub struct ActivationCapability {
49 catalog: ToolCatalog,
50 session_set: SharedSessionToolSet,
51 authority: ActivationAuthority,
52 host_prompt_allowed: bool,
56}
57
58impl ActivationCapability {
59 pub fn new(
60 catalog: ToolCatalog,
61 session_set: SharedSessionToolSet,
62 authority: ActivationAuthority,
63 ) -> Self {
64 Self {
65 catalog,
66 session_set,
67 authority,
68 host_prompt_allowed: true,
69 }
70 }
71
72 pub fn with_host_prompt(mut self, allowed: bool) -> Self {
76 self.host_prompt_allowed = allowed;
77 self
78 }
79
80 pub fn host_prompt_allowed(&self) -> bool {
81 self.host_prompt_allowed
82 }
83
84 pub fn catalog(&self) -> &ToolCatalog {
85 &self.catalog
86 }
87
88 pub fn session_set(&self) -> &SharedSessionToolSet {
89 &self.session_set
90 }
91
92 pub fn authority(&self) -> ActivationAuthority {
93 self.authority
94 }
95
96 pub fn revoke_exact_mcp_grant(
102 &self,
103 session: &crate::tools::activation::SessionId,
104 server: &str,
105 server_tool_name: &str,
106 ) -> bool {
107 let mut set = self
108 .session_set
109 .write()
110 .unwrap_or_else(std::sync::PoisonError::into_inner);
111 if set.session() != session {
112 return false;
113 }
114 set.revoke_exact(&ToolId::mcp(server, server_tool_name))
115 .is_ok()
116 }
117
118 pub fn revoke_exact_extension_grant(
124 &self,
125 session: &crate::tools::activation::SessionId,
126 plugin_id: &str,
127 tool_name: &str,
128 ) -> bool {
129 let mut set = self
130 .session_set
131 .write()
132 .unwrap_or_else(std::sync::PoisonError::into_inner);
133 if set.session() != session {
134 return false;
135 }
136 set.revoke_exact(&ToolId::extension(plugin_id, tool_name))
137 .is_ok()
138 }
139}
140
141impl std::fmt::Debug for ActivationCapability {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 f.debug_struct("ActivationCapability")
144 .field("authority", &self.authority)
145 .finish_non_exhaustive()
146 }
147}
148
149fn require_capability(ctx: &ToolContext) -> Result<ActivationCapability> {
150 ctx.capabilities.tool_activation.clone().ok_or_else(|| {
151 RuntimeError::Tool("tool discovery/activation is not available in this context".to_string())
152 })
153}
154
155async fn confirm_activation_with_host(
165 prompt: Option<&crate::tools::SecretPromptHandle>,
166 tool_ids: &[ToolId],
167) -> ActivationAuthority {
168 let Some(prompt) = prompt else {
169 return ActivationAuthority::Unauthorized;
170 };
171 let mut ids: Vec<&str> = tool_ids.iter().map(ToolId::as_str).collect();
172 ids.sort_unstable();
173 let question = format!(
174 "The model requests session-scoped activation of {} exact tool id(s):\n{}\n\
175 Type y/yes to allow exactly these tools; anything else denies.",
176 ids.len(),
177 ids.join("\n")
178 );
179 match prompt
180 .prompt(
181 crate::session::CONFIRM_ACTIVATION_PROMPT_TITLE.to_string(),
182 question,
183 )
184 .await
185 {
186 Some(answer) => {
187 let normalized = answer.trim().to_ascii_lowercase();
188 if normalized == "y" || normalized == "yes" {
189 ActivationAuthority::ModelConfirmed
190 } else {
191 ActivationAuthority::Unauthorized
192 }
193 }
194 None => ActivationAuthority::Unauthorized,
195 }
196}
197
198fn require_query(params: &Value) -> Result<DiscoveryQuery> {
199 let raw = params["query"]
200 .as_str()
201 .ok_or_else(|| RuntimeError::Tool("Missing 'query' parameter (string)".to_string()))?;
202 DiscoveryQuery::parse(raw)
203 .map_err(|err| RuntimeError::Tool(format!("invalid discovery query: {err}")))
204}
205
206pub struct SearchToolsTool;
212
213#[async_trait::async_trait]
214impl Tool for SearchToolsTool {
215 fn effect(&self) -> crate::tools::catalog::ToolEffect {
216 crate::tools::catalog::ToolEffect::ReadOnly
217 }
218
219 fn name(&self) -> &str {
220 "search_tools"
221 }
222
223 fn description(&self) -> &str {
224 "Search locally known tools by keyword. Returns bounded compact \
225 descriptors with stable tool ids (never full schemas). Use \
226 activate_tools with an exact returned id to request activation."
227 }
228
229 fn origin(&self) -> ToolOrigin {
230 ToolOrigin::Builtin
231 }
232
233 fn parameters(&self) -> Value {
234 json!({
235 "type": "object",
236 "properties": {
237 "query": {
238 "type": "string",
239 "description": "Case-insensitive keyword matched against tool ids, summaries, and tags."
240 }
241 },
242 "required": ["query"]
243 })
244 }
245
246 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
247 let capability = require_capability(&ctx)?;
248 let query = require_query(¶ms)?;
249 let limits = SearchLimits::new(SEARCH_TOOLS_MAX_RESULTS, SEARCH_TOOLS_MAX_RESULT_BYTES)
250 .expect("static search_tools budgets are within caps");
251 let index = DiscoveryIndex::build(capability.catalog())
252 .map_err(|err| RuntimeError::Tool(format!("tool discovery unavailable: {err}")))?;
253 let results = index.search(&query, &limits);
254 let body = json!({
255 "generation": results.generation(),
256 "truncated": results.truncated(),
257 "tools": results.hits(),
258 });
259 serde_json::to_string(&body)
260 .map_err(|err| RuntimeError::Tool(format!("failed to serialize search results: {err}")))
261 }
262}
263
264pub struct ActivateToolsTool;
273
274#[async_trait::async_trait]
275impl Tool for ActivateToolsTool {
276 fn name(&self) -> &str {
277 "activate_tools"
278 }
279
280 fn description(&self) -> &str {
281 "Request session-scoped activation of exact tool ids returned by \
282 search_tools. Activation is subject to the host's \
283 tools.activation_confirm policy (auto: granted; prompt: the user is \
284 asked y/n; deny: always refused); only the exact requested tools are \
285 activated for this session."
286 }
287
288 fn origin(&self) -> ToolOrigin {
289 ToolOrigin::Builtin
290 }
291
292 fn parameters(&self) -> Value {
293 json!({
294 "type": "object",
295 "properties": {
296 "tools": {
297 "type": "array",
298 "items": {"type": "string"},
299 "description": "Exact stable tool ids (namespace:name) from search_tools. No wildcards."
300 }
301 },
302 "required": ["tools"]
303 })
304 }
305
306 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
307 self.activate(params, ctx).await.map(|(summary, _)| summary)
308 }
309
310 async fn execute_rich(&self, params: Value, ctx: ToolContext) -> Result<ToolOutput> {
318 let (summary, ids) = self.activate(params, ctx).await?;
319 let mut blocks = Vec::with_capacity(ids.len() + 1);
320 blocks.push(json!({"type": "text", "text": summary}));
321 blocks.extend(
322 ids.iter()
323 .map(|id| json!({"type": "tool_reference", "tool_name": id})),
324 );
325 Ok(ToolOutput::Blocks { blocks, summary })
326 }
327}
328
329impl ActivateToolsTool {
330 async fn activate(&self, params: Value, ctx: ToolContext) -> Result<(String, Vec<String>)> {
334 let capability = require_capability(&ctx)?;
335 let requested = params["tools"].as_array().ok_or_else(|| {
336 RuntimeError::Tool("Missing 'tools' parameter (array of tool id strings)".to_string())
337 })?;
338 if requested.is_empty() {
339 return Err(RuntimeError::Tool(
340 "no tool ids requested: 'tools' must name at least one exact id".to_string(),
341 ));
342 }
343 if requested.len() > ACTIVATE_TOOLS_MAX_BATCH {
344 return Err(RuntimeError::Tool(format!(
345 "too many tool ids requested: {} exceeds limit {ACTIVATE_TOOLS_MAX_BATCH}",
346 requested.len()
347 )));
348 }
349 let mut tool_ids = Vec::with_capacity(requested.len());
353 for entry in requested {
354 let raw = entry
355 .as_str()
356 .ok_or_else(|| RuntimeError::Tool("tool ids must be strings".to_string()))?;
357 let id = ToolId::parse(raw).map_err(|err| {
358 RuntimeError::Tool(format!(
359 "invalid tool id {:?}: {err} (exact namespace:name ids only; \
360 wildcards and source-wide requests are not accepted)",
361 BoundedText::new(raw, ID_ECHO_MAX_BYTES).text
362 ))
363 })?;
364 tool_ids.push(id);
365 }
366 let authority = match capability.authority() {
377 ActivationAuthority::ModelConfirmed => ActivationAuthority::ModelConfirmed,
378 ActivationAuthority::Unauthorized if !capability.host_prompt_allowed() => {
379 ActivationAuthority::Unauthorized
380 }
381 ActivationAuthority::Unauthorized => {
382 confirm_activation_with_host(ctx.capabilities.secret_prompt.as_ref(), &tool_ids)
383 .await
384 }
385 };
386 let (activated, schema_generation) = {
392 let mut set = capability
393 .session_set()
394 .write()
395 .unwrap_or_else(std::sync::PoisonError::into_inner);
396 let applied =
397 activate_model_initiated(authority, &mut set, capability.catalog(), &tool_ids)
398 .map_err(|err| RuntimeError::Tool(format!("tool activation denied: {err}")))?;
399 (applied, set.schema_generation())
400 };
401 let ids: Vec<&str> = {
402 let mut sorted: Vec<&ToolId> = tool_ids.iter().collect();
403 sorted.sort();
404 sorted.into_iter().map(ToolId::as_str).collect()
405 };
406 let body = json!({
407 "activated": ids,
408 "count": activated,
409 "schema_generation": schema_generation,
410 });
411 let summary = serde_json::to_string(&body).map_err(|err| {
412 RuntimeError::Tool(format!("failed to serialize activation result: {err}"))
413 })?;
414 let ids = ids.into_iter().map(str::to_string).collect();
415 Ok((summary, ids))
416 }
417}