From 6919c1b03c8c8c581c6cdc123b0bef3523cd2a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 10 Jun 2026 13:39:21 +0200 Subject: [PATCH] fix: render optimized MCP command output by default --- src/mcp/__tests__/command-tools.test.ts | 68 ++++++++++++++++-- src/mcp/command-tools.ts | 93 ++++++++++++++++++++----- 2 files changed, 140 insertions(+), 21 deletions(-) diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts index 2156ea5c5..12697c1f4 100644 --- a/src/mcp/__tests__/command-tools.test.ts +++ b/src/mcp/__tests__/command-tools.test.ts @@ -14,22 +14,76 @@ test('MCP command tool executor hides client creation behind an execution adapte }, runCommand: async (actualClient, name, input) => { calls.push({ client: actualClient, name, input }); - return { name, ok: true }; + return { message: `Ran ${name}`, ok: true }; }, }); - const result = await executor.execute('devices', { stateDir: '/tmp/agent-device-mcp' }); + const result = await executor.execute('wait', { + stateDir: '/tmp/agent-device-mcp', + mcpOutputFormat: 'optimized', + }); assert.deepEqual(createdConfigs, [{ stateDir: '/tmp/agent-device-mcp' }]); assert.deepEqual(calls, [ { client, - name: 'devices', + name: 'wait', input: {}, }, ]); - assert.deepEqual(result.structuredContent, { name: 'devices', ok: true }); - assert.match(result.content[0]?.text ?? '', /"name": "devices"/); + assert.deepEqual(result.structuredContent, { message: 'Ran wait', ok: true }); + assert.equal(result.content[0]?.text, 'Ran wait'); +}); + +test('MCP command tool executor renders optimized snapshot text by default', async () => { + const executor = createCommandToolExecutor({ + createClient: () => ({}) as AgentDeviceClient, + runCommand: async () => ({ + nodes: [ + { + ref: 'e1', + index: 0, + depth: 0, + type: 'Button', + label: 'Continue', + enabled: true, + }, + ], + truncated: false, + }), + }); + + const result = await executor.execute('snapshot', {}); + + assert.match(result.content[0]?.text ?? '', /@e1 \[button\] "Continue"/); + assert.doesNotMatch(result.content[0]?.text ?? '', /^\{/); +}); + +test('MCP command tool executor renders JSON text when requested', async () => { + const executor = createCommandToolExecutor({ + createClient: () => ({}) as AgentDeviceClient, + runCommand: async (_client, _name, input) => { + assert.deepEqual(input, {}); + return { + nodes: [ + { + ref: 'e1', + index: 0, + depth: 0, + type: 'Button', + label: 'Continue', + enabled: true, + }, + ], + truncated: false, + }; + }, + }); + + const result = await executor.execute('snapshot', { mcpOutputFormat: 'json' }); + + assert.match(result.content[0]?.text ?? '', /^\{\n "nodes": \[/); + assert.match(result.content[0]?.text ?? '', /"label": "Continue"/); }); test('MCP tool schemas add MCP client config fields at the MCP boundary', () => { @@ -37,4 +91,8 @@ test('MCP tool schemas add MCP client config fields at the MCP boundary', () => assert.ok(devicesTool); assert.ok('stateDir' in (devicesTool.inputSchema.properties ?? {})); + assert.deepEqual( + (devicesTool.inputSchema.properties?.mcpOutputFormat as { enum?: unknown[] } | undefined)?.enum, + ['optimized', 'json'], + ); }); diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index 95367d27d..c0e56bcba 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -1,5 +1,6 @@ import type { AgentDeviceClient, AgentDeviceClientConfig } from '../client-types.ts'; import type { JsonSchema } from '../commands/command-contract.ts'; +import { formatCliOutput } from '../commands/cli-output.ts'; import { isCommandName, listMcpCommandMetadata, @@ -23,6 +24,13 @@ type CommandToolExecutor = { execute: (name: string, input: unknown) => Promise; }; +type McpOutputFormat = 'optimized' | 'json'; + +type McpToolConfig = { + client: AgentDeviceClientConfig; + outputFormat: McpOutputFormat; +}; + export function listCommandTools(): Array<{ name: string; description: string; @@ -41,16 +49,24 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C if (!isCommandName(name)) { throw new Error(`Unknown command tool: ${name}`); } - const client = await createClient(deps, readClientConfig(input)); - const result = await (deps.runCommand ?? runCommand)( - client, - name, - stripClientConfigFields(input), - ); + const config = readMcpToolConfig(input); + const commandInput = stripMcpConfigFields(input); + const client = await createClient(deps, config.client); + const result = await (deps.runCommand ?? runCommand)(client, name, commandInput); return { isError: false, structuredContent: result, - content: [{ type: 'text', text: renderToolText(result) }], + content: [ + { + type: 'text', + text: renderToolText({ + name, + input: commandInput, + result, + outputFormat: config.outputFormat, + }), + }, + ], }; }, }; @@ -76,19 +92,42 @@ async function runCommand( return await commandSurface.runCommand(client, name, input); } -function readClientConfig(input: unknown): AgentDeviceClientConfig { - if (!input || typeof input !== 'object' || Array.isArray(input)) return {}; - const stateDir = (input as Record).stateDir; - if (stateDir === undefined) return {}; - if (typeof stateDir !== 'string' || stateDir.length === 0) { +function readMcpToolConfig(input: unknown): McpToolConfig { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return { client: {}, outputFormat: 'optimized' }; + } + const record = input as Record; + return { + client: readClientConfig(record), + outputFormat: readMcpOutputFormat(record.mcpOutputFormat), + }; +} + +function readClientConfig(record: Record): AgentDeviceClientConfig { + const stateDir = record.stateDir; + const client: AgentDeviceClientConfig = {}; + if (stateDir !== undefined && (typeof stateDir !== 'string' || stateDir.length === 0)) { throw new Error('Expected stateDir to be a non-empty string.'); } - return { stateDir }; + if (typeof stateDir === 'string') client.stateDir = stateDir; + return client; +} + +function readMcpOutputFormat(outputFormat: unknown): McpOutputFormat { + if (outputFormat === undefined) return 'optimized'; + if (outputFormat !== 'optimized' && outputFormat !== 'json') { + throw new Error('Expected mcpOutputFormat to be "optimized" or "json".'); + } + return outputFormat; } -function stripClientConfigFields(input: unknown): unknown { +function stripMcpConfigFields(input: unknown): unknown { if (!input || typeof input !== 'object' || Array.isArray(input)) return input; - const { stateDir: _stateDir, ...commandInput } = input as Record; + const { + stateDir: _stateDir, + mcpOutputFormat: _mcpOutputFormat, + ...commandInput + } = input as Record; return commandInput; } @@ -98,10 +137,32 @@ function withMcpConfigSchema(schema: JsonSchema): JsonSchema { properties: { ...schema.properties, stateDir: { type: 'string', description: 'Agent-device state directory.' }, + mcpOutputFormat: { + type: 'string', + enum: ['optimized', 'json'], + description: + 'MCP text content format. Defaults to optimized agent-friendly text; use json for JSON text. Structured content is always returned separately.', + }, }, }; } -function renderToolText(value: unknown): string { +function renderToolText(params: { + name: CommandName; + input: unknown; + result: unknown; + outputFormat: McpOutputFormat; +}): string { + if (params.outputFormat === 'json') return renderJsonText(params.result); + const cliOutput = formatCliOutput({ + name: params.name, + input: params.input, + result: params.result, + }); + if (typeof cliOutput?.text === 'string') return cliOutput.text; + return renderJsonText(cliOutput?.data ?? params.result); +} + +function renderJsonText(value: unknown): string { return typeof value === 'string' ? value : JSON.stringify(value, null, 2); }