ai-debug-local-mcp
Version:
🎯 ENHANCED AI GUIDANCE v4.1.2: Dramatically improved tool descriptions help AI users choose the right tools instead of 'close enough' options. Ultra-fast keyboard automation (10x speed), universal recording, multi-ecosystem debugging support, and compreh
452 lines (431 loc) • 17.6 kB
JavaScript
import { BaseToolHandler } from './base-handler.js';
import { TidewaveIntegration } from '../tidewave-integration.js';
/**
* Handler for Ecto database debugging tools
*/
export class EctoHandler extends BaseToolHandler {
tidewave;
constructor() {
super();
this.tidewave = new TidewaveIntegration();
}
tools = [
{
name: 'ecto_inspect_repo_config',
description: 'Inspect runtime Ecto repository configuration',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
repoModule: { type: 'string', description: 'Repository module name (e.g., "MyApp.Repo")' }
},
required: ['sessionId', 'repoModule']
}
},
{
name: 'ecto_trace_queries',
description: 'Trace all database queries with execution time and source',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
duration: { type: 'number', description: 'Trace duration in seconds', default: 30 },
minTime: { type: 'number', description: 'Minimum query time to capture (ms)', default: 0 }
},
required: ['sessionId']
}
},
{
name: 'ecto_analyze_n_plus_one',
description: 'Detect N+1 query problems in your application',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
action: { type: 'string', description: 'Controller action or LiveView to analyze' },
threshold: { type: 'number', description: 'Query count threshold for detection', default: 5 }
},
required: ['sessionId', 'action']
}
},
{
name: 'ecto_inspect_changesets',
description: 'Debug Ecto changeset validation and errors',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
module: { type: 'string', description: 'Schema module (e.g., "User")' },
action: { type: 'string', description: 'Changeset function (e.g., "registration_changeset")' },
params: { type: 'object', description: 'Parameters to test with' }
},
required: ['sessionId', 'module']
}
},
{
name: 'ecto_migration_status',
description: 'Check migration status and pending migrations',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
repo: { type: 'string', description: 'Repository module', default: 'MyApp.Repo' }
},
required: ['sessionId']
}
},
{
name: 'ecto_query_analyzer',
description: 'Analyze query performance with EXPLAIN',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
query: { type: 'string', description: 'Ecto query or raw SQL' },
format: { type: 'string', enum: ['text', 'json', 'yaml'], default: 'text' }
},
required: ['sessionId', 'query']
}
},
{
name: 'ecto_connection_pool_status',
description: 'Monitor database connection pool health',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
repo: { type: 'string', description: 'Repository module', default: 'MyApp.Repo' }
},
required: ['sessionId']
}
},
{
name: 'ecto_sandbox_status',
description: 'Check Ecto sandbox mode for testing',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
pid: { type: 'string', description: 'Process ID to check' }
},
required: ['sessionId']
}
}
];
async handle(toolName, args, sessions) {
const methodName = this.convertToolNameToMethod(toolName);
const method = this[methodName];
if (typeof method === 'function') {
return method.call(this, args, sessions);
}
throw new Error(`Unknown Ecto tool: ${toolName}`);
}
convertToolNameToMethod(toolName) {
return toolName.replace(/_(.)/g, (_, char) => char.toUpperCase());
}
// Remove duplicate getSession - it's already in BaseToolHandler
async ectoInspectRepoConfig(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
repo = ${args.repoModule}
config = repo.config()
%{
adapter: config[:adapter],
database: config[:database],
hostname: config[:hostname],
port: config[:port],
pool_size: config[:pool_size],
timeout: config[:timeout],
queue_target: config[:queue_target],
queue_interval: config[:queue_interval],
migration_source: config[:migration_source],
priv: config[:priv]
}
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to inspect repo config: ${result.error}`);
}
return this.createTextResponse(this.formatRepoConfig(result.data));
}
async ectoTraceQueries(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
# Enable query logging
Logger.configure_backend(:console, [
format: "$time $metadata[$level] $message\\n",
metadata: [:query_time, :source, :query]
])
# Start telemetry handler
:telemetry.attach(
"ecto-query-tracer",
[:my_app, :repo, :query],
fn _event, measurements, metadata, _config ->
if measurements.query_time > ${args.minTime * 1000} do
IO.puts("Query took: #{measurements.query_time / 1_000_000}ms")
IO.puts("Source: #{metadata.source}")
IO.puts("Query: #{metadata.query}")
end
end,
nil
)
Process.sleep(${args.duration} * 1000)
:telemetry.detach("ecto-query-tracer")
"Query tracing completed"
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to trace queries: ${result.error}`);
}
return this.createTextResponse(`🔍 **Ecto Query Tracing**\n\n` +
`Duration: ${args.duration} seconds\n` +
`Min Time: ${args.minTime}ms\n\n` +
`✅ Tracing completed. Check your application logs for query details.`);
}
async ectoAnalyzeNPlusOne(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
# Track queries for N+1 detection
query_counts = %{}
:telemetry.attach(
"n-plus-one-detector",
[:my_app, :repo, :query],
fn _event, _measurements, metadata, _config ->
key = {metadata.source, metadata.query}
Agent.update(:query_counter, fn state ->
Map.update(state, key, 1, &(&1 + 1))
end)
end,
nil
)
Agent.start_link(fn -> %{} end, name: :query_counter)
# Simulate action execution
# In real implementation, this would trigger the actual action
Process.sleep(2000)
# Get results
results = Agent.get(:query_counter, & &1)
|> Enum.filter(fn {_, count} -> count > ${args.threshold} end)
|> Enum.sort_by(fn {_, count} -> count end, :desc)
:telemetry.detach("n-plus-one-detector")
Agent.stop(:query_counter)
results
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to analyze N+1: ${result.error}`);
}
return this.createTextResponse(this.formatNPlusOneResults(result.data, args.threshold));
}
async ectoInspectChangesets(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const params = args.params ? JSON.stringify(args.params) : '{}';
const action = args.action || 'changeset';
const code = `
module = ${args.module}
params = Jason.decode!(${JSON.stringify(params)})
# Create struct and changeset
struct = struct(module)
changeset = apply(module, :${action}, [struct, params])
%{
valid?: changeset.valid?,
changes: changeset.changes,
errors: changeset.errors,
action: changeset.action,
types: Map.keys(changeset.types || %{}),
required: changeset.required || [],
validations: changeset.validations || []
}
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to inspect changeset: ${result.error}`);
}
return this.createTextResponse(this.formatChangesetInfo(result.data));
}
async ectoMigrationStatus(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const result = await this.tidewave.executeTool(session.url, {
tool: 'mix',
params: {
task: 'ecto.migrations',
args: ['--repo', args.repo || 'MyApp.Repo']
}
});
if (!result.success) {
throw new Error(`Failed to check migrations: ${result.error}`);
}
return this.createTextResponse(`📋 **Migration Status**\n\n` +
`Repository: \`${args.repo || 'MyApp.Repo'}\`\n\n` +
'```\n' + result.data + '\n```');
}
async ectoQueryAnalyzer(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
query = ${args.query}
# Convert to SQL if it's an Ecto query
sql = if is_struct(query) do
{sql, params} = Ecto.Adapters.SQL.to_sql(:all, Repo, query)
sql
else
query
end
# Run EXPLAIN
result = Ecto.Adapters.SQL.query!(Repo, "EXPLAIN #{sql}", [])
%{
query: sql,
plan: result.rows,
format: "${args.format}"
}
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to analyze query: ${result.error}`);
}
return this.createTextResponse(this.formatQueryPlan(result.data));
}
async ectoConnectionPoolStatus(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
repo = ${args.repo || 'MyApp.Repo'}
# Get pool telemetry
pool_pid = Process.whereis(repo)
pool_info = if pool_pid do
:sys.get_state(pool_pid)
else
nil
end
# Get basic stats
%{
alive?: Process.alive?(pool_pid || self()),
pool_size: repo.config()[:pool_size],
timeout: repo.config()[:timeout],
queue_target: repo.config()[:queue_target],
queue_interval: repo.config()[:queue_interval]
}
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to check pool status: ${result.error}`);
}
return this.createTextResponse(this.formatPoolStatus(result.data));
}
async ectoSandboxStatus(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
pid = ${args.pid || 'self()'}
# Check if sandbox is enabled
sandbox_enabled = Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox)
# Check if process is sandboxed
is_sandboxed = if sandbox_enabled do
try do
Ecto.Adapters.SQL.Sandbox.checkin(Repo)
true
rescue
_ -> false
end
else
false
end
%{
sandbox_available: sandbox_enabled,
process_sandboxed: is_sandboxed,
pid: inspect(pid)
}
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to check sandbox status: ${result.error}`);
}
return this.createTextResponse(`🏖️ **Ecto Sandbox Status**\n\n` +
`PID: ${result.data.pid}\n` +
`Sandbox Available: ${result.data.sandbox_available ? '✅' : '❌'}\n` +
`Process Sandboxed: ${result.data.process_sandboxed ? '✅' : '❌'}\n\n` +
(result.data.process_sandboxed ?
`💡 This process is running in sandbox mode for testing.` :
`💡 This process is using the real database connection.`));
}
// Helper methods
formatRepoConfig(config) {
return `🗄️ **Repository Configuration**\n\n` +
`**Connection:**\n` +
`• Adapter: ${config.adapter}\n` +
`• Database: ${config.database}\n` +
`• Host: ${config.hostname}:${config.port}\n\n` +
`**Pool Settings:**\n` +
`• Pool Size: ${config.pool_size}\n` +
`• Timeout: ${config.timeout}ms\n` +
`• Queue Target: ${config.queue_target}ms\n` +
`• Queue Interval: ${config.queue_interval}ms\n\n` +
`**Other:**\n` +
`• Migration Source: ${config.migration_source || 'schema_migrations'}\n` +
`• Priv Directory: ${config.priv}`;
}
formatNPlusOneResults(results, threshold) {
if (!results || results.length === 0) {
return `✅ **No N+1 Queries Detected**\n\nNo queries exceeded the threshold of ${threshold} executions.`;
}
let output = `⚠️ **Potential N+1 Queries Detected**\n\n`;
output += `Found ${results.length} queries exceeding threshold (${threshold}):\n\n`;
results.forEach(([[source, query], count], i) => {
output += `${i + 1}. **${count} executions**\n`;
output += ` Source: ${source}\n`;
output += ` Query: \`${query.substring(0, 100)}${query.length > 100 ? '...' : ''}\`\n\n`;
});
output += `💡 **Recommendation**: Consider using \`preload\` or \`join\` to load associations.`;
return output;
}
formatChangesetInfo(data) {
let output = `📝 **Changeset Inspection**\n\n`;
output += `Valid: ${data.valid ? '✅' : '❌'}\n`;
output += `Action: ${data.action || 'none'}\n\n`;
if (data.changes && Object.keys(data.changes).length > 0) {
output += `**Changes:**\n`;
Object.entries(data.changes).forEach(([key, value]) => {
output += `• ${key}: ${JSON.stringify(value)}\n`;
});
output += '\n';
}
if (data.errors && data.errors.length > 0) {
output += `**Errors:**\n`;
data.errors.forEach(([field, { message }]) => {
output += `• ${field}: ${message}\n`;
});
output += '\n';
}
if (data.required && data.required.length > 0) {
output += `**Required Fields:** ${data.required.join(', ')}\n`;
}
if (data.types && data.types.length > 0) {
output += `**Field Types:** ${data.types.join(', ')}\n`;
}
return output;
}
formatQueryPlan(data) {
let output = `🔍 **Query Analysis**\n\n`;
output += `**Query:**\n\`\`\`sql\n${data.query}\n\`\`\`\n\n`;
output += `**Execution Plan:**\n`;
if (data.format === 'text' && data.plan) {
output += '```\n';
data.plan.forEach((row) => {
output += row.join(' ') + '\n';
});
output += '```';
}
else {
output += JSON.stringify(data.plan, null, 2);
}
return output;
}
formatPoolStatus(data) {
const status = data.alive ? '✅ Healthy' : '❌ Not Running';
return `🏊 **Connection Pool Status**\n\n` +
`Status: ${status}\n\n` +
`**Configuration:**\n` +
`• Pool Size: ${data.pool_size}\n` +
`• Timeout: ${data.timeout}ms\n` +
`• Queue Target: ${data.queue_target}ms\n` +
`• Queue Interval: ${data.queue_interval}ms\n\n` +
`💡 These settings control how connections are managed and queued.`;
}
}
//# sourceMappingURL=ecto-handler.js.map