diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts
index 1c0c5adf694..4a19aec2f8c 100644
--- a/packages/core/src/core/coreToolScheduler.test.ts
+++ b/packages/core/src/core/coreToolScheduler.test.ts
@@ -889,15 +889,22 @@ describe('convertToFunctionResponse', () => {
{ text: 'Another text part' },
];
const result = convertToFunctionResponse(toolName, callId, llmContent);
+ // All content should be inside the FunctionResponse:
+ // - text parts joined into response.output
+ // - media parts in response.parts
expect(result).toEqual([
{
functionResponse: {
name: toolName,
id: callId,
- response: { output: 'Tool execution succeeded.' },
+ response: {
+ output: 'Some textual description\nAnother text part',
+ },
+ parts: [
+ { inlineData: { mimeType: 'image/jpeg', data: 'base64data...' } },
+ ],
},
},
- ...llmContent,
]);
});
diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts
index e643bba624f..fc0455a8a68 100644
--- a/packages/core/src/core/coreToolScheduler.ts
+++ b/packages/core/src/core/coreToolScheduler.ts
@@ -190,12 +190,26 @@ export function convertToFunctionResponse(
}
if (Array.isArray(contentToProcess)) {
- const functionResponse = createFunctionResponsePart(
- callId,
- toolName,
- 'Tool execution succeeded.',
- );
- return [functionResponse, ...toParts(contentToProcess)];
+ // Extract text and media from all parts so that EVERYTHING is inside
+ // the FunctionResponse.
+ const textParts: string[] = [];
+ const mediaParts: FunctionResponsePart[] = [];
+
+ for (const part of toParts(contentToProcess)) {
+ if (part.text !== undefined) {
+ textParts.push(part.text);
+ } else if (part.inlineData) {
+ mediaParts.push({ inlineData: part.inlineData });
+ } else if (part.fileData) {
+ mediaParts.push({ fileData: part.fileData });
+ }
+ // Other exotic part types (e.g. functionCall) are intentionally
+ // dropped here – they should not appear inside tool results.
+ }
+
+ const output =
+ textParts.length > 0 ? textParts.join('\n') : 'Tool execution succeeded.';
+ return [createFunctionResponsePart(callId, toolName, output, mediaParts)];
}
// After this point, contentToProcess is a single Part object.
diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts
index 98a019439fb..36bbc812de2 100644
--- a/packages/core/src/core/openaiContentGenerator/converter.test.ts
+++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts
@@ -11,10 +11,12 @@ import {
Type,
type GenerateContentParameters,
type Content,
+ type Part,
type Tool,
type CallableTool,
} from '@google/genai';
import type OpenAI from 'openai';
+import { convertToFunctionResponse } from '../coreToolScheduler.js';
describe('OpenAIContentConverter', () => {
let converter: OpenAIContentConverter;
@@ -861,6 +863,151 @@ describe('OpenAIContentConverter', () => {
});
});
+ describe('MCP multi-part tool results (issue #1520)', () => {
+ /**
+ * Regression tests for https://github.com/QwenLM/qwen-code/issues/1520
+ *
+ * Ensures that when an MCP tool returns multiple content blocks
+ * (e.g., text + image, or multiple text sections), all content
+ * ends up inside the tool message – NOT in a separate user message.
+ *
+ * These tests simulate the data shape produced by the *fixed*
+ * convertToFunctionResponse(), where all text is joined into
+ * `response.output` and media is placed in `response.parts`.
+ */
+
+ it('should include all text content in tool message when function response has joined text', () => {
+ // After the fix, convertToFunctionResponse joins multiple text parts
+ // into the FunctionResponse.response.output.
+ const request: GenerateContentParameters = {
+ model: 'models/test',
+ contents: [
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: 'call_mcp_1',
+ name: 'figma_get_code',
+ args: { nodeId: '38:521' },
+ },
+ },
+ ],
+ },
+ {
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ id: 'call_mcp_1',
+ name: 'figma_get_code',
+ response: {
+ output:
+ '
...
\nSUPER CRITICAL: The generated React+Tailwind code MUST be converted...',
+ },
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const messages = converter.convertGeminiRequestToOpenAI(request);
+
+ const toolMessage = messages.find((m) => m.role === 'tool');
+ expect(toolMessage).toBeDefined();
+ expect((toolMessage as { tool_call_id?: string }).tool_call_id).toBe(
+ 'call_mcp_1',
+ );
+
+ // All content is in the tool message
+ const toolContent = toolMessage?.content;
+ expect(Array.isArray(toolContent)).toBe(true);
+ const toolTexts = (toolContent as Array<{ type: string; text?: string }>)
+ .filter((p) => p.type === 'text')
+ .map((p) => p.text);
+ expect(toolTexts).toHaveLength(1);
+ expect(toolTexts[0]).toContain('data-node-id');
+ expect(toolTexts[0]).toContain('SUPER CRITICAL');
+
+ // No user message should be created
+ const userMessages = messages.filter((m) => m.role === 'user');
+ expect(userMessages).toHaveLength(0);
+ });
+
+ it('should include text and image in tool message when function response has media parts', () => {
+ // After the fix, convertToFunctionResponse puts media into
+ // FunctionResponse.parts, which the OpenAI converter picks up
+ // in createToolMessage().
+ const request: GenerateContentParameters = {
+ model: 'models/test',
+ contents: [
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: 'call_mcp_2',
+ name: 'figma_get_screenshot',
+ args: { nodeId: '38:521' },
+ },
+ },
+ ],
+ },
+ {
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ id: 'call_mcp_2',
+ name: 'figma_get_screenshot',
+ response: {
+ output:
+ "[Tool 'figma' provided the following image data with mime-type: image/png]",
+ },
+ parts: [
+ {
+ inlineData: {
+ mimeType: 'image/png',
+ data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const messages = converter.convertGeminiRequestToOpenAI(request);
+
+ const toolMessage = messages.find((m) => m.role === 'tool');
+ expect(toolMessage).toBeDefined();
+ expect((toolMessage as { tool_call_id?: string }).tool_call_id).toBe(
+ 'call_mcp_2',
+ );
+
+ // Tool message should contain both text and image
+ const toolContent = toolMessage?.content;
+ expect(Array.isArray(toolContent)).toBe(true);
+ const contentArray = toolContent as Array<{
+ type: string;
+ text?: string;
+ image_url?: { url: string };
+ }>;
+ expect(contentArray).toHaveLength(2);
+ expect(contentArray[0].type).toBe('text');
+ expect(contentArray[0].text).toContain('image data');
+ expect(contentArray[1].type).toBe('image_url');
+ expect(contentArray[1].image_url?.url).toContain('data:image/png');
+
+ // No user message should be created
+ const userMessages = messages.filter((m) => m.role === 'user');
+ expect(userMessages).toHaveLength(0);
+ });
+ });
+
describe('OpenAI -> Gemini reasoning content', () => {
it('should convert reasoning_content to a thought part for non-streaming responses', () => {
const response = converter.convertOpenAIResponseToGemini({
@@ -1523,3 +1670,290 @@ describe('OpenAIContentConverter', () => {
});
});
});
+
+describe('MCP tool result end-to-end through OpenAI converter (issue #1520)', () => {
+ /**
+ * End-to-end regression tests for https://github.com/QwenLM/qwen-code/issues/1520
+ *
+ * Simulates the full pipeline:
+ * transformMcpContentToParts → convertToFunctionResponse → OpenAI converter
+ *
+ * Verifies that multi-part MCP tool results are properly carried through
+ * into the OpenAI tool message, with no content leaking into user messages.
+ */
+ let converter: OpenAIContentConverter;
+
+ beforeEach(() => {
+ converter = new OpenAIContentConverter('test-model');
+ });
+
+ it('should preserve MCP multi-text content in tool message (not leak to user message)', () => {
+ // Step 1: Simulate what transformMcpContentToParts returns for a Figma
+ // tool that returns code + instructions as two text blocks
+ const mcpTransformedParts: Part[] = [
+ { text: 'Welcome
' },
+ {
+ text: 'SUPER CRITICAL: Convert the React+Tailwind code to match the target stack.',
+ },
+ ];
+
+ // Step 2: convertToFunctionResponse wraps the MCP result
+ const callId = 'call_figma_1';
+ const toolName = 'figma_get_code';
+ const responseParts = convertToFunctionResponse(
+ toolName,
+ callId,
+ mcpTransformedParts,
+ );
+
+ // Step 3: Build the conversation history (model tool call + tool result)
+ const contents: Content[] = [
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: callId,
+ name: toolName,
+ args: { nodeId: '38:521' },
+ },
+ },
+ ],
+ },
+ {
+ role: 'user',
+ parts: responseParts,
+ },
+ ];
+
+ // Step 4: Convert to OpenAI format
+ const request: GenerateContentParameters = {
+ model: 'models/test',
+ contents,
+ };
+ const messages = converter.convertGeminiRequestToOpenAI(request);
+
+ const toolMessages = messages.filter((m) => m.role === 'tool');
+ const userMessages = messages.filter((m) => m.role === 'user');
+ const assistantMessages = messages.filter((m) => m.role === 'assistant');
+
+ expect(toolMessages).toHaveLength(1);
+ expect(assistantMessages).toHaveLength(1);
+ // No content should leak into a user message
+ expect(userMessages).toHaveLength(0);
+
+ // Tool message should contain the actual MCP content
+ const toolMsg = toolMessages[0];
+ expect((toolMsg as { tool_call_id: string }).tool_call_id).toBe(callId);
+
+ const toolContent = toolMsg.content;
+ expect(Array.isArray(toolContent)).toBe(true);
+ const toolTexts = (toolContent as Array<{ type: string; text?: string }>)
+ .filter((p) => p.type === 'text')
+ .map((p) => p.text);
+ expect(toolTexts).toHaveLength(1);
+ expect(toolTexts[0]).toContain('data-node-id');
+ expect(toolTexts[0]).toContain('SUPER CRITICAL');
+ });
+
+ it('should preserve MCP text+image content in tool message', () => {
+ // Simulates MCP tool returning text description + image (e.g., get_screenshot)
+ const mcpTransformedParts: Part[] = [
+ {
+ text: "[Tool 'figma' provided the following image data with mime-type: image/png]",
+ },
+ {
+ inlineData: {
+ mimeType: 'image/png',
+ data: 'iVBORw0KGgo=',
+ },
+ },
+ ];
+
+ const callId = 'call_figma_2';
+ const toolName = 'figma_get_screenshot';
+ const responseParts = convertToFunctionResponse(
+ toolName,
+ callId,
+ mcpTransformedParts,
+ );
+
+ const contents: Content[] = [
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: callId,
+ name: toolName,
+ args: { nodeId: '38:521' },
+ },
+ },
+ ],
+ },
+ {
+ role: 'user',
+ parts: responseParts,
+ },
+ ];
+
+ const request: GenerateContentParameters = {
+ model: 'models/test',
+ contents,
+ };
+ const messages = converter.convertGeminiRequestToOpenAI(request);
+
+ const toolMessages = messages.filter((m) => m.role === 'tool');
+ const userMessages = messages.filter((m) => m.role === 'user');
+
+ expect(toolMessages).toHaveLength(1);
+ // No content should leak into a user message
+ expect(userMessages).toHaveLength(0);
+
+ // Tool message should contain both text description and image
+ const toolMsg = toolMessages[0];
+ const toolContent = toolMsg.content;
+ expect(Array.isArray(toolContent)).toBe(true);
+ const contentArray = toolContent as Array<{
+ type: string;
+ text?: string;
+ image_url?: { url: string };
+ }>;
+ expect(contentArray).toHaveLength(2);
+ expect(contentArray[0].type).toBe('text');
+ expect(contentArray[0].text).toContain('image data');
+ expect(contentArray[1].type).toBe('image_url');
+ expect(contentArray[1].image_url?.url).toContain('data:image/png');
+ });
+
+ it('should work correctly when MCP tool returns a single text part', () => {
+ // Single text part — the control case that has always worked
+ const mcpTransformedParts: Part[] = [
+ { text: 'Single text response from MCP tool' },
+ ];
+
+ const callId = 'call_mcp_single';
+ const toolName = 'mcp_tool';
+ const responseParts = convertToFunctionResponse(
+ toolName,
+ callId,
+ mcpTransformedParts,
+ );
+
+ const contents: Content[] = [
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: callId,
+ name: toolName,
+ args: {},
+ },
+ },
+ ],
+ },
+ {
+ role: 'user',
+ parts: responseParts,
+ },
+ ];
+
+ const request: GenerateContentParameters = {
+ model: 'models/test',
+ contents,
+ };
+ const messages = converter.convertGeminiRequestToOpenAI(request);
+
+ const toolMessages = messages.filter((m) => m.role === 'tool');
+ const userMessages = messages.filter((m) => m.role === 'user');
+
+ expect(toolMessages).toHaveLength(1);
+ expect(userMessages).toHaveLength(0);
+
+ const toolMsg = toolMessages[0];
+ const toolContent = toolMsg.content;
+ expect(Array.isArray(toolContent)).toBe(true);
+ const toolTexts = (toolContent as Array<{ type: string; text?: string }>)
+ .filter((p) => p.type === 'text')
+ .map((p) => p.text);
+ expect(toolTexts).toHaveLength(1);
+ expect(toolTexts[0]).toBe('Single text response from MCP tool');
+ });
+
+ it('should preserve MCP multi-text + multi-image content in tool message', () => {
+ // Simulates a complex MCP response with multiple text blocks and images
+ const mcpTransformedParts: Part[] = [
+ { text: 'Here is the design mockup:' },
+ {
+ text: "[Tool 'pencil' provided the following image data with mime-type: image/png]",
+ },
+ {
+ inlineData: {
+ mimeType: 'image/png',
+ data: 'screenshotBase64Data',
+ },
+ },
+ { text: 'And here are the node details...' },
+ ];
+
+ const callId = 'call_pencil_1';
+ const toolName = 'mcp__pencil__get_screenshot';
+ const responseParts = convertToFunctionResponse(
+ toolName,
+ callId,
+ mcpTransformedParts,
+ );
+
+ const contents: Content[] = [
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: callId,
+ name: toolName,
+ args: { nodeId: 'vHOGa' },
+ },
+ },
+ ],
+ },
+ {
+ role: 'user',
+ parts: responseParts,
+ },
+ ];
+
+ const request: GenerateContentParameters = {
+ model: 'models/test',
+ contents,
+ };
+ const messages = converter.convertGeminiRequestToOpenAI(request);
+
+ const toolMessages = messages.filter((m) => m.role === 'tool');
+ const userMessages = messages.filter((m) => m.role === 'user');
+
+ expect(toolMessages).toHaveLength(1);
+ expect(userMessages).toHaveLength(0);
+
+ const toolMsg = toolMessages[0];
+ expect((toolMsg as { tool_call_id: string }).tool_call_id).toBe(callId);
+
+ const toolContent = toolMsg.content;
+ expect(Array.isArray(toolContent)).toBe(true);
+ const contentArray = toolContent as Array<{
+ type: string;
+ text?: string;
+ image_url?: { url: string };
+ }>;
+
+ // Should have text (all joined) + image
+ expect(contentArray).toHaveLength(2);
+ expect(contentArray[0].type).toBe('text');
+ expect(contentArray[0].text).toContain('design mockup');
+ expect(contentArray[0].text).toContain('image data');
+ expect(contentArray[0].text).toContain('node details');
+ expect(contentArray[1].type).toBe('image_url');
+ expect(contentArray[1].image_url?.url).toContain('data:image/png');
+ });
+});