1use rmcp::{
3 ClientHandler, RoleClient,
4 handler::client::progress::ProgressDispatcher,
5 model::{
6 ClientCapabilities, ClientInfo, 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, manager::ToolListChangedRequest};
16
17pub struct McpClient {
18 client_info: ClientInfo,
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: ClientInfo, 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) -> ClientInfo {
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 Ok(self.dispatch_elicitation(request).await)
102 }
103
104 async fn on_custom_notification(
105 &self,
106 notification: CustomNotification,
107 _context: NotificationContext<RoleClient>,
108 ) {
109 if notification.method != "notifications/elicitation/complete" {
110 return;
111 }
112 let params: Option<ElicitationCompleteParams> =
113 notification.params.and_then(|params| serde_json::from_value(params).ok());
114 let Some(params) = params else {
115 tracing::warn!("Ignoring malformed MCP elicitation completion notification");
116 return;
117 };
118 let _ = self
119 .event_sender
120 .send(McpClientEvent::ElicitationComplete {
121 server_name: self.server_name.clone(),
122 elicitation_id: params.elicitation_id,
123 })
124 .await;
125 }
126
127 async fn on_tool_list_changed(&self, context: NotificationContext<RoleClient>) {
128 let Some(sender) = &self.tool_refresh_sender else {
129 return;
130 };
131 let request = ToolListChangedRequest::new(self.server_name.clone(), self.connection_generation, context.peer);
132 if sender.send(request).await.is_err() {
133 tracing::debug!(server = %self.server_name, "MCP tool refresh receiver closed");
134 }
135 }
136}
137
138#[derive(serde::Deserialize)]
139#[serde(rename_all = "camelCase")]
140struct ElicitationCompleteParams {
141 elicitation_id: String,
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use rmcp::model::{ElicitationSchema, Implementation};
148 use std::collections::BTreeMap;
149
150 fn test_client_info() -> ClientInfo {
151 ClientInfo::new(client_capabilities(), Implementation::new("test", "0.1.0"))
152 }
153
154 fn make_client(event_sender: mpsc::Sender<McpClientEvent>) -> McpClient {
155 McpClient::new(test_client_info(), "test-server".to_string(), event_sender)
156 }
157
158 fn unwrap_elicitation(event: McpClientEvent) -> ElicitationRequest {
159 match event {
160 McpClientEvent::Elicitation(req) => *req,
161 other => panic!("expected Elicitation, got {other:?}"),
162 }
163 }
164
165 #[tokio::test]
166 async fn dispatch_elicitation_dropped_sender_returns_cancel() {
167 let (event_tx, _) = mpsc::channel(1);
168 let client = make_client(event_tx);
169
170 let request = ElicitRequestParams::FormElicitationParams {
171 meta: None,
172 message: "test".to_string(),
173 requested_schema: ElicitationSchema::new(BTreeMap::new()),
174 };
175
176 let result = client.dispatch_elicitation(request).await;
177 assert_eq!(result.action, ElicitationAction::Cancel, "dropped sender should return Cancel, not Decline");
178 assert!(result.content.is_none());
179 }
180
181 #[tokio::test]
182 async fn dispatch_elicitation_dropped_receiver_returns_cancel() {
183 let (event_tx, mut event_rx) = mpsc::channel(1);
184 let client = make_client(event_tx);
185
186 let request = ElicitRequestParams::FormElicitationParams {
187 meta: None,
188 message: "test".to_string(),
189 requested_schema: ElicitationSchema::new(BTreeMap::new()),
190 };
191
192 let handle = tokio::spawn(async move {
193 let event = event_rx.recv().await.unwrap();
194 let elicitation = unwrap_elicitation(event);
195 drop(elicitation.response_sender);
196 });
197
198 let result = client.dispatch_elicitation(request).await;
199 handle.await.unwrap();
200
201 assert_eq!(result.action, ElicitationAction::Cancel, "dropped receiver should return Cancel, not Decline");
202 assert!(result.content.is_none());
203 }
204
205 #[tokio::test]
206 async fn dispatch_elicitation_forwards_request_with_server_name() {
207 let (event_tx, mut event_rx) = mpsc::channel(1);
208 let client = make_client(event_tx);
209
210 let request = ElicitRequestParams::UrlElicitationParams {
211 meta: None,
212 message: "Auth".to_string(),
213 url: "https://example.com/auth".to_string(),
214 elicitation_id: "el-123".to_string(),
215 };
216
217 let handle = tokio::spawn(async move {
218 let event = event_rx.recv().await.unwrap();
219 let elicitation = unwrap_elicitation(event);
220 assert_eq!(elicitation.server_name, "test-server");
221 let _ = elicitation.response_sender.send(ElicitResult::new(ElicitationAction::Accept));
222 });
223
224 let result = client.dispatch_elicitation(request).await;
225 handle.await.unwrap();
226 assert_eq!(result.action, ElicitationAction::Accept);
227 }
228
229 #[test]
230 fn capabilities_include_form_url_and_tasks() {
231 let info = test_client_info();
232 let caps = &info.capabilities;
233 let elicitation = caps.elicitation.as_ref().expect("elicitation capability should be set");
234 assert!(elicitation.form.is_some(), "form capability should be advertised");
235 assert!(elicitation.url.is_some(), "url capability should be advertised");
236 assert!(
237 caps.extensions.as_ref().is_some_and(|extensions| extensions.contains_key("io.modelcontextprotocol/tasks"))
238 );
239 }
240}