feat: add image content block support

- Add ImageContent interface and images() method to CallResult
- Extract type: 'image' content blocks from MCP responses
- Save images to files in auto/image output modes
- Include images in JSON output when present
- Print images alongside text/markdown content

Fixes issue where MCP servers returning image content blocks
(e.g., photos_get_images) had their image data silently dropped.
This commit is contained in:
root 2026-02-05 23:14:33 +00:00 committed by Peter Steinberger
parent 8988adda77
commit 11bbd67969
2 changed files with 87 additions and 1 deletions

View File

@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { inspect } from 'node:util';
import type { CallResult } from '../result-utils.js';
import type { CallResult, ImageContent } from '../result-utils.js';
import { logWarn } from './logger-context.js';
export type OutputFormat = 'auto' | 'text' | 'markdown' | 'json' | 'raw';
@ -120,6 +121,60 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
}
}
export function saveCallImagesIfRequested<T>(wrapped: CallResult<T>, outputDir: string | undefined): void {
if (!outputDir) {
return;
}
const images = wrapped.images();
if (!images || images.length === 0) {
return;
}
const resolvedDir = path.resolve(outputDir);
try {
fs.mkdirSync(resolvedDir, { recursive: true });
} catch (error) {
logWarn(`Unable to create image output directory ${resolvedDir}: ${(error as Error).message}`);
return;
}
writeImages(images, resolvedDir);
}
function writeImages(images: ImageContent[], outputDir: string): void {
for (let i = 0; i < images.length; i++) {
const img = images[i];
const ext = extensionFromMimeType(img.mimeType);
const outputPath = resolveImageOutputPath(outputDir, i + 1, ext);
try {
const buffer = Buffer.from(img.data, 'base64');
fs.writeFileSync(outputPath, buffer);
console.error(`[mcporter] Saved image: ${outputPath} (${buffer.length} bytes, ${img.mimeType})`);
} catch (writeError) {
logWarn(`Failed to save image ${i + 1} (${img.mimeType}): ${(writeError as Error).message}`);
}
}
}
function extensionFromMimeType(mimeType: string): string {
const subtype = mimeType.split('/')[1]?.split(';')[0]?.trim().toLowerCase();
if (subtype && /^[a-z0-9.+-]+$/.test(subtype)) {
return subtype;
}
return 'png';
}
function resolveImageOutputPath(outputDir: string, imageIndex: number, extension: string): string {
const baseName = `image-${imageIndex}`;
let attempt = 0;
while (true) {
const suffix = attempt === 0 ? '' : `-${attempt}`;
const candidate = path.join(outputDir, `${baseName}${suffix}.${extension}`);
if (!fs.existsSync(candidate)) {
return candidate;
}
attempt += 1;
}
}
function attemptPrintJson(value: unknown): boolean {
if (value === undefined) {
return false;

View File

@ -1,10 +1,16 @@
import { analyzeConnectionError, type ConnectionIssue } from './error-classifier.js';
export interface ImageContent {
data: string;
mimeType: string;
}
export interface CallResult<T = unknown> {
raw: T;
text(joiner?: string): string | null;
markdown(joiner?: string): string | null;
json<J = unknown>(): J | null;
images(): ImageContent[] | null;
content(): unknown[] | null;
structuredContent(): unknown;
}
@ -69,6 +75,24 @@ function asString(value: unknown): string | null {
return null;
}
// collectImages extracts all image content blocks.
function collectImages(content: unknown[]): ImageContent[] | null {
const images: ImageContent[] = [];
for (const entry of content) {
if (entry && typeof entry === 'object' && 'type' in entry) {
const typedEntry = entry as Record<string, unknown>;
if (typedEntry.type === 'image') {
const data = typedEntry.data;
const mimeType = typedEntry.mimeType ?? 'image/png';
if (typeof data === 'string' && typeof mimeType === 'string') {
images.push({ data, mimeType });
}
}
}
}
return images.length > 0 ? images : null;
}
// collectText flattens all text/markdown entries into a joined string.
function collectText(content: unknown[], joiner: string): string | null {
const pieces: string[] = [];
@ -230,6 +254,13 @@ export function createCallResult<T = unknown>(raw: T): CallResult<T> {
}
return null;
},
images() {
const content = extractContentArray(raw);
if (!content) {
return null;
}
return collectImages(content);
},
content() {
return extractContentArray(raw);
},