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
45 changes: 45 additions & 0 deletions src/filesystem/__tests__/structured-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
});
});
40 changes: 29 additions & 11 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
},
Expand All @@ -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] }
Expand Down
Loading