diff --git a/src/filesystem/__tests__/structured-content.test.ts b/src/filesystem/__tests__/structured-content.test.ts index 4b8f92b0a3..0cc56f1964 100644 --- a/src/filesystem/__tests__/structured-content.test.ts +++ b/src/filesystem/__tests__/structured-content.test.ts @@ -155,4 +155,49 @@ describe('structuredContent schema compliance', () => { expect(Array.isArray(structuredContent.content)).toBe(false); }); }); + + describe('read_media_file (issue #4029)', () => { + it('returns type: "image" for image files (valid MCP content type)', async () => { + // 1x1 transparent PNG (base64) + const pngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + const pngPath = path.join(testDir, 'pixel.png'); + await fs.writeFile(pngPath, Buffer.from(pngBase64, 'base64')); + + const result = await client.callTool({ + name: 'read_media_file', + arguments: { path: pngPath } + }); + + const content = result.content as Array<{ type: string }>; + expect(Array.isArray(content)).toBe(true); + expect(content[0].type).toBe('image'); + // 'blob' is NOT a valid MCP content type per the spec. + expect(content[0].type).not.toBe('blob'); + }); + + it('returns type: "resource" for non-image/audio binaries (valid MCP content type)', async () => { + const binPath = path.join(testDir, 'data.bin'); + await fs.writeFile(binPath, Buffer.from([0x00, 0x01, 0x02, 0x03])); + + const result = await client.callTool({ + name: 'read_media_file', + arguments: { path: binPath } + }); + + const content = result.content as Array<{ + type: string; + resource?: { uri: string; mimeType: string; blob: string }; + }>; + expect(Array.isArray(content)).toBe(true); + // Must be a valid MCP content type (text | image | audio | resource_link | resource). + expect(['image', 'audio', 'resource']).toContain(content[0].type); + expect(content[0].type).not.toBe('blob'); + if (content[0].type === 'resource') { + expect(content[0].resource).toBeDefined(); + expect(typeof content[0].resource!.uri).toBe('string'); + expect(typeof content[0].resource!.blob).toBe('string'); + } + }); + }); }); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..ba69fe2495 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -256,11 +256,21 @@ server.registerTool( path: z.string() }, outputSchema: { - content: z.array(z.object({ - type: z.enum(["image", "audio", "blob"]), - data: z.string(), - mimeType: z.string() - })) + content: z.array(z.union([ + z.object({ + type: z.enum(["image", "audio"]), + data: z.string(), + mimeType: z.string() + }), + z.object({ + type: z.literal("resource"), + resource: z.object({ + uri: z.string(), + mimeType: z.string(), + blob: z.string() + }) + }) + ])) }, annotations: { readOnlyHint: true } }, @@ -283,13 +293,21 @@ server.registerTool( const mimeType = mimeTypes[extension] || "application/octet-stream"; const data = await readFileAsBase64Stream(validPath); - const type = mimeType.startsWith("image/") - ? "image" + // Map MIME type to a valid MCP content type. The spec only allows + // text, image, audio, resource_link, and resource. Non-image/audio + // binaries are returned as an embedded resource so clients accept them. + const contentItem = mimeType.startsWith("image/") + ? { type: "image" as const, data, mimeType } : mimeType.startsWith("audio/") - ? "audio" - // Fallback for other binary types, not officially supported by the spec but has been used for some time - : "blob"; - const contentItem = { type: type as 'image' | 'audio' | 'blob', data, mimeType }; + ? { type: "audio" as const, data, mimeType } + : { + type: "resource" as const, + resource: { + uri: `file://${validPath}`, + mimeType, + blob: data + } + }; return { content: [contentItem], structuredContent: { content: [contentItem] }