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
68 changes: 63 additions & 5 deletions src/mcp/__tests__/command-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,85 @@ 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', () => {
const devicesTool = listCommandTools().find((tool) => tool.name === 'devices');

assert.ok(devicesTool);
assert.ok('stateDir' in (devicesTool.inputSchema.properties ?? {}));
assert.deepEqual(
(devicesTool.inputSchema.properties?.mcpOutputFormat as { enum?: unknown[] } | undefined)?.enum,
['optimized', 'json'],
);
});
93 changes: 77 additions & 16 deletions 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 { formatCliOutput } from '../commands/cli-output.ts';
import {
isCommandName,
listMcpCommandMetadata,
Expand All @@ -23,6 +24,13 @@ type CommandToolExecutor = {
execute: (name: string, input: unknown) => Promise<ToolResult>;
};

type McpOutputFormat = 'optimized' | 'json';

type McpToolConfig = {
client: AgentDeviceClientConfig;
outputFormat: McpOutputFormat;
};

export function listCommandTools(): Array<{
name: string;
description: string;
Expand All @@ -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,
}),
},
],
};
},
};
Expand All @@ -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<string, unknown>).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<string, unknown>;
return {
client: readClientConfig(record),
outputFormat: readMcpOutputFormat(record.mcpOutputFormat),
};
}

function readClientConfig(record: Record<string, unknown>): 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<string, unknown>;
const {
stateDir: _stateDir,
mcpOutputFormat: _mcpOutputFormat,
...commandInput
} = input as Record<string, unknown>;
return commandInput;
}

Expand All @@ -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);
}
Loading