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 = {