refactor: streamline call parsing/output pipeline

This commit is contained in:
Peter Steinberger 2026-03-02 22:58:07 +00:00
parent 7ff297ef2e
commit af2f60eecf
8 changed files with 582 additions and 418 deletions

View File

@ -23,9 +23,35 @@ export interface CallArgsParseResult {
type CoercionMode = 'default' | 'raw-strings' | 'none';
interface FlagParseState {
coercionMode: CoercionMode;
}
interface FlagHandlerContext {
args: string[];
index: number;
result: CallArgsParseResult;
state: FlagParseState;
}
type FlagHandler = (context: FlagHandlerContext) => number;
const FLAG_HANDLERS: Record<string, FlagHandler> = {
'--server': handleServerFlag,
'--mcp': handleServerFlag,
'--tool': handleToolFlag,
'--timeout': handleTimeoutFlag,
'--tail-log': handleTailLogFlag,
'--save-images': handleSaveImagesFlag,
'--yes': handleNoopFlag,
'--raw-strings': handleRawStringsFlag,
'--no-coerce': handleNoCoerceFlag,
'--args': handleArgsFlag,
};
export function parseCallArguments(args: string[]): CallArgsParseResult {
const result: CallArgsParseResult = { args: {}, tailLog: false, output: 'auto' };
let coercionMode: CoercionMode = 'default';
const flagState: FlagParseState = { coercionMode: 'default' };
const ephemeral = extractEphemeralServerFlags(args);
result.ephemeral = ephemeral;
result.output = consumeOutputFormat(args, {
@ -39,76 +65,9 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
index += 1;
continue;
}
if (token === '--server' || token === '--mcp') {
const value = args[index + 1];
if (!value) {
throw new Error(`Flag '${token}' requires a value.`);
}
result.server = value;
index += 2;
continue;
}
if (token === '--tool') {
const value = args[index + 1];
if (!value) {
throw new Error(`Flag '${token}' requires a value.`);
}
result.tool = value;
index += 2;
continue;
}
if (token === '--timeout') {
result.timeoutMs = consumeTimeoutFlag(args, index, {
flagName: '--timeout',
missingValueMessage: '--timeout requires a value (milliseconds).',
});
continue;
}
if (token === '--tail-log') {
result.tailLog = true;
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;
}
if (token === '--raw-strings') {
coercionMode = 'raw-strings';
result.rawStrings = true;
index += 1;
continue;
}
if (token === '--no-coerce') {
coercionMode = 'none';
result.rawStrings = true;
index += 1;
continue;
}
if (token === '--args') {
const value = args[index + 1];
if (!value) {
throw new Error('--args requires a JSON value.');
}
try {
const decoded = JSON.parse(value);
if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) {
throw new Error('--args must be a JSON object.');
}
Object.assign(result.args, decoded);
} catch (error) {
throw new Error(`Unable to parse --args: ${(error as Error).message}`);
}
index += 2;
const flagHandler = FLAG_HANDLERS[token];
if (flagHandler) {
index = flagHandler({ args, index, result, state: flagState });
continue;
}
positional.push(token);
@ -194,12 +153,12 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
}
const parsed = parseKeyValueToken(token, positional[index + 1]);
if (!parsed) {
trailingPositional.push(coerceValue(token, coercionMode));
trailingPositional.push(coerceValue(token, flagState.coercionMode));
index += 1;
continue;
}
index += parsed.consumed;
const value = coerceValue(parsed.rawValue, coercionMode);
const value = coerceValue(parsed.rawValue, flagState.coercionMode);
if (parsed.key === 'tool' && !result.tool) {
if (typeof value !== 'string') {
throw new Error("Argument 'tool' must be a string value.");
@ -222,6 +181,80 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
return result;
}
function handleServerFlag(context: FlagHandlerContext): number {
const token = context.args[context.index] ?? '--server';
context.result.server = consumeFlagValue(context.args, context.index, token);
return context.index + 2;
}
function handleToolFlag(context: FlagHandlerContext): number {
context.result.tool = consumeFlagValue(context.args, context.index, '--tool');
return context.index + 2;
}
function handleTimeoutFlag(context: FlagHandlerContext): number {
context.result.timeoutMs = consumeTimeoutFlag(context.args, context.index, {
flagName: '--timeout',
missingValueMessage: '--timeout requires a value (milliseconds).',
});
// consumeTimeoutFlag removes the flag/value pair in-place; stay on the same index.
return context.index;
}
function handleTailLogFlag(context: FlagHandlerContext): number {
context.result.tailLog = true;
return context.index + 1;
}
function handleSaveImagesFlag(context: FlagHandlerContext): number {
context.result.saveImagesDir = consumeFlagValue(
context.args,
context.index,
'--save-images',
'--save-images requires a directory path.'
);
return context.index + 2;
}
function handleNoopFlag(context: FlagHandlerContext): number {
return context.index + 1;
}
function handleRawStringsFlag(context: FlagHandlerContext): number {
context.state.coercionMode = 'raw-strings';
context.result.rawStrings = true;
return context.index + 1;
}
function handleNoCoerceFlag(context: FlagHandlerContext): number {
context.state.coercionMode = 'none';
context.result.rawStrings = true;
return context.index + 1;
}
function handleArgsFlag(context: FlagHandlerContext): number {
const raw = consumeFlagValue(context.args, context.index, '--args', '--args requires a JSON value.');
let decoded: unknown;
try {
decoded = JSON.parse(raw);
} catch (error) {
throw new Error(`Unable to parse --args: ${(error as Error).message}`);
}
if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) {
throw new Error('Unable to parse --args: --args must be a JSON object.');
}
Object.assign(context.result.args, decoded);
return context.index + 2;
}
function consumeFlagValue(args: string[], index: number, token: string, missingValueMessage?: string): string {
const value = args[index + 1];
if (value) {
return value;
}
throw new Error(missingValueMessage ?? `Flag '${token}' requires a value.`);
}
interface ParsedKeyValueToken {
key: string;
rawValue: string;

View File

@ -9,22 +9,59 @@ import {
normalizeIdentifier,
renderIdentifierResolutionMessages,
} from './identifier-helpers.js';
import { saveCallImagesIfRequested } from './image-output.js';
import { buildConnectionIssueEnvelope } from './json-output.js';
import { handleList } from './list-command.js';
import type { OutputFormat } from './output-utils.js';
import { printCallOutput, saveCallImagesIfRequested, tailLogIfRequested } from './output-utils.js';
import { printCallOutput, tailLogIfRequested } from './output-utils.js';
import { dumpActiveHandles } from './runtime-debug.js';
import { dimText, redText, yellowText } from './terminal.js';
import { resolveCallTimeout, withTimeout } from './timeouts.js';
import { loadToolMetadata } from './tool-cache.js';
export async function handleCall(
runtime: Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>,
args: string[]
): Promise<void> {
const parsed = parseCallArguments(args);
let ephemeralSpec = parsed.ephemeral ? { ...parsed.ephemeral } : undefined;
type Runtime = Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>;
interface ResolvedCallTarget {
server: string;
tool: string;
}
interface PreparedCallRequest extends ResolvedCallTarget {
parsed: CallArgsParseResult;
hydratedArgs: Record<string, unknown>;
timeoutMs: number;
}
export async function handleCall(runtime: Runtime, args: string[]): Promise<void> {
const prepared = await prepareCallRequest(runtime, args);
if (!prepared) {
return;
}
const invocation = await invokePreparedCall(runtime, prepared);
if (!invocation) {
return;
}
renderCallResult(invocation.result, prepared.parsed);
}
async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<PreparedCallRequest | undefined> {
const parsed = parseCallArguments(args);
await normalizeParsedCallArguments(runtime, parsed);
const { server, tool } = await resolveServerAndTool(runtime, parsed);
if (await maybeDescribeServer(runtime, server, tool, parsed.output)) {
return undefined;
}
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
return { parsed, server, tool, hydratedArgs, timeoutMs };
}
async function normalizeParsedCallArguments(runtime: Runtime, parsed: CallArgsParseResult): Promise<void> {
let ephemeralSpec = parsed.ephemeral ? { ...parsed.ephemeral } : undefined;
const nameHints: string[] = [];
const absorbUrlCandidate = (value: string | undefined): string | undefined => {
if (!value) {
@ -70,7 +107,9 @@ export async function handleCall(
if (!parsed.selector) {
parsed.selector = prepared.target;
}
}
async function resolveServerAndTool(runtime: Runtime, parsed: CallArgsParseResult): Promise<ResolvedCallTarget> {
const target = resolveCallTarget(parsed, { allowMissingTool: true });
const server = target.server;
let tool = target.tool;
@ -83,28 +122,36 @@ export async function handleCall(
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
}
}
return { server, tool };
}
if (await maybeDescribeServer(runtime, server, tool, parsed.output)) {
return;
}
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
async function invokePreparedCall(
runtime: Runtime,
prepared: PreparedCallRequest
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
let invocation: { result: unknown; resolvedTool: string };
try {
invocation = await invokeWithAutoCorrection(runtime, server, tool, hydratedArgs, timeoutMs);
invocation = await invokeWithAutoCorrection(
runtime,
prepared.server,
prepared.tool,
prepared.hydratedArgs,
prepared.timeoutMs
);
} catch (error) {
const issue = maybeReportConnectionIssue(server, tool, error);
if (parsed.output === 'json' || parsed.output === 'raw') {
const payload = buildConnectionIssueEnvelope({ server, tool, error, issue });
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
if (prepared.parsed.output === 'json' || prepared.parsed.output === 'raw') {
const payload = buildConnectionIssueEnvelope({ server: prepared.server, tool: prepared.tool, error, issue });
console.log(JSON.stringify(payload, null, 2));
process.exitCode = 1;
return;
return undefined;
}
throw error;
}
const { result } = invocation;
return invocation;
}
function renderCallResult(result: unknown, parsed: CallArgsParseResult): void {
const { callResult: wrapped } = wrapCallResult(result);
printCallOutput(wrapped, result, parsed.output);
saveCallImagesIfRequested(wrapped, parsed.saveImagesDir);

62
src/cli/image-output.ts Normal file
View File

@ -0,0 +1,62 @@
import fs from 'node:fs';
import path from 'node:path';
import type { CallResult } from '../result-utils.js';
export function saveCallImagesIfRequested<T>(wrapped: CallResult<T>, outputDir: string | undefined): void {
if (!outputDir) {
return;
}
const images = wrapped.images();
if (!images || images.length === 0) {
return;
}
fs.mkdirSync(outputDir, { recursive: true });
const writtenPaths: string[] = [];
const timestamp = Date.now();
for (const [index, image] of images.entries()) {
const extension = extensionFromMimeType(image.mimeType);
const baseName = `mcp-image-${timestamp}-${index + 1}`;
const outputPath = resolveImageOutputPath(outputDir, baseName, extension);
fs.writeFileSync(outputPath, Buffer.from(image.data, 'base64'));
writtenPaths.push(outputPath);
}
for (const outputPath of writtenPaths) {
console.error(`[mcporter] Saved image: ${outputPath}`);
}
}
function extensionFromMimeType(mimeType: string | undefined): string {
if (!mimeType) {
return '.bin';
}
const normalized = mimeType.toLowerCase().split(';', 1)[0]?.trim() ?? '';
const mapping: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/webp': '.webp',
'image/gif': '.gif',
'image/svg+xml': '.svg',
'image/bmp': '.bmp',
'image/tiff': '.tiff',
'image/x-icon': '.ico',
};
return mapping[normalized] ?? '.bin';
}
function resolveImageOutputPath(outputDir: string, baseName: string, extension: string): string {
let candidate = path.join(outputDir, `${baseName}${extension}`);
if (!fs.existsSync(candidate)) {
return candidate;
}
let suffix = 2;
while (true) {
candidate = path.join(outputDir, `${baseName}-${suffix}${extension}`);
if (!fs.existsSync(candidate)) {
return candidate;
}
suffix += 1;
}
}

View File

@ -1,80 +1,30 @@
import fs from 'node:fs';
import path from 'node:path';
import { inspect } from 'node:util';
import type { CallResult, ImageContent } from '../result-utils.js';
import type { CallResult } from '../result-utils.js';
import { logWarn } from './logger-context.js';
export type OutputFormat = 'auto' | 'text' | 'markdown' | 'json' | 'raw';
const RAW_INSPECT_DEPTH = 8;
type RenderableKind = 'json' | 'markdown' | 'text' | 'raw';
interface RenderableOutput {
kind: RenderableKind;
value: unknown;
}
const PREFERRED_OUTPUT_BY_FORMAT: Record<OutputFormat, RenderableKind[]> = {
auto: ['json', 'markdown', 'text', 'raw'],
text: ['text', 'markdown', 'json', 'raw'],
markdown: ['markdown', 'text', 'json', 'raw'],
json: ['json', 'raw'],
raw: ['raw'],
};
export function printCallOutput<T>(wrapped: CallResult<T>, raw: T, format: OutputFormat): void {
switch (format) {
case 'raw': {
printRaw(raw);
return;
}
case 'json': {
const jsonValue = wrapped.json();
if (jsonValue !== null && attemptPrintJson(jsonValue)) {
return;
}
printRaw(raw);
return;
}
case 'markdown': {
const markdown = wrapped.markdown();
if (typeof markdown === 'string') {
console.log(markdown);
return;
}
const text = wrapped.text();
if (typeof text === 'string') {
console.log(text);
return;
}
const jsonValue = wrapped.json();
if (jsonValue !== null && attemptPrintJson(jsonValue)) {
return;
}
printRaw(raw);
return;
}
case 'text': {
const text = wrapped.text();
if (typeof text === 'string') {
console.log(text);
return;
}
const markdown = wrapped.markdown();
if (typeof markdown === 'string') {
console.log(markdown);
return;
}
const jsonValue = wrapped.json();
if (jsonValue !== null && attemptPrintJson(jsonValue)) {
return;
}
printRaw(raw);
return;
}
default: {
const jsonValue = wrapped.json();
if (jsonValue !== null && attemptPrintJson(jsonValue)) {
return;
}
const markdown = wrapped.markdown();
if (typeof markdown === 'string') {
console.log(markdown);
return;
}
const text = wrapped.text();
if (typeof text === 'string') {
console.log(text);
return;
}
printRaw(raw);
}
}
const preferredKinds = PREFERRED_OUTPUT_BY_FORMAT[format];
const renderable = resolveRenderableOutput(wrapped, raw, preferredKinds);
emitRenderableOutput(renderable);
}
export function tailLogIfRequested(result: unknown, enabled: boolean): void {
@ -121,61 +71,52 @@ 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];
if (!img) {
function resolveRenderableOutput<T>(
wrapped: CallResult<T>,
raw: T,
preferredKinds: RenderableKind[]
): RenderableOutput {
for (const kind of preferredKinds) {
if (kind === 'json') {
const jsonValue = wrapped.json();
if (jsonValue !== null) {
return { kind, value: jsonValue };
}
continue;
}
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}`);
if (kind === 'markdown') {
const markdown = wrapped.markdown();
if (typeof markdown === 'string') {
return { kind, value: markdown };
}
continue;
}
if (kind === 'text') {
const text = wrapped.text();
if (typeof text === 'string') {
return { kind, value: text };
}
continue;
}
if (kind === 'raw') {
return { kind, value: raw };
}
}
return { kind: 'raw', value: raw };
}
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;
function emitRenderableOutput(renderable: RenderableOutput): void {
if (renderable.kind === 'json') {
if (!attemptPrintJson(renderable.value)) {
printRaw(renderable.value);
}
attempt += 1;
return;
}
if (renderable.kind === 'markdown' || renderable.kind === 'text') {
console.log(String(renderable.value));
return;
}
printRaw(renderable.value);
}
function attemptPrintJson(value: unknown): boolean {

View File

@ -15,52 +15,47 @@ export interface CallResult<T = unknown> {
structuredContent(): unknown;
}
// extractContentArray pulls the `content` array from MCP response envelopes.
function extractContentArray(raw: unknown): unknown[] | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const obj = raw as Record<string, unknown>;
// Check for content array at top level
if ('content' in obj && Array.isArray(obj.content)) {
return obj.content as unknown[];
}
// Check for content array nested inside 'raw' wrapper
if ('raw' in obj && obj.raw && typeof obj.raw === 'object') {
const nested = obj.raw as Record<string, unknown>;
if ('content' in nested && Array.isArray(nested.content)) {
return nested.content as unknown[];
}
}
return null;
interface ExtractedEnvelope {
content: unknown[] | null;
structuredContent: unknown;
}
// extractStructuredContent returns the structuredContent field when present.
function extractStructuredContent(raw: unknown): unknown {
interface CollectedCallContent {
content: unknown[] | null;
structuredContent: unknown;
textEntries: string[];
markdownEntries: string[];
jsonCandidates: unknown[];
images: ImageContent[];
}
function extractEnvelope(raw: unknown): ExtractedEnvelope {
if (!raw || typeof raw !== 'object') {
return null;
return { content: null, structuredContent: null };
}
const obj = raw as Record<string, unknown>;
let content: unknown[] | null = null;
let structuredContent: unknown = null;
// Check for structuredContent at top level
if ('content' in obj && Array.isArray(obj.content)) {
content = obj.content as unknown[];
}
if ('structuredContent' in obj) {
return obj.structuredContent;
structuredContent = obj.structuredContent;
}
// Check for structuredContent nested inside 'raw' wrapper
if ('raw' in obj && obj.raw && typeof obj.raw === 'object') {
const nested = obj.raw as Record<string, unknown>;
if ('structuredContent' in nested) {
return nested.structuredContent;
if (!content && 'content' in nested && Array.isArray(nested.content)) {
content = nested.content as unknown[];
}
if (structuredContent === null && 'structuredContent' in nested) {
structuredContent = nested.structuredContent;
}
}
return null;
return { content, structuredContent };
}
// asString converts known content/value shapes into plain strings.
@ -75,42 +70,92 @@ function asString(value: unknown): string | null {
return null;
}
// collectImages extracts all image content blocks.
function collectImages(content: unknown[]): ImageContent[] | null {
function collectCallContent(raw: unknown): CollectedCallContent {
const envelope = extractEnvelope(raw);
const textEntries: string[] = [];
const markdownEntries: string[] = [];
const jsonCandidates: unknown[] = [];
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 });
}
if (!envelope.content) {
return {
content: envelope.content,
structuredContent: envelope.structuredContent,
textEntries,
markdownEntries,
jsonCandidates,
images,
};
}
for (const entry of envelope.content) {
if (typeof entry === 'string') {
const parsed = tryParseJson(entry);
if (parsed !== null) {
jsonCandidates.push(parsed);
}
continue;
}
if (!entry || typeof entry !== 'object' || !('type' in entry)) {
continue;
}
const typedEntry = entry as Record<string, unknown>;
if (typedEntry.type === 'json') {
const parsed = tryParseJson(entry);
if (parsed !== null) {
jsonCandidates.push(parsed);
}
continue;
}
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 });
}
continue;
}
if (typedEntry.type !== 'text' && typedEntry.type !== 'markdown') {
continue;
}
const text = asString(entry);
if (!text) {
continue;
}
textEntries.push(text);
if (typedEntry.type === 'markdown') {
markdownEntries.push(text);
}
const parsed = tryParseJson(text);
if (parsed !== null) {
jsonCandidates.push(parsed);
}
}
return images.length > 0 ? images : null;
return {
content: envelope.content,
structuredContent: envelope.structuredContent,
textEntries,
markdownEntries,
jsonCandidates,
images,
};
}
// collectText flattens all text/markdown entries into a joined string.
function collectText(content: unknown[], joiner: string): string | null {
const pieces: string[] = [];
for (const entry of content) {
if (entry && typeof entry === 'object' && 'type' in entry) {
const type = (entry as Record<string, unknown>).type;
if (type === 'text' || type === 'markdown') {
const text = asString(entry);
if (text) {
pieces.push(text);
}
}
}
function collectText(entries: string[], joiner: string): string | null {
if (entries.length === 0) {
return null;
}
if (pieces.length > 0) {
return pieces.join(joiner);
return entries.join(joiner);
}
function collectImages(images: ImageContent[]): ImageContent[] | null {
if (images.length === 0) {
return null;
}
return null;
return images;
}
// tryParseJson pulls JSON payloads out of structured responses or raw strings.
@ -138,6 +183,15 @@ function tryParseJson(value: unknown): unknown {
// createCallResult wraps a tool response with helpers for common content types.
export function createCallResult<T = unknown>(raw: T): CallResult<T> {
let cachedContent: CollectedCallContent | undefined;
const getCollectedContent = (): CollectedCallContent => {
if (cachedContent) {
return cachedContent;
}
cachedContent = collectCallContent(raw);
return cachedContent;
};
return {
raw,
text(joiner = '\n') {
@ -148,95 +202,42 @@ export function createCallResult<T = unknown>(raw: T): CallResult<T> {
return raw;
}
const content = extractContentArray(raw);
if (content) {
const collected = collectText(content, joiner);
if (collected) {
return collected;
}
const collected = getCollectedContent();
const combinedText = collectText(collected.textEntries, joiner);
if (combinedText) {
return combinedText;
}
const structured = extractStructuredContent(raw);
const asStr = asString(structured);
return asStr ?? null;
return asString(collected.structuredContent);
},
markdown(joiner = '\n') {
const structured = extractStructuredContent(raw);
const collected = getCollectedContent();
const structured = collected.structuredContent;
if (structured && typeof structured === 'object') {
const markdown = (structured as Record<string, unknown>).markdown;
if (typeof markdown === 'string') {
return markdown;
}
}
const content = extractContentArray(raw);
if (!content) {
return null;
}
const markdownEntries = content.filter(
(entry) => entry && typeof entry === 'object' && (entry as Record<string, unknown>).type === 'markdown'
);
if (markdownEntries.length === 0) {
return null;
}
return markdownEntries
.map((entry) => asString(entry) ?? '')
.filter(Boolean)
.join(joiner);
return collectText(collected.markdownEntries, joiner);
},
json<J = unknown>() {
const structured = extractStructuredContent(raw);
const parsedStructured = tryParseJson(structured);
const collected = getCollectedContent();
const parsedStructured = tryParseJson(collected.structuredContent);
if (parsedStructured !== null) {
return parsedStructured as J;
}
const content = extractContentArray(raw);
if (content) {
const collected: unknown[] = [];
for (const entry of content) {
if (entry && typeof entry === 'object') {
const typedEntry = entry as Record<string, unknown>;
if (typedEntry.type === 'json') {
const parsed = tryParseJson(entry);
if (parsed !== null) {
collected.push(parsed);
}
continue;
}
if (typedEntry.type === 'text' || typedEntry.type === 'markdown') {
const text = asString(entry);
if (typeof text === 'string') {
const parsedText = tryParseJson(text);
if (parsedText !== null) {
collected.push(parsedText);
}
}
continue;
}
}
if (typeof entry === 'string') {
const parsed = tryParseJson(entry);
if (parsed !== null) {
collected.push(parsed);
}
}
}
if (collected.length === 1) {
return collected[0] as J;
}
if (collected.length > 1) {
return collected as J;
}
if (collected.jsonCandidates.length === 1) {
return collected.jsonCandidates[0] as J;
}
if (collected.jsonCandidates.length > 1) {
return collected.jsonCandidates as J;
}
if (typeof raw === 'string') {
const parsedRaw = tryParseJson(raw);
if (parsedRaw !== null) {
return parsedRaw as J;
}
}
const textContent = this.text?.();
if (typeof textContent === 'string') {
const parsedText = tryParseJson(textContent);
@ -244,7 +245,6 @@ export function createCallResult<T = unknown>(raw: T): CallResult<T> {
return parsedText as J;
}
}
const markdownContent = this.markdown?.();
if (typeof markdownContent === 'string') {
const parsedMarkdown = tryParseJson(markdownContent);
@ -255,17 +255,14 @@ export function createCallResult<T = unknown>(raw: T): CallResult<T> {
return null;
},
images() {
const content = extractContentArray(raw);
if (!content) {
return null;
}
return collectImages(content);
const collected = getCollectedContent();
return collectImages(collected.images);
},
content() {
return extractContentArray(raw);
return getCollectedContent().content;
},
structuredContent() {
return extractStructuredContent(raw);
return getCollectedContent().structuredContent;
},
};
}

View File

@ -11,6 +11,12 @@ describe('parseCallArguments', () => {
expect(parsed.args.format).toBe('json');
});
it.each(['--server', '--mcp'] as const)('captures %s as server override', (flag) => {
const parsed = parseCallArguments([flag, 'linear', 'list_documents']);
expect(parsed.server).toBe('linear');
expect(parsed.tool).toBe('list_documents');
});
it('consumes function-style call expressions with HTTP selectors', () => {
const call = 'https://example.com/mcp.getComponents(limit: 3, projectId: "123")';
const parsed = parseCallArguments([call]);
@ -50,17 +56,14 @@ describe('parseCallArguments', () => {
warnSpy.mockRestore();
});
it('coerces numeric strings to numbers by default', () => {
const parsed = parseCallArguments(['server.tool', 'code=123456']);
expect(parsed.args.code).toBe(123456);
expect(typeof parsed.args.code).toBe('number');
});
it('preserves numeric strings when --raw-strings flag is used', () => {
const parsed = parseCallArguments(['--raw-strings', 'server.tool', 'code=123456']);
expect(parsed.args.code).toBe('123456');
expect(typeof parsed.args.code).toBe('string');
expect(parsed.rawStrings).toBe(true);
it.each([
['default', [], 123456, 'number'],
['raw-strings', ['--raw-strings'], '123456', 'string'],
['no-coerce', ['--no-coerce'], '123456', 'string'],
] as const)('handles numeric coercion in %s mode', (_mode, flags, expected, expectedType) => {
const parsed = parseCallArguments([...flags, 'server.tool', 'code=123456']);
expect(parsed.args.code).toBe(expected);
expect(typeof parsed.args.code).toBe(expectedType);
});
it('preserves leading zeros when --raw-strings flag is used', () => {
@ -99,7 +102,10 @@ describe('parseCallArguments', () => {
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/);
it.each([
['--save-images', /--save-images requires a directory path/],
['--args', /--args requires a JSON value/],
] as const)('throws when %s is missing a value', (flag, expectedError) => {
expect(() => parseCallArguments([flag])).toThrow(expectedError);
});
});

View File

@ -0,0 +1,65 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { saveCallImagesIfRequested } from '../src/cli/image-output.js';
import { printCallOutput } from '../src/cli/output-utils.js';
import { createCallResult } from '../src/result-utils.js';
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 });
}
});
});

View File

@ -1,10 +1,81 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { printCallOutput, saveCallImagesIfRequested } from '../src/cli/output-utils.js';
import { printCallOutput } from '../src/cli/output-utils.js';
import { createCallResult } from '../src/result-utils.js';
describe('printCallOutput format selection', () => {
it.each([
[
'auto prefers json payloads when available',
'auto',
{
content: [
{ type: 'text', text: 'fallback text' },
{ type: 'json', json: { source: 'json' } },
{ type: 'markdown', text: '# heading' },
],
},
(logged: unknown) => {
expect(JSON.parse(String(logged))).toEqual({ source: 'json' });
},
],
[
'text prefers text over markdown/json',
'text',
{
content: [
{ type: 'text', text: 'plain text wins' },
{ type: 'markdown', text: '# heading' },
{ type: 'json', json: { source: 'json' } },
],
},
(logged: unknown) => {
expect(logged).toBe('plain text wins\n# heading');
},
],
[
'markdown prefers markdown content',
'markdown',
{
content: [
{ type: 'text', text: 'plain text' },
{ type: 'markdown', text: '## markdown wins' },
],
},
(logged: unknown) => {
expect(logged).toBe('## markdown wins');
},
],
[
'json falls back to raw output when no JSON candidate exists',
'json',
'raw-only-string',
(logged: unknown) => {
expect(logged).toBe('raw-only-string');
},
],
[
'raw prints inspect output even when json exists',
'raw',
{ content: [{ type: 'json', json: { id: 1 } }] },
(logged: unknown) => {
expect(String(logged)).toContain("type: 'json'");
},
],
] as const)('%s', (_name, format, raw, assertLogged) => {
const wrapped = createCallResult(raw);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
printCallOutput(wrapped, raw, format);
expect(log).toHaveBeenCalledTimes(1);
const logged = log.mock.calls[0]?.[0];
assertLogged(logged);
} finally {
log.mockRestore();
}
});
});
describe('printCallOutput raw output', () => {
it('does not truncate long strings when printing raw output', () => {
const longText = 'x'.repeat(15000);
@ -56,61 +127,3 @@ 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 });
}
});
});