Skip to main content

dbmcp_server/
tool.rs

1//! Declarative tool registry for per-backend MCP routers.
2//!
3//! A backend declares its tools as a `const` slice of [`ToolSpec`] rows, each
4//! row carrying the gating flags for that tool. [`ToolRouterExt::from_specs`]
5//! folds the slice into a [`ToolRouter`], skipping tools the current mode forbids.
6//! This keeps the read-only / pinned gating matrix as data, not control flow.
7//!
8//! Tools are registered so that an error returned by a tool's `invoke` reaches
9//! the client as a tool result with `isError: true`, not a JSON-RPC error. The
10//! caller sees the message (e.g. a database error) and can self-correct. The
11//! message first passes through [`ToolHandler::redact_error`], so a backend can
12//! apply PII redaction like it does to any other tool output. When the request
13//! carries an [`ErrorContext`], its text is appended before redaction.
14
15use 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
24/// Backend handler hooks the tool registry calls at tool-call time.
25pub trait ToolHandler: Send + Sync + 'static {
26    /// Rewrites a tool error message before it is returned to the client.
27    ///
28    /// Backends use this to apply PII redaction to error text. The returned
29    /// string is sent verbatim, so implementations must fail closed.
30    fn redact_error(&self, message: &str) -> String;
31}
32
33/// Supplies extra context appended to selected tool error messages.
34///
35/// [`crate::Server`] places it in each request's extensions. The callback
36/// sees each tool error; when it returns `Some(context)`, the message becomes
37/// `<message> (<context>)` before [`ToolHandler::redact_error`] runs, so the
38/// context is redacted too.
39#[derive(Clone)]
40pub struct ErrorContext(Arc<ContextFn>);
41
42/// Callback behind [`ErrorContext`]: returns context for a tool error, if any.
43type ContextFn = dyn Fn(&ErrorData) -> Option<String> + Send + Sync;
44
45impl ErrorContext {
46    /// Creates a context from a callback evaluated on every tool error.
47    pub fn new(context: impl Fn(&ErrorData) -> Option<String> + Send + Sync + 'static) -> Self {
48        Self(Arc::new(context))
49    }
50
51    /// Returns `error`'s message, with ` (<context>)` appended when available.
52    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
60/// Extractor for the request's [`ErrorContext`], if any.
61///
62/// Unlike rmcp's `Extensions` extractor, it does not clone the whole map.
63struct 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
79/// Declarative registration entry for one MCP tool.
80///
81/// Pairs the tool's router-registration function with its mode gates.
82pub struct ToolSpec<H: ToolHandler> {
83    /// Registers the tool on a router, returning the extended router.
84    register: fn(ToolRouter<H>) -> ToolRouter<H>,
85    /// Whether the tool registers only when a database name is pinned in config.
86    pinned: bool,
87    /// Whether the tool is hidden in read-only mode.
88    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    /// Creates a spec for an async tool `T` with its pinned and read-only gates.
102    #[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
112/// Registers tool `T` on `router`, reporting its errors in-band.
113///
114/// Tools without an input schema take no arguments and are invoked with
115/// `T::Parameter::default()`, matching rmcp's own registration.
116fn 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
124/// Builds the [`Tool`] metadata advertised for `T` in `tools/list`.
125fn 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
136/// Boxed future returned by the tool invoke wrappers.
137type ToolFuture<'a, T> = Pin<Box<dyn Future<Output = Result<Json<T>, CallToolResult>> + Send + 'a>>;
138
139/// Runs tool `T` with arguments extracted from the request.
140fn 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
148/// Runs argument-less tool `T` with its default parameter value.
149fn 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
156/// Runs tool `T`, mapping its error to a [`CallToolResult`] with `isError: true`.
157fn 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
175/// Extends [`ToolRouter`] with declarative construction from a [`ToolSpec`] table.
176pub trait ToolRouterExt<H: ToolHandler>: Sized {
177    /// Builds a router from `specs`, skipping mode-gated tools.
178    ///
179    /// A spec is skipped when its `read_only` gate coincides with `read_only`
180    /// mode, or its `pinned` gate disagrees with the current `pinned` mode.
181    #[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    /// Test handler whose hook optionally masks the email in [`ERROR`].
210    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    /// Tool without an input schema; fails unless given the default parameter.
265    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    /// Returns the text of the `isError` result produced by `output`.
291    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}