Merge pull request #33 from cesarafonseca/fix/mcp-tool-output-pretty-json
feat: improve MCP tool input/output rendering
This commit is contained in:
commit
51e053a5b3
3 changed files with 174 additions and 5 deletions
|
|
@ -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}
|
||||
|
|
|
|||
37
test/renderer/components/renderOutput.test.ts
Normal file
37
test/renderer/components/renderOutput.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
59
test/renderer/utils/renderHelpers.test.ts
Normal file
59
test/renderer/utils/renderHelpers.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue