Skip to main content

rmcp_server_kit/
tool_hooks.rs

1//! Opt-in tool-call instrumentation for `ServerHandler` implementations.
2//!
3//! [`crate::tool_hooks::HookedHandler`] wraps any [`rmcp::ServerHandler`] with:
4//!
5//! - **Before hooks** (async) that observe `(tool_name, arguments, identity,
6//!   role, sub, request_id)` and may [`HookOutcome::Continue`](crate::tool_hooks::HookOutcome::Continue),
7//!   [`HookOutcome::Deny`](crate::tool_hooks::HookOutcome::Deny), or
8//!   [`HookOutcome::Replace`](crate::tool_hooks::HookOutcome::Replace) the call.
9//! - **After hooks** (async) that observe the same context plus a
10//!   [`HookDisposition`](crate::tool_hooks::HookDisposition) describing how the call resolved and the
11//!   approximate result size in bytes.  After-hooks are spawned via
12//!   `tokio::spawn` and never block the response path.
13//! - **Result-size capping**: serialized tool results larger than
14//!   `max_result_bytes` are replaced with a structured error, preventing
15//!   token-expensive or memory-expensive payloads from reaching clients.
16//!   The cap applies both to inner-handler results and to
17//!   [`HookOutcome::Replace`](crate::tool_hooks::HookOutcome::Replace) payloads.
18//!
19//! This is entirely **opt-in** at the application layer - `rmcp_server_kit::serve()`
20//! does not wrap handlers automatically.  Applications that want hooks do:
21//!
22//! ```no_run
23//! use std::sync::Arc;
24//! use rmcp_server_kit::tool_hooks::{HookedHandler, HookOutcome, ToolHooks, with_hooks};
25//!
26//! # #[derive(Clone, Default)]
27//! # struct MyHandler;
28//! # impl rmcp::ServerHandler for MyHandler {}
29//! let handler = MyHandler::default();
30//! let hooks = Arc::new(
31//!     ToolHooks::new()
32//!         .with_max_result_bytes(256 * 1024)
33//!         .with_before(Arc::new(|_ctx| Box::pin(async { HookOutcome::Continue })))
34//!         .with_after(Arc::new(|_ctx, _disp, _bytes| Box::pin(async {}))),
35//! );
36//! let _wrapped = with_hooks(handler, hooks);
37//! ```
38
39use std::{borrow::Cow, fmt, future::Future, io, pin::Pin, sync::Arc};
40
41use rmcp::{
42    ErrorData, RoleServer, ServerHandler,
43    model::{
44        CallToolRequestParams, CallToolResponse, CallToolResult, CancelTaskParams, ContentBlock,
45        DiscoverResult, GetPromptRequestParams, GetPromptResponse, GetTaskParams, GetTaskResult,
46        InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
47        ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion,
48        ReadResourceRequestParams, ReadResourceResponse, ServerInfo, SubscriptionFilter, Tool,
49        UpdateTaskParams,
50    },
51    service::{RequestContext, SubscriptionContext},
52};
53
54/// Context passed to before/after hooks for a single tool call.
55#[derive(Clone)]
56#[non_exhaustive]
57pub struct ToolCallContext {
58    /// Tool name being invoked.
59    pub tool_name: String,
60    /// JSON arguments as sent by the client (may be `None`).
61    pub arguments: Option<serde_json::Value>,
62    /// Identity name from the authenticated request, if any.
63    pub identity: Option<String>,
64    /// RBAC role associated with the request, if any.
65    pub role: Option<String>,
66    /// OAuth `sub` claim, if present.
67    pub sub: Option<String>,
68    /// Raw JSON-RPC request id rendered as a string, if available.
69    pub request_id: Option<String>,
70}
71
72impl ToolCallContext {
73    /// Construct a [`ToolCallContext`] with the given tool name and all
74    /// optional fields cleared.  Primarily for use in unit tests and
75    /// benchmarks of user-supplied hooks; the runtime path populates
76    /// these fields from the request and task-local RBAC state.
77    #[must_use]
78    pub fn for_tool(tool_name: impl Into<String>) -> Self {
79        Self {
80            tool_name: tool_name.into(),
81            arguments: None,
82            identity: None,
83            role: None,
84            sub: None,
85            request_id: None,
86        }
87    }
88}
89
90impl fmt::Debug for ToolCallContext {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        let Self {
93            tool_name,
94            arguments,
95            identity,
96            role,
97            sub,
98            request_id,
99        } = self;
100        let mut debug = f.debug_struct("ToolCallContext");
101        debug.field("tool_name", tool_name);
102        if crate::diagnostics::tool_call_arguments() {
103            debug
104                .field("arguments", arguments)
105                .field("identity", identity)
106                .field("role", role)
107                .field("sub", sub);
108        } else {
109            debug
110                .field("arguments", &"[REDACTED]")
111                .field("identity", &"[REDACTED]")
112                .field("role", &"[REDACTED]")
113                .field("sub", &"[REDACTED]");
114        }
115        debug.field("request_id", request_id).finish()
116    }
117}
118
119/// Outcome returned by a [`BeforeHook`] to control invocation flow.
120///
121/// - [`HookOutcome::Continue`] - proceed with the wrapped handler.
122/// - [`HookOutcome::Deny`] - reject the call with the supplied
123///   [`ErrorData`]; the inner handler is **not** called.
124/// - [`HookOutcome::Replace`] - return the supplied result instead of
125///   invoking the inner handler.  The result is still subject to
126///   `max_result_bytes` capping.
127#[derive(Debug)]
128#[non_exhaustive]
129pub enum HookOutcome {
130    /// Proceed with the wrapped handler.
131    Continue,
132    /// Reject the call.  The error is propagated to the client as-is.
133    Deny(ErrorData),
134    /// Skip the inner handler and return the supplied result instead.
135    Replace(Box<CallToolResult>),
136}
137
138/// How a tool call resolved, passed to the [`AfterHook`].
139#[derive(Debug, Clone, Copy)]
140#[non_exhaustive]
141pub enum HookDisposition {
142    /// The inner handler ran and returned `Ok`.
143    InnerExecuted,
144    /// The inner handler ran and returned `Err`.
145    InnerErrored,
146    /// The before-hook returned [`HookOutcome::Deny`].
147    DeniedBefore,
148    /// The before-hook returned [`HookOutcome::Replace`].
149    ReplacedBefore,
150    /// The result (from inner or replace) exceeded `max_result_bytes`
151    /// and was substituted with a structured error.
152    ResultTooLarge,
153}
154
155/// Async before-hook callback type.
156///
157/// Returns a [`HookOutcome`] controlling whether the inner handler runs.
158/// The borrow of `ToolCallContext` is held for the duration of the
159/// returned future, which avoids forcing implementations to clone the
160/// context for every invocation.
161pub type BeforeHook = Arc<
162    dyn for<'a> Fn(&'a ToolCallContext) -> Pin<Box<dyn Future<Output = HookOutcome> + Send + 'a>>
163        + Send
164        + Sync
165        + 'static,
166>;
167
168/// Async after-hook callback type.
169///
170/// Receives the call context, a [`HookDisposition`] describing how the
171/// call resolved, and the approximate serialized result size in bytes
172/// (`0` for `DeniedBefore` and `InnerErrored`).  Spawned via
173/// `tokio::spawn`, so it must not assume it runs before the response is
174/// flushed.
175pub type AfterHook = Arc<
176    dyn for<'a> Fn(
177            &'a ToolCallContext,
178            HookDisposition,
179            usize,
180        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
181        + Send
182        + Sync
183        + 'static,
184>;
185
186/// Opt-in hooks applied by [`crate::tool_hooks::HookedHandler`].
187#[allow(clippy::struct_field_names, reason = "before/after read naturally")]
188#[derive(Clone, Default)]
189#[non_exhaustive]
190pub struct ToolHooks {
191    /// Hard cap on serialized `CallToolResult` size in bytes.  When
192    /// exceeded, the result is replaced with an `is_error=true` result
193    /// carrying a `result_too_large` structured error.  `None` disables
194    /// the cap.
195    pub max_result_bytes: Option<usize>,
196    /// Optional before-hook invoked after arg deserialization, before
197    /// the wrapped handler is called.
198    pub before: Option<BeforeHook>,
199    /// Optional after-hook invoked once per call, regardless of how the
200    /// call resolved.  Spawned via `tokio::spawn` and never blocks the
201    /// response path.
202    pub after: Option<AfterHook>,
203}
204
205impl ToolHooks {
206    /// Construct an empty [`ToolHooks`] with no cap and no hooks.
207    ///
208    /// Use the `with_*` builder methods to populate fields; this avoids
209    /// the `#[non_exhaustive]` restriction that prevents struct-literal
210    /// construction from outside the crate.
211    #[must_use]
212    pub fn new() -> Self {
213        Self::default()
214    }
215
216    /// Set the serialized result size cap in bytes.
217    #[must_use]
218    pub fn with_max_result_bytes(mut self, max: usize) -> Self {
219        self.max_result_bytes = Some(max);
220        self
221    }
222
223    /// Set the before-hook.
224    #[must_use]
225    pub fn with_before(mut self, before: BeforeHook) -> Self {
226        self.before = Some(before);
227        self
228    }
229
230    /// Set the after-hook.
231    #[must_use]
232    pub fn with_after(mut self, after: AfterHook) -> Self {
233        self.after = Some(after);
234        self
235    }
236}
237
238const _HOOKED_HANDLER_DOC_ANCHOR: &str = "HookedHandler";
239
240impl fmt::Debug for ToolHooks {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        f.debug_struct("ToolHooks")
243            .field("max_result_bytes", &self.max_result_bytes)
244            .field("before", &self.before.as_ref().map(|_| "<fn>"))
245            .field("after", &self.after.as_ref().map(|_| "<fn>"))
246            .finish()
247    }
248}
249
250/// `ServerHandler` wrapper that applies [`ToolHooks`].
251#[derive(Clone)]
252pub struct HookedHandler<H: ServerHandler> {
253    inner: Arc<H>,
254    hooks: Arc<ToolHooks>,
255}
256
257impl<H: ServerHandler> fmt::Debug for HookedHandler<H> {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        f.debug_struct("HookedHandler")
260            .field("hooks", &self.hooks)
261            .finish_non_exhaustive()
262    }
263}
264
265/// Construct a [`crate::tool_hooks::HookedHandler`] from an inner handler and hooks.
266///
267/// Returning the wrapped handler is the entire point of this function;
268/// dropping it on the floor would silently disable the supplied hooks.
269#[must_use = "HookedHandler must be wired into a ServerHandler (e.g. via \
270              `serve(..., || hooked)`) to take effect; dropping the returned \
271              value silently disables the supplied hooks"]
272pub fn with_hooks<H: ServerHandler>(inner: H, hooks: Arc<ToolHooks>) -> HookedHandler<H> {
273    HookedHandler {
274        inner: Arc::new(inner),
275        hooks,
276    }
277}
278
279impl<H: ServerHandler> HookedHandler<H> {
280    /// Access the wrapped handler.
281    #[must_use]
282    pub fn inner(&self) -> &H {
283        &self.inner
284    }
285
286    fn build_context(request: &CallToolRequestParams, req_id: Option<String>) -> ToolCallContext {
287        ToolCallContext {
288            tool_name: request.name.to_string(),
289            arguments: request.arguments.clone().map(serde_json::Value::Object),
290            identity: crate::rbac::current_identity(),
291            role: crate::rbac::current_role(),
292            sub: crate::rbac::current_sub(),
293            request_id: req_id,
294        }
295    }
296
297    /// Spawn the after-hook on the current Tokio runtime.  The future
298    /// captures clones of `ctx` and the `Arc<AfterHook>` so it can run
299    /// independently of the request task; panics inside the after-hook
300    /// are caught by Tokio and never poison the response path.
301    ///
302    /// The spawned task is **instrumented** with the request span via
303    /// [`tracing::Instrument`] and re-establishes the per-request RBAC
304    /// task-locals (role, identity, token, sub) via
305    /// [`crate::rbac::with_rbac_scope`]. Without this, after-hooks lose
306    /// their parent span (breaking trace correlation) and observe
307    /// `current_role()` / `current_identity()` as `None`.
308    fn spawn_after(
309        after: Option<&Arc<AfterHookHolder>>,
310        ctx: ToolCallContext,
311        disposition: HookDisposition,
312        size: usize,
313    ) {
314        if let Some(after) = after {
315            use tracing::Instrument;
316
317            let after = Arc::clone(after);
318            // Capture the request span before leaving the request task so
319            // after-hook log lines are correlated with the originating call.
320            let span = tracing::Span::current();
321            // Snapshot RBAC task-locals; defaults are empty strings so the
322            // re-established scope is a no-op when the request had no
323            // authenticated identity (e.g. health checks, anonymous tools).
324            let role = crate::rbac::current_role().unwrap_or_default();
325            let identity = crate::rbac::current_identity().unwrap_or_default();
326            let token = crate::rbac::current_token()
327                .unwrap_or_else(|| secrecy::SecretString::from(String::new()));
328            let sub = crate::rbac::current_sub().unwrap_or_default();
329            tokio::spawn(
330                async move {
331                    crate::rbac::with_rbac_scope(role, identity, token, sub, async move {
332                        let fut = (after.f)(&ctx, disposition, size);
333                        fut.await;
334                    })
335                    .await;
336                }
337                .instrument(span),
338            );
339        }
340    }
341}
342
343/// Internal newtype that owns the [`AfterHook`] so we can `Arc::clone`
344/// the *holder* and let the spawned task borrow `ctx` for the lifetime
345/// of the future without lifetime acrobatics in `tokio::spawn`.
346struct AfterHookHolder {
347    f: AfterHook,
348}
349
350/// Structured error body returned when a result exceeds `max_result_bytes`.
351///
352/// `actual` is `None` when the result could not be serialized, so its true
353/// size is unknown. It is rendered as `"unknown"` rather than a fabricated
354/// number -- operators read `actual_bytes` as a measurement.
355fn too_large_result(limit: usize, actual: Option<usize>, tool: &str) -> CallToolResult {
356    let actual_desc =
357        actual.map_or_else(|| "an unmeasurable number of".to_owned(), |n| n.to_string());
358    let body = serde_json::json!({
359        "error": "result_too_large",
360        "message": format!(
361            "tool '{tool}' result of {actual_desc} bytes exceeds the configured \
362             max_result_bytes={limit}; ask for a narrower query"
363        ),
364        "limit_bytes": limit,
365        "actual_bytes": actual.map_or_else(
366            || serde_json::Value::from("unknown"),
367            serde_json::Value::from,
368        ),
369    });
370    let mut r = CallToolResult::error(vec![ContentBlock::text(body.to_string())]);
371    r.structured_content = None;
372    r
373}
374
375/// Outcome of the `max_result_bytes` policy for a measured -- or
376/// unmeasurable -- result.
377#[derive(Debug, PartialEq, Eq)]
378enum SizeVerdict {
379    /// Within the cap, or no cap configured. Carries the measured size.
380    Pass { size: usize },
381    /// Over the cap, or unmeasurable while a cap is configured.
382    Replace { limit: usize, actual: Option<usize> },
383    /// Unmeasurable and no cap configured: nothing to enforce.
384    PassUnmeasured,
385}
386
387/// Decide what the size cap does, given an optional size-measurement outcome.
388const fn decide_size(size: Option<SizeMeasure>, max: Option<usize>) -> SizeVerdict {
389    match size {
390        Some(SizeMeasure::Exact(size)) => match max {
391            Some(limit) if size > limit => SizeVerdict::Replace {
392                limit,
393                actual: Some(size),
394            },
395            Some(_) | None => SizeVerdict::Pass { size },
396        },
397        Some(SizeMeasure::Exceeded { limit }) => SizeVerdict::Replace {
398            limit,
399            actual: None,
400        },
401        None => match max {
402            Some(limit) => SizeVerdict::Replace {
403                limit,
404                actual: None,
405            },
406            None => SizeVerdict::PassUnmeasured,
407        },
408    }
409}
410
411/// Apply the `max_result_bytes` cap to a result.  Returns the (possibly
412/// replaced) result, the size used for accounting, and whether the cap
413/// fired.
414fn apply_size_cap(
415    result: CallToolResult,
416    max: Option<usize>,
417    tool: &str,
418) -> (CallToolResult, usize, bool) {
419    let size = if max.is_some() {
420        Some(serialized_size(&result, max))
421    } else {
422        None
423    };
424    match decide_size(size, max) {
425        SizeVerdict::Pass { size } => (result, size, false),
426        SizeVerdict::PassUnmeasured => (result, 0, false),
427        SizeVerdict::Replace { limit, actual } => {
428            tracing::warn!(
429                tool = %tool,
430                size_bytes = actual.unwrap_or_default(),
431                size_measured = actual.is_some(),
432                limit_bytes = limit,
433                "tool result exceeds max_result_bytes; replacing with structured error"
434            );
435            let accounted = actual.unwrap_or_else(|| limit.saturating_add(1));
436            (too_large_result(limit, actual, tool), accounted, true)
437        }
438    }
439}
440
441impl<H: ServerHandler> ServerHandler for HookedHandler<H> {
442    fn get_info(&self) -> ServerInfo {
443        self.inner.get_info()
444    }
445
446    async fn initialize(
447        &self,
448        request: InitializeRequestParams,
449        context: RequestContext<RoleServer>,
450    ) -> Result<InitializeResult, ErrorData> {
451        self.inner.initialize(request, context).await
452    }
453
454    async fn list_tools(
455        &self,
456        request: Option<PaginatedRequestParams>,
457        context: RequestContext<RoleServer>,
458    ) -> Result<ListToolsResult, ErrorData> {
459        self.inner.list_tools(request, context).await
460    }
461
462    fn get_tool(&self, name: &str) -> Option<Tool> {
463        self.inner.get_tool(name)
464    }
465
466    async fn list_prompts(
467        &self,
468        request: Option<PaginatedRequestParams>,
469        context: RequestContext<RoleServer>,
470    ) -> Result<ListPromptsResult, ErrorData> {
471        self.inner.list_prompts(request, context).await
472    }
473
474    async fn get_prompt(
475        &self,
476        request: GetPromptRequestParams,
477        context: RequestContext<RoleServer>,
478    ) -> Result<GetPromptResponse, ErrorData> {
479        self.inner.get_prompt(request, context).await
480    }
481
482    async fn list_resources(
483        &self,
484        request: Option<PaginatedRequestParams>,
485        context: RequestContext<RoleServer>,
486    ) -> Result<ListResourcesResult, ErrorData> {
487        self.inner.list_resources(request, context).await
488    }
489
490    async fn list_resource_templates(
491        &self,
492        request: Option<PaginatedRequestParams>,
493        context: RequestContext<RoleServer>,
494    ) -> Result<ListResourceTemplatesResult, ErrorData> {
495        self.inner.list_resource_templates(request, context).await
496    }
497
498    async fn read_resource(
499        &self,
500        request: ReadResourceRequestParams,
501        context: RequestContext<RoleServer>,
502    ) -> Result<ReadResourceResponse, ErrorData> {
503        self.inner.read_resource(request, context).await
504    }
505
506    // NOT cancel-safe: this awaits consumer-supplied before-hooks and the
507    // consumer's inner handler. After-hooks are dispatched only on the normal
508    // Deny/Replace/Ok/Err paths, so a cancellation between the before-hook and
509    // the response drops the paired after-hook -- an audit hook can therefore
510    // record a started call that is never closed out. Consumers needing
511    // guaranteed pairing should make the after-hook idempotent or run the tool
512    // body detached (see `crate::cancel`).
513    #[allow(
514        clippy::wildcard_enum_match_arm,
515        reason = "CallToolResponse is #[non_exhaustive]; the non-Complete MRTR variants (InputRequired/Task) are passed through unchanged"
516    )]
517    async fn call_tool(
518        &self,
519        request: CallToolRequestParams,
520        context: RequestContext<RoleServer>,
521    ) -> Result<CallToolResponse, ErrorData> {
522        let req_id = Some(format!("{:?}", context.id));
523        let ctx = Self::build_context(&request, req_id);
524        let max = self.hooks.max_result_bytes;
525        let after_holder = self
526            .hooks
527            .after
528            .as_ref()
529            .map(|f| Arc::new(AfterHookHolder { f: Arc::clone(f) }));
530
531        // Before hook: may Continue, Deny, or Replace.
532        if let Some(before) = self.hooks.before.as_ref() {
533            let outcome = before(&ctx).await;
534            match outcome {
535                HookOutcome::Continue => {}
536                HookOutcome::Deny(err) => {
537                    Self::spawn_after(after_holder.as_ref(), ctx, HookDisposition::DeniedBefore, 0);
538                    return Err(err);
539                }
540                HookOutcome::Replace(boxed) => {
541                    let (final_result, size, capped) = apply_size_cap(*boxed, max, &ctx.tool_name);
542                    let disposition = if capped {
543                        HookDisposition::ResultTooLarge
544                    } else {
545                        HookDisposition::ReplacedBefore
546                    };
547                    Self::spawn_after(after_holder.as_ref(), ctx, disposition, size);
548                    return Ok(final_result.into());
549                }
550            }
551        }
552
553        // Inner handler.
554        match self.inner.call_tool(request, context).await {
555            // Completed tool result: subject to the size cap + after hook.
556            Ok(CallToolResponse::Complete(result)) => {
557                let (final_result, size, capped) = apply_size_cap(result, max, &ctx.tool_name);
558                let disposition = if capped {
559                    HookDisposition::ResultTooLarge
560                } else {
561                    HookDisposition::InnerExecuted
562                };
563                Self::spawn_after(after_holder.as_ref(), ctx, disposition, size);
564                Ok(final_result.into())
565            }
566            // MRTR input-required / task responses (rmcp 3.0): no CallToolResult
567            // to size-cap, so pass them through unchanged.
568            Ok(other) => {
569                Self::spawn_after(
570                    after_holder.as_ref(),
571                    ctx,
572                    HookDisposition::InnerExecuted,
573                    0,
574                );
575                Ok(other)
576            }
577            Err(e) => {
578                Self::spawn_after(after_holder.as_ref(), ctx, HookDisposition::InnerErrored, 0);
579                Err(e)
580            }
581        }
582    }
583
584    // rmcp 3.0 added task/subscription/discovery request handlers with defaults;
585    // delegate them to `inner` so wrapping a handler that implements those stays
586    // transparent (otherwise the default would shadow the inner implementation).
587    fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
588        self.inner.supported_protocol_versions()
589    }
590
591    async fn discover(
592        &self,
593        context: RequestContext<RoleServer>,
594    ) -> Result<DiscoverResult, ErrorData> {
595        self.inner.discover(context).await
596    }
597
598    fn accepted_subscription_filter(
599        &self,
600        requested: &SubscriptionFilter,
601    ) -> Option<SubscriptionFilter> {
602        self.inner.accepted_subscription_filter(requested)
603    }
604
605    async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> {
606        self.inner.listen(context).await
607    }
608
609    async fn get_task(
610        &self,
611        request: GetTaskParams,
612        context: RequestContext<RoleServer>,
613    ) -> Result<GetTaskResult, ErrorData> {
614        self.inner.get_task(request, context).await
615    }
616
617    async fn update_task(
618        &self,
619        request: UpdateTaskParams,
620        context: RequestContext<RoleServer>,
621    ) -> Result<(), ErrorData> {
622        self.inner.update_task(request, context).await
623    }
624
625    async fn cancel_task(
626        &self,
627        request: CancelTaskParams,
628        context: RequestContext<RoleServer>,
629    ) -> Result<(), ErrorData> {
630        self.inner.cancel_task(request, context).await
631    }
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635struct SizeLimitExceeded;
636
637impl fmt::Display for SizeLimitExceeded {
638    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
639        f.write_str("serialized result exceeded configured size cap")
640    }
641}
642
643impl std::error::Error for SizeLimitExceeded {}
644
645struct CountingWriter {
646    bytes: usize,
647    limit: Option<usize>,
648}
649
650impl CountingWriter {
651    const fn unbounded() -> Self {
652        Self {
653            bytes: 0,
654            limit: None,
655        }
656    }
657
658    const fn bounded(limit: usize) -> Self {
659        Self {
660            bytes: 0,
661            limit: Some(limit),
662        }
663    }
664}
665
666impl io::Write for CountingWriter {
667    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
668        let next = self.bytes.saturating_add(buf.len());
669        if self.limit.is_some_and(|limit| next > limit) {
670            Err(io::Error::other(SizeLimitExceeded))
671        } else {
672            self.bytes = next;
673            Ok(buf.len())
674        }
675    }
676
677    fn flush(&mut self) -> io::Result<()> {
678        Ok(())
679    }
680}
681
682/// Outcome of measuring serialized result size.
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684enum SizeMeasure {
685    /// Exact serialized size in bytes.
686    Exact(usize),
687    /// Serialization crossed the configured size cap and stopped early.
688    Exceeded { limit: usize },
689}
690
691/// Serialized byte length, or a deliberate cap-abort outcome.
692fn serialized_size(result: &CallToolResult, max: Option<usize>) -> SizeMeasure {
693    let mut writer = max.map_or_else(CountingWriter::unbounded, CountingWriter::bounded);
694    match serde_json::to_writer(&mut writer, result) {
695        Ok(()) => SizeMeasure::Exact(writer.bytes),
696        Err(error) if error.io_error_kind() == Some(io::ErrorKind::Other) => {
697            SizeMeasure::Exceeded {
698                limit: max.unwrap_or(writer.bytes),
699            }
700        }
701        Err(_error) => {
702            // `CallToolResult` is made only of infallibly serializable fields
703            // (`String`, `bool`, arrays/maps, and serde_json::Value`). There is
704            // no inhabitable production value that can reach this branch.
705            SizeMeasure::Exact(writer.bytes)
706        }
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use std::sync::{
713        Arc,
714        atomic::{AtomicUsize, Ordering},
715    };
716
717    use rmcp::{
718        ErrorData, RoleServer, ServerHandler,
719        model::{
720            CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerInfo,
721        },
722        service::RequestContext,
723    };
724
725    use super::*;
726
727    #[derive(Clone, Default)]
728    struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
729
730    impl CapturedLogs {
731        fn contents(&self) -> String {
732            let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
733            String::from_utf8(bytes).unwrap_or_default()
734        }
735    }
736
737    struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
738
739    impl io::Write for CapturedLogsWriter {
740        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
741            if let Ok(mut guard) = self.0.lock() {
742                guard.extend_from_slice(buf);
743            }
744            Ok(buf.len())
745        }
746
747        fn flush(&mut self) -> io::Result<()> {
748            Ok(())
749        }
750    }
751
752    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
753        type Writer = CapturedLogsWriter;
754
755        fn make_writer(&'a self) -> Self::Writer {
756            CapturedLogsWriter(Arc::clone(&self.0))
757        }
758    }
759
760    /// Minimal in-process `ServerHandler` for tests.
761    #[derive(Clone, Default)]
762    struct TestHandler {
763        /// When Some, `call_tool` returns a body of this many 'x' bytes.
764        body_bytes: Option<usize>,
765    }
766
767    impl ServerHandler for TestHandler {
768        fn get_info(&self) -> ServerInfo {
769            ServerInfo::default()
770        }
771
772        #[allow(
773            clippy::unused_async_trait_impl,
774            reason = "async is mandated by the rmcp ServerHandler trait signature; this test handler does not await"
775        )]
776        async fn call_tool(
777            &self,
778            _request: CallToolRequestParams,
779            _context: RequestContext<RoleServer>,
780        ) -> Result<CallToolResponse, ErrorData> {
781            let body = "x".repeat(self.body_bytes.unwrap_or(4));
782            Ok(CallToolResult::success(vec![ContentBlock::text(body)]).into())
783        }
784    }
785
786    fn ctx(name: &str) -> ToolCallContext {
787        ToolCallContext {
788            tool_name: name.to_owned(),
789            arguments: None,
790            identity: None,
791            role: None,
792            sub: None,
793            request_id: None,
794        }
795    }
796
797    fn sensitive_ctx() -> ToolCallContext {
798        ToolCallContext {
799            tool_name: "safe-tool-name".to_owned(),
800            arguments: Some(serde_json::json!({ "password": "argument-secret" })),
801            identity: Some("identity-secret".to_owned()),
802            role: Some("role-secret".to_owned()),
803            sub: Some("sub-secret".to_owned()),
804            request_id: Some("request-id-visible".to_owned()),
805        }
806    }
807
808    #[test]
809    fn tool_call_context_debug_redacts_sensitive_fields_by_default() {
810        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
811        crate::diagnostics::set_diagnostic_exposure(
812            &crate::diagnostics::DiagnosticExposure::default(),
813        );
814
815        let rendered = format!("{:?}", sensitive_ctx());
816
817        assert!(rendered.contains("safe-tool-name"));
818        assert!(rendered.contains("request-id-visible"));
819        assert!(rendered.contains("[REDACTED]"));
820        for secret in [
821            "argument-secret",
822            "identity-secret",
823            "role-secret",
824            "sub-secret",
825        ] {
826            assert!(
827                !rendered.contains(secret),
828                "ToolCallContext Debug must not contain {secret}: {rendered}"
829            );
830        }
831    }
832
833    #[test]
834    fn tool_call_context_debug_can_show_sensitive_fields_when_enabled() {
835        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
836        crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
837            tool_call_arguments: true,
838            ..crate::diagnostics::DiagnosticExposure::default()
839        });
840
841        let rendered = format!("{:?}", sensitive_ctx());
842
843        for secret in [
844            "argument-secret",
845            "identity-secret",
846            "role-secret",
847            "sub-secret",
848        ] {
849            assert!(
850                rendered.contains(secret),
851                "ToolCallContext Debug must contain {secret} when enabled: {rendered}"
852            );
853        }
854    }
855
856    #[tokio::test]
857    async fn size_cap_replaces_oversized_result() {
858        let inner = TestHandler {
859            body_bytes: Some(8_192),
860        };
861        let hooks = Arc::new(ToolHooks {
862            max_result_bytes: Some(256),
863            before: None,
864            after: None,
865        });
866        let hooked = with_hooks(inner, hooks);
867
868        let small = CallToolResult::success(vec![ContentBlock::text("ok".to_owned())]);
869        assert!(exact_size(&small) < 256);
870
871        let big = CallToolResult::success(vec![ContentBlock::text("x".repeat(8_192))]);
872        let size = exact_size(&big);
873        assert!(size > 256);
874
875        let (replaced, accounted, capped) = apply_size_cap(big, Some(256), "whatever");
876        assert!(capped);
877        assert_eq!(accounted, 257);
878        assert_eq!(replaced.is_error, Some(true));
879        assert!(matches!(
880            replaced.content.first(),
881            Some(rmcp::model::ContentBlock::Text(t)) if t.text.contains("result_too_large")
882        ));
883
884        // Compile-check that HookedHandler instantiates with the test inner.
885        let _ = hooked;
886    }
887
888    fn exact_size(result: &CallToolResult) -> usize {
889        match serialized_size(result, None) {
890            SizeMeasure::Exact(size) => size,
891            SizeMeasure::Exceeded { limit } => {
892                panic!("unbounded measurement exceeded impossible limit {limit}");
893            }
894        }
895    }
896
897    #[test]
898    fn serialized_size_under_cap_is_exact() {
899        let result = CallToolResult::success(vec![ContentBlock::text("ok".to_owned())]);
900        let exact = serde_json::to_vec(&result).unwrap().len();
901
902        let measured = serialized_size(&result, Some(exact));
903
904        assert_eq!(measured, SizeMeasure::Exact(exact));
905    }
906
907    #[test]
908    fn serialized_size_over_cap_stops_with_exceeded() {
909        let result = CallToolResult::success(vec![ContentBlock::text("x".repeat(8_192))]);
910
911        let measured = serialized_size(&result, Some(256));
912
913        assert_eq!(measured, SizeMeasure::Exceeded { limit: 256 });
914    }
915
916    #[test]
917    fn over_cap_replacement_does_not_log_serialization_failure() {
918        let logs = CapturedLogs::default();
919        let subscriber = tracing_subscriber::fmt()
920            .with_max_level(tracing::Level::TRACE)
921            .with_writer(logs.clone())
922            .with_ansi(false)
923            .without_time()
924            .finish();
925        let _guard = tracing::subscriber::set_default(subscriber);
926        let result = CallToolResult::success(vec![ContentBlock::text("x".repeat(8_192))]);
927
928        let (_final_result, accounted, capped) = apply_size_cap(result, Some(256), "big_tool");
929
930        assert!(capped);
931        assert_eq!(accounted, 257);
932        assert!(
933            logs.contents()
934                .contains("tool result exceeds max_result_bytes")
935        );
936        assert!(
937            !logs.contents().contains("failed to serialize"),
938            "cap-abort must not be logged as serialization failure: {}",
939            logs.contents()
940        );
941    }
942
943    #[test]
944    fn disabled_result_cap_skips_measurement() {
945        let result = CallToolResult::success(vec![ContentBlock::text("x".repeat(8_192))]);
946
947        let (_final_result, accounted, capped) = apply_size_cap(result, None, "uncapped_tool");
948
949        assert!(!capped);
950        assert_eq!(accounted, 0);
951    }
952
953    #[tokio::test]
954    async fn before_hook_deny_builds_error() {
955        let counter = Arc::new(AtomicUsize::new(0));
956        let c = Arc::clone(&counter);
957        let before: BeforeHook = Arc::new(move |ctx_ref| {
958            let c = Arc::clone(&c);
959            let name = ctx_ref.tool_name.clone();
960            Box::pin(async move {
961                c.fetch_add(1, Ordering::Relaxed);
962                if name == "forbidden" {
963                    HookOutcome::Deny(ErrorData::invalid_request("nope", None))
964                } else {
965                    HookOutcome::Continue
966                }
967            })
968        });
969
970        let hooks = Arc::new(ToolHooks {
971            max_result_bytes: None,
972            before: Some(before),
973            after: None,
974        });
975        let hooked = with_hooks(TestHandler::default(), hooks);
976
977        let bad_ctx = ctx("forbidden");
978        let before_fn = hooked.hooks.before.as_ref().unwrap();
979        let outcome = before_fn(&bad_ctx).await;
980        assert!(matches!(outcome, HookOutcome::Deny(_)));
981        assert_eq!(counter.load(Ordering::Relaxed), 1);
982
983        let ok_ctx = ctx("allowed");
984        let outcome2 = before_fn(&ok_ctx).await;
985        assert!(matches!(outcome2, HookOutcome::Continue));
986        assert_eq!(counter.load(Ordering::Relaxed), 2);
987    }
988
989    #[test]
990    fn too_large_result_mentions_limit_and_actual() {
991        let r = too_large_result(100, Some(500), "my_tool");
992        let body = serde_json::to_string(&r).unwrap();
993        assert!(body.contains("result_too_large"));
994        assert!(body.contains("my_tool"));
995        assert!(body.contains("100"));
996        assert!(body.contains("500"));
997    }
998
999    #[test]
1000    fn decide_size_truth_table() {
1001        assert_eq!(
1002            decide_size(Some(SizeMeasure::Exact(10)), Some(100)),
1003            SizeVerdict::Pass { size: 10 }
1004        );
1005        assert_eq!(
1006            decide_size(Some(SizeMeasure::Exact(100)), Some(100)),
1007            SizeVerdict::Pass { size: 100 },
1008            "cap is inclusive: size == limit passes"
1009        );
1010        assert_eq!(
1011            decide_size(Some(SizeMeasure::Exact(101)), Some(100)),
1012            SizeVerdict::Replace {
1013                limit: 100,
1014                actual: Some(101)
1015            }
1016        );
1017        assert_eq!(
1018            decide_size(Some(SizeMeasure::Exact(999)), None),
1019            SizeVerdict::Pass { size: 999 }
1020        );
1021        assert_eq!(
1022            decide_size(None, Some(100)),
1023            SizeVerdict::Replace {
1024                limit: 100,
1025                actual: None
1026            },
1027            "unmeasurable result must fail closed when a cap is configured"
1028        );
1029        assert_eq!(decide_size(None, None), SizeVerdict::PassUnmeasured);
1030        assert_eq!(
1031            decide_size(Some(SizeMeasure::Exceeded { limit: 100 }), Some(100)),
1032            SizeVerdict::Replace {
1033                limit: 100,
1034                actual: None
1035            },
1036            "cap-abort is not an exact measurement"
1037        );
1038    }
1039
1040    #[test]
1041    fn too_large_result_does_not_fabricate_a_size_when_unmeasurable() {
1042        let r = too_large_result(100, None, "my_tool");
1043        let body = serde_json::to_string(&r).unwrap();
1044        assert!(body.contains("result_too_large"));
1045        assert!(body.contains("unknown"));
1046        assert!(
1047            !body.contains("101"),
1048            "the over-limit accounting sentinel must not leak into the client payload"
1049        );
1050    }
1051
1052    #[tokio::test]
1053    async fn replace_outcome_skips_inner_and_returns_payload() {
1054        // Returning Replace from before-hook must yield the supplied
1055        // CallToolResult directly, with no need for the inner handler.
1056        let before: BeforeHook = Arc::new(|_ctx| {
1057            Box::pin(async {
1058                HookOutcome::Replace(Box::new(CallToolResult::success(vec![ContentBlock::text(
1059                    "from-replace".to_owned(),
1060                )])))
1061            })
1062        });
1063        let hooks = Arc::new(ToolHooks {
1064            max_result_bytes: None,
1065            before: Some(before),
1066            after: None,
1067        });
1068        let _hooked = with_hooks(TestHandler::default(), Arc::clone(&hooks));
1069
1070        // Exercise the before-hook closure + apply_size_cap helper directly,
1071        // matching the established test pattern in this module.
1072        let outcome = (hooks.before.as_ref().unwrap())(&ctx("any")).await;
1073        let HookOutcome::Replace(boxed) = outcome else {
1074            panic!("expected HookOutcome::Replace");
1075        };
1076        let (result, size, capped) = apply_size_cap(*boxed, None, "any");
1077        assert!(!capped);
1078        assert_eq!(size, 0);
1079        assert!(!result.is_error.unwrap_or(false));
1080        assert!(matches!(
1081            result.content.first(),
1082            Some(rmcp::model::ContentBlock::Text(t)) if t.text == "from-replace"
1083        ));
1084    }
1085
1086    #[tokio::test]
1087    async fn replace_outcome_subject_to_size_cap() {
1088        // A Replace payload that exceeds max_result_bytes must be rewritten
1089        // to result_too_large just like an inner-handler result would be,
1090        // and the disposition must reflect ResultTooLarge.
1091        let huge = CallToolResult::success(vec![ContentBlock::text("y".repeat(8_192))]);
1092        let huge_size = serde_json::to_vec(&huge).unwrap().len();
1093        assert!(huge_size > 256);
1094
1095        let (final_result, accounted, capped) = apply_size_cap(huge, Some(256), "replaced_tool");
1096        assert!(capped);
1097        assert_eq!(accounted, 257);
1098        assert_eq!(final_result.is_error, Some(true));
1099        assert!(matches!(
1100            final_result.content.first(),
1101            Some(rmcp::model::ContentBlock::Text(t)) if t.text.contains("result_too_large")
1102        ));
1103    }
1104
1105    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1106    async fn after_hook_fires_exactly_once_via_spawn() {
1107        // spawn_after must enqueue the after-hook exactly one time per
1108        // invocation and never block the caller; we wait for the spawned
1109        // task to run by polling the counter with a short timeout.
1110        let counter = Arc::new(AtomicUsize::new(0));
1111        let c = Arc::clone(&counter);
1112        let after: AfterHook = Arc::new(move |_ctx, _disp, _size| {
1113            let c = Arc::clone(&c);
1114            Box::pin(async move {
1115                c.fetch_add(1, Ordering::Relaxed);
1116            })
1117        });
1118        let holder = Arc::new(AfterHookHolder { f: after });
1119
1120        HookedHandler::<TestHandler>::spawn_after(
1121            Some(&holder),
1122            ctx("t"),
1123            HookDisposition::InnerExecuted,
1124            42,
1125        );
1126
1127        // Wait up to 1s for the spawned task to run.
1128        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
1129        while counter.load(Ordering::Relaxed) == 0 && std::time::Instant::now() < deadline {
1130            tokio::task::yield_now().await;
1131            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1132        }
1133        assert_eq!(counter.load(Ordering::Relaxed), 1);
1134    }
1135
1136    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1137    async fn after_hook_panic_is_isolated_from_response_path() {
1138        // A panicking after-hook must not affect the request task.  We
1139        // spawn a panicking after-hook and then verify the current task
1140        // can still complete an unrelated future to completion.
1141        let after: AfterHook = Arc::new(|_ctx, _disp, _size| {
1142            Box::pin(async {
1143                panic!("intentional panic in after-hook");
1144            })
1145        });
1146        let holder = Arc::new(AfterHookHolder { f: after });
1147
1148        HookedHandler::<TestHandler>::spawn_after(
1149            Some(&holder),
1150            ctx("boom"),
1151            HookDisposition::InnerExecuted,
1152            0,
1153        );
1154
1155        // Give Tokio a chance to run + abort the panicking task, then
1156        // confirm we're still alive and the runtime is healthy.
1157        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1158        let still_alive = tokio::spawn(async { 1_u32 + 2 }).await.unwrap();
1159        assert_eq!(still_alive, 3);
1160    }
1161}