1use std::pin::Pin;
16use std::sync::Arc;
17
18use rmcp::ErrorData;
19use rmcp::handler::server::common::{AsRequestContext, FromContextPart, schema_for_empty_input};
20use rmcp::handler::server::router::tool::{AsyncTool, ToolBase, ToolRouter};
21use rmcp::handler::server::wrapper::{Json, Parameters};
22use rmcp::model::{CallToolResult, ContentBlock, Tool};
23
24pub trait ToolHandler: Send + Sync + 'static {
26 fn redact_error(&self, message: &str) -> String;
31}
32
33#[derive(Clone)]
40pub struct ErrorContext(Arc<ContextFn>);
41
42type ContextFn = dyn Fn(&ErrorData) -> Option<String> + Send + Sync;
44
45impl ErrorContext {
46 pub fn new(context: impl Fn(&ErrorData) -> Option<String> + Send + Sync + 'static) -> Self {
48 Self(Arc::new(context))
49 }
50
51 fn annotate(&self, error: ErrorData) -> String {
53 match (self.0)(&error) {
54 Some(context) => format!("{} ({context})", error.message),
55 None => error.message.into_owned(),
56 }
57 }
58}
59
60struct ErrorContextPart(Option<ErrorContext>);
64
65impl<C: AsRequestContext> FromContextPart<C> for ErrorContextPart {
66 fn from_context_part(context: &mut C) -> Result<Self, ErrorData> {
67 Ok(Self(
68 context.as_request_context().extensions.get::<ErrorContext>().cloned(),
69 ))
70 }
71}
72
73impl std::fmt::Debug for ErrorContext {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.debug_struct("ErrorContext").finish_non_exhaustive()
76 }
77}
78
79pub struct ToolSpec<H: ToolHandler> {
83 register: fn(ToolRouter<H>) -> ToolRouter<H>,
85 pinned: bool,
87 read_only: bool,
89}
90
91impl<H: ToolHandler> std::fmt::Debug for ToolSpec<H> {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.debug_struct("ToolSpec")
94 .field("pinned", &self.pinned)
95 .field("read_only", &self.read_only)
96 .finish_non_exhaustive()
97 }
98}
99
100impl<H: ToolHandler> ToolSpec<H> {
101 #[must_use]
103 pub const fn async_tool<T: AsyncTool<H> + 'static>(pinned: bool, read_only: bool) -> Self {
104 Self {
105 register: register::<H, T>,
106 pinned,
107 read_only,
108 }
109 }
110}
111
112fn register<H: ToolHandler, T: AsyncTool<H> + 'static>(router: ToolRouter<H>) -> ToolRouter<H> {
117 if T::input_schema().is_some() {
118 router.with_route((attribute::<T>(), invoke::<H, T>))
119 } else {
120 router.with_route((attribute::<T>(), invoke_without_params::<H, T>))
121 }
122}
123
124fn attribute<T: ToolBase>() -> Tool {
126 let input_schema = T::input_schema().unwrap_or_else(schema_for_empty_input);
127 let mut tool = Tool::new_with_raw(T::name(), T::description(), input_schema);
128 tool.title = T::title();
129 tool.output_schema = T::output_schema();
130 tool.annotations = T::annotations();
131 tool.icons = T::icons();
132 tool.meta = T::meta();
133 tool
134}
135
136type ToolFuture<'a, T> = Pin<Box<dyn Future<Output = Result<Json<T>, CallToolResult>> + Send + 'a>>;
138
139fn invoke<H: ToolHandler, T: AsyncTool<H>>(
141 handler: &H,
142 Parameters(params): Parameters<T::Parameter>,
143 ErrorContextPart(context): ErrorContextPart,
144) -> ToolFuture<'_, T::Output> {
145 run::<H, T>(handler, params, context)
146}
147
148fn invoke_without_params<H: ToolHandler, T: AsyncTool<H>>(
150 handler: &H,
151 ErrorContextPart(context): ErrorContextPart,
152) -> ToolFuture<'_, T::Output> {
153 run::<H, T>(handler, T::Parameter::default(), context)
154}
155
156fn run<H: ToolHandler, T: AsyncTool<H>>(
158 handler: &H,
159 params: T::Parameter,
160 context: Option<ErrorContext>,
161) -> ToolFuture<'_, T::Output> {
162 Box::pin(async move {
163 T::invoke(handler, params).await.map(Json).map_err(|error| {
164 let error = error.into();
165 let message = match &context {
166 Some(context) => context.annotate(error),
167 None => error.message.into_owned(),
168 };
169 let message = handler.redact_error(&message);
170 CallToolResult::error(vec![ContentBlock::text(message)])
171 })
172 })
173}
174
175pub trait ToolRouterExt<H: ToolHandler>: Sized {
177 #[must_use]
182 fn from_specs(specs: &[ToolSpec<H>], read_only: bool, pinned: bool) -> Self;
183}
184
185impl<H: ToolHandler> ToolRouterExt<H> for ToolRouter<H> {
186 fn from_specs(specs: &[ToolSpec<H>], read_only: bool, pinned: bool) -> Self {
187 specs
188 .iter()
189 .filter(|spec| (!spec.read_only || !read_only) && spec.pinned == pinned)
190 .fold(ToolRouter::new(), |router, spec| (spec.register)(router))
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use std::borrow::Cow;
197
198 use rmcp::handler::server::tool::IntoCallToolResult;
199 use rmcp::model::{CallToolResponse, JsonObject, ToolAnnotations};
200 use schemars::JsonSchema;
201 use serde::{Deserialize, Serialize};
202
203 use super::*;
204
205 const ERROR: &str = "Duplicate entry 'jane@doe.com' for key 'email'";
206
207 const REDACTED: &str = "Duplicate entry '<EMAIL_ADDRESS>' for key 'email'";
208
209 struct Handler {
211 redact: bool,
212 }
213
214 impl ToolHandler for Handler {
215 fn redact_error(&self, message: &str) -> String {
216 if self.redact {
217 message.replace("jane@doe.com", "<EMAIL_ADDRESS>")
218 } else {
219 message.to_owned()
220 }
221 }
222 }
223
224 #[derive(Default, Deserialize, JsonSchema)]
225 struct Request {
226 marker: Option<String>,
227 }
228
229 #[derive(Serialize, JsonSchema)]
230 struct Response {
231 ok: bool,
232 }
233
234 struct FailingTool;
235
236 impl ToolBase for FailingTool {
237 type Parameter = Request;
238 type Output = Response;
239 type Error = ErrorData;
240
241 fn name() -> Cow<'static, str> {
242 "failing".into()
243 }
244
245 fn title() -> Option<String> {
246 Some("Failing".into())
247 }
248
249 fn description() -> Option<Cow<'static, str>> {
250 Some("Always fails.".into())
251 }
252
253 fn annotations() -> Option<ToolAnnotations> {
254 Some(ToolAnnotations::new().read_only(true))
255 }
256 }
257
258 impl AsyncTool<Handler> for FailingTool {
259 async fn invoke(_: &Handler, _: Request) -> Result<Response, ErrorData> {
260 Err(ErrorData::internal_error(ERROR, None))
261 }
262 }
263
264 struct NoInputTool;
266
267 impl ToolBase for NoInputTool {
268 type Parameter = Request;
269 type Output = Response;
270 type Error = ErrorData;
271
272 fn name() -> Cow<'static, str> {
273 "no_input".into()
274 }
275
276 fn input_schema() -> Option<Arc<JsonObject>> {
277 None
278 }
279 }
280
281 impl AsyncTool<Handler> for NoInputTool {
282 async fn invoke(_: &Handler, request: Request) -> Result<Response, ErrorData> {
283 match request.marker {
284 None => Err(ErrorData::internal_error(ERROR, None)),
285 Some(_) => Ok(Response { ok: true }),
286 }
287 }
288 }
289
290 fn error_text(output: Result<Json<Response>, CallToolResult>) -> String {
292 let response = output.into_call_tool_result();
293 let Ok(CallToolResponse::Complete(result)) = response else {
294 panic!("expected a completed tool result, got {response:?}");
295 };
296 assert_eq!(result.is_error, Some(true));
297 result.content[0].as_text().expect("text content").text.clone()
298 }
299
300 #[tokio::test]
301 async fn tool_error_becomes_tool_result_with_is_error() {
302 let handler = Handler { redact: false };
303 let output =
304 invoke::<Handler, FailingTool>(&handler, Parameters(Request::default()), ErrorContextPart(None)).await;
305
306 assert_eq!(error_text(output), ERROR);
307 }
308
309 #[tokio::test]
310 async fn tool_error_passes_through_redact_error_hook() {
311 let handler = Handler { redact: true };
312 let output =
313 invoke::<Handler, FailingTool>(&handler, Parameters(Request::default()), ErrorContextPart(None)).await;
314
315 assert_eq!(error_text(output), REDACTED);
316 }
317
318 #[tokio::test]
319 async fn error_context_is_appended_before_redaction() {
320 let handler = Handler { redact: true };
321 let context = ErrorContext::new(|_| Some("SSH tunnel: auth failed for jane@doe.com".into()));
322 let output = invoke::<Handler, FailingTool>(
323 &handler,
324 Parameters(Request::default()),
325 ErrorContextPart(Some(context)),
326 )
327 .await;
328
329 assert_eq!(
330 error_text(output),
331 format!("{REDACTED} (SSH tunnel: auth failed for <EMAIL_ADDRESS>)")
332 );
333 }
334
335 #[tokio::test]
336 async fn error_context_without_cause_leaves_message_unchanged() {
337 let handler = Handler { redact: false };
338 let output = invoke::<Handler, FailingTool>(
339 &handler,
340 Parameters(Request::default()),
341 ErrorContextPart(Some(ErrorContext::new(|_| None))),
342 )
343 .await;
344
345 assert_eq!(error_text(output), ERROR);
346 }
347
348 #[tokio::test]
349 async fn error_context_callback_sees_the_error() {
350 let handler = Handler { redact: false };
351 let context = ErrorContext::new(|error| error.message.contains("Duplicate").then(|| "seen".into()));
352 let output = invoke::<Handler, FailingTool>(
353 &handler,
354 Parameters(Request::default()),
355 ErrorContextPart(Some(context)),
356 )
357 .await;
358
359 assert_eq!(error_text(output), format!("{ERROR} (seen)"));
360 }
361
362 #[tokio::test]
363 async fn error_context_applies_to_tools_without_input() {
364 let handler = Handler { redact: false };
365 let context = ErrorContext::new(|_| Some("SSH tunnel: down".into()));
366 let output = invoke_without_params::<Handler, NoInputTool>(&handler, ErrorContextPart(Some(context))).await;
367
368 assert_eq!(error_text(output), format!("{ERROR} (SSH tunnel: down)"));
369 }
370
371 #[tokio::test]
372 async fn no_input_tool_runs_with_default_parameter() {
373 let handler = Handler { redact: false };
374 let output = invoke_without_params::<Handler, NoInputTool>(&handler, ErrorContextPart(None)).await;
375
376 assert_eq!(error_text(output), ERROR);
377 }
378
379 #[test]
380 fn no_input_attribute_matches_rmcp_registration() {
381 let rmcp = ToolRouter::<Handler>::new().with_async_tool::<NoInputTool>();
382 let ours = register::<Handler, NoInputTool>(ToolRouter::new());
383
384 assert_eq!(ours.get("no_input"), rmcp.get("no_input"));
385 }
386
387 #[test]
388 fn attribute_matches_rmcp_registration() {
389 let rmcp = ToolRouter::<Handler>::new().with_async_tool::<FailingTool>();
390 let ours = register::<Handler, FailingTool>(ToolRouter::new());
391
392 assert_eq!(ours.get("failing"), rmcp.get("failing"));
393 }
394}