diff --git a/CHANGELOG.md b/CHANGELOG.md
index 251f7c6..edc9726 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
- `createCallResult().json()` now collects all parseable JSON entries from MCP content arrays (single item stays backward-compatible), and raw inspect depth now stays readable without unbounded traversal. (PR #91, thanks @Blankdlh)
- OAuth wait/redirect now share one deferred to eliminate authorization race windows and preserve stable close-path errors, including wait-before-redirect and repeated-redirect flows. (PR #70, thanks @monotykamary)
- Added `--raw-strings` (numeric coercion off) and `--no-coerce` (all coercion off) for `mcporter call` argument parsing so IDs/codes can stay literal strings. (PR #59, thanks @nobrainer-tech)
+- Added `CallResult.images()` plus opt-in `mcporter call --save-images
` so image content blocks can be persisted without changing existing stdout output contracts. (PR #61, thanks @daniella-11ways)
### Tooling / Dependencies
- Updated dependencies to latest releases (including MCP SDK, Rolldown RC, Zod, Biome, Oxlint, Vitest, Bun types).
diff --git a/README.md b/README.md
index 26ec296..de02710 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ MCPorter helps you lean into the "code execution" workflows highlighted in Anthr
- **Zero-config discovery.** `createRuntime()` merges your home config (`~/.mcporter/mcporter.json[c]`) first, then `config/mcporter.json`, plus Cursor/Claude/Codex/Windsurf/OpenCode/VS Code imports, expands `${ENV}` placeholders, and pools connections so you can reuse transports across multiple calls.
- **One-command CLI generation.** `mcporter generate-cli` turns any MCP server definition into a ready-to-run CLI, with optional bundling/compilation and metadata for easy regeneration.
- **Typed tool clients.** `mcporter emit-ts` emits `.d.ts` interfaces or ready-to-run client wrappers so agents/tests can call MCP servers with strong TypeScript types without hand-writing plumbing.
-- **Friendly composable API.** `createServerProxy()` exposes tools as ergonomic camelCase methods, automatically applies JSON-schema defaults, validates required arguments, and hands back a `CallResult` with `.text()`, `.markdown()`, `.json()`, and `.content()` helpers.
+- **Friendly composable API.** `createServerProxy()` exposes tools as ergonomic camelCase methods, automatically applies JSON-schema defaults, validates required arguments, and hands back a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, and `.content()` helpers.
- **OAuth and stdio ergonomics.** Built-in OAuth caching, log tailing, and stdio wrappers let you work with HTTP, SSE, and stdio transports from the same interface.
- **Ad-hoc connections.** Point the CLI at *any* MCP endpoint (HTTP or stdio) without touching config, then persist it later if you want. Hosted MCPs that expect a browser login (Supabase, Vercel, etc.) are auto-detected—just run `mcporter auth ` and the CLI promotes the definition to OAuth on the fly. See [docs/adhoc.md](docs/adhoc.md).
@@ -146,6 +146,7 @@ Helpful flags:
- `--oauth-timeout ` -- shorten/extend the OAuth browser wait; same as `MCPORTER_OAUTH_TIMEOUT_MS` / `MCPORTER_OAUTH_TIMEOUT`.
- `--tail-log` -- stream the last 20 lines of any log files referenced by the tool response.
- `--output ` or `--raw` -- control formatted output (defaults to pretty-printed auto detection).
+- `--save-images ` (on `mcporter call`) -- save MCP image content blocks to files in the given directory (opt-in; stdout output shape stays unchanged).
- `--raw-strings` (on `mcporter call`) -- keep numeric-looking argument values (for `key=value`, `key:value`, and trailing positional values) as strings.
- `--no-coerce` (on `mcporter call`) -- keep all `key=value` and positional values as raw strings (disables bool/null/number/JSON coercion).
- `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata.
@@ -275,7 +276,7 @@ Friendly ergonomics baked into the proxy and result helpers:
- Property names map from camelCase to kebab-case tool names (`takeSnapshot` -> `take_snapshot`).
- Positional arguments map onto schema-required fields automatically, and option objects respect JSON-schema defaults.
-- Results are wrapped in a `CallResult`, so you can choose `.text()`, `.markdown()`, `.json()`, `.content()`, or access `.raw` when you need the full envelope.
+- Results are wrapped in a `CallResult`, so you can choose `.text()`, `.markdown()`, `.json()`, `.images()`, `.content()`, or access `.raw` when you need the full envelope.
Drop down to `runtime.callTool()` whenever you need explicit control over arguments, metadata, or streaming options.
diff --git a/docs/call-syntax.md b/docs/call-syntax.md
index 4effbf6..50ee7b2 100644
--- a/docs/call-syntax.md
+++ b/docs/call-syntax.md
@@ -71,4 +71,5 @@ Key details:
- By default, arguments keep the same validation pipeline as the function-call syntax—enums, numbers, and booleans are coerced automatically, and missing required fields raise errors.
- `--raw-strings` disables numeric coercion for flag-style and positional values so IDs/codes stay literal strings (`code=12345` stays `"12345"`).
- `--no-coerce` disables all coercion for flag-style and positional values (`true`, `null`, and JSON-like values remain strings).
+- `--save-images ` keeps stdout formatting untouched while writing image content blocks to disk when a tool response includes `type: "image"` entries.
- `tool=value`/`tool:value` and `server=value` still act as aliases for `--tool` / `--server` when you need to override the selector.
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index b292d8b..155200a 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -28,6 +28,7 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--server`, `--tool` – alternate way to target a tool.
- `--timeout ` – override call timeout (defaults to `CALL_TIMEOUT_MS`).
- `--output text|markdown|json|raw` – choose how to render the `CallResult`.
+ - `--save-images ` – persist image content blocks to files under the specified directory.
- `--raw-strings` – disable numeric coercion for flag-style and positional values.
- `--no-coerce` – disable all flag-style/positional value coercion.
- `--tail-log` – stream tail output when the tool returns log handles.
diff --git a/src/cli/call-arguments.ts b/src/cli/call-arguments.ts
index 425fc6a..93183fa 100644
--- a/src/cli/call-arguments.ts
+++ b/src/cli/call-arguments.ts
@@ -18,6 +18,7 @@ export interface CallArgsParseResult {
timeoutMs?: number;
ephemeral?: EphemeralServerSpec;
rawStrings?: boolean;
+ saveImagesDir?: string;
}
type CoercionMode = 'default' | 'raw-strings' | 'none';
@@ -68,6 +69,15 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
index += 1;
continue;
}
+ if (token === '--save-images') {
+ const value = args[index + 1];
+ if (!value) {
+ throw new Error('--save-images requires a directory path.');
+ }
+ result.saveImagesDir = value;
+ index += 2;
+ continue;
+ }
if (token === '--yes') {
index += 1;
continue;
diff --git a/src/cli/call-command.ts b/src/cli/call-command.ts
index a1d6d12..7af4f7d 100644
--- a/src/cli/call-command.ts
+++ b/src/cli/call-command.ts
@@ -12,7 +12,7 @@ import {
import { buildConnectionIssueEnvelope } from './json-output.js';
import { handleList } from './list-command.js';
import type { OutputFormat } from './output-utils.js';
-import { printCallOutput, tailLogIfRequested } from './output-utils.js';
+import { printCallOutput, saveCallImagesIfRequested, tailLogIfRequested } from './output-utils.js';
import { dumpActiveHandles } from './runtime-debug.js';
import { dimText, redText, yellowText } from './terminal.js';
import { resolveCallTimeout, withTimeout } from './timeouts.js';
@@ -107,6 +107,7 @@ export async function handleCall(
const { callResult: wrapped } = wrapCallResult(result);
printCallOutput(wrapped, result, parsed.output);
+ saveCallImagesIfRequested(wrapped, parsed.saveImagesDir);
tailLogIfRequested(result, parsed.tailLog);
dumpActiveHandles('after call (formatted result)');
}
@@ -130,6 +131,7 @@ export function printCallHelp(): void {
'Runtime flags:',
' --timeout Override the call timeout.',
' --output text|markdown|json|raw Control formatting.',
+ ' --save-images Save image content blocks to a directory.',
' --raw-strings Keep numeric-looking argument values as strings.',
' --no-coerce Keep all key/value and positional arguments as raw strings.',
' --tail-log Stream returned log handles.',
diff --git a/src/cli/output-utils.ts b/src/cli/output-utils.ts
index cc3f58e..4563e5f 100644
--- a/src/cli/output-utils.ts
+++ b/src/cli/output-utils.ts
@@ -142,6 +142,9 @@ export function saveCallImagesIfRequested(wrapped: CallResult, outputDir:
function writeImages(images: ImageContent[], outputDir: string): void {
for (let i = 0; i < images.length; i++) {
const img = images[i];
+ if (!img) {
+ continue;
+ }
const ext = extensionFromMimeType(img.mimeType);
const outputPath = resolveImageOutputPath(outputDir, i + 1, ext);
try {
diff --git a/src/index.ts b/src/index.ts
index a5f7a37..622361b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,6 @@
export type { CommandSpec, ServerDefinition } from './config.js';
export { loadServerDefinitions } from './config.js';
-export type { CallResult, ConnectionIssue } from './result-utils.js';
+export type { CallResult, ConnectionIssue, ImageContent } from './result-utils.js';
export { createCallResult, describeConnectionIssue, wrapCallResult } from './result-utils.js';
export type {
CallOptions,
diff --git a/tests/call-arguments.test.ts b/tests/call-arguments.test.ts
index b73e62c..8883f2b 100644
--- a/tests/call-arguments.test.ts
+++ b/tests/call-arguments.test.ts
@@ -93,4 +93,13 @@ describe('parseCallArguments', () => {
expect(typeof parsed.args.id).toBe('string');
expect(parsed.positionalArgs).toEqual(['123']);
});
+
+ it('captures --save-images output directory', () => {
+ const parsed = parseCallArguments(['--save-images', './tmp/images', 'server.tool']);
+ expect(parsed.saveImagesDir).toBe('./tmp/images');
+ });
+
+ it('throws when --save-images has no value', () => {
+ expect(() => parseCallArguments(['--save-images'])).toThrow(/--save-images requires a directory path/);
+ });
});
diff --git a/tests/cli-output-utils.test.ts b/tests/cli-output-utils.test.ts
index bbb7001..8ec543d 100644
--- a/tests/cli-output-utils.test.ts
+++ b/tests/cli-output-utils.test.ts
@@ -1,5 +1,8 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
-import { printCallOutput } from '../src/cli/output-utils.js';
+import { printCallOutput, saveCallImagesIfRequested } from '../src/cli/output-utils.js';
import { createCallResult } from '../src/result-utils.js';
describe('printCallOutput raw output', () => {
@@ -53,3 +56,61 @@ describe('printCallOutput raw output', () => {
}
});
});
+
+describe('saveCallImagesIfRequested', () => {
+ it('does nothing when no output directory is provided', () => {
+ const wrapped = createCallResult({
+ content: [{ type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' }],
+ });
+ const writeSpy = vi.spyOn(fs, 'writeFileSync');
+ try {
+ saveCallImagesIfRequested(wrapped, undefined);
+ expect(writeSpy).not.toHaveBeenCalled();
+ } finally {
+ writeSpy.mockRestore();
+ }
+ });
+
+ it('saves image content blocks to the requested directory', () => {
+ const wrapped = createCallResult({
+ content: [{ type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' }],
+ });
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-images-'));
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ saveCallImagesIfRequested(wrapped, tempDir);
+ const files = fs.readdirSync(tempDir);
+ expect(files.length).toBe(1);
+ const first = files[0];
+ expect(first?.endsWith('.png')).toBe(true);
+ const outputPath = path.join(tempDir, first ?? '');
+ expect(fs.readFileSync(outputPath, 'utf8')).toBe('hello');
+ } finally {
+ errorSpy.mockRestore();
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it('keeps json output on stdout unchanged when saving images', () => {
+ const raw = {
+ content: [
+ { type: 'json', json: { id: 1 } },
+ { type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' },
+ ],
+ };
+ const wrapped = createCallResult(raw);
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-images-'));
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ printCallOutput(wrapped, raw, 'json');
+ saveCallImagesIfRequested(wrapped, tempDir);
+ expect(logSpy).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual({ id: 1 });
+ } finally {
+ logSpy.mockRestore();
+ errorSpy.mockRestore();
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/tests/result-utils.test.ts b/tests/result-utils.test.ts
index 47e1c33..0c61d26 100644
--- a/tests/result-utils.test.ts
+++ b/tests/result-utils.test.ts
@@ -74,6 +74,40 @@ describe('createCallResult text extraction', () => {
});
});
+describe('createCallResult image extraction', () => {
+ it('extracts image blocks from content', () => {
+ const response = {
+ content: [
+ { type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' },
+ { type: 'image', mimeType: 'image/jpeg', data: 'd29ybGQ=' },
+ ],
+ };
+ const result = createCallResult(response);
+ expect(result.images()).toEqual([
+ { mimeType: 'image/png', data: 'aGVsbG8=' },
+ { mimeType: 'image/jpeg', data: 'd29ybGQ=' },
+ ]);
+ });
+
+ it('extracts image blocks nested under raw.content', () => {
+ const response = {
+ raw: {
+ content: [{ type: 'image', data: 'aGVsbG8=' }],
+ },
+ };
+ const result = createCallResult(response);
+ expect(result.images()).toEqual([{ mimeType: 'image/png', data: 'aGVsbG8=' }]);
+ });
+
+ it('returns null when no images exist', () => {
+ const response = {
+ content: [{ type: 'text', text: 'no image here' }],
+ };
+ const result = createCallResult(response);
+ expect(result.images()).toBeNull();
+ });
+});
+
describe('createCallResult markdown extraction', () => {
it('extracts markdown from content array', () => {
const response = {