1use rmcp::{
3 ClientHandler, RoleClient,
4 handler::client::progress::ProgressDispatcher,
5 model::{
6 ClientCapabilities, ClientConfig, CustomNotification, ElicitRequestParams, ElicitResult, ElicitationAction,
7 ElicitationCapability, ErrorData, FormElicitationCapability, ProgressNotificationParam,
8 UrlElicitationCapability,
9 },
10 service::{NotificationContext, RequestContext},
11};
12use std::result::Result;
13use tokio::sync::{mpsc, oneshot};
14
15use crate::client::{ElicitationRequest, McpClientEvent, elicitation::with_meta, manager::ToolListChangedRequest};
16
17pub struct McpClient {
18 client_info: ClientConfig,
19 server_name: String,
20 pub(crate) progress_dispatcher: ProgressDispatcher,
21 event_sender: mpsc::Sender<McpClientEvent>,
22 tool_refresh_sender: Option<mpsc::Sender<ToolListChangedRequest>>,
23 connection_generation: u64,
24}
25
26impl McpClient {
27 pub fn new(client_info: ClientConfig, server_name: String, event_sender: mpsc::Sender<McpClientEvent>) -> Self {
28 Self {
29 client_info,
30 server_name,
31 progress_dispatcher: ProgressDispatcher::new(),
32 event_sender,
33 tool_refresh_sender: None,
34 connection_generation: 0,
35 }
36 }
37
38 pub(super) fn with_tool_refresh(
39 mut self,
40 sender: mpsc::Sender<ToolListChangedRequest>,
41 connection_generation: u64,
42 ) -> Self {
43 self.tool_refresh_sender = Some(sender);
44 self.connection_generation = connection_generation;
45 self
46 }
47
48 pub fn server_name(&self) -> &str {
49 &self.server_name
50 }
51
52 pub async fn dispatch_elicitation(&self, request: ElicitRequestParams) -> ElicitResult {
57 let (response_tx, response_rx) = oneshot::channel();
58 let elicitation_request =
59 ElicitationRequest { server_name: self.server_name.clone(), request, response_sender: response_tx };
60
61 if self.event_sender.send(McpClientEvent::Elicitation(Box::new(elicitation_request))).await.is_err() {
62 return cancel_result();
63 }
64 response_rx.await.unwrap_or_else(|_| cancel_result())
65 }
66}
67
68pub fn cancel_result() -> ElicitResult {
69 ElicitResult::new(ElicitationAction::Cancel)
70}
71
72pub fn client_capabilities() -> ClientCapabilities {
73 client_capabilities_for(true, true)
74}
75
76pub fn client_capabilities_for(form: bool, url: bool) -> ClientCapabilities {
77 let mut capabilities = ClientCapabilities::builder().enable_tasks().build();
78 if form || url {
79 let mut elicitation = ElicitationCapability::new();
80 elicitation.form = form.then(FormElicitationCapability::default);
81 elicitation.url = url.then(UrlElicitationCapability::default);
82 capabilities.elicitation = Some(elicitation);
83 }
84 capabilities
85}
86
87impl ClientHandler for McpClient {
88 fn get_info(&self) -> ClientConfig {
89 self.client_info.clone()
90 }
91
92 async fn on_progress(&self, params: ProgressNotificationParam, _context: NotificationContext<RoleClient>) -> () {
93 self.progress_dispatcher.handle_notification(params).await;
94 }
95
96 async fn create_elicitation(
97 &self,
98 request: ElicitRequestParams,
99 context: RequestContext<RoleClient>,
100 ) -> Result<ElicitResult, ErrorData> {
101 let meta = (!context.meta.is_empty()).then_some(context.meta);
102 Ok(self.dispatch_elicitation(with_meta(request, meta)).await)
103 }
104
105 async fn on_custom_notification(
106 &self,
107 notification: CustomNotification,
108 _context: NotificationContext<RoleClient>,
109 ) {
110 if notification.method != "notifications/elicitation/complete" {
111 return;
112 }
113 let params: Option<ElicitationCompleteParams> =
114 notification.params.and_then(|params| serde_json::from_value(params).ok());
115 let Some(params) = params else {
116 tracing::warn!("Ignoring malformed MCP elicitation completion notification");
117 return;
118 };
119 let _ = self
120 .event_sender
121 .send(McpClientEvent::ElicitationComplete {
122 server_name: self.server_name.clone(),
123 elicitation_id: params.elicitation_id,
124 })
125 .await;
126 }
127
128 async fn on_tool_list_changed(&self, context: NotificationContext<RoleClient>) {
129 let Some(sender) = &self.tool_refresh_sender else {
130 return;
131 };
132 let request = ToolListChangedRequest::new(self.server_name.clone(), self.connection_generation, context.peer);
133 if sender.send(request).await.is_err() {
134 tracing::debug!(server = %self.server_name, "MCP tool refresh receiver closed");
135 }
136 }
137}
138
139#[derive(serde::Deserialize)]
140#[serde(rename_all = "camelCase")]
141struct ElicitationCompleteParams {
142 elicitation_id: String,
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use rmcp::model::{ElicitationSchema, Implementation};
149 use std::collections::BTreeMap;
150
151 fn test_client_info() -> ClientConfig {
152 ClientConfig::new(client_capabilities(), Implementation::new("test", "0.1.0"))
153 }
154
155 fn make_client(event_sender: mpsc::Sender<McpClientEvent>) -> McpClient {
156 McpClient::new(test_client_info(), "test-server".to_string(), event_sender)
157 }
158
159 fn unwrap_elicitation(event: McpClientEvent) -> ElicitationRequest {
160 match event {
161 McpClientEvent::Elicitation(req) => *req,
162 other => panic!("expected Elicitation, got {other:?}"),
163 }
164 }
165
166 #[tokio::test]
167 async fn dispatch_elicitation_dropped_sender_returns_cancel() {
168 let (event_tx, _) = mpsc::channel(1);
169 let client = make_client(event_tx);
170
171 let request = ElicitRequestParams::FormElicitationParams {
172 meta: None,
173 message: "test".to_string(),
174 requested_schema: ElicitationSchema::new(BTreeMap::new()),
175 };
176
177 let result = client.dispatch_elicitation(request).await;
178 assert_eq!(result.action, ElicitationAction::Cancel, "dropped sender should return Cancel, not Decline");
179 assert!(result.content.is_none());
180 }
181
182 #[tokio::test]
183 async fn dispatch_elicitation_dropped_receiver_returns_cancel() {
184 let (event_tx, mut event_rx) = mpsc::channel(1);
185 let client = make_client(event_tx);
186
187 let request = ElicitRequestParams::FormElicitationParams {
188 meta: None,
189 message: "test".to_string(),
190 requested_schema: ElicitationSchema::new(BTreeMap::new()),
191 };
192
193 let handle = tokio::spawn(async move {
194 let event = event_rx.recv().await.unwrap();
195 let elicitation = unwrap_elicitation(event);
196 drop(elicitation.response_sender);
197 });
198
199 let result = client.dispatch_elicitation(request).await;
200 handle.await.unwrap();
201
202 assert_eq!(result.action, ElicitationAction::Cancel, "dropped receiver should return Cancel, not Decline");
203 assert!(result.content.is_none());
204 }
205
206 #[tokio::test]
207 async fn dispatch_elicitation_forwards_request_with_server_name() {
208 let (event_tx, mut event_rx) = mpsc::channel(1);
209 let client = make_client(event_tx);
210
211 let request = ElicitRequestParams::UrlElicitationParams {
212 meta: None,
213 message: "Auth".to_string(),
214 url: "https://example.com/auth".to_string(),
215 elicitation_id: "el-123".to_string(),
216 };
217
218 let handle = tokio::spawn(async move {
219 let event = event_rx.recv().await.unwrap();
220 let elicitation = unwrap_elicitation(event);
221 assert_eq!(elicitation.server_name, "test-server");
222 let _ = elicitation.response_sender.send(ElicitResult::new(ElicitationAction::Accept));
223 });
224
225 let result = client.dispatch_elicitation(request).await;
226 handle.await.unwrap();
227 assert_eq!(result.action, ElicitationAction::Accept);
228 }
229
230 #[test]
231 fn capabilities_include_form_url_and_tasks() {
232 let info = test_client_info();
233 let caps = &info.capabilities;
234 let elicitation = caps.elicitation.as_ref().expect("elicitation capability should be set");
235 assert!(elicitation.form.is_some(), "form capability should be advertised");
236 assert!(elicitation.url.is_some(), "url capability should be advertised");
237 assert!(
238 caps.extensions.as_ref().is_some_and(|extensions| extensions.contains_key("io.modelcontextprotocol/tasks"))
239 );
240 }
241}