Skip to content

Commit 3d17418

Browse files
committed
feat(anthropic-ext): normalize_call_tool_result strips empty structuredContent
Wire the normalizer into ToolRouter::call so every tool routed through #[tool_router] is post-processed automatically. When a tool returns a CallToolResult whose structured_content serializes to an empty object {} (e.g. a response struct whose only fields are Option<...> skipped via skip_serializing_if), Claude Code shadows every block in content[] (resource_link, image, summary text) from the model-visible payload — even when structuredContent is empty. Stripping the redundant {} envelope unblocks the model seeing the extras callers pushed onto content[]; the serialized JSON dump auto-pushed onto content[0] by Json<T>::into_call_tool_result already carries the same payload, so the normalization is information-preserving. Covers full-viewport screenshots, artifact-only downloads with inline=false, count-only thumbnail tools, and every future tool whose response struct serializes to {}. The populated-structured + extras case is intentionally left alone — tool authors keep response structs empty when pushing resource_link / image extras onto content[]. Hook is gated on the existing anthropic-ext cargo feature so non-Claude Code consumers see no behavior change. Adds 5 unit tests for the helper plus 1 router-level integration test asserting the strip happens post-dispatch. See claude-code#41361 for the related rendering bug.
1 parent 7c789be commit 3d17418

2 files changed

Lines changed: 207 additions & 1 deletion

File tree

crates/rmcp/src/anthropic_ext.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@
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]

crates/rmcp/src/handler/server/router/tool.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,18 @@ where
547547
.get(name)
548548
.ok_or_else(|| crate::ErrorData::invalid_params("tool not found", None))?;
549549

550-
let result = (item.call)(context).await?;
550+
// `let mut` is required when `anthropic-ext` is on for the
551+
// post-dispatch normalizer; `let _ = name;` keeps the binding live for
552+
// the warning-free no-feature build below.
553+
#[allow(unused_mut)]
554+
let mut result = (item.call)(context).await?;
555+
556+
// Strip empty `structuredContent: {}` so Claude Code surfaces
557+
// `content[]` extras (resource_link / image / summary text). See
558+
// `anthropic_ext::normalize_call_tool_result` for the rationale and
559+
// claude-code#41361 for the related rendering bug.
560+
#[cfg(feature = "anthropic-ext")]
561+
crate::anthropic_ext::normalize_call_tool_result(&mut result);
551562

552563
Ok(result)
553564
}
@@ -640,4 +651,55 @@ mod tests {
640651
assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
641652
assert_eq!(err.message, "tool not found");
642653
}
654+
655+
#[cfg(feature = "anthropic-ext")]
656+
#[tokio::test]
657+
async fn router_call_strips_empty_structured_content() {
658+
use crate::model::{Content, RawResource};
659+
660+
let service = DummyService;
661+
let router = ToolRouter::new().with_route(ToolRoute::new_dyn(
662+
crate::model::Tool::new("empty_struct_tool", "test", Arc::new(Default::default())),
663+
|_ctx| {
664+
Box::pin(async {
665+
// Mirrors the screenshot full-viewport case: response
666+
// serializes to `{}`, plus a resource_link pushed onto
667+
// content[]. Without normalization, Claude Code shadows
668+
// the resource_link.
669+
let mut result = CallToolResult::structured(serde_json::json!({}));
670+
let raw = RawResource::new("studio://download/uuid/x.png", "x.png");
671+
result.content.push(Content::resource_link(raw));
672+
Ok(result)
673+
})
674+
},
675+
));
676+
677+
let id_provider: Arc<dyn crate::service::RequestIdProvider> =
678+
Arc::new(AtomicU32RequestIdProvider::default());
679+
let (peer, _rx) = Peer::<RoleServer>::new(id_provider, None);
680+
let ctx = crate::handler::server::tool::ToolCallContext::new(
681+
&service,
682+
CallToolRequestParams {
683+
meta: None,
684+
name: Cow::Borrowed("empty_struct_tool"),
685+
arguments: None,
686+
task: None,
687+
},
688+
RequestContext::new(NumberOrString::Number(1), peer),
689+
);
690+
691+
let result = router.call(ctx).await.expect("call succeeds");
692+
assert!(
693+
result.structured_content.is_none(),
694+
"router must strip empty `{{}}` structuredContent post-dispatch \
695+
so Claude Code surfaces content[] extras"
696+
);
697+
assert!(
698+
result
699+
.content
700+
.iter()
701+
.any(|c| c.raw.as_resource_link().is_some()),
702+
"resource_link must survive normalization on content[]"
703+
);
704+
}
643705
}

0 commit comments

Comments
 (0)