Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/mcp/__tests__/command-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();

Expand Down
35 changes: 34 additions & 1 deletion src/mcp/command-tools.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -77,6 +78,7 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C
input: commandInput,
result,
outputFormat: config.outputFormat,
responseLevel: config.client.responseLevel,
}),
},
],
Expand Down Expand Up @@ -119,6 +121,7 @@ function readMcpToolConfig(input: unknown): McpToolConfig {
function readClientConfig(record: Record<string, unknown>): 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.');
Expand All @@ -130,9 +133,21 @@ function readClientConfig(record: Record<string, unknown>): 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') {
Expand All @@ -147,6 +162,7 @@ function stripMcpConfigFields(input: unknown): unknown {
stateDir: _stateDir,
mcpOutputFormat: _mcpOutputFormat,
includeCost: _includeCost,
responseLevel: _responseLevel,
...commandInput
} = input as Record<string, unknown>;
return commandInput;
Expand All @@ -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.',
},
},
};
}
Expand All @@ -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,
Expand Down
Loading