1use 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#[derive(Clone)]
56#[non_exhaustive]
57pub struct ToolCallContext {
58 pub tool_name: String,
60 pub arguments: Option<serde_json::Value>,
62 pub identity: Option<String>,
64 pub role: Option<String>,
66 pub sub: Option<String>,
68 pub request_id: Option<String>,
70}
71
72impl ToolCallContext {
73 #[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#[derive(Debug)]
128#[non_exhaustive]
129pub enum HookOutcome {
130 Continue,
132 Deny(ErrorData),
134 Replace(Box<CallToolResult>),
136}
137
138#[derive(Debug, Clone, Copy)]
140#[non_exhaustive]
141pub enum HookDisposition {
142 InnerExecuted,
144 InnerErrored,
146 DeniedBefore,
148 ReplacedBefore,
150 ResultTooLarge,
153}
154
155pub 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
168pub 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#[allow(clippy::struct_field_names, reason = "before/after read naturally")]
188#[derive(Clone, Default)]
189#[non_exhaustive]
190pub struct ToolHooks {
191 pub max_result_bytes: Option<usize>,
196 pub before: Option<BeforeHook>,
199 pub after: Option<AfterHook>,
203}
204
205impl ToolHooks {
206 #[must_use]
212 pub fn new() -> Self {
213 Self::default()
214 }
215
216 #[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 #[must_use]
225 pub fn with_before(mut self, before: BeforeHook) -> Self {
226 self.before = Some(before);
227 self
228 }
229
230 #[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#[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#[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 #[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 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 let span = tracing::Span::current();
321 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
343struct AfterHookHolder {
347 f: AfterHook,
348}
349
350fn 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#[derive(Debug, PartialEq, Eq)]
378enum SizeVerdict {
379 Pass { size: usize },
381 Replace { limit: usize, actual: Option<usize> },
383 PassUnmeasured,
385}
386
387const 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
411fn 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 #[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 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 match self.inner.call_tool(request, context).await {
555 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684enum SizeMeasure {
685 Exact(usize),
687 Exceeded { limit: usize },
689}
690
691fn 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 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 #[derive(Clone, Default)]
762 struct TestHandler {
763 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 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 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 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 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 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 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 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 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}