543 lines
22 KiB
JavaScript
543 lines
22 KiB
JavaScript
|
|
/**
|
||
|
|
* Alfred Agent Harness — Tool Registry
|
||
|
|
*
|
||
|
|
* Inspired by Claude Code's tool architecture:
|
||
|
|
* - Each tool has name, description, inputSchema, execute()
|
||
|
|
* - Tools are registered in a central registry
|
||
|
|
* - Execution is sandboxed and results streamed back
|
||
|
|
*/
|
||
|
|
import { execSync, spawn } from 'child_process';
|
||
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
||
|
|
import { resolve, dirname, join, relative } from 'path';
|
||
|
|
import { homedir } from 'os';
|
||
|
|
|
||
|
|
const HOME = homedir();
|
||
|
|
const WORKSPACE = process.env.ALFRED_WORKSPACE || HOME;
|
||
|
|
|
||
|
|
/** All registered tools */
|
||
|
|
const registry = new Map();
|
||
|
|
|
||
|
|
/** Register a tool */
|
||
|
|
export function registerTool(tool) {
|
||
|
|
registry.set(tool.name, tool);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Get all tools */
|
||
|
|
export function getTools() {
|
||
|
|
return Array.from(registry.values());
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Get tool by name */
|
||
|
|
export function getTool(name) {
|
||
|
|
return registry.get(name);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Execute a tool by name */
|
||
|
|
export async function executeTool(name, input) {
|
||
|
|
const tool = registry.get(name);
|
||
|
|
if (!tool) return { error: `Unknown tool: ${name}` };
|
||
|
|
try {
|
||
|
|
return await tool.execute(input);
|
||
|
|
} catch (err) {
|
||
|
|
return { error: `Tool ${name} failed: ${err.message}` };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// CORE TOOLS — File Operations
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'read_file',
|
||
|
|
description: 'Read the contents of a file. Specify startLine/endLine for partial reads (1-based). Returns the file content as text.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
path: { type: 'string', description: 'Absolute or workspace-relative path' },
|
||
|
|
startLine: { type: 'number', description: 'Start line (1-based, optional)' },
|
||
|
|
endLine: { type: 'number', description: 'End line (1-based, optional)' },
|
||
|
|
},
|
||
|
|
required: ['path'],
|
||
|
|
},
|
||
|
|
async execute({ path, startLine, endLine }) {
|
||
|
|
const fullPath = resolve(WORKSPACE, path);
|
||
|
|
if (!existsSync(fullPath)) return { error: `File not found: ${fullPath}` };
|
||
|
|
const content = readFileSync(fullPath, 'utf8');
|
||
|
|
if (startLine || endLine) {
|
||
|
|
const lines = content.split('\n');
|
||
|
|
const start = (startLine || 1) - 1;
|
||
|
|
const end = endLine || lines.length;
|
||
|
|
return { content: lines.slice(start, end).join('\n'), totalLines: lines.length };
|
||
|
|
}
|
||
|
|
return { content, totalLines: content.split('\n').length };
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'write_file',
|
||
|
|
description: 'Create or overwrite a file with the given content. Creates directories as needed.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
path: { type: 'string', description: 'Absolute or workspace-relative path' },
|
||
|
|
content: { type: 'string', description: 'File content to write' },
|
||
|
|
},
|
||
|
|
required: ['path', 'content'],
|
||
|
|
},
|
||
|
|
async execute({ path, content }) {
|
||
|
|
const fullPath = resolve(WORKSPACE, path);
|
||
|
|
mkdirSync(dirname(fullPath), { recursive: true });
|
||
|
|
writeFileSync(fullPath, content, 'utf8');
|
||
|
|
return { success: true, path: fullPath, bytes: Buffer.byteLength(content) };
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'edit_file',
|
||
|
|
description: 'Replace an exact string in a file with a new string. The oldString must match exactly (including whitespace).',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
path: { type: 'string', description: 'Absolute or workspace-relative path' },
|
||
|
|
oldString: { type: 'string', description: 'Exact text to find and replace' },
|
||
|
|
newString: { type: 'string', description: 'Replacement text' },
|
||
|
|
},
|
||
|
|
required: ['path', 'oldString', 'newString'],
|
||
|
|
},
|
||
|
|
async execute({ path, oldString, newString }) {
|
||
|
|
const fullPath = resolve(WORKSPACE, path);
|
||
|
|
if (!existsSync(fullPath)) return { error: `File not found: ${fullPath}` };
|
||
|
|
const content = readFileSync(fullPath, 'utf8');
|
||
|
|
const count = content.split(oldString).length - 1;
|
||
|
|
if (count === 0) return { error: 'oldString not found in file' };
|
||
|
|
if (count > 1) return { error: `oldString found ${count} times — must be unique` };
|
||
|
|
writeFileSync(fullPath, content.replace(oldString, newString), 'utf8');
|
||
|
|
return { success: true, path: fullPath };
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// CORE TOOLS — Shell / Bash
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'bash',
|
||
|
|
description: 'Execute a shell command and return stdout/stderr. Use for system operations, git, package managers, etc. Commands run as the gositeme user.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
command: { type: 'string', description: 'Shell command to execute' },
|
||
|
|
cwd: { type: 'string', description: 'Working directory (optional, defaults to workspace)' },
|
||
|
|
timeout: { type: 'number', description: 'Timeout in ms (default: 30000)' },
|
||
|
|
},
|
||
|
|
required: ['command'],
|
||
|
|
},
|
||
|
|
async execute({ command, cwd, timeout = 30000 }) {
|
||
|
|
// Security: block obviously dangerous commands
|
||
|
|
const blocked = [/rm\s+-rf\s+\/[^\/]*/i, /mkfs/i, /dd\s+if.*of=\/dev/i, /:(){ :\|:& };:/];
|
||
|
|
for (const pattern of blocked) {
|
||
|
|
if (pattern.test(command)) return { error: 'Command blocked for safety. Ask the Commander to approve.' };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const stdout = execSync(command, {
|
||
|
|
cwd: cwd || WORKSPACE,
|
||
|
|
timeout,
|
||
|
|
encoding: 'utf8',
|
||
|
|
maxBuffer: 1024 * 1024,
|
||
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||
|
|
});
|
||
|
|
return { stdout: stdout.slice(0, 50000), exitCode: 0 };
|
||
|
|
} catch (err) {
|
||
|
|
return {
|
||
|
|
stdout: (err.stdout || '').slice(0, 50000),
|
||
|
|
stderr: (err.stderr || '').slice(0, 10000),
|
||
|
|
exitCode: err.status || 1,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// CORE TOOLS — Search
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'glob',
|
||
|
|
description: 'Search for files matching a glob pattern in the workspace.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
pattern: { type: 'string', description: 'Glob pattern (e.g. **/*.js, src/**/*.php)' },
|
||
|
|
cwd: { type: 'string', description: 'Base directory (optional)' },
|
||
|
|
},
|
||
|
|
required: ['pattern'],
|
||
|
|
},
|
||
|
|
async execute({ pattern, cwd }) {
|
||
|
|
const base = cwd || WORKSPACE;
|
||
|
|
try {
|
||
|
|
const result = execSync(`find ${base} -path "${base}/${pattern}" -type f 2>/dev/null | head -100`, {
|
||
|
|
encoding: 'utf8', timeout: 10000,
|
||
|
|
});
|
||
|
|
const files = result.trim().split('\n').filter(Boolean);
|
||
|
|
return { files, count: files.length };
|
||
|
|
} catch {
|
||
|
|
// Fallback: use shell glob
|
||
|
|
try {
|
||
|
|
const result = execSync(`ls -1 ${base}/${pattern} 2>/dev/null | head -100`, { encoding: 'utf8', timeout: 10000 });
|
||
|
|
const files = result.trim().split('\n').filter(Boolean);
|
||
|
|
return { files, count: files.length };
|
||
|
|
} catch {
|
||
|
|
return { files: [], count: 0 };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'grep',
|
||
|
|
description: 'Search for text patterns in files. Returns matching lines with file paths and line numbers.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
pattern: { type: 'string', description: 'Search pattern (regex supported)' },
|
||
|
|
path: { type: 'string', description: 'Directory or file to search in' },
|
||
|
|
include: { type: 'string', description: 'File pattern to include (e.g. *.js)' },
|
||
|
|
maxResults: { type: 'number', description: 'Maximum results (default 50)' },
|
||
|
|
},
|
||
|
|
required: ['pattern'],
|
||
|
|
},
|
||
|
|
async execute({ pattern, path, include, maxResults = 50 }) {
|
||
|
|
const searchPath = path || WORKSPACE;
|
||
|
|
let cmd = `grep -rn --color=never`;
|
||
|
|
if (include) cmd += ` --include="${include}"`;
|
||
|
|
cmd += ` "${pattern.replace(/"/g, '\\"')}" "${searchPath}" 2>/dev/null | head -${maxResults}`;
|
||
|
|
try {
|
||
|
|
const result = execSync(cmd, { encoding: 'utf8', timeout: 15000 });
|
||
|
|
const matches = result.trim().split('\n').filter(Boolean);
|
||
|
|
return { matches, count: matches.length };
|
||
|
|
} catch {
|
||
|
|
return { matches: [], count: 0 };
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// CORE TOOLS — Directory Listing
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'list_dir',
|
||
|
|
description: 'List the contents of a directory with file sizes and types.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
path: { type: 'string', description: 'Directory path' },
|
||
|
|
},
|
||
|
|
required: ['path'],
|
||
|
|
},
|
||
|
|
async execute({ path }) {
|
||
|
|
const fullPath = resolve(WORKSPACE, path);
|
||
|
|
if (!existsSync(fullPath)) return { error: `Directory not found: ${fullPath}` };
|
||
|
|
const entries = readdirSync(fullPath).map(name => {
|
||
|
|
try {
|
||
|
|
const stat = statSync(join(fullPath, name));
|
||
|
|
return { name: stat.isDirectory() ? name + '/' : name, size: stat.size, isDir: stat.isDirectory() };
|
||
|
|
} catch {
|
||
|
|
return { name, size: 0, isDir: false };
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return { entries, count: entries.length, path: fullPath };
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// ALFRED-SPECIFIC TOOLS — Vault, PM2, Database
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'vault_get_credential',
|
||
|
|
description: 'Retrieve a decrypted credential from the Alfred vault. Returns username and password for the matching service.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
service: { type: 'string', description: 'Service name pattern to search (e.g. "SSH", "OVH", "email")' },
|
||
|
|
},
|
||
|
|
required: ['service'],
|
||
|
|
},
|
||
|
|
async execute({ service }) {
|
||
|
|
try {
|
||
|
|
const result = execSync(`php /home/gositeme/alfred-services/get-credential.php "${service.replace(/"/g, '')}"`, {
|
||
|
|
encoding: 'utf8', timeout: 5000,
|
||
|
|
});
|
||
|
|
const cred = JSON.parse(result);
|
||
|
|
if (cred.error) return { error: cred.error };
|
||
|
|
return { service: cred.service_name, username: cred.username, note: 'Password retrieved (not shown in output)' };
|
||
|
|
} catch (err) {
|
||
|
|
return { error: `Vault lookup failed: ${err.message}` };
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'pm2_status',
|
||
|
|
description: 'Get the status of PM2 services. Can list all or check a specific service.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
service: { type: 'string', description: 'Service name (optional — omit for full list)' },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
async execute({ service }) {
|
||
|
|
const cmd = service ? `pm2 show ${service} 2>&1 | head -30` : `pm2 jlist 2>/dev/null`;
|
||
|
|
try {
|
||
|
|
const result = execSync(cmd, { encoding: 'utf8', timeout: 10000 });
|
||
|
|
if (!service) {
|
||
|
|
const list = JSON.parse(result);
|
||
|
|
const summary = list.map(p => ({
|
||
|
|
name: p.name,
|
||
|
|
status: p.pm2_env?.status,
|
||
|
|
uptime: p.pm2_env?.pm_uptime,
|
||
|
|
restarts: p.pm2_env?.restart_time,
|
||
|
|
cpu: p.monit?.cpu,
|
||
|
|
memory: Math.round((p.monit?.memory || 0) / 1024 / 1024) + 'MB',
|
||
|
|
}));
|
||
|
|
return { services: summary, total: summary.length };
|
||
|
|
}
|
||
|
|
return { details: result };
|
||
|
|
} catch (err) {
|
||
|
|
return { error: err.message };
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'db_query',
|
||
|
|
description: 'Execute a read-only SQL query against the gositeme_whmcs database. Only SELECT queries are allowed.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
query: { type: 'string', description: 'SQL SELECT query' },
|
||
|
|
},
|
||
|
|
required: ['query'],
|
||
|
|
},
|
||
|
|
async execute({ query }) {
|
||
|
|
// Security: only allow SELECT queries
|
||
|
|
const trimmed = query.trim().toUpperCase();
|
||
|
|
if (!trimmed.startsWith('SELECT') && !trimmed.startsWith('SHOW') && !trimmed.startsWith('DESCRIBE')) {
|
||
|
|
return { error: 'Only SELECT, SHOW, and DESCRIBE queries are allowed. Mutations require Commander approval.' };
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const phpCode = `<?php
|
||
|
|
$db = new PDO('mysql:host=localhost;dbname=gositeme_whmcs;unix_socket=/run/mysql/mysql.sock','gositeme_whmcs','!q@w#e\$r5t');
|
||
|
|
$stmt = $db->query(base64_decode('${Buffer.from(query).toString('base64')}'));
|
||
|
|
echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC));`;
|
||
|
|
const result = execSync(`php -r '${phpCode.replace(/'/g, "'\\''")}'`, { encoding: 'utf8', timeout: 10000 });
|
||
|
|
const rows = JSON.parse(result);
|
||
|
|
return { rows, count: rows.length };
|
||
|
|
} catch (err) {
|
||
|
|
return { error: `Query failed: ${err.message}` };
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'memory_store',
|
||
|
|
description: 'Store a persistent memory note for Alfred. Survives across sessions. Used to remember important facts, decisions, and context.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
key: { type: 'string', description: 'Memory key/topic (e.g. "server-ports", "eden-birthday")' },
|
||
|
|
content: { type: 'string', description: 'Content to remember' },
|
||
|
|
},
|
||
|
|
required: ['key', 'content'],
|
||
|
|
},
|
||
|
|
async execute({ key, content }) {
|
||
|
|
const memDir = join(HOME, 'alfred-agent', 'data', 'memories');
|
||
|
|
mkdirSync(memDir, { recursive: true });
|
||
|
|
const file = join(memDir, `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.md`);
|
||
|
|
const entry = `\n## ${new Date().toISOString()}\n${content}\n`;
|
||
|
|
const existing = existsSync(file) ? readFileSync(file, 'utf8') : `# Memory: ${key}\n`;
|
||
|
|
writeFileSync(file, existing + entry, 'utf8');
|
||
|
|
return { success: true, file, key };
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'memory_recall',
|
||
|
|
description: 'Recall a stored memory by key, or list all memory keys if no key given.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
key: { type: 'string', description: 'Memory key to recall (omit to list all)' },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
async execute({ key }) {
|
||
|
|
const memDir = join(HOME, 'alfred-agent', 'data', 'memories');
|
||
|
|
if (!existsSync(memDir)) return { memories: [], note: 'No memories stored yet' };
|
||
|
|
if (!key) {
|
||
|
|
const files = readdirSync(memDir).filter(f => f.endsWith('.md'));
|
||
|
|
return { keys: files.map(f => f.replace('.md', '')), count: files.length };
|
||
|
|
}
|
||
|
|
const file = join(memDir, `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.md`);
|
||
|
|
if (!existsSync(file)) return { error: `No memory found for key: ${key}` };
|
||
|
|
return { content: readFileSync(file, 'utf8'), key };
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'web_fetch',
|
||
|
|
description: 'Fetch the content of a web page. Returns text content (HTML stripped).',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
url: { type: 'string', description: 'URL to fetch' },
|
||
|
|
},
|
||
|
|
required: ['url'],
|
||
|
|
},
|
||
|
|
async execute({ url }) {
|
||
|
|
// Validate the URL to prevent SSRF
|
||
|
|
const parsed = new URL(url);
|
||
|
|
if (['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname) || parsed.hostname.startsWith('192.168.') || parsed.hostname.startsWith('10.')) {
|
||
|
|
return { error: 'Cannot fetch internal/private URLs for security reasons' };
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const res = await fetch(url, {
|
||
|
|
headers: { 'User-Agent': 'Alfred-Agent/1.0' },
|
||
|
|
signal: AbortSignal.timeout(15000),
|
||
|
|
});
|
||
|
|
const text = await res.text();
|
||
|
|
// Strip HTML tags for readability
|
||
|
|
const clean = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||
|
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||
|
|
.replace(/<[^>]+>/g, ' ')
|
||
|
|
.replace(/\s+/g, ' ')
|
||
|
|
.trim()
|
||
|
|
.slice(0, 50000);
|
||
|
|
return { content: clean, status: res.status, url };
|
||
|
|
} catch (err) {
|
||
|
|
return { error: `Fetch failed: ${err.message}` };
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'session_journal',
|
||
|
|
description: 'Save a session journal entry using the Alfred session save system. Call this at the end of each session to record what was accomplished.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
summary: { type: 'string', description: 'Summary of what was accomplished this session' },
|
||
|
|
},
|
||
|
|
required: ['summary'],
|
||
|
|
},
|
||
|
|
async execute({ summary }) {
|
||
|
|
try {
|
||
|
|
const result = execSync(
|
||
|
|
`php /home/gositeme/.vault/session-save.php "${summary.replace(/"/g, '\\"').slice(0, 500)}"`,
|
||
|
|
{ encoding: 'utf8', timeout: 5000 }
|
||
|
|
);
|
||
|
|
return { success: true, result: result.trim() };
|
||
|
|
} catch (err) {
|
||
|
|
return { error: `Session save failed: ${err.message}` };
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// MCP BRIDGE — Access all 856+ GoCodeMe MCP tools
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'mcp_call',
|
||
|
|
description: 'Call any tool from the GoCodeMe MCP server (856+ tools across 32 categories). Use mcp_list first to discover available tools, then call them by name with their required arguments.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
tool: { type: 'string', description: 'The MCP tool name to call (e.g. "read_file", "billing_get_invoices", "code_interpreter")' },
|
||
|
|
args: { type: 'object', description: 'Arguments to pass to the MCP tool (varies per tool)' },
|
||
|
|
},
|
||
|
|
required: ['tool'],
|
||
|
|
},
|
||
|
|
async execute({ tool, args }) {
|
||
|
|
try {
|
||
|
|
const payload = JSON.stringify({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
method: 'tools/call',
|
||
|
|
id: Date.now(),
|
||
|
|
params: { name: tool, arguments: args || {} },
|
||
|
|
});
|
||
|
|
const result = execSync(
|
||
|
|
`curl -s -X POST http://127.0.0.1:3006/mcp -H 'Content-Type: application/json' -d '${payload.replace(/'/g, "'\\''")}'`,
|
||
|
|
{ encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 }
|
||
|
|
);
|
||
|
|
const parsed = JSON.parse(result);
|
||
|
|
if (parsed.error) return { error: `MCP error: ${parsed.error.message || JSON.stringify(parsed.error)}` };
|
||
|
|
// Extract content from MCP result format
|
||
|
|
const content = parsed.result?.content;
|
||
|
|
if (Array.isArray(content) && content.length > 0) {
|
||
|
|
const textParts = content.filter(c => c.type === 'text').map(c => c.text);
|
||
|
|
return { result: textParts.join('\n') || JSON.stringify(content) };
|
||
|
|
}
|
||
|
|
return { result: JSON.stringify(parsed.result || parsed) };
|
||
|
|
} catch (err) {
|
||
|
|
return { error: `MCP call failed: ${err.message}` };
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
registerTool({
|
||
|
|
name: 'mcp_list',
|
||
|
|
description: 'List available MCP tools from the GoCodeMe server. Use category filter to narrow results, or search by keyword. Returns tool names and descriptions.',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
category: { type: 'string', description: 'Filter by category (e.g. "billing", "files", "sentinel", "cortex", "empathy"). Leave empty for all.' },
|
||
|
|
search: { type: 'string', description: 'Search keyword to filter tools by name or description' },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
async execute({ category, search }) {
|
||
|
|
try {
|
||
|
|
const result = execSync(
|
||
|
|
`curl -s http://127.0.0.1:3006/mcp/docs/summary`,
|
||
|
|
{ encoding: 'utf8', timeout: 10000 }
|
||
|
|
);
|
||
|
|
const data = JSON.parse(result);
|
||
|
|
let summary = `Total: ${data.totalTools} tools in ${data.totalCategories} categories\n\n`;
|
||
|
|
|
||
|
|
if (category || search) {
|
||
|
|
// Get full tool list for filtering
|
||
|
|
const listResult = execSync(
|
||
|
|
`curl -s -X POST http://127.0.0.1:3006/mcp -H 'Content-Type: application/json' -d '${JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 })}'`,
|
||
|
|
{ encoding: 'utf8', timeout: 10000, maxBuffer: 2 * 1024 * 1024 }
|
||
|
|
);
|
||
|
|
const listData = JSON.parse(listResult);
|
||
|
|
let tools = listData.result?.tools || [];
|
||
|
|
|
||
|
|
if (category) {
|
||
|
|
tools = tools.filter(t => (t.category || '').toLowerCase().includes(category.toLowerCase()));
|
||
|
|
}
|
||
|
|
if (search) {
|
||
|
|
const q = search.toLowerCase();
|
||
|
|
tools = tools.filter(t =>
|
||
|
|
(t.name || '').toLowerCase().includes(q) ||
|
||
|
|
(t.description || '').toLowerCase().includes(q)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
summary += `Filtered: ${tools.length} tools\n\n`;
|
||
|
|
for (const t of tools.slice(0, 50)) {
|
||
|
|
summary += `• ${t.name}: ${(t.description || '').slice(0, 120)}\n`;
|
||
|
|
}
|
||
|
|
if (tools.length > 50) summary += `\n... and ${tools.length - 50} more`;
|
||
|
|
} else {
|
||
|
|
for (const cat of (data.categories || [])) {
|
||
|
|
summary += `${cat.icon} ${cat.label}: ${cat.toolCount} tools\n`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { result: summary };
|
||
|
|
} catch (err) {
|
||
|
|
return { error: `MCP list failed: ${err.message}` };
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|