DynamicToolFactory.ts•5.43 kB
import { z, ZodTypeAny } from 'zod';
import { spawn, ChildProcess } from 'child_process';
import { ParamConfig, ToolConfig } from '../types';
import { createOutputCollector, formatTruncationSuffix } from './outputCollector';
/**
* JSON Schema type for MCP tool input (with index signature for SDK compatibility)
*/
export interface JsonSchema {
type: 'object';
properties: Record<string, {
type: string;
description: string;
}>;
required: string[];
[key: string]: unknown;
}
/**
* Maps config type strings to Zod schema builders
*/
function getZodType(type: ParamConfig['type']): ZodTypeAny {
switch (type) {
case 'string':
return z.string();
case 'number':
return z.number();
case 'boolean':
return z.boolean();
default:
throw new Error(`Unsupported parameter type: ${type}`);
}
}
/**
* Builds a Zod schema from parameter configurations
*/
export function buildZodSchema(parameters: ParamConfig[]): z.ZodObject<Record<string, ZodTypeAny>> {
const schemaShape: Record<string, ZodTypeAny> = {};
for (const param of parameters) {
let zodType = getZodType(param.type).describe(param.description);
if (!param.required) {
zodType = zodType.optional();
}
schemaShape[param.name] = zodType;
}
return z.object(schemaShape);
}
/**
* Builds a JSON Schema from parameter configurations (for MCP tool definition)
*/
export function buildJsonSchema(parameters: ParamConfig[]): JsonSchema {
const properties: JsonSchema['properties'] = {};
const required: string[] = [];
for (const param of parameters) {
properties[param.name] = {
type: param.type,
description: param.description,
};
if (param.required) {
required.push(param.name);
}
}
return {
type: 'object',
properties,
required,
};
}
/**
* Formats command result with actual stdout, stderr, and signal info
*/
function formatResult(
stdout: string,
stderr: string,
stdoutTruncated: boolean,
stderrTruncated: boolean,
exitCode: number | null,
signal: NodeJS.Signals | null
): string {
// If killed by signal, treat as error
if (signal) {
return [
'Error:',
`Stdout: ${stdout || '(empty)'}${formatTruncationSuffix(stdoutTruncated)}`,
`Stderr: ${stderr || '(empty)'}${formatTruncationSuffix(stderrTruncated)}`,
`Exit code: (killed by signal)`,
`Signal: ${signal}`,
].join('\n');
}
const code = exitCode ?? 0;
const status = code === 0 ? 'Success' : 'Error';
return [
`${status}:`,
`Stdout: ${stdout || '(empty)'}${formatTruncationSuffix(stdoutTruncated)}`,
`Stderr: ${stderr || '(empty)'}${formatTruncationSuffix(stderrTruncated)}`,
`Exit code: ${code}`,
].join('\n');
}
/**
* Formats spawn error (e.g., command not found)
*/
function formatSpawnError(error: Error): string {
return [
'Error:',
`Stdout: `,
`Stderr: `,
`Exit code: unknown`,
`Message: ${error.message}`,
].join('\n');
}
/**
* Builds an array of arguments from input and parameter configs.
* Preserves declaration order so flags and positionals stay interleaved as defined.
*/
export function buildArgs(
paramConfigs: ParamConfig[],
input: Record<string, unknown>
): string[] {
const args: string[] = [];
// Process parameters in declaration order to preserve interleaving
for (const param of paramConfigs) {
const value = input[param.name];
// Skip undefined optional parameters
if (value === undefined) {
continue;
}
if (param.flag) {
// Flagged parameter
if (param.type === 'boolean') {
// Boolean flags: include flag only if true
if (value === true) {
args.push(param.flag);
}
} else {
// String/number flags: include flag and value as separate args
args.push(param.flag, String(value));
}
} else {
// Positional parameter
args.push(String(value));
}
}
return args;
}
/**
* Executes a dynamic tool based on config using async spawn for safe argument passing.
* Returns a Promise to avoid blocking the event loop.
*/
export function executeDynamicTool(
baseCli: string,
config: ToolConfig,
input: Record<string, unknown>
): Promise<string> {
return new Promise((resolve) => {
// Build args array preserving declaration order
const paramArgs = buildArgs(config.parameters, input);
// Split subcommand into parts, trim whitespace and filter empty strings
// This handles " status " -> ["status"] correctly
const subcommandParts = config.subcommand
? config.subcommand.trim().split(/\s+/).filter(Boolean)
: [];
// Combine: subcommand parts + parameter args (in declaration order)
const allArgs = [...subcommandParts, ...paramArgs];
const child: ChildProcess = spawn(baseCli, allArgs);
const stdoutCollector = createOutputCollector(child.stdout);
const stderrCollector = createOutputCollector(child.stderr);
child.on('error', (error: Error) => {
resolve(formatSpawnError(error));
});
child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
resolve(
formatResult(
stdoutCollector.getText(),
stderrCollector.getText(),
stdoutCollector.isTruncated(),
stderrCollector.isTruncated(),
code,
signal
)
);
});
});
}