@@ -80,6 +80,19 @@ function FormattedOutput({ value }: { value: JSONValue }) {
8080
8181type Tab = "result" | "metadata" ;
8282
83+ function isMcpCallToolResultArtifact (
84+ value : JSONValue | undefined ,
85+ ) : value is Record < string , JSONValue > {
86+ return (
87+ value !== null &&
88+ value !== undefined &&
89+ typeof value === "object" &&
90+ ! Array . isArray ( value ) &&
91+ Array . isArray ( value . content ) &&
92+ typeof value . isError === "boolean"
93+ ) ;
94+ }
95+
8396/** Tab control sized for the inside of a tool-call card. Matches the
8497 * underline-on-active pattern used across assistant-ui / Claude /
8598 * ChatGPT — quiet by default, the active tab gets a 2px underline in
@@ -118,9 +131,10 @@ function TabButton({
118131 * - LangChain ToolMessage envelope with non-standard metadata keys
119132 * (additional_kwargs, response_metadata, type, ...) gets a 2-tab UI:
120133 * "Result" prefers the structured `.artifact` and falls back to the
121- * model-facing `.content`; "Metadata" shows the rest of the envelope
122- * as pretty JSON. Plumbing keys (name, id, tool_call_id, status) are
123- * suppressed because the accordion trigger already surfaces them.
134+ * model-facing `.content`. MCP `CallToolResult` artifacts are protocol
135+ * envelopes, so their readable content stays in Result and the raw
136+ * artifact stays in Metadata. Plumbing keys (name, id, tool_call_id,
137+ * status) are suppressed because the accordion trigger surfaces them.
124138 * - Anything else (simple string, plain dict, unwrappable envelope)
125139 * falls through to FormattedOutput directly under the eyebrow —
126140 * no tabs, just the body. */
@@ -137,17 +151,20 @@ export function ToolOutputDisplay({ output }: { output: JSONValue }) {
137151 ) ;
138152 }
139153
140- const result = output . artifact ?? output . content ;
154+ const artifact = output . artifact ;
155+ const isMcpArtifact = isMcpCallToolResultArtifact ( artifact ) ;
156+ const result = artifact != null && ! isMcpArtifact ? artifact : output . content ;
141157 // Strip the standard ToolMessage plumbing keys from the metadata view —
142158 // the accordion trigger already shows tool name and status, and id /
143159 // tool_call_id aren't useful in the UI. What remains is the actually
144160 // interesting custom metadata (additional_kwargs, response_metadata,
145- // type, custom fields). Artifact is the primary result, so don't duplicate
146- // it in the Metadata tab .
161+ // type, custom fields). Component artifacts are the primary result, while
162+ // raw MCP protocol artifacts remain metadata behind readable content .
147163 const metadata = Object . fromEntries (
148- Object . entries ( output ) . filter (
149- ( [ k ] ) => ! TOOL_MESSAGE_KEYS_PLUS_CONTENT . has ( k ) ,
150- ) ,
164+ Object . entries ( output ) . filter ( ( [ key , value ] ) => {
165+ if ( key === "artifact" ) return value != null && isMcpArtifact ;
166+ return ! TOOL_MESSAGE_KEYS_PLUS_CONTENT . has ( key ) ;
167+ } ) ,
151168 ) ;
152169 const hasMetadata = Object . keys ( metadata ) . length > 0 ;
153170
@@ -205,12 +222,12 @@ export function ToolOutputDisplay({ output }: { output: JSONValue }) {
205222 ) ;
206223}
207224
208- // `content` and `artifact` belong in the Result tab; the rest of the
209- // canonical plumbing fields aren't worth exposing — keep this set local
210- // rather than re-exporting yet another constant.
225+ // `content` belongs in the Result tab; the canonical plumbing fields aren't
226+ // worth exposing — keep this set local rather than re-exporting another
227+ // constant. Artifact routing depends on whether it is component data or an
228+ // MCP protocol envelope, so it is handled separately above.
211229const TOOL_MESSAGE_KEYS_PLUS_CONTENT = new Set ( [
212230 "content" ,
213- "artifact" ,
214231 "name" ,
215232 "id" ,
216233 "tool_call_id" ,
0 commit comments