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//! # Cancel safety
20//!
21//! The transparent `ServerHandler` delegation methods are cancel-safe with
22//! respect to this wrapper: cancellation only drops the delegated inner
23//! future, so the wrapped handler's own cancel-safety contract is inherited
24//! unchanged.  The `call_tool` implementation on
25//! [`crate::tool_hooks::HookedHandler`] is the exception and is documented
26//! as **NOT cancel-safe** at its definition: once a before-hook or the
27//! inner handler has been awaited, cancellation can prevent the paired
28//! after-hook from being spawned.
29//!
30//! This is entirely **opt-in** at the application layer - `rmcp_server_kit::serve()`
31//! does not wrap handlers automatically.  Applications that want hooks do:
32//!
33//! ```no_run
34//! use std::sync::Arc;
35//! use rmcp_server_kit::tool_hooks::{HookedHandler, HookOutcome, ToolHooks, with_hooks};
36//!
37//! # #[derive(Clone, Default)]
38//! # struct MyHandler;
39//! # impl rmcp::ServerHandler for MyHandler {}
40//! let handler = MyHandler::default();
41//! let hooks = Arc::new(
42//!     ToolHooks::new()
43//!         .with_max_result_bytes(256 * 1024)
44//!         .with_before(Arc::new(|_ctx| Box::pin(async { HookOutcome::Continue })))
45//!         .with_after(Arc::new(|_ctx, _disp, _bytes| Box::pin(async {}))),
46//! );
47//! let _wrapped = with_hooks(handler, hooks);
48//! ```
49
50use std::{borrow::Cow, fmt, future::Future, io, pin::Pin, sync::Arc};
51
52#[allow(
53    deprecated,
54    reason = "transparent ServerHandler delegation must import legacy logging/subscription parameter types until rmcp removes those methods"
55)]
56use rmcp::{
57    ErrorData, RoleServer, ServerHandler,
58    model::{
59        CallToolRequestParams, CallToolResponse, CallToolResult, CancelTaskParams,
60        CancelledNotificationParam, CompleteRequestParams, CompleteResult, ContentBlock,
61        CustomNotification, CustomRequest, CustomResult, DiscoverResult, GetPromptRequestParams,
62        GetPromptResponse, GetTaskParams, GetTaskResult, InitializeRequestParams, InitializeResult,
63        ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, ListToolsResult,
64        PaginatedRequestParams, ProgressNotificationParam, ProtocolVersion,
65        ReadResourceRequestParams, ReadResourceResponse, ServerConfig, SetLevelRequestParams,
66        SubscribeRequestParams, SubscriptionFilter, Tool, UnsubscribeRequestParams,
67        UpdateTaskParams,
68    },
69    service::{NotificationContext, RequestContext, SubscriptionContext},
70};
71
72/// Context passed to before/after hooks for a single tool call.
73#[derive(Clone)]
74#[non_exhaustive]
75pub struct ToolCallContext {
76    /// Tool name being invoked.
77    pub tool_name: String,
78    /// JSON arguments as sent by the client (may be `None`).
79    pub arguments: Option<serde_json::Value>,
80    /// Identity name from the authenticated request, if any.
81    pub identity: Option<String>,
82    /// RBAC role associated with the request, if any.
83    pub role: Option<String>,
84    /// OAuth `sub` claim, if present.
85    pub sub: Option<String>,
86    /// Raw JSON-RPC request id rendered as a string, if available.
87    pub request_id: Option<String>,
88}
89
90impl ToolCallContext {
91    /// Construct a [`ToolCallContext`] with the given tool name and all
92    /// optional fields cleared.  Primarily for use in unit tests and
93    /// benchmarks of user-supplied hooks; the runtime path populates
94    /// these fields from the request and task-local RBAC state.
95    #[must_use]
96    pub fn for_tool(tool_name: impl Into<String>) -> Self {
97        Self {
98            tool_name: tool_name.into(),
99            arguments: None,
100            identity: None,
101            role: None,
102            sub: None,
103            request_id: None,
104        }
105    }
106}
107
108impl fmt::Debug for ToolCallContext {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        let Self {
111            tool_name,
112            arguments,
113            identity,
114            role,
115            sub,
116            request_id,
117        } = self;
118        let mut debug = f.debug_struct("ToolCallContext");
119        debug.field("tool_name", tool_name);
120        if crate::diagnostics::tool_call_arguments() {
121            debug
122                .field("arguments", arguments)
123                .field("identity", identity)
124                .field("role", role)
125                .field("sub", sub);
126        } else {
127            debug
128                .field("arguments", &"[REDACTED]")
129                .field("identity", &"[REDACTED]")
130                .field("role", &"[REDACTED]")
131                .field("sub", &"[REDACTED]");
132        }
133        debug.field("request_id", request_id).finish()
134    }
135}
136
137/// Outcome returned by a [`BeforeHook`] to control invocation flow.
138///
139/// - [`HookOutcome::Continue`] - proceed with the wrapped handler.
140/// - [`HookOutcome::Deny`] - reject the call with the supplied
141///   [`ErrorData`]; the inner handler is **not** called.
142/// - [`HookOutcome::Replace`] - return the supplied result instead of
143///   invoking the inner handler.  The result is still subject to
144///   `max_result_bytes` capping.
145#[derive(Debug)]
146#[non_exhaustive]
147pub enum HookOutcome {
148    /// Proceed with the wrapped handler.
149    Continue,
150    /// Reject the call.  The error is propagated to the client as-is.
151    Deny(ErrorData),
152    /// Skip the inner handler and return the supplied result instead.
153    Replace(Box<CallToolResult>),
154}
155
156/// How a tool call resolved, passed to the [`AfterHook`].
157#[derive(Debug, Clone, Copy)]
158#[non_exhaustive]
159pub enum HookDisposition {
160    /// The inner handler ran and returned `Ok`.
161    InnerExecuted,
162    /// The inner handler ran and returned `Err`.
163    InnerErrored,
164    /// The before-hook returned [`HookOutcome::Deny`].
165    DeniedBefore,
166    /// The before-hook returned [`HookOutcome::Replace`].
167    ReplacedBefore,
168    /// The result (from inner or replace) exceeded `max_result_bytes`
169    /// and was substituted with a structured error.
170    ResultTooLarge,
171}
172
173/// Async before-hook callback type.
174///
175/// Returns a [`HookOutcome`] controlling whether the inner handler runs.
176/// The borrow of `ToolCallContext` is held for the duration of the
177/// returned future, which avoids forcing implementations to clone the
178/// context for every invocation.
179pub type BeforeHook = Arc<
180    dyn for<'a> Fn(&'a ToolCallContext) -> Pin<Box<dyn Future<Output = HookOutcome> + Send + 'a>>
181        + Send
182        + Sync
183        + 'static,
184>;
185
186/// Async after-hook callback type.
187///
188/// Receives the call context, a [`HookDisposition`] describing how the
189/// call resolved, and the approximate serialized result size in bytes
190/// (`0` for `DeniedBefore` and `InnerErrored`).  Spawned via
191/// `tokio::spawn`, so it must not assume it runs before the response is
192/// flushed.
193pub type AfterHook = Arc<
194    dyn for<'a> Fn(
195            &'a ToolCallContext,
196            HookDisposition,
197            usize,
198        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
199        + Send
200        + Sync
201        + 'static,
202>;
203
204/// Opt-in hooks applied by [`crate::tool_hooks::HookedHandler`].
205#[allow(clippy::struct_field_names, reason = "before/after read naturally")]
206#[derive(Clone, Default)]
207#[non_exhaustive]
208pub struct ToolHooks {
209    /// Hard cap on serialized `CallToolResult` size in bytes.  When
210    /// exceeded, the result is replaced with an `is_error=true` result
211    /// carrying a `result_too_large` structured error.  `None` disables
212    /// the cap.
213    pub max_result_bytes: Option<usize>,
214    /// Optional before-hook invoked after arg deserialization, before
215    /// the wrapped handler is called.
216    pub before: Option<BeforeHook>,
217    /// Optional after-hook invoked once per normally-resolved call - that
218    /// is, on the Deny / Replace / Ok / Err paths.  Spawned via
219    /// `tokio::spawn` and never blocks the response path.
220    ///
221    /// **Not guaranteed under cancellation.**  If the `call_tool` future is
222    /// dropped after a before-hook has run but before the call resolves,
223    /// the paired after-hook is never spawned.  Do not use before/after
224    /// pairing as a mandatory resource guard; make the after-hook
225    /// idempotent or tolerant of missing closes (see [`crate::cancel`]).
226    pub after: Option<AfterHook>,
227}
228
229impl ToolHooks {
230    /// Construct an empty [`ToolHooks`] with no cap and no hooks.
231    ///
232    /// Use the `with_*` builder methods to populate fields; this avoids
233    /// the `#[non_exhaustive]` restriction that prevents struct-literal
234    /// construction from outside the crate.
235    #[must_use]
236    pub fn new() -> Self {
237        Self::default()
238    }
239
240    /// Set the serialized result size cap in bytes.
241    #[must_use]
242    pub fn with_max_result_bytes(mut self, max: usize) -> Self {
243        self.max_result_bytes = Some(max);
244        self
245    }
246
247    /// Set the before-hook.
248    #[must_use]
249    pub fn with_before(mut self, before: BeforeHook) -> Self {
250        self.before = Some(before);
251        self
252    }
253
254    /// Set the after-hook.
255    #[must_use]
256    pub fn with_after(mut self, after: AfterHook) -> Self {
257        self.after = Some(after);
258        self
259    }
260}
261
262const _HOOKED_HANDLER_DOC_ANCHOR: &str = "HookedHandler";
263
264impl fmt::Debug for ToolHooks {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        f.debug_struct("ToolHooks")
267            .field("max_result_bytes", &self.max_result_bytes)
268            .field("before", &self.before.as_ref().map(|_| "<fn>"))
269            .field("after", &self.after.as_ref().map(|_| "<fn>"))
270            .finish()
271    }
272}
273
274/// `ServerHandler` wrapper that applies [`ToolHooks`].
275#[derive(Clone)]
276pub struct HookedHandler<H: ServerHandler> {
277    inner: Arc<H>,
278    hooks: Arc<ToolHooks>,
279}
280
281impl<H: ServerHandler> fmt::Debug for HookedHandler<H> {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        f.debug_struct("HookedHandler")
284            .field("hooks", &self.hooks)
285            .finish_non_exhaustive()
286    }
287}
288
289/// Construct a [`crate::tool_hooks::HookedHandler`] from an inner handler and hooks.
290///
291/// Returning the wrapped handler is the entire point of this function;
292/// dropping it on the floor would silently disable the supplied hooks.
293#[must_use = "HookedHandler must be wired into a ServerHandler (e.g. via \
294              `serve(..., || hooked)`) to take effect; dropping the returned \
295              value silently disables the supplied hooks"]
296pub fn with_hooks<H: ServerHandler>(inner: H, hooks: Arc<ToolHooks>) -> HookedHandler<H> {
297    HookedHandler {
298        inner: Arc::new(inner),
299        hooks,
300    }
301}
302
303impl<H: ServerHandler> HookedHandler<H> {
304    /// Access the wrapped handler.
305    #[must_use]
306    pub fn inner(&self) -> &H {
307        &self.inner
308    }
309
310    fn build_context(request: &CallToolRequestParams, req_id: Option<String>) -> ToolCallContext {
311        ToolCallContext {
312            tool_name: request.name.to_string(),
313            arguments: request.arguments.clone().map(serde_json::Value::Object),
314            identity: crate::rbac::current_identity(),
315            role: crate::rbac::current_role(),
316            sub: crate::rbac::current_sub(),
317            request_id: req_id,
318        }
319    }
320
321    /// Spawn the after-hook on the current Tokio runtime.  The future
322    /// captures clones of `ctx` and the `Arc<AfterHook>` so it can run
323    /// independently of the request task; panics inside the after-hook
324    /// are caught by Tokio and never poison the response path.
325    ///
326    /// The spawned task is **instrumented** with the request span via
327    /// [`tracing::Instrument`] and re-establishes the per-request RBAC
328    /// task-locals (role, identity, token, sub) via
329    /// [`crate::rbac::with_rbac_scope`]. Without this, after-hooks lose
330    /// their parent span (breaking trace correlation) and observe
331    /// `current_role()` / `current_identity()` as `None`.
332    fn spawn_after(
333        after: Option<&Arc<AfterHookHolder>>,
334        ctx: ToolCallContext,
335        disposition: HookDisposition,
336        size: usize,
337    ) {
338        if let Some(after) = after {
339            use tracing::Instrument;
340
341            let after = Arc::clone(after);
342            // Capture the request span before leaving the request task so
343            // after-hook log lines are correlated with the originating call.
344            let span = tracing::Span::current();
345            // Snapshot RBAC task-locals; defaults are empty strings so the
346            // re-established scope is a no-op when the request had no
347            // authenticated identity (e.g. health checks, anonymous tools).
348            let role = crate::rbac::current_role().unwrap_or_default();
349            let identity = crate::rbac::current_identity().unwrap_or_default();
350            let token = crate::rbac::current_token()
351                .unwrap_or_else(|| secrecy::SecretString::from(String::new()));
352            let sub = crate::rbac::current_sub().unwrap_or_default();
353            tokio::spawn(
354                async move {
355                    crate::rbac::with_rbac_scope(role, identity, token, sub, async move {
356                        let fut = (after.f)(&ctx, disposition, size);
357                        fut.await;
358                    })
359                    .await;
360                }
361                .instrument(span),
362            );
363        }
364    }
365}
366
367/// Internal newtype that owns the [`AfterHook`] so we can `Arc::clone`
368/// the *holder* and let the spawned task borrow `ctx` for the lifetime
369/// of the future without lifetime acrobatics in `tokio::spawn`.
370struct AfterHookHolder {
371    f: AfterHook,
372}
373
374/// Structured error body returned when a result exceeds `max_result_bytes`.
375///
376/// `actual` is `None` when the result could not be serialized, so its true
377/// size is unknown. It is rendered as `"unknown"` rather than a fabricated
378/// number -- operators read `actual_bytes` as a measurement.
379fn too_large_result(limit: usize, actual: Option<usize>, tool: &str) -> CallToolResult {
380    let actual_desc =
381        actual.map_or_else(|| "an unmeasurable number of".to_owned(), |n| n.to_string());
382    let body = serde_json::json!({
383        "error": "result_too_large",
384        "message": format!(
385            "tool '{tool}' result of {actual_desc} bytes exceeds the configured \
386             max_result_bytes={limit}; ask for a narrower query"
387        ),
388        "limit_bytes": limit,
389        "actual_bytes": actual.map_or_else(
390            || serde_json::Value::from("unknown"),
391            serde_json::Value::from,
392        ),
393    });
394    let mut r = CallToolResult::error(vec![ContentBlock::text(body.to_string())]);
395    r.structured_content = None;
396    r
397}
398
399/// Outcome of the `max_result_bytes` policy for a measured -- or
400/// unmeasurable -- result.
401#[derive(Debug, PartialEq, Eq)]
402enum SizeVerdict {
403    /// Within the cap, or no cap configured. Carries the measured size.
404    Pass { size: usize },
405    /// Over the cap, or unmeasurable while a cap is configured.
406    Replace { limit: usize, actual: Option<usize> },
407    /// Unmeasurable and no cap configured: nothing to enforce.
408    PassUnmeasured,
409}
410
411/// Decide what the size cap does, given an optional size-measurement outcome.
412const fn decide_size(size: Option<SizeMeasure>, max: Option<usize>) -> SizeVerdict {
413    match size {
414        Some(SizeMeasure::Exact(size)) => match max {
415            Some(limit) if size > limit => SizeVerdict::Replace {
416                limit,
417                actual: Some(size),
418            },
419            Some(_) | None => SizeVerdict::Pass { size },
420        },
421        Some(SizeMeasure::Exceeded { limit }) => SizeVerdict::Replace {
422            limit,
423            actual: None,
424        },
425        None => match max {
426            Some(limit) => SizeVerdict::Replace {
427                limit,
428                actual: None,
429            },
430            None => SizeVerdict::PassUnmeasured,
431        },
432    }
433}
434
435/// Apply the `max_result_bytes` cap to a result.  Returns the (possibly
436/// replaced) result, the size used for accounting, and whether the cap
437/// fired.
438fn apply_size_cap(
439    result: CallToolResult,
440    max: Option<usize>,
441    tool: &str,
442) -> (CallToolResult, usize, bool) {
443    let size = if max.is_some() {
444        Some(serialized_size(&result, max))
445    } else {
446        None
447    };
448    match decide_size(size, max) {
449        SizeVerdict::Pass { size } => (result, size, false),
450        SizeVerdict::PassUnmeasured => (result, 0, false),
451        SizeVerdict::Replace { limit, actual } => {
452            tracing::warn!(
453                tool = %tool,
454                size_bytes = actual.unwrap_or_default(),
455                size_measured = actual.is_some(),
456                limit_bytes = limit,
457                "tool result exceeds max_result_bytes; replacing with structured error"
458            );
459            let accounted = actual.unwrap_or_else(|| limit.saturating_add(1));
460            (too_large_result(limit, actual, tool), accounted, true)
461        }
462    }
463}
464
465#[allow(
466    deprecated,
467    reason = "transparent ServerHandler delegation must include legacy logging/subscription methods until rmcp removes them"
468)]
469impl<H: ServerHandler> ServerHandler for HookedHandler<H> {
470    async fn ping(&self, context: RequestContext<RoleServer>) -> Result<(), ErrorData> {
471        self.inner.ping(context).await
472    }
473
474    fn get_info(&self) -> ServerConfig {
475        self.inner.get_info()
476    }
477
478    async fn initialize(
479        &self,
480        request: InitializeRequestParams,
481        context: RequestContext<RoleServer>,
482    ) -> Result<InitializeResult, ErrorData> {
483        self.inner.initialize(request, context).await
484    }
485
486    async fn list_tools(
487        &self,
488        request: Option<PaginatedRequestParams>,
489        context: RequestContext<RoleServer>,
490    ) -> Result<ListToolsResult, ErrorData> {
491        self.inner.list_tools(request, context).await
492    }
493
494    async fn complete(
495        &self,
496        request: CompleteRequestParams,
497        context: RequestContext<RoleServer>,
498    ) -> Result<CompleteResult, ErrorData> {
499        self.inner.complete(request, context).await
500    }
501
502    async fn set_level(
503        &self,
504        request: SetLevelRequestParams,
505        context: RequestContext<RoleServer>,
506    ) -> Result<(), ErrorData> {
507        self.inner.set_level(request, context).await
508    }
509
510    fn get_tool(&self, name: &str) -> Option<Tool> {
511        self.inner.get_tool(name)
512    }
513
514    async fn list_prompts(
515        &self,
516        request: Option<PaginatedRequestParams>,
517        context: RequestContext<RoleServer>,
518    ) -> Result<ListPromptsResult, ErrorData> {
519        self.inner.list_prompts(request, context).await
520    }
521
522    async fn get_prompt(
523        &self,
524        request: GetPromptRequestParams,
525        context: RequestContext<RoleServer>,
526    ) -> Result<GetPromptResponse, ErrorData> {
527        self.inner.get_prompt(request, context).await
528    }
529
530    async fn list_resources(
531        &self,
532        request: Option<PaginatedRequestParams>,
533        context: RequestContext<RoleServer>,
534    ) -> Result<ListResourcesResult, ErrorData> {
535        self.inner.list_resources(request, context).await
536    }
537
538    async fn list_resource_templates(
539        &self,
540        request: Option<PaginatedRequestParams>,
541        context: RequestContext<RoleServer>,
542    ) -> Result<ListResourceTemplatesResult, ErrorData> {
543        self.inner.list_resource_templates(request, context).await
544    }
545
546    async fn read_resource(
547        &self,
548        request: ReadResourceRequestParams,
549        context: RequestContext<RoleServer>,
550    ) -> Result<ReadResourceResponse, ErrorData> {
551        self.inner.read_resource(request, context).await
552    }
553
554    // NOT cancel-safe: this awaits consumer-supplied before-hooks and the
555    // consumer's inner handler. After-hooks are dispatched only on the normal
556    // Deny/Replace/Ok/Err paths, so a cancellation between the before-hook and
557    // the response drops the paired after-hook -- an audit hook can therefore
558    // record a started call that is never closed out. Consumers needing
559    // guaranteed pairing should make the after-hook idempotent or run the tool
560    // body detached (see `crate::cancel`).
561    #[allow(
562        clippy::wildcard_enum_match_arm,
563        reason = "CallToolResponse is #[non_exhaustive]; the non-Complete MRTR variants (InputRequired/Task) are passed through unchanged"
564    )]
565    async fn call_tool(
566        &self,
567        request: CallToolRequestParams,
568        context: RequestContext<RoleServer>,
569    ) -> Result<CallToolResponse, ErrorData> {
570        let req_id = Some(format!("{:?}", context.id));
571        let ctx = Self::build_context(&request, req_id);
572        let max = self.hooks.max_result_bytes;
573        let after_holder = self
574            .hooks
575            .after
576            .as_ref()
577            .map(|f| Arc::new(AfterHookHolder { f: Arc::clone(f) }));
578
579        // Before hook: may Continue, Deny, or Replace.
580        if let Some(before) = self.hooks.before.as_ref() {
581            let outcome = before(&ctx).await;
582            match outcome {
583                HookOutcome::Continue => {}
584                HookOutcome::Deny(err) => {
585                    Self::spawn_after(after_holder.as_ref(), ctx, HookDisposition::DeniedBefore, 0);
586                    return Err(err);
587                }
588                HookOutcome::Replace(boxed) => {
589                    let (final_result, size, capped) = apply_size_cap(*boxed, max, &ctx.tool_name);
590                    let disposition = if capped {
591                        HookDisposition::ResultTooLarge
592                    } else {
593                        HookDisposition::ReplacedBefore
594                    };
595                    Self::spawn_after(after_holder.as_ref(), ctx, disposition, size);
596                    return Ok(final_result.into());
597                }
598            }
599        }
600
601        // Inner handler.
602        match self.inner.call_tool(request, context).await {
603            // Completed tool result: subject to the size cap + after hook.
604            Ok(CallToolResponse::Complete(result)) => {
605                let (final_result, size, capped) = apply_size_cap(result, max, &ctx.tool_name);
606                let disposition = if capped {
607                    HookDisposition::ResultTooLarge
608                } else {
609                    HookDisposition::InnerExecuted
610                };
611                Self::spawn_after(after_holder.as_ref(), ctx, disposition, size);
612                Ok(final_result.into())
613            }
614            // MRTR input-required / task responses (rmcp 3.0): no CallToolResult
615            // to size-cap, so pass them through unchanged.
616            Ok(other) => {
617                Self::spawn_after(
618                    after_holder.as_ref(),
619                    ctx,
620                    HookDisposition::InnerExecuted,
621                    0,
622                );
623                Ok(other)
624            }
625            Err(e) => {
626                Self::spawn_after(after_holder.as_ref(), ctx, HookDisposition::InnerErrored, 0);
627                Err(e)
628            }
629        }
630    }
631
632    // rmcp 3.0 added task/subscription/discovery request handlers with defaults;
633    // delegate them to `inner` so wrapping a handler that implements those stays
634    // transparent (otherwise the default would shadow the inner implementation).
635    fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
636        self.inner.supported_protocol_versions()
637    }
638
639    async fn discover(
640        &self,
641        context: RequestContext<RoleServer>,
642    ) -> Result<DiscoverResult, ErrorData> {
643        self.inner.discover(context).await
644    }
645
646    fn accepted_subscription_filter(
647        &self,
648        requested: &SubscriptionFilter,
649    ) -> Option<SubscriptionFilter> {
650        self.inner.accepted_subscription_filter(requested)
651    }
652
653    async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> {
654        self.inner.listen(context).await
655    }
656
657    async fn subscribe(
658        &self,
659        request: SubscribeRequestParams,
660        context: RequestContext<RoleServer>,
661    ) -> Result<(), ErrorData> {
662        self.inner.subscribe(request, context).await
663    }
664
665    async fn unsubscribe(
666        &self,
667        request: UnsubscribeRequestParams,
668        context: RequestContext<RoleServer>,
669    ) -> Result<(), ErrorData> {
670        self.inner.unsubscribe(request, context).await
671    }
672
673    async fn get_task(
674        &self,
675        request: GetTaskParams,
676        context: RequestContext<RoleServer>,
677    ) -> Result<GetTaskResult, ErrorData> {
678        self.inner.get_task(request, context).await
679    }
680
681    async fn update_task(
682        &self,
683        request: UpdateTaskParams,
684        context: RequestContext<RoleServer>,
685    ) -> Result<(), ErrorData> {
686        self.inner.update_task(request, context).await
687    }
688
689    async fn cancel_task(
690        &self,
691        request: CancelTaskParams,
692        context: RequestContext<RoleServer>,
693    ) -> Result<(), ErrorData> {
694        self.inner.cancel_task(request, context).await
695    }
696
697    async fn on_custom_request(
698        &self,
699        request: CustomRequest,
700        context: RequestContext<RoleServer>,
701    ) -> Result<CustomResult, ErrorData> {
702        self.inner.on_custom_request(request, context).await
703    }
704
705    async fn on_cancelled(
706        &self,
707        notification: CancelledNotificationParam,
708        context: NotificationContext<RoleServer>,
709    ) {
710        self.inner.on_cancelled(notification, context).await;
711    }
712
713    async fn on_progress(
714        &self,
715        notification: ProgressNotificationParam,
716        context: NotificationContext<RoleServer>,
717    ) {
718        self.inner.on_progress(notification, context).await;
719    }
720
721    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
722        self.inner.on_initialized(context).await;
723    }
724
725    async fn on_roots_list_changed(&self, context: NotificationContext<RoleServer>) {
726        self.inner.on_roots_list_changed(context).await;
727    }
728
729    async fn on_custom_notification(
730        &self,
731        notification: CustomNotification,
732        context: NotificationContext<RoleServer>,
733    ) {
734        self.inner
735            .on_custom_notification(notification, context)
736            .await;
737    }
738}
739
740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
741struct SizeLimitExceeded;
742
743impl fmt::Display for SizeLimitExceeded {
744    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
745        f.write_str("serialized result exceeded configured size cap")
746    }
747}
748
749impl std::error::Error for SizeLimitExceeded {}
750
751struct CountingWriter {
752    bytes: usize,
753    limit: Option<usize>,
754}
755
756impl CountingWriter {
757    const fn unbounded() -> Self {
758        Self {
759            bytes: 0,
760            limit: None,
761        }
762    }
763
764    const fn bounded(limit: usize) -> Self {
765        Self {
766            bytes: 0,
767            limit: Some(limit),
768        }
769    }
770}
771
772impl io::Write for CountingWriter {
773    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
774        let next = self.bytes.saturating_add(buf.len());
775        if self.limit.is_some_and(|limit| next > limit) {
776            Err(io::Error::other(SizeLimitExceeded))
777        } else {
778            self.bytes = next;
779            Ok(buf.len())
780        }
781    }
782
783    fn flush(&mut self) -> io::Result<()> {
784        Ok(())
785    }
786}
787
788/// Outcome of measuring serialized result size.
789#[derive(Debug, Clone, Copy, PartialEq, Eq)]
790enum SizeMeasure {
791    /// Exact serialized size in bytes.
792    Exact(usize),
793    /// Serialization crossed the configured size cap and stopped early.
794    Exceeded { limit: usize },
795}
796
797/// Serialized byte length, or a deliberate cap-abort outcome.
798fn serialized_size(result: &CallToolResult, max: Option<usize>) -> SizeMeasure {
799    let mut writer = max.map_or_else(CountingWriter::unbounded, CountingWriter::bounded);
800    match serde_json::to_writer(&mut writer, result) {
801        Ok(()) => SizeMeasure::Exact(writer.bytes),
802        Err(error) if error.io_error_kind() == Some(io::ErrorKind::Other) => {
803            SizeMeasure::Exceeded {
804                limit: max.unwrap_or(writer.bytes),
805            }
806        }
807        Err(_error) => {
808            // `CallToolResult` is made only of infallibly serializable fields
809            // (`String`, `bool`, arrays/maps, and serde_json::Value`). There is
810            // no inhabitable production value that can reach this branch.
811            SizeMeasure::Exact(writer.bytes)
812        }
813    }
814}
815
816#[cfg(test)]
817mod tests {
818    use std::sync::{
819        Arc,
820        atomic::{AtomicUsize, Ordering},
821    };
822
823    #[allow(
824        deprecated,
825        reason = "delegation tests cover legacy logging/subscription methods"
826    )]
827    use rmcp::{
828        ErrorData, RoleServer, ServerHandler,
829        model::{
830            CallToolRequestParams, CallToolResponse, CallToolResult, CancelledNotificationParam,
831            CompleteRequestParams, CompleteResult, CompletionInfo, ContentBlock,
832            CustomNotification, CustomRequest, CustomResult, ProgressNotificationParam,
833            ServerConfig, SetLevelRequestParams, SubscribeRequestParams, UnsubscribeRequestParams,
834        },
835        service::RequestContext,
836    };
837    use serde_json::json;
838    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
839
840    use super::*;
841
842    type DelegationTransport = (
843        DelegationProbe,
844        BufReader<tokio::io::ReadHalf<DuplexStream>>,
845        tokio::io::WriteHalf<DuplexStream>,
846        rmcp::service::RunningService<RoleServer, HookedHandler<DelegationProbe>>,
847    );
848
849    /// Maintenance aid only: rmcp gives every `ServerHandler` method a default,
850    /// so this count cannot guarantee compile-time completeness. It makes future
851    /// upstream method additions visible in review alongside the delegation impl.
852    const HOOKED_HANDLER_DELEGATED_METHODS: &[&str] = &[
853        "ping",
854        "initialize",
855        "supported_protocol_versions",
856        "discover",
857        "complete",
858        "set_level",
859        "get_prompt",
860        "list_prompts",
861        "list_resources",
862        "list_resource_templates",
863        "read_resource",
864        "accepted_subscription_filter",
865        "listen",
866        "subscribe",
867        "unsubscribe",
868        "call_tool",
869        "list_tools",
870        "get_tool",
871        "on_custom_request",
872        "on_cancelled",
873        "on_progress",
874        "on_initialized",
875        "on_roots_list_changed",
876        "on_custom_notification",
877        "get_info",
878        "get_task",
879        "update_task",
880        "cancel_task",
881    ];
882
883    #[derive(Clone, Default)]
884    struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
885
886    impl CapturedLogs {
887        fn contents(&self) -> String {
888            let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
889            String::from_utf8(bytes).unwrap_or_default()
890        }
891    }
892
893    struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
894
895    impl io::Write for CapturedLogsWriter {
896        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
897            if let Ok(mut guard) = self.0.lock() {
898                guard.extend_from_slice(buf);
899            }
900            Ok(buf.len())
901        }
902
903        fn flush(&mut self) -> io::Result<()> {
904            Ok(())
905        }
906    }
907
908    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
909        type Writer = CapturedLogsWriter;
910
911        fn make_writer(&'a self) -> Self::Writer {
912            CapturedLogsWriter(Arc::clone(&self.0))
913        }
914    }
915
916    /// Minimal in-process `ServerHandler` for tests.
917    #[derive(Clone, Default)]
918    struct TestHandler {
919        /// When Some, `call_tool` returns a body of this many 'x' bytes.
920        body_bytes: Option<usize>,
921    }
922
923    impl ServerHandler for TestHandler {
924        fn get_info(&self) -> ServerConfig {
925            ServerConfig::default()
926        }
927
928        #[allow(
929            clippy::unused_async_trait_impl,
930            reason = "async is mandated by the rmcp ServerHandler trait signature; this test handler does not await"
931        )]
932        async fn call_tool(
933            &self,
934            _request: CallToolRequestParams,
935            _context: RequestContext<RoleServer>,
936        ) -> Result<CallToolResponse, ErrorData> {
937            let body = "x".repeat(self.body_bytes.unwrap_or(4));
938            Ok(CallToolResult::success(vec![ContentBlock::text(body)]).into())
939        }
940    }
941
942    #[derive(Clone, Default)]
943    struct DelegationProbe {
944        seen: Arc<std::sync::Mutex<Vec<&'static str>>>,
945        notify: Arc<tokio::sync::Notify>,
946    }
947
948    impl DelegationProbe {
949        fn record(&self, method: &'static str) {
950            if let Ok(mut seen) = self.seen.lock() {
951                seen.push(method);
952            }
953            self.notify.notify_waiters();
954        }
955
956        fn seen(&self) -> Vec<&'static str> {
957            self.seen
958                .lock()
959                .map(|seen| seen.clone())
960                .unwrap_or_default()
961        }
962
963        async fn wait_for_seen_count(&self, count: usize) {
964            tokio::time::timeout(std::time::Duration::from_secs(1), async {
965                while self.seen().len() < count {
966                    self.notify.notified().await;
967                }
968            })
969            .await
970            .expect("delegated handler methods should be observed");
971        }
972    }
973
974    #[allow(
975        clippy::unused_async_trait_impl,
976        deprecated,
977        reason = "delegation tests cover rmcp async trait methods whose probe implementations return immediately"
978    )]
979    impl ServerHandler for DelegationProbe {
980        fn get_info(&self) -> ServerConfig {
981            ServerConfig::default()
982        }
983
984        async fn ping(&self, _context: RequestContext<RoleServer>) -> Result<(), ErrorData> {
985            self.record("ping");
986            Ok(())
987        }
988
989        async fn complete(
990            &self,
991            _request: CompleteRequestParams,
992            _context: RequestContext<RoleServer>,
993        ) -> Result<CompleteResult, ErrorData> {
994            self.record("complete");
995            let completion = CompletionInfo::with_all_values(vec!["delegated".to_owned()])
996                .expect("single completion is within rmcp max");
997            Ok(CompleteResult::new(completion))
998        }
999
1000        async fn set_level(
1001            &self,
1002            _request: SetLevelRequestParams,
1003            _context: RequestContext<RoleServer>,
1004        ) -> Result<(), ErrorData> {
1005            self.record("set_level");
1006            Ok(())
1007        }
1008
1009        async fn subscribe(
1010            &self,
1011            _request: SubscribeRequestParams,
1012            _context: RequestContext<RoleServer>,
1013        ) -> Result<(), ErrorData> {
1014            self.record("subscribe");
1015            Ok(())
1016        }
1017
1018        async fn unsubscribe(
1019            &self,
1020            _request: UnsubscribeRequestParams,
1021            _context: RequestContext<RoleServer>,
1022        ) -> Result<(), ErrorData> {
1023            self.record("unsubscribe");
1024            Ok(())
1025        }
1026
1027        async fn call_tool(
1028            &self,
1029            _request: CallToolRequestParams,
1030            _context: RequestContext<RoleServer>,
1031        ) -> Result<CallToolResponse, ErrorData> {
1032            self.record("call_tool");
1033            Ok(CallToolResult::success(vec![ContentBlock::text("inner")]).into())
1034        }
1035
1036        async fn on_custom_request(
1037            &self,
1038            _request: CustomRequest,
1039            _context: RequestContext<RoleServer>,
1040        ) -> Result<CustomResult, ErrorData> {
1041            self.record("on_custom_request");
1042            Ok(CustomResult::new(json!({ "delegated": true })))
1043        }
1044
1045        async fn on_cancelled(
1046            &self,
1047            _notification: CancelledNotificationParam,
1048            _context: NotificationContext<RoleServer>,
1049        ) {
1050            self.record("on_cancelled");
1051        }
1052
1053        async fn on_progress(
1054            &self,
1055            _notification: ProgressNotificationParam,
1056            _context: NotificationContext<RoleServer>,
1057        ) {
1058            self.record("on_progress");
1059        }
1060
1061        async fn on_initialized(&self, _context: NotificationContext<RoleServer>) {
1062            self.record("on_initialized");
1063        }
1064
1065        async fn on_roots_list_changed(&self, _context: NotificationContext<RoleServer>) {
1066            self.record("on_roots_list_changed");
1067        }
1068
1069        async fn on_custom_notification(
1070            &self,
1071            _notification: CustomNotification,
1072            _context: NotificationContext<RoleServer>,
1073        ) {
1074            self.record("on_custom_notification");
1075        }
1076    }
1077
1078    fn delegation_transport(probe: DelegationProbe, hooks: Arc<ToolHooks>) -> DelegationTransport {
1079        let (client, server) = tokio::io::duplex(16 * 1024);
1080        let (client_read, client_write) = tokio::io::split(client);
1081        let service = rmcp::service::serve_directly::<RoleServer, _, _, io::Error, _>(
1082            with_hooks(probe.clone(), hooks),
1083            server,
1084            None,
1085        );
1086        (probe, BufReader::new(client_read), client_write, service)
1087    }
1088
1089    async fn send_json_rpc(
1090        writer: &mut tokio::io::WriteHalf<DuplexStream>,
1091        reader: &mut BufReader<tokio::io::ReadHalf<DuplexStream>>,
1092        request: serde_json::Value,
1093    ) -> serde_json::Value {
1094        writer
1095            .write_all(request.to_string().as_bytes())
1096            .await
1097            .expect("write request");
1098        writer.write_all(b"\n").await.expect("write newline");
1099        writer.flush().await.expect("flush request");
1100
1101        let mut line = String::new();
1102        reader.read_line(&mut line).await.expect("read response");
1103        serde_json::from_str(&line).expect("response is JSON")
1104    }
1105
1106    async fn send_notification(
1107        writer: &mut tokio::io::WriteHalf<DuplexStream>,
1108        notification: serde_json::Value,
1109    ) {
1110        writer
1111            .write_all(notification.to_string().as_bytes())
1112            .await
1113            .expect("write notification");
1114        writer.write_all(b"\n").await.expect("write newline");
1115        writer.flush().await.expect("flush notification");
1116    }
1117
1118    #[test]
1119    fn hooked_handler_delegation_count_is_maintenance_aid() {
1120        assert_eq!(
1121            HOOKED_HANDLER_DELEGATED_METHODS.len(),
1122            28,
1123            "maintenance aid only: update this list and the HookedHandler impl when rmcp adds ServerHandler methods"
1124        );
1125    }
1126
1127    #[tokio::test]
1128    async fn hooked_handler_delegates_ping() {
1129        let (probe, mut reader, mut writer, _service) =
1130            delegation_transport(DelegationProbe::default(), Arc::new(ToolHooks::new()));
1131
1132        let response = send_json_rpc(
1133            &mut writer,
1134            &mut reader,
1135            json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }),
1136        )
1137        .await;
1138
1139        assert_eq!(response["result"], json!({}));
1140        assert_eq!(probe.seen(), vec!["ping"]);
1141    }
1142
1143    #[tokio::test]
1144    async fn hooked_handler_delegates_notifications() {
1145        let (probe, _reader, mut writer, _service) =
1146            delegation_transport(DelegationProbe::default(), Arc::new(ToolHooks::new()));
1147
1148        send_notification(
1149            &mut writer,
1150            json!({
1151                "jsonrpc": "2.0",
1152                "method": "notifications/cancelled",
1153                "params": { "requestId": 1, "reason": "test" }
1154            }),
1155        )
1156        .await;
1157        send_notification(
1158            &mut writer,
1159            json!({
1160                "jsonrpc": "2.0",
1161                "method": "notifications/progress",
1162                "params": { "progressToken": 1, "progress": 0.5 }
1163            }),
1164        )
1165        .await;
1166        send_notification(
1167            &mut writer,
1168            json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }),
1169        )
1170        .await;
1171        send_notification(
1172            &mut writer,
1173            json!({ "jsonrpc": "2.0", "method": "notifications/roots/list_changed" }),
1174        )
1175        .await;
1176        send_notification(
1177            &mut writer,
1178            json!({ "jsonrpc": "2.0", "method": "notifications/custom/probe" }),
1179        )
1180        .await;
1181
1182        probe.wait_for_seen_count(5).await;
1183        assert_eq!(
1184            probe.seen(),
1185            vec![
1186                "on_cancelled",
1187                "on_progress",
1188                "on_initialized",
1189                "on_roots_list_changed",
1190                "on_custom_notification"
1191            ]
1192        );
1193    }
1194
1195    #[tokio::test]
1196    #[allow(
1197        deprecated,
1198        reason = "set_level is deprecated by rmcp but must delegate"
1199    )]
1200    async fn hooked_handler_delegates_completion_and_level() {
1201        let (probe, mut reader, mut writer, _service) =
1202            delegation_transport(DelegationProbe::default(), Arc::new(ToolHooks::new()));
1203
1204        let completion = send_json_rpc(
1205            &mut writer,
1206            &mut reader,
1207            json!({
1208                "jsonrpc": "2.0",
1209                "id": 1,
1210                "method": "completion/complete",
1211                "params": {
1212                    "ref": { "type": "ref/prompt", "name": "prompt" },
1213                    "argument": { "name": "arg", "value": "de" }
1214                }
1215            }),
1216        )
1217        .await;
1218        let level = send_json_rpc(
1219            &mut writer,
1220            &mut reader,
1221            json!({
1222                "jsonrpc": "2.0",
1223                "id": 2,
1224                "method": "logging/setLevel",
1225                "params": { "level": "debug" }
1226            }),
1227        )
1228        .await;
1229
1230        assert_eq!(
1231            completion["result"]["completion"]["values"],
1232            json!(["delegated"])
1233        );
1234        assert_eq!(level["result"], json!({}));
1235        assert_eq!(probe.seen(), vec!["complete", "set_level"]);
1236    }
1237
1238    #[tokio::test]
1239    #[allow(
1240        deprecated,
1241        reason = "subscribe/unsubscribe are deprecated by rmcp but must delegate"
1242    )]
1243    async fn hooked_handler_delegates_subscriptions() {
1244        let (probe, mut reader, mut writer, _service) =
1245            delegation_transport(DelegationProbe::default(), Arc::new(ToolHooks::new()));
1246
1247        let subscribe = send_json_rpc(
1248            &mut writer,
1249            &mut reader,
1250            json!({
1251                "jsonrpc": "2.0",
1252                "id": 1,
1253                "method": "resources/subscribe",
1254                "params": { "uri": "file:///tmp/a" }
1255            }),
1256        )
1257        .await;
1258        let unsubscribe = send_json_rpc(
1259            &mut writer,
1260            &mut reader,
1261            json!({
1262                "jsonrpc": "2.0",
1263                "id": 2,
1264                "method": "resources/unsubscribe",
1265                "params": { "uri": "file:///tmp/a" }
1266            }),
1267        )
1268        .await;
1269
1270        assert_eq!(subscribe["result"], json!({}));
1271        assert_eq!(unsubscribe["result"], json!({}));
1272        assert_eq!(probe.seen(), vec!["subscribe", "unsubscribe"]);
1273    }
1274
1275    #[tokio::test]
1276    async fn hooked_handler_delegates_custom_request() {
1277        let (probe, mut reader, mut writer, _service) =
1278            delegation_transport(DelegationProbe::default(), Arc::new(ToolHooks::new()));
1279
1280        let response = send_json_rpc(
1281            &mut writer,
1282            &mut reader,
1283            json!({
1284                "jsonrpc": "2.0",
1285                "id": 1,
1286                "method": "requests/custom/probe",
1287                "params": { "x": true }
1288            }),
1289        )
1290        .await;
1291
1292        assert_eq!(response["result"], json!({ "delegated": true }));
1293        assert_eq!(probe.seen(), vec!["on_custom_request"]);
1294    }
1295
1296    #[tokio::test]
1297    async fn hooked_handler_still_applies_hooks_to_call_tool() {
1298        let before_count = Arc::new(AtomicUsize::new(0));
1299        let before_seen = Arc::clone(&before_count);
1300        let before: BeforeHook = Arc::new(move |_ctx| {
1301            let before_seen = Arc::clone(&before_seen);
1302            Box::pin(async move {
1303                before_seen.fetch_add(1, Ordering::Relaxed);
1304                HookOutcome::Continue
1305            })
1306        });
1307        let after_count = Arc::new(AtomicUsize::new(0));
1308        let after_seen = Arc::clone(&after_count);
1309        let after_notify = Arc::new(tokio::sync::Notify::new());
1310        let after_notify_seen = Arc::clone(&after_notify);
1311        let after: AfterHook = Arc::new(move |_ctx, _disp, _size| {
1312            let after_seen = Arc::clone(&after_seen);
1313            let after_notify_seen = Arc::clone(&after_notify_seen);
1314            Box::pin(async move {
1315                after_seen.fetch_add(1, Ordering::Relaxed);
1316                after_notify_seen.notify_waiters();
1317            })
1318        });
1319        let hooks = Arc::new(
1320            ToolHooks::new()
1321                .with_before(before)
1322                .with_after(after)
1323                .with_max_result_bytes(1024),
1324        );
1325        let (probe, mut reader, mut writer, _service) =
1326            delegation_transport(DelegationProbe::default(), hooks);
1327
1328        let response = send_json_rpc(
1329            &mut writer,
1330            &mut reader,
1331            json!({
1332                "jsonrpc": "2.0",
1333                "id": 1,
1334                "method": "tools/call",
1335                "params": { "name": "probe", "arguments": {} }
1336            }),
1337        )
1338        .await;
1339
1340        tokio::time::timeout(std::time::Duration::from_secs(1), async {
1341            while after_count.load(Ordering::Relaxed) == 0 {
1342                after_notify.notified().await;
1343            }
1344        })
1345        .await
1346        .expect("after hook should run");
1347        assert_eq!(response["result"]["content"][0]["text"], "inner");
1348        assert_eq!(probe.seen(), vec!["call_tool"]);
1349        assert_eq!(before_count.load(Ordering::Relaxed), 1);
1350        assert_eq!(after_count.load(Ordering::Relaxed), 1);
1351    }
1352
1353    fn ctx(name: &str) -> ToolCallContext {
1354        ToolCallContext {
1355            tool_name: name.to_owned(),
1356            arguments: None,
1357            identity: None,
1358            role: None,
1359            sub: None,
1360            request_id: None,
1361        }
1362    }
1363
1364    fn sensitive_ctx() -> ToolCallContext {
1365        ToolCallContext {
1366            tool_name: "safe-tool-name".to_owned(),
1367            arguments: Some(serde_json::json!({ "password": "argument-secret" })),
1368            identity: Some("identity-secret".to_owned()),
1369            role: Some("role-secret".to_owned()),
1370            sub: Some("sub-secret".to_owned()),
1371            request_id: Some("request-id-visible".to_owned()),
1372        }
1373    }
1374
1375    #[test]
1376    fn tool_call_context_debug_redacts_sensitive_fields_by_default() {
1377        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
1378        crate::diagnostics::set_diagnostic_exposure(
1379            &crate::diagnostics::DiagnosticExposure::default(),
1380        );
1381
1382        let rendered = format!("{:?}", sensitive_ctx());
1383
1384        assert!(rendered.contains("safe-tool-name"));
1385        assert!(rendered.contains("request-id-visible"));
1386        assert!(rendered.contains("[REDACTED]"));
1387        for secret in [
1388            "argument-secret",
1389            "identity-secret",
1390            "role-secret",
1391            "sub-secret",
1392        ] {
1393            assert!(
1394                !rendered.contains(secret),
1395                "ToolCallContext Debug must not contain {secret}: {rendered}"
1396            );
1397        }
1398    }
1399
1400    #[test]
1401    fn tool_call_context_debug_can_show_sensitive_fields_when_enabled() {
1402        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
1403        crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
1404            tool_call_arguments: true,
1405            ..crate::diagnostics::DiagnosticExposure::default()
1406        });
1407
1408        let rendered = format!("{:?}", sensitive_ctx());
1409
1410        for secret in [
1411            "argument-secret",
1412            "identity-secret",
1413            "role-secret",
1414            "sub-secret",
1415        ] {
1416            assert!(
1417                rendered.contains(secret),
1418                "ToolCallContext Debug must contain {secret} when enabled: {rendered}"
1419            );
1420        }
1421    }
1422
1423    #[tokio::test]
1424    async fn size_cap_replaces_oversized_result() {
1425        let inner = TestHandler {
1426            body_bytes: Some(8_192),
1427        };
1428        let hooks = Arc::new(ToolHooks {
1429            max_result_bytes: Some(256),
1430            before: None,
1431            after: None,
1432        });
1433        let hooked = with_hooks(inner, hooks);
1434
1435        let small = CallToolResult::success(vec![ContentBlock::text("ok".to_owned())]);
1436        assert!(exact_size(&small) < 256);
1437
1438        let big = CallToolResult::success(vec![ContentBlock::text("x".repeat(8_192))]);
1439        let size = exact_size(&big);
1440        assert!(size > 256);
1441
1442        let (replaced, accounted, capped) = apply_size_cap(big, Some(256), "whatever");
1443        assert!(capped);
1444        assert_eq!(accounted, 257);
1445        assert_eq!(replaced.is_error, Some(true));
1446        assert!(matches!(
1447            replaced.content.first(),
1448            Some(rmcp::model::ContentBlock::Text(t)) if t.text.contains("result_too_large")
1449        ));
1450
1451        // Compile-check that HookedHandler instantiates with the test inner.
1452        let _ = hooked;
1453    }
1454
1455    fn exact_size(result: &CallToolResult) -> usize {
1456        match serialized_size(result, None) {
1457            SizeMeasure::Exact(size) => size,
1458            SizeMeasure::Exceeded { limit } => {
1459                panic!("unbounded measurement exceeded impossible limit {limit}");
1460            }
1461        }
1462    }
1463
1464    #[test]
1465    fn serialized_size_under_cap_is_exact() {
1466        let result = CallToolResult::success(vec![ContentBlock::text("ok".to_owned())]);
1467        let exact = serde_json::to_vec(&result).unwrap().len();
1468
1469        let measured = serialized_size(&result, Some(exact));
1470
1471        assert_eq!(measured, SizeMeasure::Exact(exact));
1472    }
1473
1474    #[test]
1475    fn serialized_size_over_cap_stops_with_exceeded() {
1476        let result = CallToolResult::success(vec![ContentBlock::text("x".repeat(8_192))]);
1477
1478        let measured = serialized_size(&result, Some(256));
1479
1480        assert_eq!(measured, SizeMeasure::Exceeded { limit: 256 });
1481    }
1482
1483    #[test]
1484    fn over_cap_replacement_does_not_log_serialization_failure() {
1485        let logs = CapturedLogs::default();
1486        let subscriber = tracing_subscriber::fmt()
1487            .with_max_level(tracing::Level::TRACE)
1488            .with_writer(logs.clone())
1489            .with_ansi(false)
1490            .without_time()
1491            .finish();
1492        let _guard = tracing::subscriber::set_default(subscriber);
1493        let result = CallToolResult::success(vec![ContentBlock::text("x".repeat(8_192))]);
1494
1495        let (_final_result, accounted, capped) = apply_size_cap(result, Some(256), "big_tool");
1496
1497        assert!(capped);
1498        assert_eq!(accounted, 257);
1499        assert!(
1500            logs.contents()
1501                .contains("tool result exceeds max_result_bytes")
1502        );
1503        assert!(
1504            !logs.contents().contains("failed to serialize"),
1505            "cap-abort must not be logged as serialization failure: {}",
1506            logs.contents()
1507        );
1508    }
1509
1510    #[test]
1511    fn disabled_result_cap_skips_measurement() {
1512        let result = CallToolResult::success(vec![ContentBlock::text("x".repeat(8_192))]);
1513
1514        let (_final_result, accounted, capped) = apply_size_cap(result, None, "uncapped_tool");
1515
1516        assert!(!capped);
1517        assert_eq!(accounted, 0);
1518    }
1519
1520    #[tokio::test]
1521    async fn before_hook_deny_builds_error() {
1522        let counter = Arc::new(AtomicUsize::new(0));
1523        let c = Arc::clone(&counter);
1524        let before: BeforeHook = Arc::new(move |ctx_ref| {
1525            let c = Arc::clone(&c);
1526            let name = ctx_ref.tool_name.clone();
1527            Box::pin(async move {
1528                c.fetch_add(1, Ordering::Relaxed);
1529                if name == "forbidden" {
1530                    HookOutcome::Deny(ErrorData::invalid_request("nope", None))
1531                } else {
1532                    HookOutcome::Continue
1533                }
1534            })
1535        });
1536
1537        let hooks = Arc::new(ToolHooks {
1538            max_result_bytes: None,
1539            before: Some(before),
1540            after: None,
1541        });
1542        let hooked = with_hooks(TestHandler::default(), hooks);
1543
1544        let bad_ctx = ctx("forbidden");
1545        let before_fn = hooked.hooks.before.as_ref().unwrap();
1546        let outcome = before_fn(&bad_ctx).await;
1547        assert!(matches!(outcome, HookOutcome::Deny(_)));
1548        assert_eq!(counter.load(Ordering::Relaxed), 1);
1549
1550        let ok_ctx = ctx("allowed");
1551        let outcome2 = before_fn(&ok_ctx).await;
1552        assert!(matches!(outcome2, HookOutcome::Continue));
1553        assert_eq!(counter.load(Ordering::Relaxed), 2);
1554    }
1555
1556    #[test]
1557    fn too_large_result_mentions_limit_and_actual() {
1558        let r = too_large_result(100, Some(500), "my_tool");
1559        let body = serde_json::to_string(&r).unwrap();
1560        assert!(body.contains("result_too_large"));
1561        assert!(body.contains("my_tool"));
1562        assert!(body.contains("100"));
1563        assert!(body.contains("500"));
1564    }
1565
1566    #[test]
1567    fn decide_size_truth_table() {
1568        assert_eq!(
1569            decide_size(Some(SizeMeasure::Exact(10)), Some(100)),
1570            SizeVerdict::Pass { size: 10 }
1571        );
1572        assert_eq!(
1573            decide_size(Some(SizeMeasure::Exact(100)), Some(100)),
1574            SizeVerdict::Pass { size: 100 },
1575            "cap is inclusive: size == limit passes"
1576        );
1577        assert_eq!(
1578            decide_size(Some(SizeMeasure::Exact(101)), Some(100)),
1579            SizeVerdict::Replace {
1580                limit: 100,
1581                actual: Some(101)
1582            }
1583        );
1584        assert_eq!(
1585            decide_size(Some(SizeMeasure::Exact(999)), None),
1586            SizeVerdict::Pass { size: 999 }
1587        );
1588        assert_eq!(
1589            decide_size(None, Some(100)),
1590            SizeVerdict::Replace {
1591                limit: 100,
1592                actual: None
1593            },
1594            "unmeasurable result must fail closed when a cap is configured"
1595        );
1596        assert_eq!(decide_size(None, None), SizeVerdict::PassUnmeasured);
1597        assert_eq!(
1598            decide_size(Some(SizeMeasure::Exceeded { limit: 100 }), Some(100)),
1599            SizeVerdict::Replace {
1600                limit: 100,
1601                actual: None
1602            },
1603            "cap-abort is not an exact measurement"
1604        );
1605    }
1606
1607    #[test]
1608    fn too_large_result_does_not_fabricate_a_size_when_unmeasurable() {
1609        let r = too_large_result(100, None, "my_tool");
1610        let body = serde_json::to_string(&r).unwrap();
1611        assert!(body.contains("result_too_large"));
1612        assert!(body.contains("unknown"));
1613        assert!(
1614            !body.contains("101"),
1615            "the over-limit accounting sentinel must not leak into the client payload"
1616        );
1617    }
1618
1619    #[tokio::test]
1620    async fn replace_outcome_skips_inner_and_returns_payload() {
1621        // Returning Replace from before-hook must yield the supplied
1622        // CallToolResult directly, with no need for the inner handler.
1623        let before: BeforeHook = Arc::new(|_ctx| {
1624            Box::pin(async {
1625                HookOutcome::Replace(Box::new(CallToolResult::success(vec![ContentBlock::text(
1626                    "from-replace".to_owned(),
1627                )])))
1628            })
1629        });
1630        let hooks = Arc::new(ToolHooks {
1631            max_result_bytes: None,
1632            before: Some(before),
1633            after: None,
1634        });
1635        let _hooked = with_hooks(TestHandler::default(), Arc::clone(&hooks));
1636
1637        // Exercise the before-hook closure + apply_size_cap helper directly,
1638        // matching the established test pattern in this module.
1639        let outcome = (hooks.before.as_ref().unwrap())(&ctx("any")).await;
1640        let HookOutcome::Replace(boxed) = outcome else {
1641            panic!("expected HookOutcome::Replace");
1642        };
1643        let (result, size, capped) = apply_size_cap(*boxed, None, "any");
1644        assert!(!capped);
1645        assert_eq!(size, 0);
1646        assert!(!result.is_error.unwrap_or(false));
1647        assert!(matches!(
1648            result.content.first(),
1649            Some(rmcp::model::ContentBlock::Text(t)) if t.text == "from-replace"
1650        ));
1651    }
1652
1653    #[tokio::test]
1654    async fn replace_outcome_subject_to_size_cap() {
1655        // A Replace payload that exceeds max_result_bytes must be rewritten
1656        // to result_too_large just like an inner-handler result would be,
1657        // and the disposition must reflect ResultTooLarge.
1658        let huge = CallToolResult::success(vec![ContentBlock::text("y".repeat(8_192))]);
1659        let huge_size = serde_json::to_vec(&huge).unwrap().len();
1660        assert!(huge_size > 256);
1661
1662        let (final_result, accounted, capped) = apply_size_cap(huge, Some(256), "replaced_tool");
1663        assert!(capped);
1664        assert_eq!(accounted, 257);
1665        assert_eq!(final_result.is_error, Some(true));
1666        assert!(matches!(
1667            final_result.content.first(),
1668            Some(rmcp::model::ContentBlock::Text(t)) if t.text.contains("result_too_large")
1669        ));
1670    }
1671
1672    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1673    async fn after_hook_fires_exactly_once_via_spawn() {
1674        // spawn_after must enqueue the after-hook exactly one time per
1675        // invocation and never block the caller; we wait for the spawned
1676        // task to run by polling the counter with a short timeout.
1677        let counter = Arc::new(AtomicUsize::new(0));
1678        let c = Arc::clone(&counter);
1679        let after: AfterHook = Arc::new(move |_ctx, _disp, _size| {
1680            let c = Arc::clone(&c);
1681            Box::pin(async move {
1682                c.fetch_add(1, Ordering::Relaxed);
1683            })
1684        });
1685        let holder = Arc::new(AfterHookHolder { f: after });
1686
1687        HookedHandler::<TestHandler>::spawn_after(
1688            Some(&holder),
1689            ctx("t"),
1690            HookDisposition::InnerExecuted,
1691            42,
1692        );
1693
1694        // Wait up to 1s for the spawned task to run.
1695        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
1696        while counter.load(Ordering::Relaxed) == 0 && std::time::Instant::now() < deadline {
1697            tokio::task::yield_now().await;
1698            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1699        }
1700        assert_eq!(counter.load(Ordering::Relaxed), 1);
1701    }
1702
1703    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1704    async fn after_hook_panic_is_isolated_from_response_path() {
1705        // A panicking after-hook must not affect the request task.  We
1706        // spawn a panicking after-hook and then verify the current task
1707        // can still complete an unrelated future to completion.
1708        let after: AfterHook = Arc::new(|_ctx, _disp, _size| {
1709            Box::pin(async {
1710                panic!("intentional panic in after-hook");
1711            })
1712        });
1713        let holder = Arc::new(AfterHookHolder { f: after });
1714
1715        HookedHandler::<TestHandler>::spawn_after(
1716            Some(&holder),
1717            ctx("boom"),
1718            HookDisposition::InnerExecuted,
1719            0,
1720        );
1721
1722        // Give Tokio a chance to run + abort the panicking task, then
1723        // confirm we're still alive and the runtime is healthy.
1724        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1725        let still_alive = tokio::spawn(async { 1_u32 + 2 }).await.unwrap();
1726        assert_eq!(still_alive, 3);
1727    }
1728}