fix: make image handling opt-in and preserve output contracts (#61) (thanks @daniella-11ways)
This commit is contained in:
parent
8991515fa8
commit
09dd8da147
@ -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 <dir>` 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).
|
||||
|
||||
@ -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 <url>` 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 <ms>` -- 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 <format>` or `--raw` -- control formatted output (defaults to pretty-printed auto detection).
|
||||
- `--save-images <dir>` (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.
|
||||
|
||||
|
||||
@ -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 <dir>` 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.
|
||||
|
||||
@ -28,6 +28,7 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
||||
- `--server`, `--tool` – alternate way to target a tool.
|
||||
- `--timeout <ms>` – override call timeout (defaults to `CALL_TIMEOUT_MS`).
|
||||
- `--output text|markdown|json|raw` – choose how to render the `CallResult`.
|
||||
- `--save-images <dir>` – 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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 <ms> Override the call timeout.',
|
||||
' --output text|markdown|json|raw Control formatting.',
|
||||
' --save-images <dir> 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.',
|
||||
|
||||
@ -142,6 +142,9 @@ export function saveCallImagesIfRequested<T>(wrapped: CallResult<T>, 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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user