rowboat/apps/cli/src/application/lib/builtin-tools.ts

571 lines
24 KiB
TypeScript
Raw Normal View History

2025-11-15 01:51:22 +05:30
import { z, ZodType } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
import { WorkDir as BASE_DIR } from "../config/config.js";
import { executeCommand } from "./command-executor.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Client } from "@modelcontextprotocol/sdk/client";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { McpServerDefinition, McpServerConfig } from "../entities/mcp.js";
2025-11-15 01:51:22 +05:30
const BuiltinToolsSchema = z.record(z.string(), z.object({
description: z.string(),
inputSchema: z.custom<ZodType>(),
execute: z.function({
input: z.any(),
output: z.promise(z.any()),
}),
}));
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
loadSkill: {
description: "Load a Rowboat skill definition into context by fetching its guidance string",
inputSchema: z.object({
skillName: z.string().describe("Skill identifier or path (e.g., 'workflow-run-ops' or 'src/application/assistant/skills/workflow-run-ops/skill.ts')"),
}),
execute: async ({ skillName }: { skillName: string }) => {
const resolved = resolveSkill(skillName);
if (!resolved) {
return {
success: false,
message: `Skill '${skillName}' not found. Available skills: ${availableSkills.join(", ")}`,
};
}
return {
success: true,
skillName: resolved.id,
path: resolved.catalogPath,
content: resolved.content,
};
},
},
2025-11-15 01:51:22 +05:30
exploreDirectory: {
2025-11-17 23:27:00 +05:30
description: 'Recursively explore directory structure to understand existing agents and file organization',
2025-11-15 01:51:22 +05:30
inputSchema: z.object({
subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'),
maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'),
}),
execute: async ({ subdirectory, maxDepth = 3 }: { subdirectory?: string, maxDepth?: number }) => {
async function explore(dir: string, depth: number = 0): Promise<any> {
if (depth > maxDepth) return null;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const result: any = { files: [], directories: {} };
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile()) {
const ext = path.extname(entry.name);
const size = (await fs.stat(fullPath)).size;
result.files.push({
name: entry.name,
type: ext || 'no-extension',
size: size,
relativePath: path.relative(BASE_DIR, fullPath),
});
} else if (entry.isDirectory()) {
result.directories[entry.name] = await explore(fullPath, depth + 1);
}
}
return result;
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const structure = await explore(dirPath);
return {
success: true,
basePath: path.relative(BASE_DIR, dirPath) || '.',
structure,
};
},
},
readFile: {
description: 'Read and parse file contents. For JSON files, provides parsed structure.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'),
}),
execute: async ({ filename }: { filename: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const content = await fs.readFile(filePath, 'utf-8');
let parsed = null;
let fileType = path.extname(filename);
if (fileType === '.json') {
try {
parsed = JSON.parse(content);
} catch {
parsed = { error: 'Invalid JSON' };
}
}
return {
success: true,
filename,
fileType,
content,
parsed,
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
createFile: {
description: 'Create a new file with content. Automatically creates parent directories if needed.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'),
content: z.string().describe('The content to write to the file'),
description: z.string().optional().describe('Optional description of why this file is being created'),
}),
execute: async ({ filename, content, description }: { filename: string, content: string, description?: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const dir = path.dirname(filePath);
// Ensure directory exists
await fs.mkdir(dir, { recursive: true });
// Write file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' created successfully`,
description: description || 'No description provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
updateFile: {
description: 'Update or overwrite the contents of an existing file',
inputSchema: z.object({
filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'),
content: z.string().describe('The new content to write to the file'),
reason: z.string().optional().describe('Optional reason for the update'),
}),
execute: async ({ filename, content, reason }: { filename: string, content: string, reason?: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
// Check if file exists
await fs.access(filePath);
// Update file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' updated successfully`,
reason: reason || 'No reason provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
deleteFile: {
description: 'Delete a file from the .rowboat directory',
inputSchema: z.object({
filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'),
}),
execute: async ({ filename }: { filename: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
await fs.unlink(filePath);
return {
success: true,
message: `File '${filename}' deleted successfully`,
path: filePath,
};
} catch (error) {
return {
success: false,
message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
listFiles: {
description: 'List all files and directories in the .rowboat directory or subdirectory',
inputSchema: z.object({
subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'),
}),
execute: async ({ subdirectory }: { subdirectory?: string }) => {
try {
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files = entries
.filter(entry => entry.isFile())
.map(entry => ({
name: entry.name,
type: path.extname(entry.name) || 'no-extension',
relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)),
}));
const directories = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
return {
success: true,
path: dirPath,
relativePath: path.relative(BASE_DIR, dirPath) || '.',
files,
directories,
totalFiles: files.length,
totalDirectories: directories.length,
};
} catch (error) {
return {
success: false,
message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
2025-11-17 23:27:00 +05:30
analyzeAgent: {
description: 'Read and analyze an agent file to understand its structure, tools, and configuration',
2025-11-15 01:51:22 +05:30
inputSchema: z.object({
2025-11-17 23:27:00 +05:30
agentName: z.string().describe('Name of the agent file to analyze (with or without .json extension)'),
2025-11-15 01:51:22 +05:30
}),
2025-11-17 23:27:00 +05:30
execute: async ({ agentName }: { agentName: string }) => {
2025-11-15 01:51:22 +05:30
try {
2025-11-17 23:27:00 +05:30
const filename = agentName.endsWith('.json') ? agentName : `${agentName}.json`;
const filePath = path.join(BASE_DIR, 'agents', filename);
2025-11-15 01:51:22 +05:30
const content = await fs.readFile(filePath, 'utf-8');
2025-11-17 23:27:00 +05:30
const agent = JSON.parse(content);
2025-11-15 01:51:22 +05:30
// Extract key information
2025-11-17 23:27:00 +05:30
const toolsList = agent.tools ? Object.keys(agent.tools) : [];
const agentTools = agent.tools ? Object.entries(agent.tools).map(([key, tool]: [string, any]) => ({
key,
type: tool.type,
name: tool.name || key,
})) : [];
2025-11-15 01:51:22 +05:30
const analysis = {
2025-11-17 23:27:00 +05:30
name: agent.name,
description: agent.description || 'No description',
model: agent.model || 'Not specified',
toolCount: toolsList.length,
tools: agentTools,
hasOtherAgents: agentTools.some((t: any) => t.type === 'agent'),
structure: agent,
2025-11-15 01:51:22 +05:30
};
return {
success: true,
filePath: path.relative(BASE_DIR, filePath),
analysis,
};
} catch (error) {
return {
success: false,
2025-11-17 23:27:00 +05:30
message: `Failed to analyze agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
2025-11-15 01:51:22 +05:30
};
}
},
},
addMcpServer: {
description: 'Add or update an MCP server in the configuration with validation. This ensures the server definition is valid before saving.',
inputSchema: z.object({
serverName: z.string().describe('Name/alias for the MCP server'),
serverType: z.enum(['stdio', 'http']).describe('Type of MCP server: "stdio" for command-based or "http" for HTTP/SSE-based'),
command: z.string().optional().describe('Command to execute (required for stdio type, e.g., "npx", "python", "node")'),
args: z.array(z.string()).optional().describe('Command arguments (optional, for stdio type)'),
env: z.record(z.string(), z.string()).optional().describe('Environment variables (optional, for stdio type)'),
url: z.string().optional().describe('HTTP/SSE endpoint URL (required for http type)'),
headers: z.record(z.string(), z.string()).optional().describe('HTTP headers (optional, for http type)'),
}),
execute: async ({ serverName, serverType, command, args, env, url, headers }: {
serverName: string;
serverType: 'stdio' | 'http';
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
}) => {
try {
// Build server definition based on type
let serverDef: any;
if (serverType === 'stdio') {
if (!command) {
return {
success: false,
message: 'For stdio type servers, "command" is required. Example: "npx" or "python"',
validationErrors: ['Missing required field: command'],
};
}
serverDef = {
type: 'stdio',
command,
...(args ? { args } : {}),
...(env ? { env } : {}),
};
} else if (serverType === 'http') {
if (!url) {
return {
success: false,
message: 'For http type servers, "url" is required. Example: "http://localhost:3000/sse"',
validationErrors: ['Missing required field: url'],
};
}
serverDef = {
type: 'http',
url,
...(headers ? { headers } : {}),
};
} else {
return {
success: false,
message: `Invalid serverType: ${serverType}. Must be "stdio" or "http"`,
validationErrors: [`Invalid serverType: ${serverType}`],
};
}
// Validate against Zod schema
const validationResult = McpServerDefinition.safeParse(serverDef);
if (!validationResult.success) {
return {
success: false,
message: 'Server definition failed validation. Check the errors below.',
validationErrors: validationResult.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`),
providedDefinition: serverDef,
};
}
// Read existing config
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
let currentConfig: z.infer<typeof McpServerConfig> = { mcpServers: {} };
try {
const content = await fs.readFile(configPath, 'utf-8');
currentConfig = McpServerConfig.parse(JSON.parse(content));
} catch (error: any) {
if (error?.code !== 'ENOENT') {
return {
success: false,
message: `Failed to read existing MCP config: ${error.message}`,
};
}
// File doesn't exist, use empty config
}
// Check if server already exists
const isUpdate = !!currentConfig.mcpServers[serverName];
// Add/update server
currentConfig.mcpServers[serverName] = validationResult.data;
// Write back to file
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(currentConfig, null, 2), 'utf-8');
return {
success: true,
message: `MCP server '${serverName}' ${isUpdate ? 'updated' : 'added'} successfully`,
serverName,
serverType,
isUpdate,
configuration: validationResult.data,
};
} catch (error) {
return {
success: false,
message: `Failed to add MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
2025-11-15 01:51:22 +05:30
listMcpServers: {
description: 'List all available MCP servers from the configuration',
inputSchema: z.object({}),
execute: async (): Promise<{ success: boolean, servers: any[], count: number, message: string }> => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
// Check if config exists
try {
await fs.access(configPath);
} catch {
return {
success: true,
servers: [],
count: 0,
message: 'No MCP servers configured yet',
};
}
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const servers = Object.keys(config.mcpServers || {}).map(name => {
const server = config.mcpServers[name];
return {
name,
type: 'command' in server ? 'stdio' : 'http',
command: server.command,
url: server.url,
};
});
return {
success: true,
servers,
count: servers.length,
message: `Found ${servers.length} MCP server(s)`,
};
} catch (error) {
return {
success: false,
servers: [],
count: 0,
message: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
listMcpTools: {
description: 'List all available tools from a specific MCP server',
inputSchema: z.object({
serverName: z.string().describe('Name of the MCP server to query'),
}),
execute: async ({ serverName }: { serverName: string }) => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const mcpConfig = config.mcpServers[serverName];
if (!mcpConfig) {
return {
success: false,
message: `MCP server '${serverName}' not found in configuration`,
};
}
// Create transport based on config type
let transport;
if ('command' in mcpConfig) {
transport = new StdioClientTransport({
command: mcpConfig.command,
args: mcpConfig.args || [],
env: mcpConfig.env || {},
});
} else {
try {
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
} catch {
transport = new SSEClientTransport(new URL(mcpConfig.url));
}
}
// Create and connect client
const client = new Client({
name: 'rowboat-copilot',
version: '1.0.0',
});
await client.connect(transport);
// List available tools
const toolsList = await client.listTools();
// Close connection
client.close();
transport.close();
const tools = toolsList.tools.map((t: any) => ({
name: t.name,
description: t.description || 'No description',
inputSchema: t.inputSchema,
}));
return {
success: true,
serverName,
tools,
count: tools.length,
message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`,
};
} catch (error) {
return {
success: false,
message: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
executeCommand: {
description: 'Execute a shell command and return the output. Use this to run bash/shell commands.',
inputSchema: z.object({
command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'),
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to .rowboat directory)'),
}),
execute: async ({ command, cwd }: { command: string, cwd?: string }) => {
try {
const workingDir = cwd ? path.join(BASE_DIR, cwd) : BASE_DIR;
const result = await executeCommand(command, { cwd: workingDir });
return {
success: result.exitCode === 0,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
command,
workingDir,
};
} catch (error) {
return {
success: false,
message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`,
command,
};
}
},
},
};