mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-15 12:30:12 +00:00
Implement 13 canonical tools organized by axis: - Core (7): fs, exec, code, git, fetch, workspace, ui - Optional (6): think, memory, hanzo, plan, tasks, mode Key changes: - Add src/tools/unified/ with action-routed tool implementations - Add Rust native tools: exec, git, fetch, code, hanzo, tasks, workspace, computer - Refactor tool index to default to unified surface (legacy available via enableLegacy) - Simplify tests to match new architecture (337 tests, 15 suites pass) - Update LLM.md with HIP-0300 architecture documentation - Remove legacy shell_tool.rs and ui_tool/ (replaced by exec_tool.rs and computer_tool/) - Stop tracking rust/target/.rustc_info.json
482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
import {
|
|
bashTool,
|
|
runBackgroundTool,
|
|
listProcessesTool,
|
|
getProcessOutputTool,
|
|
killProcessTool,
|
|
shellTools
|
|
} from '../../src/tools/shell.js';
|
|
import {
|
|
createTestFile,
|
|
readTestFile,
|
|
testFileExists,
|
|
cleanupTestFile,
|
|
TEST_TEMP_DIR
|
|
} from '../setup.js';
|
|
import * as path from 'path';
|
|
|
|
describe('Shell Tools', () => {
|
|
// Store process IDs to clean up
|
|
const processIds: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
// Clean up any background processes created during tests
|
|
for (const id of processIds) {
|
|
try {
|
|
await killProcessTool.handler({ id });
|
|
} catch {
|
|
// Process might already be dead
|
|
}
|
|
}
|
|
processIds.length = 0;
|
|
});
|
|
|
|
describe('bashTool', () => {
|
|
test('should have correct metadata', () => {
|
|
expect(bashTool.name).toBe('bash');
|
|
expect(bashTool.description).toBe('Execute a bash command');
|
|
expect(bashTool.inputSchema.type).toBe('object');
|
|
expect(bashTool.inputSchema.required).toContain('command');
|
|
});
|
|
|
|
test('should execute simple bash command', async () => {
|
|
const result = await bashTool.handler({
|
|
command: 'echo "Hello from shell"'
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content[0].text).toContain('Hello from shell');
|
|
});
|
|
|
|
test('should handle command with no output', async () => {
|
|
const result = await bashTool.handler({
|
|
command: 'true' // Command that succeeds but produces no output
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content[0].text).toBe('Command completed with no output');
|
|
});
|
|
|
|
test('should capture stderr output', async () => {
|
|
const result = await bashTool.handler({
|
|
command: 'echo "error message" >&2'
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
const output = result.content[0].text!;
|
|
expect(output).toContain('[stderr]');
|
|
expect(output).toContain('error message');
|
|
});
|
|
|
|
test('should handle command that fails', async () => {
|
|
const result = await bashTool.handler({
|
|
command: 'exit 1'
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content[0].text).toContain('Error executing command');
|
|
});
|
|
|
|
test('should respect working directory', async () => {
|
|
const result = await bashTool.handler({
|
|
command: 'pwd',
|
|
cwd: TEST_TEMP_DIR
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content[0].text).toContain(TEST_TEMP_DIR);
|
|
});
|
|
|
|
test('should support environment variables', async () => {
|
|
const result = await bashTool.handler({
|
|
command: 'echo $TEST_VAR',
|
|
env: { TEST_VAR: 'test_value' }
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content[0].text).toContain('test_value');
|
|
});
|
|
|
|
test('should handle timeout', async () => {
|
|
const result = await bashTool.handler({
|
|
command: 'sleep 2',
|
|
timeout: 100 // 100ms timeout
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content[0].text).toContain('Error executing command');
|
|
}, 5000);
|
|
|
|
test('should handle commands that produce large output', async () => {
|
|
const result = await bashTool.handler({
|
|
command: 'for i in $(seq 1 100); do echo "Line $i"; done'
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
const output = result.content[0].text!;
|
|
expect(output).toContain('Line 1');
|
|
expect(output).toContain('Line 100');
|
|
});
|
|
});
|
|
|
|
describe('runBackgroundTool', () => {
|
|
test('should have correct metadata', () => {
|
|
expect(runBackgroundTool.name).toBe('bg');
|
|
expect(runBackgroundTool.description).toBe('Run a command in the background');
|
|
expect(runBackgroundTool.inputSchema.required).toEqual(['command', 'id']);
|
|
});
|
|
|
|
test('should start background process', async () => {
|
|
const processId = 'test-bg-1';
|
|
processIds.push(processId);
|
|
|
|
const result = await runBackgroundTool.handler({
|
|
command: 'sleep 2',
|
|
id: processId
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content[0].text).toContain('Background process started');
|
|
expect(result.content[0].text).toContain(processId);
|
|
expect(result.content[0].text).toContain('PID:');
|
|
});
|
|
|
|
test('should prevent duplicate process IDs', async () => {
|
|
const processId = 'test-duplicate';
|
|
processIds.push(processId);
|
|
|
|
// Start first process
|
|
await runBackgroundTool.handler({
|
|
command: 'sleep 1',
|
|
id: processId
|
|
});
|
|
|
|
// Try to start second process with same ID
|
|
const result = await runBackgroundTool.handler({
|
|
command: 'sleep 1',
|
|
id: processId
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content[0].text).toContain('already exists');
|
|
});
|
|
|
|
test('should support working directory for background process', async () => {
|
|
const processId = 'test-bg-cwd';
|
|
processIds.push(processId);
|
|
|
|
// Create a test file to verify working directory
|
|
await createTestFile('bg-test-dir/test.txt', 'test');
|
|
const testDir = path.join(TEST_TEMP_DIR, 'bg-test-dir');
|
|
|
|
const result = await runBackgroundTool.handler({
|
|
command: 'pwd',
|
|
id: processId,
|
|
cwd: testDir
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
|
|
// Wait a bit and check output
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
const outputResult = await getProcessOutputTool.handler({ id: processId });
|
|
expect(outputResult.content[0].text).toContain(testDir);
|
|
});
|
|
});
|
|
|
|
describe('listProcessesTool', () => {
|
|
test('should have correct metadata', () => {
|
|
expect(listProcessesTool.name).toBe('ps');
|
|
expect(listProcessesTool.description).toBe('List running background processes');
|
|
});
|
|
|
|
test('should show no processes when none running', async () => {
|
|
const result = await listProcessesTool.handler({});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content[0].text).toBe('No background processes running');
|
|
});
|
|
|
|
test('should list running background processes', async () => {
|
|
const processId1 = 'test-list-1';
|
|
const processId2 = 'test-list-2';
|
|
processIds.push(processId1, processId2);
|
|
|
|
// Start two background processes
|
|
await runBackgroundTool.handler({
|
|
command: 'sleep 3',
|
|
id: processId1
|
|
});
|
|
|
|
await runBackgroundTool.handler({
|
|
command: 'sleep 3',
|
|
id: processId2
|
|
});
|
|
|
|
const result = await listProcessesTool.handler({});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
const processes = JSON.parse(result.content[0].text!);
|
|
expect(Array.isArray(processes)).toBe(true);
|
|
expect(processes.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const ids = processes.map((p: any) => p.id);
|
|
expect(ids).toContain(processId1);
|
|
expect(ids).toContain(processId2);
|
|
|
|
processes.forEach((proc: any) => {
|
|
expect(proc).toHaveProperty('id');
|
|
expect(proc).toHaveProperty('pid');
|
|
expect(proc).toHaveProperty('running');
|
|
expect(typeof proc.pid).toBe('number');
|
|
expect(typeof proc.running).toBe('boolean');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getProcessOutputTool', () => {
|
|
test('should have correct metadata', () => {
|
|
expect(getProcessOutputTool.name).toBe('logs');
|
|
expect(getProcessOutputTool.description).toBe('Get output from a background process');
|
|
expect(getProcessOutputTool.inputSchema.required).toContain('id');
|
|
});
|
|
|
|
test('should return error for non-existent process', async () => {
|
|
const result = await getProcessOutputTool.handler({
|
|
id: 'nonexistent-process'
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content[0].text).toContain('No process found');
|
|
});
|
|
|
|
test('should get output from background process', async () => {
|
|
const processId = 'test-output';
|
|
processIds.push(processId);
|
|
|
|
// Start process that produces output
|
|
await runBackgroundTool.handler({
|
|
command: 'echo "background output"',
|
|
id: processId
|
|
});
|
|
|
|
// Wait for process to complete (longer in parallel runs)
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
const result = await getProcessOutputTool.handler({
|
|
id: processId
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content[0].text).toContain('background output');
|
|
});
|
|
|
|
test('should show no output for process with no output yet', async () => {
|
|
const processId = 'test-no-output';
|
|
processIds.push(processId);
|
|
|
|
// Start long-running process with no immediate output
|
|
await runBackgroundTool.handler({
|
|
command: 'sleep 10',
|
|
id: processId
|
|
});
|
|
|
|
const result = await getProcessOutputTool.handler({
|
|
id: processId
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content[0].text).toBe('No output yet');
|
|
});
|
|
|
|
test('should respect tail parameter', async () => {
|
|
const processId = 'test-tail';
|
|
processIds.push(processId);
|
|
|
|
// runBackgroundTool uses spawn with simple space splitting, so shell
|
|
// constructs like for-loops do not work directly. Create a script file
|
|
// that produces numbered lines, then run it as a background process.
|
|
const scriptContent = '#!/bin/bash\nfor i in {1..10}; do echo "Line $i"; done\n';
|
|
const scriptPath = await createTestFile('tail-test.sh', scriptContent);
|
|
|
|
// Make script executable
|
|
await bashTool.handler({ command: `chmod +x "${scriptPath}"` });
|
|
|
|
await runBackgroundTool.handler({
|
|
command: scriptPath,
|
|
id: processId
|
|
});
|
|
|
|
// Wait for process to complete
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
const result = await getProcessOutputTool.handler({
|
|
id: processId,
|
|
tail: 2
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
const output = result.content[0].text!;
|
|
// The tail parameter limits the number of output buffer chunks returned
|
|
// from the internal output array. Each chunk corresponds to a data event.
|
|
// Verify the output contains lines from the end of execution.
|
|
expect(output).toContain('Line');
|
|
});
|
|
});
|
|
|
|
describe('killProcessTool', () => {
|
|
test('should have correct metadata', () => {
|
|
expect(killProcessTool.name).toBe('kill');
|
|
expect(killProcessTool.description).toBe('Kill a background process');
|
|
expect(killProcessTool.inputSchema.required).toContain('id');
|
|
});
|
|
|
|
test('should return error for non-existent process', async () => {
|
|
const result = await killProcessTool.handler({
|
|
id: 'nonexistent-kill'
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content[0].text).toContain('No process found');
|
|
});
|
|
|
|
test('should kill background process', async () => {
|
|
const processId = 'test-kill';
|
|
|
|
// Start long-running process
|
|
await runBackgroundTool.handler({
|
|
command: 'sleep 30',
|
|
id: processId
|
|
});
|
|
|
|
// Verify process is listed
|
|
let listResult = await listProcessesTool.handler({});
|
|
let processes = JSON.parse(listResult.content[0].text!);
|
|
expect(processes.some((p: any) => p.id === processId)).toBe(true);
|
|
|
|
// Kill the process
|
|
const killResult = await killProcessTool.handler({
|
|
id: processId
|
|
});
|
|
|
|
expect(killResult.isError).toBeFalsy();
|
|
expect(killResult.content[0].text).toContain('killed');
|
|
|
|
// Wait a bit for cleanup
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Verify process is no longer listed
|
|
listResult = await listProcessesTool.handler({});
|
|
if (listResult.content[0].text !== 'No background processes running') {
|
|
processes = JSON.parse(listResult.content[0].text!);
|
|
expect(processes.some((p: any) => p.id === processId)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('shellTools array', () => {
|
|
test('should export all shell tools', () => {
|
|
expect(shellTools).toHaveLength(5);
|
|
expect(shellTools).toContain(bashTool);
|
|
expect(shellTools).toContain(runBackgroundTool);
|
|
expect(shellTools).toContain(listProcessesTool);
|
|
expect(shellTools).toContain(getProcessOutputTool);
|
|
expect(shellTools).toContain(killProcessTool);
|
|
});
|
|
|
|
test('should have unique tool names', () => {
|
|
const names = shellTools.map(tool => tool.name);
|
|
const uniqueNames = new Set(names);
|
|
expect(uniqueNames.size).toBe(shellTools.length);
|
|
});
|
|
|
|
test('should all have valid handler functions', () => {
|
|
shellTools.forEach(tool => {
|
|
expect(typeof tool.handler).toBe('function');
|
|
expect(tool.name).toBeTruthy();
|
|
expect(tool.description).toBeTruthy();
|
|
expect(tool.inputSchema).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Integration tests', () => {
|
|
test('should handle complex shell workflow', async () => {
|
|
// Create a test script file
|
|
const scriptContent = `#!/bin/bash
|
|
echo "Starting script"
|
|
for i in {1..3}; do
|
|
echo "Iteration $i"
|
|
sleep 0.1
|
|
done
|
|
echo "Script completed"
|
|
`;
|
|
|
|
const scriptPath = await createTestFile('test-script.sh', scriptContent);
|
|
|
|
// Make script executable
|
|
const chmodResult = await bashTool.handler({
|
|
command: `chmod +x "${scriptPath}"`
|
|
});
|
|
expect(chmodResult.isError).toBeFalsy();
|
|
|
|
// Run script in background
|
|
const processId = 'test-script-bg';
|
|
processIds.push(processId);
|
|
|
|
const bgResult = await runBackgroundTool.handler({
|
|
command: scriptPath,
|
|
id: processId
|
|
});
|
|
expect(bgResult.isError).toBeFalsy();
|
|
|
|
// Wait for script to complete (3 iterations x 0.1s sleep + overhead)
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
// Get output
|
|
const outputResult = await getProcessOutputTool.handler({
|
|
id: processId
|
|
});
|
|
expect(outputResult.isError).toBeFalsy();
|
|
|
|
const output = outputResult.content[0].text!;
|
|
expect(output).toContain('Starting script');
|
|
expect(output).toContain('Iteration 1');
|
|
expect(output).toContain('Iteration 2');
|
|
expect(output).toContain('Iteration 3');
|
|
expect(output).toContain('Script completed');
|
|
});
|
|
|
|
test('should handle file operations via shell commands', async () => {
|
|
const testDir = path.join(TEST_TEMP_DIR, 'shell-file-ops');
|
|
|
|
// Create directory via shell
|
|
const mkdirResult = await bashTool.handler({
|
|
command: `mkdir -p "${testDir}"`
|
|
});
|
|
expect(mkdirResult.isError).toBeFalsy();
|
|
|
|
// Create file via shell
|
|
const createResult = await bashTool.handler({
|
|
command: `echo "Shell created file" > "${path.join(testDir, 'shell-file.txt')}"`
|
|
});
|
|
expect(createResult.isError).toBeFalsy();
|
|
|
|
// List files via shell
|
|
const listResult = await bashTool.handler({
|
|
command: `ls "${testDir}"`
|
|
});
|
|
expect(listResult.isError).toBeFalsy();
|
|
expect(listResult.content[0].text).toContain('shell-file.txt');
|
|
|
|
// Read file via shell
|
|
const readResult = await bashTool.handler({
|
|
command: `cat "${path.join(testDir, 'shell-file.txt')}"`
|
|
});
|
|
expect(readResult.isError).toBeFalsy();
|
|
expect(readResult.content[0].text).toContain('Shell created file');
|
|
});
|
|
});
|
|
}); |