Skip to content

Commit 18b7c02

Browse files
committed
feat(server): json-and-artifact wrapper + correct empty-{} shadow scoping
Adds `JsonAndArtifact<T>` wrapper to `handler::server::wrapper::json` — typed structured response with extra `Content` blocks. Tools that emit a `resource_link` / `image` / inline resource alongside their typed body return `Result<JsonAndArtifact<T>, _>` instead of `Result<CallToolResult, _>`, preserving auto `outputSchema` extraction. The macro recognises the new wrapper alongside `Json<T>` via a 3-line edit to `extract_json_inner_type` in `rmcp-macros/src/tool.rs`. Corrects the misleading documentation in `anthropic_ext.rs` that overstated the Claude Code shadow bug. Empirical verification (live screenshot calls against Claude Code) confirms populated `structuredContent` does NOT shadow `content[]` — both surface side-by-side. Only empty `{}` shadows, and that's already handled by `normalize_call_tool_result`. The previous wording implied populated structured shadowed too, which is false. Updates: - `wrapper/json.rs`: new `JsonAndArtifact<T>` struct + `IntoCallToolResult` impl + `JsonSchema` delegation + 4 unit tests covering empty / single / multiple extras and schema delegation to inner T. - `rmcp-macros/src/tool.rs`: `extract_json_inner_type` recognises both `Json` and `JsonAndArtifact` idents. - `anthropic_ext.rs`: rewrites the crate-level + per-fn docstrings on `normalize_call_tool_result` to scope the shadow bug to empty `{}` only. Renames the corresponding test from `normalize_keeps_populated_structured_with_extras_default` to `normalize_keeps_populated_structured_alongside_extras` and rewrites its body comment from "Latent footgun" framing to "intended behavior". - `lib.rs`: re-exports `JsonAndArtifact` alongside `Json` from the crate root. Spec cross-check: MCP 2025-11-25 has `content` and `structuredContent` as independent fields on `CallToolResult` with no precedence rule (SEP #2200 in flight). Any client-side shadowing is a product decision, not a spec rule. The empty-`{}` shadow is the only known Claude Code quirk and is the only thing this normaliser addresses.
1 parent bf77fc8 commit 18b7c02

4 files changed

Lines changed: 202 additions & 39 deletions

File tree

crates/rmcp-macros/src/tool.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ use syn::{Expr, Ident, ImplItemFn, LitStr, ReturnType, parse_quote};
55

66
use crate::common::extract_doc_line;
77

8-
/// Check if a type is Json<T> and extract the inner type T
8+
/// Check if a type is `Json<T>` or `JsonAndArtifact<T>` and extract the inner
9+
/// type `T`. Both wrappers carry the same structured payload contract from
10+
/// the macro's perspective; `JsonAndArtifact<T>` additionally appends extra
11+
/// `Content` blocks (e.g. a `resource_link`) to the tool result, but its
12+
/// `outputSchema` is still derived from the inner `T`.
913
fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
1014
if let syn::Type::Path(type_path) = ty {
1115
if let Some(last_segment) = type_path.path.segments.last() {
12-
if last_segment.ident == "Json" {
16+
if last_segment.ident == "Json" || last_segment.ident == "JsonAndArtifact" {
1317
if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments {
1418
if let Some(syn::GenericArgument::Type(inner_type)) = args.args.first() {
1519
return Some(inner_type);
@@ -21,16 +25,17 @@ fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
2125
None
2226
}
2327

24-
/// Extract schema expression from a function's return type
25-
/// Handles patterns like Json<T> and Result<Json<T>, E>
28+
/// Extract schema expression from a function's return type.
29+
/// Handles `Json<T>` / `JsonAndArtifact<T>` and `Result<Json<T> | JsonAndArtifact<T>, E>`.
2630
fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
27-
// First, try direct Json<T>
31+
// First, try direct Json<T> / JsonAndArtifact<T>
2832
if let Some(inner_type) = extract_json_inner_type(ret_type) {
2933
return syn::parse2::<Expr>(quote! {
3034
rmcp::handler::server::tool::schema_for_output::<#inner_type>()
3135
.unwrap_or_else(|e| {
3236
panic!(
33-
"Invalid output schema for Json<{}>: {}",
37+
"Invalid output schema for Json<{}> / JsonAndArtifact<{}>: {}",
38+
std::any::type_name::<#inner_type>(),
3439
std::any::type_name::<#inner_type>(),
3540
e
3641
)
@@ -39,7 +44,7 @@ fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
3944
.ok();
4045
}
4146

42-
// Then, try Result<Json<T>, E>
47+
// Then, try Result<Json<T> | JsonAndArtifact<T>, E>
4348
let type_path = match ret_type {
4449
syn::Type::Path(path) => path,
4550
_ => return None,
@@ -67,7 +72,8 @@ fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
6772
rmcp::handler::server::tool::schema_for_output::<#inner_type>()
6873
.unwrap_or_else(|e| {
6974
panic!(
70-
"Invalid output schema for Result<Json<{}>, E>: {}",
75+
"Invalid output schema for Result<Json<{}> | JsonAndArtifact<{}>, E>: {}",
76+
std::any::type_name::<#inner_type>(),
7177
std::any::type_name::<#inner_type>(),
7278
e
7379
)

crates/rmcp/src/anthropic_ext.rs

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,13 @@
3232
//! - `normalize_call_tool_result` - silently strips `structuredContent` when
3333
//! it serializes to an empty object (`{}`). Wired into `ToolRouter::call`
3434
//! 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.
35+
//! automatically. Claude Code's empty-`{}` shadow bug — and *only* the
36+
//! empty-`{}` case — drops every block in `content[]`; populated
37+
//! `structuredContent` coexists fine with `content[]` extras
38+
//! (`resource_link` / `image` / summary text). Empirically verified
39+
//! against Claude Code; spec-side, MCP 2025-11-25 has no precedence
40+
//! rule between `content[]` and `structuredContent` (SEP #2200 still
41+
//! in flight).
4142
//! - `lint::warn_if_over_2kb` - pure `tracing::warn!` helper catching Claude
4243
//! Code's silent 2 KB truncation of tool descriptions and server
4344
//! `instructions`.
@@ -487,30 +488,38 @@ where
487488
/// Silently strip `structuredContent` when it serializes to an empty object
488489
/// `{}`.
489490
///
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.
491+
/// Claude Code's empty-`{}` shadow bug: when `structuredContent` is
492+
/// `Some(Value::Object({}))`, Claude Code surfaces only that empty
493+
/// envelope to the model and drops every block in `content[]`
494+
/// (`resource_link`, `image`, human-readable summary text). Stripping
495+
/// the redundant `{}` envelope server-side unblocks the model seeing the
496+
/// extras. The serialized JSON dump auto-pushed onto `content[0]` by
497+
/// [`Json<T>::into_call_tool_result`] already carries the same payload
498+
/// (which is `null` / `{}` here), so this normalization is
499+
/// information-preserving.
500+
///
501+
/// Populated `structuredContent` does NOT shadow `content[]` — both
502+
/// surface to the model side-by-side. Empirically verified against
503+
/// Claude Code; the MCP 2025-11-25 spec has no precedence rule between
504+
/// the two fields (SEP #2200 still in flight). The empty-`{}` case is
505+
/// the only known shadow trigger and the only thing this normalizer
506+
/// addresses.
507+
///
508+
/// Covers tools whose response struct serializes to `{}` — full-viewport
509+
/// screenshots, artifact-only downloads with `inline=false`, count-only
510+
/// thumbnail tools, and any tool that conditionally omits every field
511+
/// (`Option<...>` with `skip_serializing_if`). Tools with a populated
512+
/// structured response and `content[]` extras (e.g. cropped screenshots
513+
/// emitting a populated `crop_bounds` alongside a `resource_link`) need
514+
/// no special handling — both fields reach the model.
506515
///
507516
/// Wired into [`ToolRouter::call`](crate::handler::server::tool::ToolRouter)
508517
/// so every tool routed through `#[tool_router]` is normalized
509518
/// automatically. Tool authors do not call this directly.
510519
///
511520
/// 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.
521+
/// adjacent `outputSchema.safeParse` blank-UI bug; that's a different
522+
/// failure mode, not addressed by this normalizer.
514523
#[cfg(feature = "server")]
515524
pub fn normalize_call_tool_result(result: &mut CallToolResult) {
516525
let is_empty_object = matches!(
@@ -832,7 +841,7 @@ mod tests {
832841

833842
#[cfg(feature = "server")]
834843
#[test]
835-
fn normalize_keeps_populated_structured_with_extras_default() {
844+
fn normalize_keeps_populated_structured_alongside_extras() {
836845
use crate::model::{CallToolResult, Content, RawResource};
837846

838847
let mut result = CallToolResult::structured(serde_json::json!({"k": "v"}));
@@ -841,12 +850,13 @@ mod tests {
841850

842851
normalize_call_tool_result(&mut result);
843852

844-
// Latent footgun: populated structured + extras is intentionally not
845-
// auto-stripped. Tool authors are expected to keep response structs
846-
// empty when pushing resource_link / image extras.
853+
// Intended behavior: populated structured + extras coexist fine.
854+
// Empirically verified against Claude Code — only the empty-{}
855+
// case shadows content[]. Tools that emit a resource_link
856+
// alongside a populated typed body need no special handling.
847857
assert!(
848858
result.structured_content.is_some(),
849-
"default normalizer leaves populated structured alone, even with extras"
859+
"populated structuredContent survives alongside content[] extras"
850860
);
851861
}
852862

crates/rmcp/src/handler/server/wrapper/json.rs

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ use std::borrow::Cow;
33
use schemars::JsonSchema;
44
use serde::Serialize;
55

6-
use crate::{handler::server::tool::IntoCallToolResult, model::CallToolResult};
6+
use crate::{
7+
handler::server::tool::IntoCallToolResult,
8+
model::{CallToolResult, Content},
9+
};
710

811
/// Json wrapper for structured output
912
///
@@ -38,3 +41,147 @@ impl<T: Serialize + JsonSchema + 'static> IntoCallToolResult for Json<T> {
3841
Ok(CallToolResult::structured(value))
3942
}
4043
}
44+
45+
/// Typed structured response with extra `Content` blocks.
46+
///
47+
/// Returned by tools that emit a `resource_link` / `image` / inline
48+
/// resource alongside their structured response. The serialized JSON of
49+
/// `value` lands at `content[0]` (via `CallToolResult::structured`) and
50+
/// also populates `structuredContent`; `extras` is appended in order.
51+
///
52+
/// The `#[tool]` macro recognizes this wrapper alongside `Json<T>` for
53+
/// `outputSchema` extraction — the schema is taken from the inner `T`.
54+
/// See `extract_json_inner_type` in `rmcp-macros/src/tool.rs`.
55+
///
56+
/// Typical use:
57+
///
58+
/// ```ignore
59+
/// async fn get_log_history(...) -> Result<JsonAndArtifact<LogHistoryResponse>, String> {
60+
/// let resp = self.dispatch_typed::<_, LogHistoryResponse>(&ctx, "get_log_history", &args).await?;
61+
/// artifact_ctx::structured_response("studio-output-log.json", resp.0)
62+
/// }
63+
/// ```
64+
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
65+
pub struct JsonAndArtifact<T> {
66+
pub value: T,
67+
pub extras: Vec<Content>,
68+
}
69+
70+
// JsonSchema delegates to T so the macro picks up the inner type's schema.
71+
impl<T: JsonSchema> JsonSchema for JsonAndArtifact<T> {
72+
fn schema_name() -> Cow<'static, str> {
73+
T::schema_name()
74+
}
75+
76+
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
77+
T::json_schema(generator)
78+
}
79+
}
80+
81+
impl<T: Serialize + JsonSchema + 'static> IntoCallToolResult for JsonAndArtifact<T> {
82+
fn into_call_tool_result(self) -> Result<CallToolResult, crate::ErrorData> {
83+
let value = serde_json::to_value(self.value).map_err(|e| {
84+
crate::ErrorData::internal_error(
85+
format!("Failed to serialize structured content: {}", e),
86+
None,
87+
)
88+
})?;
89+
90+
let mut result = CallToolResult::structured(value);
91+
result.content.extend(self.extras);
92+
Ok(result)
93+
}
94+
}
95+
96+
#[cfg(test)]
97+
#[allow(clippy::unwrap_used)]
98+
mod tests {
99+
use schemars::JsonSchema;
100+
use serde::Serialize;
101+
102+
use super::*;
103+
use crate::model::RawResource;
104+
105+
#[derive(Serialize, JsonSchema)]
106+
struct DummyResp {
107+
count: u32,
108+
}
109+
110+
/// Empty extras — content[] should carry only the auto-pushed JSON
111+
/// dump from `CallToolResult::structured`; structured_content set.
112+
#[test]
113+
fn json_and_artifact_no_extras_matches_json_shape() {
114+
let value = DummyResp { count: 7 };
115+
let result = JsonAndArtifact {
116+
value,
117+
extras: vec![],
118+
}
119+
.into_call_tool_result()
120+
.unwrap();
121+
122+
assert!(result.structured_content.is_some());
123+
let stored = result.structured_content.as_ref().unwrap();
124+
assert_eq!(stored, &serde_json::json!({"count": 7}));
125+
assert_eq!(result.content.len(), 1, "auto-pushed JSON dump only");
126+
assert_eq!(
127+
result.content[0].as_text().unwrap().text,
128+
serde_json::json!({"count": 7}).to_string()
129+
);
130+
}
131+
132+
/// With a resource_link extra: content[0] is the JSON dump,
133+
/// content[1] is the resource_link; structured_content still set.
134+
#[test]
135+
fn json_and_artifact_with_resource_link_appends_to_content() {
136+
let value = DummyResp { count: 3 };
137+
let raw = RawResource::new("studio://download/uuid/file.png", "file.png");
138+
let link = Content::resource_link(raw);
139+
let result = JsonAndArtifact {
140+
value,
141+
extras: vec![link],
142+
}
143+
.into_call_tool_result()
144+
.unwrap();
145+
146+
assert!(result.structured_content.is_some());
147+
assert_eq!(result.content.len(), 2, "JSON dump + resource_link");
148+
assert!(
149+
result.content[1].raw.as_resource_link().is_some(),
150+
"second block is a resource_link"
151+
);
152+
}
153+
154+
/// Multiple extras (resource_link + image-style text) preserve order.
155+
#[test]
156+
fn json_and_artifact_preserves_extras_order() {
157+
let value = DummyResp { count: 1 };
158+
let raw = RawResource::new("studio://download/uuid/a.png", "a.png");
159+
let extras = vec![Content::resource_link(raw), Content::text("summary")];
160+
let result = JsonAndArtifact { value, extras }
161+
.into_call_tool_result()
162+
.unwrap();
163+
164+
assert_eq!(result.content.len(), 3);
165+
assert!(result.content[1].raw.as_resource_link().is_some());
166+
assert_eq!(result.content[2].as_text().unwrap().text, "summary");
167+
}
168+
169+
/// JsonSchema delegates to inner T — a tool returning
170+
/// `Result<JsonAndArtifact<DummyResp>, _>` advertises the same
171+
/// outputSchema as `Result<Json<DummyResp>, _>`.
172+
#[test]
173+
fn json_and_artifact_schema_delegates_to_inner() {
174+
assert_eq!(
175+
<JsonAndArtifact<DummyResp> as JsonSchema>::schema_name(),
176+
<DummyResp as JsonSchema>::schema_name()
177+
);
178+
let mut g1 = schemars::SchemaGenerator::default();
179+
let mut g2 = schemars::SchemaGenerator::default();
180+
let s1 = <JsonAndArtifact<DummyResp> as JsonSchema>::json_schema(&mut g1);
181+
let s2 = <DummyResp as JsonSchema>::json_schema(&mut g2);
182+
assert_eq!(
183+
serde_json::to_value(&s1).unwrap(),
184+
serde_json::to_value(&s2).unwrap()
185+
);
186+
}
187+
}

crates/rmcp/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub use handler::client::ClientHandler;
1919
#[cfg(feature = "server")]
2020
pub use handler::server::ServerHandler;
2121
#[cfg(feature = "server")]
22-
pub use handler::server::wrapper::Json;
22+
pub use handler::server::wrapper::{Json, JsonAndArtifact};
2323
#[cfg(any(feature = "client", feature = "server"))]
2424
pub use service::{Peer, Service, ServiceError, ServiceExt};
2525
#[cfg(feature = "client")]

0 commit comments

Comments
 (0)