2929//! `structuredContent` fails `outputSchema.safeParse`). Pushes a
3030//! human-readable summary `Content::text` alongside the `Json<T>` structured
3131//! value.
32+ //! - `normalize_call_tool_result` - silently strips `structuredContent` when
33+ //! it serializes to an empty object (`{}`). Wired into `ToolRouter::call`
34+ //! so every tool routed through `#[tool_router]` is normalized
35+ //! automatically. Lets Claude Code surface `content[]` extras
36+ //! (`resource_link` / `image` / summary text) for tools whose response
37+ //! struct serializes to `{}` (full-viewport screenshots, artifact-only
38+ //! downloads with `inline=false`, count-only thumbnail tools, etc.) —
39+ //! Claude Code shadows every block in `content[]` whenever
40+ //! `structuredContent` is set, even when empty.
3241//! - `lint::warn_if_over_2kb` - pure `tracing::warn!` helper catching Claude
3342//! Code's silent 2 KB truncation of tool descriptions and server
3443//! `instructions`.
@@ -435,6 +444,17 @@ impl Peer<RoleServer> {
435444/// `text_summary` receives a borrow of the value so callers can render fields
436445/// without cloning.
437446///
447+ /// # Empty-structured normalization
448+ ///
449+ /// When `value` serializes to an empty object `{}` (e.g. a response struct
450+ /// whose only fields are `Option<...>` skipped via `skip_serializing_if`),
451+ /// the resulting `structuredContent: {}` would shadow every extra block
452+ /// pushed onto `content[]` afterward (`resource_link`, `image`, summary text)
453+ /// on Claude Code — see [`normalize_call_tool_result`]. The normalizer is
454+ /// wired into `ToolRouter::call` so every tool routed through
455+ /// `#[tool_router]` is stripped automatically; you do not need to hand-strip
456+ /// empties at call sites.
457+ ///
438458/// # Example
439459///
440460/// ```ignore
@@ -460,6 +480,48 @@ where
460480 Ok ( result)
461481}
462482
483+ // ---------------------------------------------------------------------------
484+ // CallToolResult normalization (`normalize_call_tool_result`)
485+ // ---------------------------------------------------------------------------
486+
487+ /// Silently strip `structuredContent` when it serializes to an empty object
488+ /// `{}`.
489+ ///
490+ /// Claude Code surfaces only `structuredContent` to the model when set, even
491+ /// when `{}`. Every other block in `content[]` (`resource_link`, `image`,
492+ /// human-readable summary text) gets dropped from the model-visible payload.
493+ /// Dropping the redundant `{}` envelope unblocks the model seeing the extras
494+ /// callers pushed onto `content[]`. The serialized JSON dump auto-pushed
495+ /// onto `content[0]` by [`Json<T>::into_call_tool_result`] already carries
496+ /// the same payload, so this normalization is information-preserving.
497+ ///
498+ /// Covers every tool whose response struct serializes to `{}` —
499+ /// full-viewport screenshots, artifact-only downloads with `inline=false`,
500+ /// count-only thumbnail tools, etc. The populated-structured + extras case
501+ /// (someone adds a populated field to a response struct that also pushes a
502+ /// `resource_link` / `image`) is silently left alone here; treat it as a
503+ /// per-tool design constraint instead — keep response structs empty when
504+ /// the call site pushes extras onto `content[]`, or surface the structured
505+ /// field via a `Content::text` block.
506+ ///
507+ /// Wired into [`ToolRouter::call`](crate::handler::server::tool::ToolRouter)
508+ /// so every tool routed through `#[tool_router]` is normalized
509+ /// automatically. Tool authors do not call this directly.
510+ ///
511+ /// See <https://github.com/anthropics/claude-code/issues/41361> for the
512+ /// related rendering bug; this normalizer addresses the closely-related
513+ /// "structured exists but is empty" failure mode.
514+ #[ cfg( feature = "server" ) ]
515+ pub fn normalize_call_tool_result ( result : & mut CallToolResult ) {
516+ let is_empty_object = matches ! (
517+ result. structured_content. as_ref( ) ,
518+ Some ( Value :: Object ( map) ) if map. is_empty( )
519+ ) ;
520+ if is_empty_object {
521+ result. structured_content = None ;
522+ }
523+ }
524+
463525// ---------------------------------------------------------------------------
464526// Lint helpers
465527// ---------------------------------------------------------------------------
@@ -670,6 +732,88 @@ mod tests {
670732 assert_eq ! ( last_text. text, "3 items" ) ;
671733 }
672734
735+ #[ cfg( feature = "server" ) ]
736+ #[ test]
737+ fn normalize_strips_empty_object ( ) {
738+ use crate :: model:: CallToolResult ;
739+
740+ let mut result = CallToolResult :: structured ( serde_json:: json!( { } ) ) ;
741+ assert ! (
742+ result. structured_content. is_some( ) ,
743+ "test setup: structured starts populated"
744+ ) ;
745+ normalize_call_tool_result ( & mut result) ;
746+ assert ! (
747+ result. structured_content. is_none( ) ,
748+ "empty `{{}}` envelope must be stripped so Claude Code surfaces content[]"
749+ ) ;
750+ }
751+
752+ #[ cfg( feature = "server" ) ]
753+ #[ test]
754+ fn normalize_keeps_populated_structured ( ) {
755+ use crate :: model:: CallToolResult ;
756+
757+ let mut result = CallToolResult :: structured ( serde_json:: json!( { "k" : "v" } ) ) ;
758+ normalize_call_tool_result ( & mut result) ;
759+ let stored = result
760+ . structured_content
761+ . as_ref ( )
762+ . expect ( "populated structured must survive normalization" ) ;
763+ assert_eq ! ( stored, & serde_json:: json!( { "k" : "v" } ) ) ;
764+ }
765+
766+ #[ cfg( feature = "server" ) ]
767+ #[ test]
768+ fn normalize_keeps_populated_structured_with_extras_default ( ) {
769+ use crate :: model:: { CallToolResult , Content , RawResource } ;
770+
771+ let mut result = CallToolResult :: structured ( serde_json:: json!( { "k" : "v" } ) ) ;
772+ let raw = RawResource :: new ( "studio://download/uuid/file.png" , "file.png" ) ;
773+ result. content . push ( Content :: resource_link ( raw) ) ;
774+
775+ normalize_call_tool_result ( & mut result) ;
776+
777+ // Latent footgun: populated structured + extras is intentionally not
778+ // auto-stripped. Tool authors are expected to keep response structs
779+ // empty when pushing resource_link / image extras.
780+ assert ! (
781+ result. structured_content. is_some( ) ,
782+ "default normalizer leaves populated structured alone, even with extras"
783+ ) ;
784+ }
785+
786+ #[ cfg( feature = "server" ) ]
787+ #[ test]
788+ fn normalize_leaves_none_structured_alone ( ) {
789+ use crate :: model:: { CallToolResult , Content } ;
790+
791+ let mut result = CallToolResult :: success ( vec ! [ Content :: text( "hi" ) ] ) ;
792+ assert ! ( result. structured_content. is_none( ) ) ;
793+ normalize_call_tool_result ( & mut result) ;
794+ assert ! (
795+ result. structured_content. is_none( ) ,
796+ "None structured stays None"
797+ ) ;
798+ }
799+
800+ #[ cfg( feature = "server" ) ]
801+ #[ test]
802+ fn normalize_leaves_non_object_structured_alone ( ) {
803+ use crate :: model:: CallToolResult ;
804+
805+ // Json<T>::into_call_tool_result always emits an Object, but
806+ // CallToolResult::structured(value) accepts arbitrary JSON. Defensive
807+ // check that a non-object Value (array, string, etc.) doesn't get
808+ // matched as "empty" by the normalizer.
809+ let mut result = CallToolResult :: structured ( serde_json:: json!( [ ] ) ) ;
810+ normalize_call_tool_result ( & mut result) ;
811+ assert ! (
812+ result. structured_content. is_some( ) ,
813+ "non-object structured (array, scalar, etc.) is left untouched"
814+ ) ;
815+ }
816+
673817 // ----- permission relay -------------------------------------------------
674818
675819 #[ test]
0 commit comments