diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts index b795cccb5..6d911da0a 100644 --- a/src/mcp/__tests__/command-tools.test.ts +++ b/src/mcp/__tests__/command-tools.test.ts @@ -59,6 +59,28 @@ test('MCP command tool executor renders optimized snapshot text by default', asy assert.doesNotMatch(result.content[0]?.text ?? '', /^\{/); }); +test('MCP renders a non-default response level as JSON text, not misleading optimized text', async () => { + // With responseLevel:digest the daemon returns the digest shape (no `nodes`). + // The optimized snapshot formatter expects `nodes` and would print + // "Snapshot: 0 nodes" — contradicting structuredContent. The text must instead + // be the digest payload verbatim (JSON), even though mcpOutputFormat is optimized. + const digest = { nodeCount: 3, refs: [{ ref: 'e1', label: 'Continue' }], truncated: false }; + const executor = createCommandToolExecutor({ + createClient: () => ({}) as AgentDeviceClient, + runCommand: async () => digest, + }); + + const result = await executor.execute('snapshot', { + mcpOutputFormat: 'optimized', + responseLevel: 'digest', + }); + + assert.deepEqual(result.structuredContent, digest); + assert.match(result.content[0]?.text ?? '', /^\{/); + assert.deepEqual(JSON.parse(result.content[0]?.text ?? ''), digest); + assert.doesNotMatch(result.content[0]?.text ?? '', /Snapshot: 0 nodes/); +}); + test('MCP command tool executor renders JSON text when requested', async () => { const executor = createCommandToolExecutor({ createClient: () => ({}) as AgentDeviceClient, @@ -99,6 +121,10 @@ test('MCP tool schemas add MCP client config fields at the MCP boundary', () => (devicesTool.inputSchema.properties?.includeCost as { type?: string } | undefined)?.type, 'boolean', ); + assert.deepEqual( + (devicesTool.inputSchema.properties?.responseLevel as { enum?: unknown[] } | undefined)?.enum, + ['digest', 'default', 'full'], + ); }); test('MCP includeCost:true opts into agent-cost: sets client.cost, strips the arg, surfaces cost', async () => { @@ -156,6 +182,58 @@ test('MCP includeCost rejects non-boolean values at the boundary', async () => { ); }); +test('MCP responseLevel:digest opts into a verbosity level: sets client.responseLevel, strips the arg', async () => { + const createdConfigs: unknown[] = []; + const calls: unknown[] = []; + const executor = createCommandToolExecutor({ + createClient: (config) => { + createdConfigs.push(config); + return {} as AgentDeviceClient; + }, + runCommand: async (_client, name, input) => { + calls.push({ name, input }); + return { message: `Ran ${name}` }; + }, + }); + + const result = await executor.execute('wait', { responseLevel: 'digest' }); + + // responseLevel maps to the client `responseLevel` config (→ meta.responseLevel on the daemon). + assert.deepEqual(createdConfigs, [{ responseLevel: 'digest' }]); + // responseLevel is an MCP-boundary field and must not leak into the command input. + assert.deepEqual(calls, [{ name: 'wait', input: {} }]); + assert.deepEqual(result.structuredContent, { message: 'Ran wait' }); +}); + +test('MCP responseLevel absent leaves the request shape untouched (no responseLevel config)', async () => { + const createdConfigs: unknown[] = []; + const executor = createCommandToolExecutor({ + createClient: (config) => { + createdConfigs.push(config); + return {} as AgentDeviceClient; + }, + runCommand: async () => ({ message: 'ok' }), + }); + + const absent = await executor.execute('wait', {}); + + // The absent path never sets `responseLevel`; the config is byte-identical to today. + assert.deepEqual(createdConfigs, [{}]); + assert.deepEqual(absent.structuredContent, { message: 'ok' }); +}); + +test('MCP responseLevel rejects unknown values at the boundary', async () => { + const executor = createCommandToolExecutor({ + createClient: () => ({}) as AgentDeviceClient, + runCommand: async () => ({}), + }); + + await assert.rejects( + executor.execute('wait', { responseLevel: 'verbose' }), + /Expected responseLevel to be one of 'digest', 'default', or 'full'\./, + ); +}); + test('MCP typed commands advertise an outputSchema with the contract discriminant', () => { const tools = listCommandTools(); diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index fd56d8bcf..1487b25ca 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 { RESPONSE_LEVELS, type ResponseLevel } from '../contracts.ts'; import { formatCliOutput } from '../commands/cli-output.ts'; import { isCommandName, @@ -77,6 +78,7 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C input: commandInput, result, outputFormat: config.outputFormat, + responseLevel: config.client.responseLevel, }), }, ], @@ -119,6 +121,7 @@ function readMcpToolConfig(input: unknown): McpToolConfig { function readClientConfig(record: Record): AgentDeviceClientConfig { const stateDir = record.stateDir; const includeCost = record.includeCost; + const responseLevel = record.responseLevel; const client: AgentDeviceClientConfig = {}; if (stateDir !== undefined && (typeof stateDir !== 'string' || stateDir.length === 0)) { throw new Error('Expected stateDir to be a non-empty string.'); @@ -130,9 +133,21 @@ function readClientConfig(record: Record): AgentDeviceClientCon // Only set when explicitly true so the default request shape is untouched // (cost rides on response.data → structuredContent only when opted in). if (includeCost === true) client.cost = true; + // Only set when it names a known level so the default request shape is + // untouched (responseLevel rides on meta.responseLevel only when opted in). + const level = readResponseLevel(responseLevel); + if (level !== undefined) client.responseLevel = level; return client; } +function readResponseLevel(value: unknown): ResponseLevel | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !(RESPONSE_LEVELS as readonly string[]).includes(value)) { + throw new Error("Expected responseLevel to be one of 'digest', 'default', or 'full'."); + } + return value as ResponseLevel; +} + function readMcpOutputFormat(outputFormat: unknown): McpOutputFormat { if (outputFormat === undefined) return 'optimized'; if (outputFormat !== 'optimized' && outputFormat !== 'json') { @@ -147,6 +162,7 @@ function stripMcpConfigFields(input: unknown): unknown { stateDir: _stateDir, mcpOutputFormat: _mcpOutputFormat, includeCost: _includeCost, + responseLevel: _responseLevel, ...commandInput } = input as Record; return commandInput; @@ -169,6 +185,12 @@ function withMcpConfigSchema(schema: JsonSchema): JsonSchema { description: 'Include per-command agent-cost (cost.wallClockMs, …) in structuredContent. Defaults to off; the default response shape is unchanged.', }, + responseLevel: { + type: 'string', + enum: ['digest', 'default', 'full'], + description: + 'Response verbosity: token-cheap digest / default (today) / full. Defaults to default; the default response shape is unchanged.', + }, }, }; } @@ -178,8 +200,19 @@ function renderToolText(params: { input: unknown; result: unknown; outputFormat: McpOutputFormat; + responseLevel?: ResponseLevel; }): string { - if (params.outputFormat === 'json') return renderJsonText(params.result); + // A non-default responseLevel (digest/full) hands back a leveled payload whose + // shape the optimized CLI formatters do not understand (e.g. the snapshot + // formatter expects `nodes`, which the digest drops) — rendering it through + // them would print misleading text that contradicts `structuredContent`. Emit + // the leveled payload verbatim as JSON instead. + if ( + params.outputFormat === 'json' || + (params.responseLevel !== undefined && params.responseLevel !== 'default') + ) { + return renderJsonText(params.result); + } const cliOutput = formatCliOutput({ name: params.name, input: params.input,