kodegen_mcp_schema/tool/traits.rs
1use rmcp::handler::server::tool::schema_for_type;
2use rmcp::model::{CallToolResult, Content, Meta, PromptArgument, PromptMessage};
3use schemars::JsonSchema;
4use serde::{Serialize, de::DeserializeOwned};
5use serde_json::Value;
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use log::{debug, error, info, warn};
12
13use super::error::McpError;
14
15// Re-export ToolArgs from parent crate
16pub use crate::ToolArgs;
17
18// ============================================================================
19// SEALED PROMPT PROVIDER TRAIT
20// ============================================================================
21
22/// Sealed module - ONLY kodegen-mcp-schema can implement Sealed trait
23mod sealed {
24 /// Sealed trait that prevents external crates from implementing PromptProvider
25 pub trait Sealed {}
26}
27
28/// Trait that ONLY kodegen-mcp-schema can implement.
29///
30/// This trait provides prompt generation for tools. Tools CANNOT implement this
31/// trait directly - they must reference a PromptProvider implementation from
32/// the schema package.
33///
34/// This enforces architectural constraint: ALL prompt logic MUST be centralized
35/// in kodegen-mcp-schema, tools cannot implement prompts inline.
36pub trait PromptProvider: sealed::Sealed + Send + Sync + 'static {
37 /// Prompt arguments type - what customization does the prompt accept?
38 type PromptArgs: DeserializeOwned + JsonSchema + Send + 'static;
39
40 /// Generate prompt messages - ONLY schema can implement.
41 ///
42 /// Returns a conversation showing agents how/when to use the tool.
43 /// Should include examples, common patterns, gotchas, requirements.
44 fn generate_prompts(args: &Self::PromptArgs) -> Vec<PromptMessage>;
45
46 /// What arguments does the teaching prompt accept?
47 ///
48 /// These let agents customize what they want to learn about.
49 /// Example: "repo" (which repo?), "shallow" (learn about shallow clones?)
50 fn prompt_arguments() -> Vec<PromptArgument>;
51}
52
53/// Re-export sealed trait for schema package to implement
54///
55/// Only kodegen-mcp-schema can `impl SealedPromptProvider for MyPrompts {}`.
56/// Tool packages cannot implement this - it's sealed to enforce centralization.
57pub use sealed::Sealed as SealedPromptProvider;
58
59// ============================================================================
60// BRANDED DISPLAY LINE
61// ============================================================================
62
63/// Tool execution status for branded line coloring
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ToolStatus {
66 /// Tool executed successfully
67 Success,
68 /// Tool execution failed
69 Error,
70}
71
72/// Add branded display line to CallToolResult as new Content[0].
73///
74/// Inserts a branded line at the beginning of the content vector:
75/// - New Content[0]: Branded line (ⓚ icon tool_name duration)
76/// - Content[1]: Original Content[0] (display)
77/// - Content[2]: Original Content[1] (metadata)
78/// - etc.
79///
80/// # Arguments
81/// - `call_result`: The CallToolResult to modify (Success case)
82/// - `tool_name`: Name of the tool being executed
83/// - `icon`: Tool icon character
84/// - `duration_ms`: Execution duration in milliseconds
85/// - `status`: Success or Error status for coloring
86pub fn add_branded_line_to_result(
87 call_result: &mut CallToolResult,
88 tool_name: &str,
89 icon: char,
90 duration_ms: u64,
91 status: ToolStatus,
92) {
93 // Calculate duration in seconds (ceil to >= 1s)
94 let duration_s = ((duration_ms as f64 / 1000.0).ceil() as u64).max(1);
95
96 // Select timing color based on status
97 // Success: green (35 #00af5f)
98 // Error: red (204 #ff5f87)
99 let timing_color = match status {
100 ToolStatus::Success => 35,
101 ToolStatus::Error => 204,
102 };
103
104 // Format branded line with ANSI colors
105 // ⓚ brand symbol: color 132 (#af5f87)
106 // icon + tool_name: color 32 (#0087d7)
107 // duration: status-based color
108 let branded_line = format!(
109 "\x1b[38;5;132mⓚ\x1b[0m \x1b[38;5;32m{} {}\x1b[0m \x1b[38;5;{}m{}s\x1b[0m",
110 icon, tool_name, timing_color, duration_s
111 );
112
113 // Insert branded content at position 0, shifting all existing content
114 let branded_content = Content::text(branded_line);
115 call_result.content.insert(0, branded_content);
116}
117
118#[derive(Debug, Clone)]
119pub struct ToolResponse<M> {
120 /// Human-readable display output - goes to Content[0].
121 ///
122 /// This is the PRIMARY output that humans read:
123 /// - Terminal: the full output of the last command (stdout/stderr)
124 /// - File read: the file contents
125 /// - Search: formatted human-readable results
126 /// - Database query: formatted table output
127 ///
128 /// Always present - every tool has human-readable output.
129 /// Use empty string for tools with no display output.
130 pub display: String,
131
132 /// Typed, schema-enforced metadata - goes to Content[1].
133 ///
134 /// This is SEPARATE from display - NO DUPLICATION.
135 /// Contains only structured data (exit_code, duration_ms, etc.)
136 /// NOT the display content.
137 pub metadata: M,
138}
139
140impl<M> ToolResponse<M> {
141 /// Create response with both display and metadata.
142 ///
143 /// # Arguments
144 /// - `display`: Human-readable output (full command output, file contents, etc.)
145 /// - `metadata`: Typed output struct (derived from Args::Output)
146 #[inline]
147 pub fn new(display: impl Into<String>, metadata: M) -> Self {
148 Self {
149 display: display.into(),
150 metadata,
151 }
152 }
153
154 /// Create response with empty display.
155 ///
156 /// Use when there's no human-readable output but metadata is present.
157 #[inline]
158 pub fn empty_display(metadata: M) -> Self {
159 Self {
160 display: String::new(),
161 metadata,
162 }
163 }
164}
165
166impl<M: Serialize> ToolResponse<M> {
167 /// Convert to CallToolResult for MCP response.
168 ///
169 /// # Content Layout
170 /// - `content[0]`: Human-readable display (always present, may be empty)
171 /// - `content[1]`: Typed metadata as pretty-printed JSON
172 ///
173 /// Both display and metadata are in the content Vec - no structured_content.
174 pub fn into_call_tool_result(self) -> Result<CallToolResult, serde_json::Error> {
175 let display_content = Content::text(self.display);
176 let json = serde_json::to_string_pretty(&self.metadata)?;
177 let metadata_content = Content::text(json);
178
179 Ok(CallToolResult {
180 content: vec![display_content, metadata_content],
181 structured_content: None,
182 is_error: None,
183 meta: None,
184 })
185 }
186
187 /// Get metadata as JSON Value (for history recording).
188 pub fn metadata_as_json(&self) -> serde_json::Value {
189 serde_json::to_value(&self.metadata).unwrap_or_else(|_| serde_json::json!({}))
190 }
191
192 /// Convert to CallToolResult with branded display line.
193 ///
194 /// Creates a branded line as a separate Content[0], shifting display and metadata:
195 /// - `content[0]`: Branded line (ⓚ icon tool_name duration)
196 /// - `content[1]`: Human-readable display (was content[0])
197 /// - `content[2]`: Typed metadata as pretty-printed JSON (was content[1])
198 ///
199 /// # Arguments
200 /// - `tool_name`: Name of the tool being executed
201 /// - `icon`: Tool icon character (from metadata or fallback)
202 /// - `duration_ms`: Execution duration in milliseconds
203 /// - `status`: Success or Error status for coloring
204 pub fn into_call_tool_result_with_branding(
205 self,
206 tool_name: &str,
207 icon: char,
208 duration_ms: u64,
209 status: ToolStatus,
210 ) -> Result<CallToolResult, serde_json::Error> {
211 // Calculate duration in seconds (ceil to >= 1s)
212 let duration_s = ((duration_ms as f64 / 1000.0).ceil() as u64).max(1);
213
214 // Select timing color based on status
215 // Success: green (35 #00af5f)
216 // Error: red (204 #ff5f87)
217 let timing_color = match status {
218 ToolStatus::Success => 35,
219 ToolStatus::Error => 204,
220 };
221
222 // Format branded line with ANSI colors
223 // ⓚ brand symbol: color 132 (#af5f87)
224 // icon + tool_name: color 32 (#0087d7)
225 // duration: status-based color
226 let branded_line = format!(
227 "\x1b[38;5;132mⓚ\x1b[0m \x1b[38;5;32m{} {}\x1b[0m \x1b[38;5;{}m{}s\x1b[0m",
228 icon, tool_name, timing_color, duration_s
229 );
230
231 // Create content vector with branded line first
232 let branded_content = Content::text(branded_line);
233 let display_content = Content::text(self.display);
234 let json = serde_json::to_string_pretty(&self.metadata)?;
235 let metadata_content = Content::text(json);
236
237 Ok(CallToolResult {
238 content: vec![branded_content, display_content, metadata_content],
239 structured_content: None,
240 is_error: None,
241 meta: None,
242 })
243 }
244}
245
246// ============================================================================
247// PERFORMANCE OPTIMIZATIONS
248// ============================================================================
249
250/// Type alias for the schema cache to reduce complexity
251type SchemaCache =
252 parking_lot::RwLock<HashMap<&'static str, std::sync::Arc<serde_json::Map<String, Value>>>>;
253
254/// Schema cache to avoid repeated serialization
255static SCHEMA_CACHE: std::sync::LazyLock<SchemaCache> =
256 std::sync::LazyLock::new(|| parking_lot::RwLock::new(HashMap::new()));
257
258// ============================================================================
259// CORE TRAIT
260// ============================================================================
261
262/// Core trait that all tools must implement
263///
264/// Tools are STRUCTS that hold their own dependencies (`GitClient`, `GitHubClient`, etc.)
265/// The trait is generic and knows nothing about specific services.
266/// Every method (except execute/prompt) has a sensible default.
267pub trait Tool: Send + Sync + Sized + 'static {
268 /// Tool execution arguments - ALSO determines output type via ToolArgs::Output.
269 ///
270 /// The output type is DERIVED from Args - tools cannot choose wrong output type.
271 /// This binding is defined in `kodegen-mcp-schema` and enforced at compile time.
272 type Args: ToolArgs;
273
274 /// Prompt provider (MUST be from schema - tools cannot implement PromptProvider).
275 ///
276 /// Tools declare which PromptProvider they use, but cannot implement it themselves.
277 /// PromptProvider is sealed - only kodegen-mcp-schema can implement it.
278 /// This enforces that all prompt logic is centralized in the schema package.
279 type Prompts: PromptProvider;
280
281 // ========================================================================
282 // IDENTITY (Required)
283 // ========================================================================
284
285 /// Unique tool name (e.g., "`git_clone`")
286 fn name() -> &'static str;
287
288 /// Human-readable description of what this tool does
289 fn description() -> &'static str;
290
291 // ========================================================================
292 // SCHEMA (Auto-generated with caching)
293 // ========================================================================
294
295 /// Validate that schema generation works for this tool's Args type.
296 ///
297 /// This method attempts to generate the JSON schema and catches any panics
298 /// that occur during schema generation. Should be called during tool
299 /// registration to catch schema issues early.
300 ///
301 /// # Returns
302 /// - `Ok(())` if schema generation succeeds
303 /// - `Err(String)` with detailed error message if schema generation fails
304 fn validate_schema() -> Result<(), String> {
305 // Wrap schema_for_type in a panic catch
306 let result = std::panic::catch_unwind(|| {
307 let _ = schema_for_type::<Self::Args>();
308 });
309
310 match result {
311 Ok(_) => Ok(()),
312 Err(e) => {
313 let error_msg = if let Some(s) = e.downcast_ref::<&str>() {
314 s.to_string()
315 } else if let Some(s) = e.downcast_ref::<String>() {
316 s.clone()
317 } else {
318 "Unknown panic during schema generation".to_string()
319 };
320 Err(format!(
321 "Schema generation failed for tool '{}': {}",
322 Self::name(),
323 error_msg
324 ))
325 }
326 }
327 }
328
329 /// Input schema - AUTO-GENERATED from Args type via `JsonSchema` derive
330 /// Cached for performance - schema is computed once and reused
331 #[inline]
332 fn input_schema() -> std::sync::Arc<serde_json::Map<String, Value>> {
333 let name = Self::name();
334
335 // Fast path: read from cache
336 if let Some(schema) = SCHEMA_CACHE.read().get(name) {
337 return schema.clone();
338 }
339
340 // Slow path: generate and cache
341 // Log schema generation attempt
342 debug!("Generating schema for tool: {}", name);
343
344 // Validate schema generation with panic catching
345 if let Err(e) = Self::validate_schema() {
346 error!("{}", e);
347 // For now, still proceed but with warning - could be made fatal in future
348 warn!("Tool '{}' registered with potentially invalid schema", name);
349 } else {
350 info!("✓ Schema generated successfully for tool: {}", name);
351 }
352
353 let schema = std::sync::Arc::new(schema_for_type::<Self::Args>());
354 SCHEMA_CACHE.write().insert(name, schema.clone());
355 schema
356 }
357
358 /// Output schema - AUTO-GENERATED from `<Args as ToolArgs>::Output`.
359 ///
360 /// Unlike input_schema which is optional to override, output_schema is
361 /// always derived from the Args→Output mapping in the schema package.
362 /// This ensures compile-time enforcement of correct output types.
363 #[must_use]
364 #[inline]
365 fn output_schema() -> std::sync::Arc<serde_json::Map<String, Value>> {
366 // Use a separate cache namespace for output schemas
367 static OUTPUT_SCHEMA_CACHE: std::sync::LazyLock<SchemaCache> =
368 std::sync::LazyLock::new(|| parking_lot::RwLock::new(HashMap::new()));
369
370 let name = Self::name();
371 let cache_key = Box::leak(format!("{}_output", name).into_boxed_str());
372
373 // Fast path: read from cache
374 if let Some(schema) = OUTPUT_SCHEMA_CACHE.read().get(cache_key) {
375 return schema.clone();
376 }
377
378 // Slow path: generate and cache from Args::Output
379 let schema = std::sync::Arc::new(
380 schema_for_type::<<Self::Args as ToolArgs>::Output>()
381 );
382 OUTPUT_SCHEMA_CACHE.write().insert(cache_key, schema.clone());
383 schema
384 }
385
386 // ========================================================================
387 // BEHAVIOR ANNOTATIONS (Tool IS its behavior)
388 // ========================================================================
389
390 /// Does this tool only read (never modify) state?
391 ///
392 /// true = read-only (safe, can't break things)
393 /// false = writes/modifies state (requires caution)
394 ///
395 /// Default: true (assumes read-only by default - safe default)
396 #[must_use]
397 #[inline]
398 fn read_only() -> bool {
399 true
400 }
401
402 /// Can this tool delete or overwrite existing data?
403 ///
404 /// Only meaningful when `read_only` = false.
405 /// true = can delete/overwrite (dangerous)
406 /// false = only adds/creates (safer)
407 ///
408 /// Default: false (assumes non-destructive by default - safe default)
409 #[must_use]
410 #[inline]
411 fn destructive() -> bool {
412 false
413 }
414
415 /// Is calling this tool repeatedly with same args safe/idempotent?
416 ///
417 /// Only meaningful when `read_only` = false.
418 /// true = safe to retry (same result every time)
419 /// false = each call has different effect
420 ///
421 /// Default: true (assumes idempotent by default - safe default)
422 #[must_use]
423 #[inline]
424 fn idempotent() -> bool {
425 true
426 }
427
428 /// Does this tool interact with external systems (network, filesystem outside repo)?
429 ///
430 /// true = open world (network calls, external APIs, can fail due to external factors)
431 /// false = closed world (only local operations, deterministic)
432 ///
433 /// Default: false (assumes local operations by default - safe default)
434 #[must_use]
435 #[inline]
436 fn open_world() -> bool {
437 false
438 }
439
440 // ========================================================================
441 // EXECUTION (Required)
442 // ========================================================================
443
444 /// Execute the tool with given arguments.
445 ///
446 /// Return type is `ToolResponse<<Self::Args as ToolArgs>::Output>`.
447 /// The output type is DERIVED from Args - compiler enforces correct type.
448 ///
449 /// # Example
450 ///
451 /// ```rust
452 /// impl Tool for TerminalTool {
453 /// type Args = TerminalInput;
454 /// // TerminalInput::Output = TerminalOutput (defined in schema)
455 /// // So execute() MUST return ToolResponse<TerminalOutput>
456 ///
457 /// async fn execute(&self, args: Self::Args, ctx: ToolExecutionContext)
458 /// -> Result<ToolResponse<TerminalOutput>, McpError>
459 /// {
460 /// Ok(ToolResponse::new(output_text, TerminalOutput { ... }))
461 /// }
462 /// }
463 /// ```
464 fn execute(
465 &self,
466 args: Self::Args,
467 ctx: ToolExecutionContext,
468 ) -> impl std::future::Future<Output = Result<
469 ToolResponse<<Self::Args as ToolArgs>::Output>,
470 McpError
471 >> + Send;
472
473 // ========================================================================
474 // RMCP INTEGRATION (Default implementations)
475 // ========================================================================
476
477 /// Prompt name (defaults to "{`tool_name`}_help")
478 #[must_use]
479 #[inline]
480 fn prompt_name() -> Cow<'static, str> {
481 Cow::Owned(format!("{}_help", Self::name()))
482 }
483
484 /// Prompt description (defaults to tool description)
485 #[must_use]
486 #[inline]
487 fn prompt_description() -> &'static str {
488 Self::description()
489 }
490
491 /// Convert this tool into an RMCP `ToolRoute`
492 ///
493 /// This default implementation builds the route from trait methods.
494 /// Tools get this for free - no need to implement `IntoToolRoute` manually.
495 fn into_tool_route<S>(self) -> rmcp::handler::server::router::tool::ToolRoute<S>
496 where
497 S: Send + Sync + 'static,
498 {
499 use rmcp::handler::server::router::tool::ToolRoute;
500 use rmcp::model::{Tool as RmcpTool, ToolAnnotations, Meta};
501 use std::sync::Arc;
502
503 // Build annotations from trait methods
504 let annotations = ToolAnnotations::new()
505 .read_only(Self::read_only())
506 .destructive(Self::destructive())
507 .idempotent(Self::idempotent())
508 .open_world(Self::open_world());
509
510 // Store icon in meta field
511 let mut meta = Meta::new();
512 meta.0.insert("icon".to_string(), serde_json::json!(<Self::Args as ToolArgs>::icon().to_string()));
513
514 // Build RMCP Tool metadata
515 let metadata = RmcpTool {
516 name: Self::name().into(),
517 title: None,
518 description: Some(Self::description().into()),
519 input_schema: Self::input_schema(),
520 output_schema: Some(Self::output_schema()),
521 annotations: Some(annotations),
522 icons: None,
523 meta: Some(meta),
524 };
525
526 // Create handler with ToolHandler wrapper (HRTB-compatible, zero-cost)
527 let handler = ToolHandler {
528 tool: Arc::new(self),
529 };
530
531 // Use ToolRoute::new() - handles HRTB internally
532 ToolRoute::new(metadata, handler)
533 }
534
535 /// Convert this tool into an RMCP `PromptRoute`
536 ///
537 /// This default implementation builds the route from trait methods.
538 /// Tools get this for free - no need to implement `IntoPromptRoute` manually.
539 fn into_prompt_route<S>(self) -> rmcp::handler::server::router::prompt::PromptRoute<S>
540 where
541 S: Send + Sync + 'static,
542 {
543 use rmcp::handler::server::router::prompt::PromptRoute;
544 use rmcp::handler::server::wrapper::Parameters;
545 use rmcp::model::{GetPromptResult, Prompt as RmcpPrompt};
546
547 // Build meta following the same pattern as tools (see line 434-435)
548 let mut meta = Meta::new();
549 meta.0.insert("category".to_string(), serde_json::json!(Self::Args::CATEGORY.name));
550 meta.0.insert("icon".to_string(), serde_json::json!(Self::Args::CATEGORY.icon.to_string()));
551
552 // Build RMCP Prompt metadata using PromptProvider
553 let metadata = RmcpPrompt {
554 name: Self::prompt_name().into_owned(),
555 title: None,
556 description: Some(Self::prompt_description().to_string()),
557 arguments: Some(<Self as ToolPrompts>::prompt_arguments()),
558 icons: None,
559 meta: Some(meta),
560 };
561
562 // Handler calls static PromptProvider methods (no tool instance needed)
563 let handler = move |Parameters(args): Parameters<<Self::Prompts as PromptProvider>::PromptArgs>| {
564 async move {
565 let messages = <Self as ToolPrompts>::prompt(args)?;
566
567 Ok(GetPromptResult {
568 description: Some(Self::prompt_description().to_string()),
569 messages,
570 })
571 }
572 };
573
574 PromptRoute::new(metadata, handler)
575 }
576
577 /// Convert Arc-wrapped tool into an RMCP `ToolRoute` (optimized - no extra Arc allocation)
578 ///
579 /// This is more efficient than `into_tool_route(self)` when the tool is already wrapped in Arc.
580 /// The tool is used directly without creating an additional Arc wrapper.
581 fn arc_into_tool_route<S>(self: Arc<Self>) -> rmcp::handler::server::router::tool::ToolRoute<S>
582 where
583 S: Send + Sync + 'static,
584 {
585 use rmcp::handler::server::router::tool::ToolRoute;
586 use rmcp::model::{Tool as RmcpTool, ToolAnnotations, Meta};
587
588 // Build annotations from trait methods
589 let annotations = ToolAnnotations::new()
590 .read_only(Self::read_only())
591 .destructive(Self::destructive())
592 .idempotent(Self::idempotent())
593 .open_world(Self::open_world());
594
595 // Store icon in meta field
596 let mut meta = Meta::new();
597 meta.0.insert("icon".to_string(), serde_json::json!(<Self::Args as ToolArgs>::icon().to_string()));
598
599 // Build RMCP Tool metadata
600 let metadata = RmcpTool {
601 name: Self::name().into(),
602 title: None,
603 description: Some(Self::description().into()),
604 input_schema: Self::input_schema(),
605 output_schema: Some(Self::output_schema()),
606 annotations: Some(annotations),
607 icons: None,
608 meta: Some(meta),
609 };
610
611 // Create handler with ToolHandler wrapper (HRTB-compatible, zero-cost)
612 // Use self directly (already Arc<Self>) - no extra Arc allocation
613 let handler = ToolHandler {
614 tool: self,
615 };
616
617 // Use ToolRoute::new() - handles HRTB internally
618 ToolRoute::new(metadata, handler)
619 }
620
621 /// Convert Arc-wrapped tool into an RMCP `PromptRoute` (optimized - no extra Arc allocation)
622 ///
623 /// This is more efficient than `into_prompt_route(self)` when the tool is already wrapped in Arc.
624 /// The tool is used directly without creating an additional Arc wrapper.
625 fn arc_into_prompt_route<S>(
626 self: Arc<Self>,
627 ) -> rmcp::handler::server::router::prompt::PromptRoute<S>
628 where
629 S: Send + Sync + 'static,
630 {
631 use rmcp::handler::server::router::prompt::PromptRoute;
632 use rmcp::handler::server::wrapper::Parameters;
633 use rmcp::model::{GetPromptResult, Prompt as RmcpPrompt};
634
635 // Build meta following the same pattern as tools (see line 434-435)
636 let mut meta = Meta::new();
637 meta.0.insert("category".to_string(), serde_json::json!(Self::Args::CATEGORY.name));
638 meta.0.insert("icon".to_string(), serde_json::json!(Self::Args::CATEGORY.icon.to_string()));
639
640 // Build RMCP Prompt metadata using PromptProvider
641 let metadata = RmcpPrompt {
642 name: Self::prompt_name().into_owned(),
643 title: None,
644 description: Some(Self::prompt_description().to_string()),
645 arguments: Some(<Self as ToolPrompts>::prompt_arguments()),
646 icons: None,
647 meta: Some(meta),
648 };
649
650 // Handler calls static PromptProvider methods (no tool instance needed)
651 let handler = move |Parameters(args): Parameters<<Self::Prompts as PromptProvider>::PromptArgs>| {
652 async move {
653 let messages = <Self as ToolPrompts>::prompt(args)?;
654
655 Ok(GetPromptResult {
656 description: Some(Self::prompt_description().to_string()),
657 messages,
658 })
659 }
660 };
661
662 PromptRoute::new(metadata, handler)
663 }
664}
665
666// ============================================================================
667// TOOL PROMPTS EXTENSION TRAIT
668// ============================================================================
669
670/// Extension trait providing prompt methods via PromptProvider.
671///
672/// This is implemented automatically for all Tool implementations via blanket impl.
673/// Tools get prompt() and prompt_arguments() methods for free by specifying
674/// their Prompts associated type.
675///
676/// The methods delegate to the PromptProvider, which only schema can implement.
677pub trait ToolPrompts: Tool {
678 /// Get prompt arguments (calls schema's PromptProvider)
679 #[inline]
680 fn prompt_arguments() -> Vec<PromptArgument> {
681 <Self::Prompts as PromptProvider>::prompt_arguments()
682 }
683
684 /// Generate prompts (calls schema's PromptProvider)
685 ///
686 /// Takes PromptArgs from the PromptProvider, not from Self.
687 /// This ensures tools cannot override prompt generation.
688 #[inline]
689 fn prompt(args: <Self::Prompts as PromptProvider>::PromptArgs) -> Result<Vec<PromptMessage>, McpError> {
690 Ok(<Self::Prompts as PromptProvider>::generate_prompts(&args))
691 }
692}
693
694/// Blanket implementation - all Tools automatically get ToolPrompts
695impl<T: Tool> ToolPrompts for T {}
696
697// ============================================================================
698// PROGRESS NOTIFICATION CONTEXT
699// ============================================================================
700
701/// Execution context provided to tools for progress notifications and cancellation.
702///
703/// Supports three patterns:
704/// 1. Stream text messages: `ctx.stream("output\n")`
705/// 2. Report numeric progress: `ctx.progress(50, 100)`
706/// 3. Combined: `ctx.update(50, 100, "Processing file 50/100")`
707#[derive(Clone)]
708pub struct ToolExecutionContext {
709 /// Peer interface for sending progress notifications
710 peer: rmcp::service::Peer<rmcp::RoleServer>,
711
712 /// Cancellation token (tool should check periodically)
713 ct: tokio_util::sync::CancellationToken,
714
715 /// Unique request identifier (used for progress_token)
716 request_id: rmcp::model::RequestId,
717
718 /// Infrastructure context from kodegen stdio server (via HTTP headers)
719 /// These are None for non-HTTP transports
720
721 /// Connection ID - identifies the stdio connection instance
722 connection_id: Option<String>,
723
724 /// Current working directory from client environment
725 pwd: Option<PathBuf>,
726
727 /// Git repository root from client environment
728 git_root: Option<PathBuf>,
729}
730
731impl ToolExecutionContext {
732 /// Create a new ToolExecutionContext with the given peer, cancellation token, and request ID.
733 ///
734 /// This constructor is public to allow custom integration contexts (e.g., bridging
735 /// to non-RMCP transports or in-process sessions). Most tools should not need to
736 /// call this directly - the context is typically provided by the RMCP framework.
737 ///
738 /// # Arguments
739 /// * `peer` - RMCP peer for sending progress notifications
740 /// * `ct` - Cancellation token for this execution
741 /// * `request_id` - Unique identifier for this request
742 #[must_use]
743 pub fn new(
744 peer: rmcp::service::Peer<rmcp::RoleServer>,
745 ct: tokio_util::sync::CancellationToken,
746 request_id: rmcp::model::RequestId,
747 ) -> Self {
748 Self {
749 peer,
750 ct,
751 request_id,
752 connection_id: None,
753 pwd: None,
754 git_root: None,
755 }
756 }
757
758 /// Get connection ID from stdio server (for resource isolation)
759 /// Always present for kodegen stdio connections, None for direct HTTP clients
760 #[must_use]
761 pub fn connection_id(&self) -> Option<&str> {
762 self.connection_id.as_deref()
763 }
764
765 /// Get current working directory from client environment
766 #[must_use]
767 pub fn pwd(&self) -> Option<&std::path::Path> {
768 self.pwd.as_deref()
769 }
770
771 /// Get git repository root from client environment
772 #[must_use]
773 pub fn git_root(&self) -> Option<&std::path::Path> {
774 self.git_root.as_deref()
775 }
776
777 /// Get the request ID for this tool execution
778 ///
779 /// The request ID uniquely identifies this tool call and can be used for:
780 /// - Filtering events in multi-request scenarios
781 /// - Correlating logs and outputs
782 /// - Tracking execution history
783 #[must_use]
784 pub fn request_id(&self) -> &rmcp::model::RequestId {
785 &self.request_id
786 }
787
788 /// Stream a text message (for terminal output, logs, status updates).
789 ///
790 /// Use this for incrementally streaming text output as it becomes available.
791 ///
792 /// # Example
793 /// ```
794 /// // Terminal streaming command output
795 /// ctx.stream("npm info using npm@8.19.2\n").await.ok();
796 /// ctx.stream("added 234 packages in 15s\n").await.ok();
797 /// ```
798 pub async fn stream(&self, message: impl Into<String>) -> Result<(), McpError> {
799 self.notify_internal(0.0, None, Some(message.into())).await
800 }
801
802 /// Report numeric progress (for progress bars, counters).
803 ///
804 /// Use this when you have a known total and current progress value.
805 ///
806 /// # Example
807 /// ```
808 /// // Processing 50 out of 100 files
809 /// ctx.progress(50.0, 100.0).await.ok();
810 /// ```
811 pub async fn progress(&self, current: f64, total: f64) -> Result<(), McpError> {
812 self.notify_internal(current, Some(total), None).await
813 }
814
815 /// Report both numeric progress and a descriptive message.
816 ///
817 /// Use this when you want both a progress bar AND status text.
818 ///
819 /// # Example
820 /// ```
821 /// ctx.update(50.0, 100.0, "Generating embeddings 50/100").await.ok();
822 /// ```
823 pub async fn update(
824 &self,
825 current: f64,
826 total: f64,
827 message: impl Into<String>
828 ) -> Result<(), McpError> {
829 self.notify_internal(current, Some(total), Some(message.into())).await
830 }
831
832 /// Advanced: Full control over progress notification fields.
833 ///
834 /// Use this when you need fine-grained control (e.g., unknown total).
835 ///
836 /// # Example
837 /// ```
838 /// // Unknown total, just report current count
839 /// ctx.notify(lines_read as f64, None, Some("Reading file...")).await.ok();
840 /// ```
841 pub async fn notify(
842 &self,
843 progress: f64,
844 total: Option<f64>,
845 message: Option<String>
846 ) -> Result<(), McpError> {
847 self.notify_internal(progress, total, message).await
848 }
849
850 /// Internal implementation - sends the actual notification
851 async fn notify_internal(
852 &self,
853 progress: f64,
854 total: Option<f64>,
855 message: Option<String>
856 ) -> Result<(), McpError> {
857 use rmcp::model::{ProgressNotificationParam, ProgressToken, NumberOrString};
858
859 // Generate unique progress token from request ID
860 let progress_token = ProgressToken(NumberOrString::String(
861 format!("tool_{}", match &self.request_id {
862 NumberOrString::Number(n) => n.to_string(),
863 NumberOrString::String(s) => s.to_string(),
864 }).into()
865 ));
866
867 let params = ProgressNotificationParam {
868 progress_token,
869 progress,
870 total,
871 message,
872 };
873
874 self.peer
875 .notify_progress(params)
876 .await
877 .map_err(|e| McpError::Other(anyhow::anyhow!(
878 "Failed to send progress notification: {}", e
879 )))
880 }
881
882 /// Check if tool execution was cancelled by the client.
883 ///
884 /// Tools should check this periodically during long operations.
885 ///
886 /// # Example
887 /// ```
888 /// for item in items {
889 /// if ctx.is_cancelled() {
890 /// return Err(McpError::cancelled("Operation cancelled"));
891 /// }
892 /// process_item(item).await?;
893 /// }
894 /// ```
895 pub fn is_cancelled(&self) -> bool {
896 self.ct.is_cancelled()
897 }
898
899 /// Get the cancellation token for use with `tokio::select!` or custom logic.
900 pub fn cancellation_token(&self) -> &tokio_util::sync::CancellationToken {
901 &self.ct
902 }
903}
904
905// ============================================================================
906// FromContextPart implementation for ToolExecutionContext
907// ============================================================================
908
909impl<S> rmcp::handler::server::common::FromContextPart<rmcp::handler::server::tool::ToolCallContext<'_, S>>
910 for ToolExecutionContext
911where
912 S: Send + Sync + 'static,
913{
914 fn from_context_part(
915 context: &mut rmcp::handler::server::tool::ToolCallContext<'_, S>
916 ) -> Result<Self, rmcp::ErrorData> {
917 // Extract HTTP request Parts (automatically injected by rmcp)
918 let parts = context.request_context.extensions.get::<http::request::Parts>();
919
920 // Extract kodegen headers from Parts
921 use kodegen_config::{X_KODEGEN_CONNECTION_ID, X_KODEGEN_PWD, X_KODEGEN_GITROOT};
922
923 let (connection_id, pwd, git_root) = if let Some(parts) = parts {
924 let conn_id = parts.headers.get(X_KODEGEN_CONNECTION_ID)
925 .and_then(|v| v.to_str().ok())
926 .map(|s| s.to_string());
927
928 let pwd_val = parts.headers.get(X_KODEGEN_PWD)
929 .and_then(|v| v.to_str().ok())
930 .map(PathBuf::from);
931
932 let git_root_val = parts.headers.get(X_KODEGEN_GITROOT)
933 .and_then(|v| v.to_str().ok())
934 .map(PathBuf::from);
935
936 (conn_id, pwd_val, git_root_val)
937 } else {
938 // Non-HTTP transport (direct stdio, child process)
939 (None, None, None)
940 };
941
942 Ok(ToolExecutionContext {
943 peer: context.request_context.peer.clone(),
944 ct: context.request_context.ct.clone(),
945 request_id: context.request_context.id.clone(),
946 connection_id,
947 pwd,
948 git_root,
949 })
950 }
951}
952
953// ============================================================================
954// ToolHandler - Zero-cost wrapper for CallToolHandler implementation
955// ============================================================================
956
957/// Wrapper struct that holds a tool and implements CallToolHandler.
958/// This enables HRTB-compatible tool routing without closure lifetime issues.
959struct ToolHandler<T: Tool> {
960 tool: Arc<T>,
961}
962
963impl<T: Tool> Clone for ToolHandler<T> {
964 fn clone(&self) -> Self {
965 Self {
966 tool: self.tool.clone(),
967 }
968 }
969}
970
971impl<T, S> rmcp::handler::server::tool::CallToolHandler<S, ()> for ToolHandler<T>
972where
973 T: Tool,
974 S: Send + Sync + 'static,
975{
976 fn call(
977 self,
978 mut context: rmcp::handler::server::tool::ToolCallContext<'_, S>,
979 ) -> futures::future::BoxFuture<'_, Result<rmcp::model::CallToolResult, rmcp::ErrorData>> {
980 use rmcp::handler::server::wrapper::Parameters;
981 use rmcp::handler::server::common::FromContextPart;
982
983 Box::pin(async move {
984 // Extract arguments and execution context
985 let Parameters(args) = Parameters::<T::Args>::from_context_part(&mut context)?;
986 let exec_ctx = ToolExecutionContext::from_context_part(&mut context)?;
987
988 // Execute tool - returns ToolResponse<<T::Args as ToolArgs>::Output>
989 let result = self.tool.execute(args, exec_ctx).await;
990
991 match result {
992 Ok(response) => {
993 // Convert ToolResponse to CallToolResult with structured validation
994 let result = response.into_call_tool_result()
995 .map_err(|e| rmcp::ErrorData::internal_error(
996 format!("Failed to serialize tool output: {}", e),
997 None
998 ))?;
999
1000 Ok(result)
1001 }
1002 Err(e) => {
1003 Err(rmcp::ErrorData::from(e))
1004 }
1005 }
1006 })
1007 }
1008}