1use 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#[derive(Clone)]
74#[non_exhaustive]
75pub struct ToolCallContext {
76 pub tool_name: String,
78 pub arguments: Option<serde_json::Value>,
80 pub identity: Option<String>,
82 pub role: Option<String>,
84 pub sub: Option<String>,
86 pub request_id: Option<String>,
88}
89
90impl ToolCallContext {
91 #[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#[derive(Debug)]
146#[non_exhaustive]
147pub enum HookOutcome {
148 Continue,
150 Deny(ErrorData),
152 Replace(Box<CallToolResult>),
154}
155
156#[derive(Debug, Clone, Copy)]
158#[non_exhaustive]
159pub enum HookDisposition {
160 InnerExecuted,
162 InnerErrored,
164 DeniedBefore,
166 ReplacedBefore,
168 ResultTooLarge,
171}
172
173pub 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
186pub 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#[allow(clippy::struct_field_names, reason = "before/after read naturally")]
206#[derive(Clone, Default)]
207#[non_exhaustive]
208pub struct ToolHooks {
209 pub max_result_bytes: Option<usize>,
214 pub before: Option<BeforeHook>,
217 pub after: Option<AfterHook>,
227}
228
229impl ToolHooks {
230 #[must_use]
236 pub fn new() -> Self {
237 Self::default()
238 }
239
240 #[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 #[must_use]
249 pub fn with_before(mut self, before: BeforeHook) -> Self {
250 self.before = Some(before);
251 self
252 }
253
254 #[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#[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#[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 #[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 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 let span = tracing::Span::current();
345 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
367struct AfterHookHolder {
371 f: AfterHook,
372}
373
374fn 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#[derive(Debug, PartialEq, Eq)]
402enum SizeVerdict {
403 Pass { size: usize },
405 Replace { limit: usize, actual: Option<usize> },
407 PassUnmeasured,
409}
410
411const 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
435fn 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 #[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 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 match self.inner.call_tool(request, context).await {
603 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
790enum SizeMeasure {
791 Exact(usize),
793 Exceeded { limit: usize },
795}
796
797fn 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 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 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 #[derive(Clone, Default)]
918 struct TestHandler {
919 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 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 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 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 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 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 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 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 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}