Skip to main content

turbomcp_client/
sampling.rs

1//! MCP-Compliant Client-Side Sampling Support
2//!
3//! This module provides the correct MCP architecture for handling sampling requests.
4//! The client's role is to:
5//! 1. Receive sampling/createMessage requests from servers
6//! 2. Present them to users for approval (human-in-the-loop)
7//! 3. Delegate to external LLM services (which can be MCP servers themselves)
8//! 4. Return standardized results
9//!
10//! ## MCP Compliance
11//!
12//! Unlike embedding LLM APIs directly (anti-pattern), this implementation:
13//! - Delegates to external services
14//! - Maintains protocol boundaries
15//! - Enables composition and flexibility
16//! - Provides maximum developer experience through simplicity
17
18use std::future::Future;
19use std::pin::Pin;
20use std::sync::Arc;
21use turbomcp_protocol::types::{CreateMessageRequest, CreateMessageResult};
22
23/// Boxed future type alias for sampling operations
24pub type BoxSamplingFuture<'a, T> =
25    Pin<Box<dyn Future<Output = Result<T, Box<dyn std::error::Error + Send + Sync>>> + Send + 'a>>;
26
27/// MCP-compliant sampling handler trait
28///
29/// The client receives sampling requests and delegates to configured LLM services.
30/// This maintains separation of concerns per MCP specification.
31pub trait SamplingHandler: Send + Sync + std::fmt::Debug {
32    /// Handle a sampling/createMessage request from a server
33    ///
34    /// This method should:
35    /// 1. Present the request to the user for approval
36    /// 2. Delegate to an external LLM service (could be another MCP server)
37    /// 3. Present the result to the user for review
38    /// 4. Return the approved result
39    ///
40    /// # Arguments
41    ///
42    /// * `request_id` - The JSON-RPC request ID from the server for proper response correlation
43    /// * `request` - The sampling request parameters
44    fn handle_create_message(
45        &self,
46        request_id: String,
47        request: CreateMessageRequest,
48    ) -> BoxSamplingFuture<'_, CreateMessageResult>;
49}
50
51/// Default implementation that delegates to external MCP servers
52///
53/// This is the "batteries included" approach - it connects to LLM MCP servers
54/// but maintains protocol compliance.
55#[derive(Debug)]
56pub struct DelegatingSamplingHandler {
57    /// Client instances for LLM MCP servers
58    llm_clients: Vec<Arc<dyn LLMServerClient>>,
59    /// User interaction handler
60    user_handler: Arc<dyn UserInteractionHandler>,
61}
62
63/// Interface for connecting to LLM MCP servers
64pub trait LLMServerClient: Send + Sync + std::fmt::Debug {
65    /// Forward a sampling request to an LLM MCP server
66    fn create_message(
67        &self,
68        request: CreateMessageRequest,
69    ) -> BoxSamplingFuture<'_, CreateMessageResult>;
70
71    /// Get server capabilities/model info
72    fn get_server_info(&self) -> BoxSamplingFuture<'_, LlmServerInfo>;
73}
74
75/// Interface for user interaction (human-in-the-loop)
76pub trait UserInteractionHandler: Send + Sync + std::fmt::Debug {
77    /// Present sampling request to user for approval
78    fn approve_request(&self, request: &CreateMessageRequest) -> BoxSamplingFuture<'_, bool>;
79
80    /// Present result to user for review
81    fn approve_response(
82        &self,
83        request: &CreateMessageRequest,
84        response: &CreateMessageResult,
85    ) -> BoxSamplingFuture<'_, Option<CreateMessageResult>>;
86}
87
88/// LLM-server descriptor used by sampling handlers for model selection.
89#[derive(Debug, Clone)]
90pub struct LlmServerInfo {
91    pub name: String,
92    pub models: Vec<String>,
93    pub capabilities: Vec<String>,
94}
95
96impl SamplingHandler for DelegatingSamplingHandler {
97    fn handle_create_message(
98        &self,
99        _request_id: String,
100        request: CreateMessageRequest,
101    ) -> BoxSamplingFuture<'_, CreateMessageResult> {
102        Box::pin(async move {
103            // 1. Human-in-the-loop: Get user approval
104            if !self.user_handler.approve_request(&request).await? {
105                // FIXED: Return HandlerError::UserCancelled (code -1) instead of string error
106                // This ensures the error code is preserved when sent back to the server
107                return Err(Box::new(crate::handlers::HandlerError::UserCancelled)
108                    as Box<dyn std::error::Error + Send + Sync>);
109            }
110
111            // 2. Select appropriate LLM server based on model preferences
112            let selected_client = self.select_llm_client(&request).await?;
113
114            // 3. Delegate to external LLM MCP server
115            let result = selected_client.create_message(request.clone()).await?;
116
117            // 4. Present result for user review
118            let approved_result = self
119                .user_handler
120                .approve_response(&request, &result)
121                .await?;
122
123            Ok(approved_result.unwrap_or(result))
124        })
125    }
126}
127
128impl DelegatingSamplingHandler {
129    /// Create new handler with LLM server clients
130    pub fn new(
131        llm_clients: Vec<Arc<dyn LLMServerClient>>,
132        user_handler: Arc<dyn UserInteractionHandler>,
133    ) -> Self {
134        Self {
135            llm_clients,
136            user_handler,
137        }
138    }
139
140    /// Select best LLM client based on model preferences
141    async fn select_llm_client(
142        &self,
143        _request: &CreateMessageRequest,
144    ) -> Result<Arc<dyn LLMServerClient>, Box<dyn std::error::Error + Send + Sync>> {
145        // This is where the intelligence goes - matching model preferences
146        // to available LLM servers, exactly as the MCP spec describes
147
148        if let Some(first_client) = self.llm_clients.first() {
149            Ok(first_client.clone())
150        } else {
151            // FIXED: Return HandlerError::Configuration instead of string error
152            // This ensures proper error code mapping (-32601)
153            Err(Box::new(crate::handlers::HandlerError::Configuration {
154                message: "No LLM servers configured".to_string(),
155            }))
156        }
157    }
158}
159
160/// **Development-only** user handler that auto-approves every sampling
161/// request and every response without prompting.
162///
163/// MCP specifies that sampling MUST have human-in-the-loop approval
164/// (`schema.ts:2316-2333` security note). This implementation defeats
165/// that — use it for tests, demos, and local CLI tools, never in a
166/// deployed agent that processes untrusted prompts. Logs a warning at
167/// construction time so the choice shows up in operator-visible output.
168#[derive(Debug)]
169pub struct AutoApprovingUserHandler;
170
171impl AutoApprovingUserHandler {
172    /// Construct an auto-approving handler. Emits a `tracing::warn!`
173    /// to make the unsafe-by-default behavior auditable in deployed logs.
174    #[must_use]
175    pub fn new() -> Self {
176        tracing::warn!(
177            "AutoApprovingUserHandler constructed; sampling requests will be \
178             approved without human review. Do not use in production agents."
179        );
180        Self
181    }
182}
183
184impl Default for AutoApprovingUserHandler {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190impl UserInteractionHandler for AutoApprovingUserHandler {
191    fn approve_request(&self, _request: &CreateMessageRequest) -> BoxSamplingFuture<'_, bool> {
192        Box::pin(async move {
193            Ok(true) // Auto-approve for development
194        })
195    }
196
197    fn approve_response(
198        &self,
199        _request: &CreateMessageRequest,
200        _response: &CreateMessageResult,
201    ) -> BoxSamplingFuture<'_, Option<CreateMessageResult>> {
202        Box::pin(async move {
203            Ok(None) // Auto-approve, don't modify
204        })
205    }
206}