Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions common/src/mcp/__tests__/call-mcp-tool-resources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'bun:test'
import { writeFileSync } from 'node:fs'
import { join, dirname } from 'node:path'

import { callMCPTool, getMCPClient } from '../client'

import type { MCPConfig } from '../../types/mcp'

/**
* Wiring guard: the resource-mapping fix lives in
* mcpContentToToolResultOutputs (unit-tested exhaustively next door in
* mcp-content-mapping.test.ts). This test pins only the unique confidence
* of the wiring — that the real stdio transport's tool results flow
* through that mapping and reach callMCPTool's caller — not the mapping
* itself.
*/

const SERVER_SCRIPT = String.raw`
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'

const server = new McpServer({ name: 'mapping-contract-server', version: '1.0.0' })

server.registerTool('get_text_resource', { inputSchema: {} }, async () => ({
content: [{
type: 'resource',
resource: {
uri: 'file:///notes.txt',
mimeType: 'text/plain',
text: 'Resource 1: This is a plain text resource.',
},
}],
}))

await server.connect(new StdioServerTransport())
`

const EXPECTED_TEXT = 'Resource 1: This is a plain text resource.'

test('callMCPTool wires real stdio tool results through the resource mapping', async () => {
const scriptPath = join(dirname(import.meta.path), 'mapping-contract-server.ts')
writeFileSync(scriptPath, SERVER_SCRIPT)
const config: MCPConfig = {
type: 'stdio',
command: 'bun',
args: [scriptPath],
env: process.env as Record<string, string>,
}

const clientId = await getMCPClient(config)

const outputs = (await callMCPTool(clientId, {
name: 'get_text_resource',
arguments: {},
} as never)) as { type: string; value?: string }[]

expect(outputs).toHaveLength(1)
expect(outputs[0].type).toBe('json')
expect(outputs[0].value).toBe(EXPECTED_TEXT)
})
18 changes: 18 additions & 0 deletions common/src/mcp/__tests__/mapping-contract-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'

const server = new McpServer({ name: 'mapping-contract-server', version: '1.0.0' })

server.registerTool('get_text_resource', { inputSchema: {} }, async () => ({
content: [{
type: 'resource',
resource: {
uri: 'file:///notes.txt',
mimeType: 'text/plain',
text: 'Resource 1: This is a plain text resource.',
},
}],
}))

await server.connect(new StdioServerTransport())
102 changes: 102 additions & 0 deletions common/src/mcp/__tests__/mcp-content-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, test, expect } from 'bun:test'

import { mcpContentToToolResultOutputs } from '../content-mapping'

/**
* Regression tests for MCP tool-result content mapping.
*
* Tool results live in message history and are replayed into every later
* prompt build, and the AI SDK base64-decodes file-part data at prompt
* build. Text content therefore never travels as media: prose stored as
* media died with "The string contains invalid characters" on every
* subsequent turn, permanently, because the poisoned message replays from
* history.
*/
describe('mcpContentToToolResultOutputs resources', () => {
/**
* Given: an MCP resource whose contents are plain text.
* When: it is mapped.
* Then: the output is a json value carrying that text - never media.
*/
test('maps text resource to json value not media', () => {
const outputs = mcpContentToToolResultOutputs([
{
type: 'resource',
resource: {
uri: 'file:///notes.txt',
mimeType: 'text/plain',
text: 'Resource 1: This is a plain text resource.',
},
},
] as never)

expect(outputs).toEqual([
{
type: 'json',
value: 'Resource 1: This is a plain text resource.',
},
])
})

/**
* Given: an MCP resource carrying binary image data.
* When: it is mapped.
* Then: the output stays media with the server's mime type, because
* every provider path accepts image file parts.
*/
test('keeps image resource as media with server mime type', () => {
const outputs = mcpContentToToolResultOutputs([
{
type: 'resource',
resource: {
uri: 'file:///logo.png',
mimeType: 'image/png',
blob: 'aGVsbG8=',
},
},
] as never)

expect(outputs).toHaveLength(1)
expect(outputs[0].type).toBe('media')
expect((outputs[0] as { mediaType?: string }).mediaType).toBe('image/png')
})

/**
* Given: an MCP resource carrying non-image binary data.
* When: it is mapped.
* Then: the output is descriptive text, not media - media here killed
* the OpenAI-compatible converter at prompt build (session death).
*/
test('maps non-image binary resource to descriptive text not media', () => {
const outputs = mcpContentToToolResultOutputs([
{
type: 'resource',
resource: {
uri: 'file:///archive.gz',
mimeType: 'application/gzip',
blob: 'aGVsbG8=',
},
},
] as never)

expect(outputs[0].type).toBe('json')

const value = (outputs[0] as { value: string }).value
expect(value).toContain('application/gzip')
expect(value).toContain('not displayable')
})

/**
* Given: an ordinary MCP text content block (no resource involved).
* When: it is mapped.
* Then: it stays a json value - the extraction must not alter the
* pre-existing text mapping.
*/
test('maps plain text content to json value', () => {
const outputs = mcpContentToToolResultOutputs([
{ type: 'text', text: 'Echo: hello' },
] as never)

expect(outputs).toEqual([{ type: 'json', value: 'Echo: hello' }])
})
})
49 changes: 2 additions & 47 deletions common/src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'

import { getErrorObject } from '../util/error'
import { mcpContentToToolResultOutputs } from './content-mapping'

import type { MCPConfig } from '../types/mcp'
import type { ToolResultOutput } from '../types/messages/content-part'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import type {
BlobResourceContents,
CallToolResult,
TextResourceContents,
} from '@modelcontextprotocol/sdk/types.js'

// Cap on how much of a failed stdio server's stderr we retain for the error
Expand Down Expand Up @@ -173,14 +172,6 @@ export function listMCPTools(
return listToolsCache[clientId]
}

function getResourceData(
resource: TextResourceContents | BlobResourceContents,
): string {
if ('text' in resource) return resource.text as string
if ('blob' in resource) return resource.blob as string
return ''
}

export async function callMCPTool(
clientId: string,
...args: Parameters<typeof Client.prototype.callTool>
Expand All @@ -193,41 +184,5 @@ export async function callMCPTool(
const result = callResult as CallToolResult
const content = result.content

return content.map((c: (typeof content)[number]) => {
if (c.type === 'text') {
return {
type: 'json',
value: c.text,
} satisfies ToolResultOutput
}
if (c.type === 'audio') {
return {
type: 'media',
data: c.data,
mediaType: c.mimeType,
} satisfies ToolResultOutput
}
if (c.type === 'image') {
return {
type: 'media',
data: c.data,
mediaType: c.mimeType,
} satisfies ToolResultOutput
}
if (c.type === 'resource') {
return {
type: 'media',
data: getResourceData(c.resource),
mediaType: c.resource.mimeType ?? 'text/plain',
} satisfies ToolResultOutput
}
const fallbackValue =
'uri' in c && typeof (c as { uri: unknown }).uri === 'string'
? (c as { uri: string }).uri
: JSON.stringify(c)
return {
type: 'json',
value: fallbackValue,
} satisfies ToolResultOutput
})
return mcpContentToToolResultOutputs(content)
}
82 changes: 82 additions & 0 deletions common/src/mcp/content-mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { CallToolResult, TextResourceContents, BlobResourceContents } from '@modelcontextprotocol/sdk/types.js'

import type { ToolResultOutput } from '../types/messages/content-part'

function getResourceData(
resource: TextResourceContents | BlobResourceContents,
): string {
if ('text' in resource) return resource.text as string
if ('blob' in resource) return resource.blob as string
return ''
}

/**
* Convert MCP tool-result content blocks into codebuff tool-result outputs.
*
* A resource with text contents is text, not media. Wrapping prose as
* media makes the AI SDK base64-decode it when rebuilding the prompt on
* every later turn, which dies with "The string contains invalid
* characters" forever, since the poisoned message replays from history.
*
* Only images stay media: every provider path (including the
* OpenAI-compatible chat converter used by GLM) accepts image file
* parts but throws on anything else — and a thrown converter poisons
* the whole session, since the message replays on every later turn.
* Other binary resources (gzip, PDF, ...) surface metadata instead of
* undecodable bytes.
*/
export function mcpContentToToolResultOutputs(
content: CallToolResult['content'],
): ToolResultOutput[] {
return content.map((c: (typeof content)[number]) => {
if (c.type === 'text') {
return {
type: 'json',
value: c.text,
} satisfies ToolResultOutput
}
if (c.type === 'audio') {
return {
type: 'media',
data: c.data,
mediaType: c.mimeType,
} satisfies ToolResultOutput
}
if (c.type === 'image') {
return {
type: 'media',
data: c.data,
mediaType: c.mimeType,
} satisfies ToolResultOutput
}
if (c.type === 'resource') {
if ('text' in c.resource) {
return {
type: 'json',
value: c.resource.text,
} satisfies ToolResultOutput
}
const mimeType = c.resource.mimeType ?? 'application/octet-stream'
if (mimeType.startsWith('image/')) {
return {
type: 'media',
data: getResourceData(c.resource),
mediaType: mimeType,
} satisfies ToolResultOutput
}
const blobData = getResourceData(c.resource)
return {
type: 'json',
value: `[Binary resource ${c.resource.uri}: ${mimeType}, ~${Math.round((blobData.length * 3) / 4)} bytes, not displayable]`,
} satisfies ToolResultOutput
}
const fallbackValue =
'uri' in c && typeof (c as { uri: unknown }).uri === 'string'
? (c as { uri: string }).uri
: JSON.stringify(c)
return {
type: 'json',
value: fallbackValue,
} satisfies ToolResultOutput
})
}
Loading
Loading