From 9cc7b967654013d3b791ccdf280178efceba8d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 07:52:13 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20expose=20responseLevel=20over=20MCP?= =?UTF-8?q?=20=E2=80=94=20Phase=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mcp/__tests__/command-tools.test.ts | 56 +++++++++++++++++++++++++ src/mcp/command-tools.ts | 21 ++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts index b795cccb5..37a478956 100644 --- a/src/mcp/__tests__/command-tools.test.ts +++ b/src/mcp/__tests__/command-tools.test.ts @@ -99,6 +99,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 +160,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..442069639 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, @@ -119,6 +120,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 +132,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 +161,7 @@ function stripMcpConfigFields(input: unknown): unknown { stateDir: _stateDir, mcpOutputFormat: _mcpOutputFormat, includeCost: _includeCost, + responseLevel: _responseLevel, ...commandInput } = input as Record; return commandInput; @@ -169,6 +184,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.', + }, }, }; } From 7437029360759502e4fe45f6c007cd5124cc764d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 08:27:53 +0200 Subject: [PATCH 2/2] fix: render non-default MCP response levels as JSON text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an MCP caller sets responseLevel:digest/full, structuredContent carries the leveled (e.g. snapshot digest) payload, but renderToolText still ran it through the optimized CLI formatters, which assume the default shape — the snapshot formatter expects `nodes` (the digest drops them) and printed 'Snapshot: 0 nodes', contradicting structuredContent. Bypass the optimized formatters for any non-default responseLevel and emit the leveled payload verbatim as JSON. Adds the shipped-path test (snapshot, mcpOutputFormat:optimized, responseLevel:digest). --- src/mcp/__tests__/command-tools.test.ts | 22 ++++++++++++++++++++++ src/mcp/command-tools.ts | 14 +++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts index 37a478956..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, diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index 442069639..1487b25ca 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -78,6 +78,7 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C input: commandInput, result, outputFormat: config.outputFormat, + responseLevel: config.client.responseLevel, }), }, ], @@ -199,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,