Merge pull request #33 from cesarafonseca/fix/mcp-tool-output-pretty-json

feat: improve MCP tool input/output rendering
This commit is contained in:
matt 2026-02-20 12:34:18 +09:00 committed by GitHub
commit 51e053a5b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 174 additions and 5 deletions

View file

@ -95,19 +95,92 @@ export function renderInput(toolName: string, input: Record<string, unknown>): R
);
}
// Default: JSON format
// Default: key-value format with readable string values
return (
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
{JSON.stringify(input, null, 2)}
</pre>
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
{Object.entries(input).map(([key, value]) => (
<div key={key}>
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
{key}
</div>
<pre className="whitespace-pre-wrap break-all">{formatInputValue(value)}</pre>
</div>
))}
</div>
);
}
function formatInputValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value, null, 2);
}
/**
* Renders the output section with theme-aware styling.
*/
/**
* Extracts display text from tool output content.
* Handles content block arrays from the API by extracting text fields
* and pretty-printing JSON when possible.
*/
export function extractOutputText(content: string | unknown[]): string {
let displayText: string;
// Normalize: if content is a string that parses to an array of content blocks, treat as array
let normalizedContent: string | unknown[] = content;
if (typeof content === 'string') {
try {
const parsed: unknown = JSON.parse(content);
if (Array.isArray(parsed) && parsed.length > 0 && isContentBlock(parsed[0])) {
normalizedContent = parsed as unknown[];
}
} catch {
// Not JSON, keep as string
}
}
if (typeof normalizedContent === 'string') {
displayText = normalizedContent;
} else if (Array.isArray(normalizedContent)) {
// Extract text from content blocks (e.g. [{"type":"text","text":"..."}])
displayText = normalizedContent
.map((block) =>
typeof block === 'object' && block !== null && 'text' in block
? (block as { text: string }).text
: JSON.stringify(block, null, 2),
)
.join('\n');
} else {
displayText = JSON.stringify(normalizedContent, null, 2);
}
// Try to pretty-print if the extracted text is valid JSON
try {
const parsed: unknown = JSON.parse(displayText);
displayText = JSON.stringify(parsed, null, 2);
} catch {
// Not JSON, use as-is
}
return displayText;
}
function isContentBlock(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
typeof (value as Record<string, unknown>).type === 'string'
);
}
export function renderOutput(content: string | unknown[]): React.ReactElement {
const displayText = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
const displayText = extractOutputText(content);
return (
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
{displayText}

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers';
describe('extractOutputText', () => {
it('should return plain string as-is', () => {
expect(extractOutputText('hello world')).toBe('hello world');
});
it('should pretty-print a plain string that is valid JSON', () => {
expect(extractOutputText('{"key":"value"}')).toBe(JSON.stringify({ key: 'value' }, null, 2));
});
it('should extract text from content blocks with plain text', () => {
expect(extractOutputText([{ type: 'text', text: 'plain text' }])).toBe('plain text');
});
it('should extract and pretty-print JSON from content blocks', () => {
expect(extractOutputText([{ type: 'text', text: '{"key":"value"}' }])).toBe(
JSON.stringify({ key: 'value' }, null, 2),
);
});
it('should concatenate multiple content blocks with newline', () => {
expect(
extractOutputText([
{ type: 'text', text: 'line one' },
{ type: 'text', text: 'line two' },
]),
).toBe('line one\nline two');
});
it('should fallback to stringify for blocks without text field', () => {
const block = { type: 'image', url: 'http://example.com/img.png' };
expect(extractOutputText([block])).toBe(JSON.stringify(block, null, 2));
});
});

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers';
describe('renderHelpers', () => {
describe('extractOutputText', () => {
it('should return plain string content as-is', () => {
expect(extractOutputText('hello world')).toBe('hello world');
});
it('should pretty-print string content that is valid JSON', () => {
const json = '{"name":"test","value":42}';
expect(extractOutputText(json)).toBe('{\n "name": "test",\n "value": 42\n}');
});
it('should extract text from content block arrays', () => {
const content = [{ type: 'text', text: 'hello world' }];
expect(extractOutputText(content)).toBe('hello world');
});
it('should extract and pretty-print JSON from content block arrays', () => {
const inner = { teams: [{ id: '1', name: 'Test' }] };
const content = [{ type: 'text', text: JSON.stringify(inner) }];
expect(extractOutputText(content)).toBe(JSON.stringify(inner, null, 2));
});
it('should handle serialized content block arrays (string wrapping content blocks)', () => {
// This is what SemanticStepExtractor produces when content is an array
const inner = { teams: [{ id: '1', name: 'Test' }] };
const contentBlocks = [{ type: 'text', text: JSON.stringify(inner) }];
const serialized = JSON.stringify(contentBlocks);
const result = extractOutputText(serialized);
expect(result).toBe(JSON.stringify(inner, null, 2));
});
it('should handle serialized content blocks with plain text', () => {
const contentBlocks = [{ type: 'text', text: 'Some plain text\nwith newlines' }];
const serialized = JSON.stringify(contentBlocks);
const result = extractOutputText(serialized);
expect(result).toBe('Some plain text\nwith newlines');
});
it('should join multiple content blocks with newlines', () => {
const content = [
{ type: 'text', text: 'first' },
{ type: 'text', text: 'second' },
];
expect(extractOutputText(content)).toBe('first\nsecond');
});
it('should stringify non-text content blocks', () => {
const content = [{ type: 'image', url: 'http://example.com/img.png' }];
const result = extractOutputText(content);
expect(result).toContain('"type": "image"');
});
});
});