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:
parent
8988adda77
commit
11bbd67969
@ -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;
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user