Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ List of `{ cli: "executable", description?: "hint" }`. Install fails if any comm
## Tools Section

- **MCP tool**: `def.server: "@server-id"`, `def.tool: tool_name`. Optional `def.defaults` for pre-filled args. Reference in skills as `@server_id.tool_id`.
- **MCP formatter** (optional): `def.formatter.cmd` pipes the serialized tool output through a shell command on the CAPA server before returning it to MCP clients and `capa sh`. Use for human-oriented output (e.g. `jq` to turn JSON rows into TSV). On failure or timeout (default 3000ms, override with `def.formatter.timeout`) the original output is returned unchanged. Command-type tools do not use `formatter` — pipe inside `def.run.cmd` instead. Use `capa sh --json <tool>` to skip formatting and get raw JSON.
- **Command tool**: `def.run.cmd` with `{arg}` placeholders, `def.run.args` (name, type, description, required, optional `default`). Optional `def.init.cmd` for one-time setup. Optional `group` for nesting in `capa sh`.

Optional top-level `description` for MCP schema and `capa sh`.
Expand Down
18 changes: 17 additions & 1 deletion src/cli/commands/__tests__/sh.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'bun:test';
import { parseInlineArgs, resolveArgs, coerceValue } from '../sh';
import { parseInlineArgs, resolveArgs, coerceValue, parseShellGlobalFlags } from '../sh';
import type { ShellCommand } from '../sh';
import { slugify } from '../../../shared/slug';

Expand All @@ -20,6 +20,22 @@ function makeCommand(overrides: Partial<ShellCommand> = {}): ShellCommand {
};
}

describe('parseShellGlobalFlags', () => {
it('detects and removes --json', () => {
expect(parseShellGlobalFlags(['--json', 'my-tool', '--x', '1'])).toEqual({
jsonMode: true,
tokens: ['my-tool', '--x', '1'],
});
});

it('defaults jsonMode to false', () => {
expect(parseShellGlobalFlags(['my-tool'])).toEqual({
jsonMode: false,
tokens: ['my-tool'],
});
});
});

describe('parseInlineArgs', () => {
it('should parse key-value pairs', () => {
const result = parseInlineArgs(['--name', 'Alice', '--age', '30']);
Expand Down
34 changes: 26 additions & 8 deletions src/cli/commands/sh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getServerStatus } from '../utils/server-manager';
import type { Capabilities } from '../../types/capabilities';
import { getQualifiedToolName } from '../../types/capabilities';
import { slugify } from '../../shared/slug';
import { CAPA_JSON_ARG } from '../../server/tool-formatter';

interface ShellToolInfo {
id: string;
Expand Down Expand Up @@ -156,6 +157,16 @@ class ShellRegistry {
}


export function parseShellGlobalFlags(args: string[]): { jsonMode: boolean; tokens: string[] } {
const tokens = [...args];
let jsonMode = false;
while (tokens.length > 0 && tokens[0] === '--json') {
jsonMode = true;
tokens.shift();
}
return { jsonMode, tokens };
}

export function parseInlineArgs(tokens: string[]): Record<string, string> {
const result: Record<string, string> = {};
let i = 0;
Expand Down Expand Up @@ -301,8 +312,10 @@ async function executeToolViaMCP(
serverUrl: string,
projectId: string,
toolId: string,
args: Record<string, any>
args: Record<string, any>,
jsonMode = false
): Promise<string> {
const callArgs = jsonMode ? { ...args, [CAPA_JSON_ARG]: true } : args;
// Send initialize first to establish a session (needed for on-demand mode)
await fetch(`${serverUrl}/${projectId}/mcp`, {
method: 'POST',
Expand All @@ -327,7 +340,7 @@ async function executeToolViaMCP(
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: toolId, arguments: args },
params: { name: toolId, arguments: callArgs },
}),
signal: AbortSignal.timeout(60000),
});
Expand Down Expand Up @@ -406,6 +419,7 @@ function printAvailableCommands(registry: ShellRegistry): void {
console.log(' capa sh <group> List tools in a group');
console.log(' capa sh <group> <tool> [--arg val] Run a tool');
console.log(' capa sh <command> [--arg val] Run a top-level command');
console.log(' capa sh --json <command> [--arg val] Return raw JSON (skip formatter)');
console.log(' capa sh <other> Pass through to OS shell\n');
}

Expand Down Expand Up @@ -462,7 +476,8 @@ async function execCommand(
cmd: ShellCommand,
rawArgTokens: string[],
serverUrl: string,
projectId: string
projectId: string,
jsonMode = false
): Promise<void> {
const rawArgs = parseInlineArgs(rawArgTokens);
const resolved = resolveArgs(cmd, rawArgs);
Expand Down Expand Up @@ -497,7 +512,7 @@ async function execCommand(
process.exit(1);
}

const result = await executeToolViaMCP(serverUrl, projectId, cmd.id, resolved);
const result = await executeToolViaMCP(serverUrl, projectId, cmd.id, resolved, jsonMode);
try {
const parsed = JSON.parse(result);
console.log(JSON.stringify(parsed, null, 2));
Expand Down Expand Up @@ -532,7 +547,8 @@ async function dispatch(
tokens: string[],
registry: ShellRegistry,
serverUrl: string,
projectId: string
projectId: string,
jsonMode = false
): Promise<void> {
if (tokens.length === 0) {
printAvailableCommands(registry);
Expand Down Expand Up @@ -579,7 +595,7 @@ async function dispatch(
return;
}

await execCommand(cmd, subRest, serverUrl, projectId);
await execCommand(cmd, subRest, serverUrl, projectId, jsonMode);
return;
}

Expand All @@ -596,7 +612,7 @@ async function dispatch(
return;
}

await execCommand(cmd, rest, serverUrl, projectId);
await execCommand(cmd, rest, serverUrl, projectId, jsonMode);
return;
}

Expand Down Expand Up @@ -672,5 +688,7 @@ export async function shellCommand(args: string[]): Promise<void> {
const registry = new ShellRegistry();
registry.build(tools);

await dispatch(args, registry, serverUrl, projectId);
const { jsonMode, tokens } = parseShellGlobalFlags(args);

await dispatch(tokens, registry, serverUrl, projectId, jsonMode);
}
112 changes: 112 additions & 0 deletions src/server/__tests__/tool-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'bun:test';
import {
applyToolFormatter,
buildToolCallText,
extractCapaShellMeta,
serializeToolResult,
CAPA_JSON_ARG,
} from '../tool-formatter';
import type { Tool } from '../../types/capabilities';

describe('extractCapaShellMeta', () => {
it('returns args unchanged when meta key is absent', () => {
expect(extractCapaShellMeta({ query: 'select 1' })).toEqual({
cleanArgs: { query: 'select 1' },
skipFormatter: false,
});
});

it('strips _capa_json and sets skipFormatter', () => {
expect(extractCapaShellMeta({ query: 'x', [CAPA_JSON_ARG]: true })).toEqual({
cleanArgs: { query: 'x' },
skipFormatter: true,
});
});

it('treats string "true" as skipFormatter', () => {
expect(extractCapaShellMeta({ [CAPA_JSON_ARG]: 'true' }).skipFormatter).toBe(true);
});
});

describe('serializeToolResult', () => {
it('unwraps command tool success output', () => {
expect(serializeToolResult({ success: true, result: 'hello' })).toBe('hello');
});

it('unwraps MCP proxy text content', () => {
expect(
serializeToolResult({
result: [{ type: 'text', text: '{"a":1}' }],
})
).toBe('{\n "a": 1\n}');
});
});

describe('buildToolCallText', () => {
const mcpTool: Tool = {
id: 'query',
type: 'mcp',
def: {
server: '@db',
tool: 'run_query',
formatter: { cmd: "sed 's/OLD/NEW/'" },
},
};

it('applies formatter for MCP tools', async () => {
const text = await buildToolCallText({ success: true, result: 'OLD value' }, mcpTool);
expect(text).toBe('NEW value');
});

it('skips formatter when requested', async () => {
const text = await buildToolCallText(
{ success: true, result: 'OLD value' },
mcpTool,
{ skipFormatter: true }
);
expect(text).toBe('OLD value');
});

it('ignores formatter on command tools', async () => {
const commandTool: Tool = {
id: 'echo',
type: 'command',
def: { run: { cmd: 'echo hi' } },
};
const text = await buildToolCallText({ success: true, result: 'OLD value' }, commandTool);
expect(text).toBe('OLD value');
});
});

describe('applyToolFormatter', () => {
it('returns transformed stdout on success', async () => {
const out = await applyToolFormatter('{"name":"alice"}', {
cmd: "jq -r '.name'",
});
expect(out).toBe('alice');
});
Comment on lines +82 to +87

it('preserves tab and newline characters in output', async () => {
const out = await applyToolFormatter('a\tb\nc', {
cmd: 'cat',
});
expect(out).toBe('a\tb\nc');
});

it('returns original input when command fails', async () => {
const input = '{"keep":true}';
const out = await applyToolFormatter(input, {
cmd: 'exit 1',
});
expect(out).toBe(input);
});

it('returns original input on timeout', async () => {
const input = 'slow';
const out = await applyToolFormatter(input, {
cmd: 'sleep 5',
timeout: 50,
});
expect(out).toBe(input);
});
});
Loading