What happened?
The Gemini sidecar wraps each MCP tool result in an extra content layer before putting it on the ACP wire. The Jaeger AI gateway's flattener expects the result without this extra layer, so its type assertion fails and it falls back to JSON-encoding the entire nested value. As a result, TOOL_CALL_RESULT.content ends up containing a serialized protocol envelope instead of the tool's actual text.
Sidecar wraps the result — [sidecar.py:309](
|
raw_output={"content": tool_output}, |
):
raw_output={"content": tool_output},
tool_output comes from [sidecar.py:299](
|
tool_output = await self._mcp.call_tool(tool_name, args) |
), which calls
[mcp_bridge.py:104](https://github.com/jaegertracing/jaeger/blob/2487d690445e0d51b04075f04ea70abe078b4a35/scripts/ai-sidecar/gemini/mcp_bridge.py#L104):
return _to_jsonable(result)
_to_jsonable ([sidecar_helpers.py:86-91](
|
def _to_jsonable(value: Any) -> Any: |
|
if hasattr(value, "model_dump"): |
|
return value.model_dump() |
|
if hasattr(value, "dict"): |
|
return value.dict() |
|
return value |
)) converts Pydantic models to mappings and leaves non-model values unchanged. This means the MCP
CallToolResult reaches the gateway as a dictionary rather than the list of content blocks the gateway expects. The captured frame confirms that this is the actual runtime shape.
Gateway expects a flat result — [translation.go:268-291](
|
func flattenToolResultContent(raw any) string { |
|
if envelope, ok := raw.(map[string]any); ok { |
|
if blocks, ok := envelope["content"].([]any); ok { |
|
texts := make([]string, 0, len(blocks)) |
|
for _, block := range blocks { |
|
blockMap, ok := block.(map[string]any) |
|
if !ok { |
|
continue |
|
} |
|
if text, ok := blockMap["text"].(string); ok { |
|
texts = append(texts, text) |
|
} |
|
} |
|
if len(texts) > 0 { |
|
return strings.Join(texts, "\n") |
|
} |
|
} |
|
} |
|
payload, err := json.Marshal(raw) |
|
if err != nil { |
|
return fmt.Sprintf("%v", raw) |
|
} |
|
return string(payload) |
|
} |
):
if envelope, ok := raw.(map[string]any); ok {
if blocks, ok := envelope["content"].([]any); ok { // receives map[string]any
The gateway expects envelope["content"] to be a []any, but it actually receives a map[string]any because the sidecar has already wrapped the MCP result.
The comment above this code ([translation.go:259-267](
|
// flattenToolResultContent reduces the sidecar's tool output to the single |
|
// string AG-UI's TOOL_CALL_RESULT.content field expects. The sidecar |
|
// forwards MCP CallToolResult envelopes verbatim — {content:[{type:"text", |
|
// text:"..."}, ...], structuredContent:{...}} — so each text block is |
|
// collected and the result is joined with "\n" to keep block boundaries |
|
// readable (concatenating with no delimiter would mash distinct paragraphs |
|
// like "Found 3 services" + "Top latency: 1.2s" into one run-on string). |
|
// Anything else is JSON-encoded so the frontend always receives a |
|
// deterministic string instead of a nested object. |
)) says that the sidecar forwards MCP
CallToolResult envelopes verbatim. In practice, it does not; it adds an extra wrapper around the result.
When the type assertion fails, the gateway falls back to json.Marshal(raw) at [translation.go:286](
|
payload, err := json.Marshal(raw) |
). The serialized value is then emitted as the tool result by [
streaming_client.go:270-276](
|
if u.ToolCallUpdate.RawOutput != nil { |
|
c.emit(aguievents.NewToolCallResultEvent( |
|
toolResultMessageID(u.ToolCallUpdate.ToolCallId), |
|
string(u.ToolCallUpdate.ToolCallId), |
|
flattenToolResultContent(u.ToolCallUpdate.RawOutput), |
|
)) |
|
} |
).
Steps to reproduce
Observed TOOL_CALL_RESULT frame from a live get_services call:
{
"type": "TOOL_CALL_RESULT",
"timestamp": 1785949730603,
"messageId": "tool-msg-W0IWpkE5",
"toolCallId": "W0IWpkE5",
"content": "{\"content\":{\"content\":[{\"text\":\"{\\\"services\\\":[\\\"jaeger\\\",\\\"jaeger-gemini-sidecar\\\"],\\\"total_count\\\":2,\\\"truncated\\\":false}\",\"type\":\"text\"}],\"isError\":false,\"structuredContent\":{\"services\":[\"jaeger\",\"jaeger-gemini-sidecar\"],\"total_count\":2,\"truncated\":false}}}",
"role": "tool"
}
The content field is the whole nested envelope, JSON-marshalled. A successful
flatten would have produced the text block alone:
{"services":["jaeger","jaeger-gemini-sidecar"],"total_count":2,"truncated":false}
Two details confirm this came from the json.Marshal(raw) fallback rather than the
text-extraction branch: the value under the outer content is an object, not an
array (so the []any assertion could not have succeeded), and the keys are
alphabetically ordered — content, isError, structuredContent, and text
before type — which is Go's encoding/json map ordering, not MCP's emission
order.
The same shape in isolation, fed to the flattener, reproduces that content field
byte-for-byte:
callToolResult := map[string]any{
"content": []any{map[string]any{"type": "text", "text": toolText}},
"isError": false,
"structuredContent": map[string]any{"services": []any{"jaeger", "jaeger-gemini-sidecar"}, "total_count": 2, "truncated": false},
}
got := flattenToolResultContent(map[string]any{"content": callToolResult})
Expected behavior
TOOL_CALL_RESULT.content should carry the tool's text — the newline-joined text
blocks the flattener is written to produce.
Relevant log output
Screenshot
No response
Additional context
Impact
The gateway emits TOOL_CALL_RESULT.content as a nested JSON string where the code
intends joined text, for every MCP tool call in the Gemini sidecar chat path. The
multi-block newline-joining that flattenToolResultContent exists to provide is
unreachable in production — only its fallback branch ever runs. If the frontend
tool-result panel displays content directly, that serialized envelope is what
users see.
Suggested fix, and the design question it raises
A — fix the sidecar. Send raw_output=tool_output unwrapped at sidecar.py:309,
matching what the gateway, its doc comment, and its test already assume. This
breaks test_sidecar_workflow.py:358,
which currently encodes the wrapped shape.
B — fix the gateway. Have flattenToolResultContent unwrap one extra layer.
No sidecar change, no test churn — but it bakes one sidecar's quirk into a
protocol-generic component shared by every ACP sidecar (cf. #9273, #8529).
Whoever picks this up has to decide which side is canonical for ACP raw_output.
I lean toward A, since the gateway is the shared component and the sidecar is
the one deviating from what it documents — but that determines whether the Python
test is corrected or is the spec, so it's a maintainer call.
Jaeger backend version
main @ 2487d69
SDK
No response
Pipeline
No response
Stogage backend
No response
Operating system
No response
Deployment model
No response
Deployment configs
What happened?
The Gemini sidecar wraps each MCP tool result in an extra
contentlayer before putting it on the ACP wire. The Jaeger AI gateway's flattener expects the result without this extra layer, so its type assertion fails and it falls back to JSON-encoding the entire nested value. As a result,TOOL_CALL_RESULT.contentends up containing a serialized protocol envelope instead of the tool's actual text.Sidecar wraps the result — [sidecar.py:309](
jaeger/scripts/ai-sidecar/gemini/sidecar.py
Line 309 in 2487d69
tool_outputcomes from [sidecar.py:299](jaeger/scripts/ai-sidecar/gemini/sidecar.py
Line 299 in 2487d69
[mcp_bridge.py:104](https://github.com/jaegertracing/jaeger/blob/2487d690445e0d51b04075f04ea70abe078b4a35/scripts/ai-sidecar/gemini/mcp_bridge.py#L104):_to_jsonable([sidecar_helpers.py:86-91](jaeger/scripts/ai-sidecar/gemini/sidecar_helpers.py
Lines 86 to 91 in 2487d69
CallToolResultreaches the gateway as a dictionary rather than the list of content blocks the gateway expects. The captured frame confirms that this is the actual runtime shape.Gateway expects a flat result — [translation.go:268-291](
jaeger/cmd/jaeger/internal/extension/jaegerquery/internal/jaegerai/translation.go
Lines 268 to 291 in 2487d69
The gateway expects
envelope["content"]to be a[]any, but it actually receives amap[string]anybecause the sidecar has already wrapped the MCP result.The comment above this code ([translation.go:259-267](
jaeger/cmd/jaeger/internal/extension/jaegerquery/internal/jaegerai/translation.go
Lines 259 to 267 in 2487d69
CallToolResultenvelopes verbatim. In practice, it does not; it adds an extra wrapper around the result.When the type assertion fails, the gateway falls back to
json.Marshal(raw)at [translation.go:286](jaeger/cmd/jaeger/internal/extension/jaegerquery/internal/jaegerai/translation.go
Line 286 in 2487d69
jaeger/cmd/jaeger/internal/extension/jaegerquery/internal/jaegerai/streaming_client.go
Lines 270 to 276 in 2487d69
Steps to reproduce
Observed
TOOL_CALL_RESULTframe from a liveget_servicescall:{ "type": "TOOL_CALL_RESULT", "timestamp": 1785949730603, "messageId": "tool-msg-W0IWpkE5", "toolCallId": "W0IWpkE5", "content": "{\"content\":{\"content\":[{\"text\":\"{\\\"services\\\":[\\\"jaeger\\\",\\\"jaeger-gemini-sidecar\\\"],\\\"total_count\\\":2,\\\"truncated\\\":false}\",\"type\":\"text\"}],\"isError\":false,\"structuredContent\":{\"services\":[\"jaeger\",\"jaeger-gemini-sidecar\"],\"total_count\":2,\"truncated\":false}}}", "role": "tool" }The
contentfield is the whole nested envelope, JSON-marshalled. A successfulflatten would have produced the text block alone:
Two details confirm this came from the
json.Marshal(raw)fallback rather than thetext-extraction branch: the value under the outer
contentis an object, not anarray (so the
[]anyassertion could not have succeeded), and the keys arealphabetically ordered —
content,isError,structuredContent, andtextbefore
type— which is Go'sencoding/jsonmap ordering, not MCP's emissionorder.
The same shape in isolation, fed to the flattener, reproduces that
contentfieldbyte-for-byte:
Expected behavior
TOOL_CALL_RESULT.contentshould carry the tool's text — the newline-joined textblocks the flattener is written to produce.
Relevant log output
Screenshot
No response
Additional context
Impact
The gateway emits
TOOL_CALL_RESULT.contentas a nested JSON string where the codeintends joined text, for every MCP tool call in the Gemini sidecar chat path. The
multi-block newline-joining that
flattenToolResultContentexists to provide isunreachable in production — only its fallback branch ever runs. If the frontend
tool-result panel displays
contentdirectly, that serialized envelope is whatusers see.
Suggested fix, and the design question it raises
A — fix the sidecar. Send
raw_output=tool_outputunwrapped at sidecar.py:309,matching what the gateway, its doc comment, and its test already assume. This
breaks test_sidecar_workflow.py:358,
which currently encodes the wrapped shape.
B — fix the gateway. Have
flattenToolResultContentunwrap one extra layer.No sidecar change, no test churn — but it bakes one sidecar's quirk into a
protocol-generic component shared by every ACP sidecar (cf. #9273, #8529).
Whoever picks this up has to decide which side is canonical for ACP
raw_output.I lean toward A, since the gateway is the shared component and the sidecar is
the one deviating from what it documents — but that determines whether the Python
test is corrected or is the spec, so it's a maintainer call.
Jaeger backend version
main @ 2487d69
SDK
No response
Pipeline
No response
Stogage backend
No response
Operating system
No response
Deployment model
No response
Deployment configs