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
644 lines (625 loc) ⢠26.2 kB
JavaScript
import { BaseToolHandler } from './base-handler.js';
import { TidewaveIntegration } from '../tidewave-integration.js';
/**
* Handler for advanced LiveView debugging tools
*/
export class LiveViewAdvancedHandler extends BaseToolHandler {
tidewave;
constructor() {
super();
this.tidewave = new TidewaveIntegration();
}
tools = [
{
name: 'liveview_inspect_mount_state',
description: 'Inspect LiveView mount/3 callback state and assigns',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
liveViewModule: { type: 'string', description: 'LiveView module name' },
params: { type: 'object', description: 'Mount parameters to test with' }
},
required: ['sessionId', 'liveViewModule']
}
},
{
name: 'liveview_trace_handle_event',
description: 'Trace LiveView event handlers with payloads and state changes',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
liveViewModule: { type: 'string', description: 'LiveView module name' },
event: { type: 'string', description: 'Event name to trace' },
duration: { type: 'number', description: 'Trace duration in seconds', default: 30 }
},
required: ['sessionId', 'liveViewModule', 'event']
}
},
{
name: 'liveview_analyze_diff_size',
description: 'Analyze DOM diff sizes and optimize large payloads',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
threshold: { type: 'number', description: 'Diff size threshold in KB', default: 10 }
},
required: ['sessionId']
}
},
{
name: 'liveview_inspect_uploads',
description: 'Debug LiveView file upload state and configuration',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
uploadName: { type: 'string', description: 'Upload configuration name' }
},
required: ['sessionId']
}
},
{
name: 'liveview_profile_components',
description: 'Profile LiveComponent render times and frequency',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
duration: { type: 'number', description: 'Profile duration in seconds', default: 30 }
},
required: ['sessionId']
}
},
{
name: 'liveview_inspect_socket',
description: 'Deep inspect LiveView socket assigns and metadata',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
socketId: { type: 'string', description: 'Socket ID to inspect' }
},
required: ['sessionId']
}
},
{
name: 'liveview_stream_analysis',
description: 'Analyze LiveView streams performance and memory usage',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
streamName: { type: 'string', description: 'Stream name to analyze' }
},
required: ['sessionId']
}
},
{
name: 'liveview_form_state_debug',
description: 'Debug form state, changesets, and validation in LiveView',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
formName: { type: 'string', description: 'Form name to debug' }
},
required: ['sessionId']
}
},
{
name: 'liveview_js_commands_trace',
description: 'Trace JS commands execution and client-side effects',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' }
},
required: ['sessionId']
}
},
{
name: 'liveview_memory_usage',
description: 'Analyze LiveView process memory usage and detect leaks',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Debug session ID' },
includeComponents: { type: 'boolean', description: 'Include component analysis', default: true }
},
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 LiveView advanced tool: ${toolName}`);
}
convertToolNameToMethod(toolName) {
return toolName.replace(/_(.)/g, (_, char) => char.toUpperCase());
}
// Remove duplicate getSession - it's already in BaseToolHandler
async liveviewInspectMountState(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const params = args.params ? JSON.stringify(args.params) : '{}';
const code = `
module = ${args.liveViewModule}
params = Jason.decode!(${JSON.stringify(params)})
session = %{}
# Create a test socket
socket = %Phoenix.LiveView.Socket{
assigns: %{},
endpoint: MyAppWeb.Endpoint,
view: module
}
# Call mount
case module.mount(params, session, socket) do
{:ok, socket} ->
%{
success: true,
assigns: Map.from_struct(socket.assigns),
connected?: socket.connected?,
root_pid: inspect(socket.root_pid)
}
{:ok, socket, opts} ->
%{
success: true,
assigns: Map.from_struct(socket.assigns),
options: opts,
connected?: socket.connected?
}
error ->
%{success: false, error: inspect(error)}
end
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to inspect mount state: ${result.error}`);
}
return this.createTextResponse(this.formatMountState(result.data));
}
async liveviewTraceHandleEvent(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
# Set up tracing for handle_event
:dbg.start()
:dbg.tracer()
:dbg.tp(${args.liveViewModule}, :handle_event, [{:_, [], [{:return_trace}]}])
:dbg.p(:all, :c)
# Trace for specified duration
Process.sleep(${args.duration} * 1000)
:dbg.stop_clear()
"Event tracing started for ${args.event} in ${args.liveViewModule}"
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to trace events: ${result.error}`);
}
return this.createTextResponse(`š **LiveView Event Tracing**\n\n` +
`Module: \`${args.liveViewModule}\`\n` +
`Event: \`${args.event}\`\n` +
`Duration: ${args.duration} seconds\n\n` +
`ā
Tracing active. Event handler calls will appear in your logs.`);
}
async liveviewAnalyzeDiffSize(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
// Inject client-side monitoring
const monitoringCode = `
(() => {
if (!window.liveSocket) return { error: 'LiveSocket not found' };
const diffs = [];
const originalPush = window.liveSocket.push;
window.liveSocket.push = function(data) {
const size = JSON.stringify(data).length / 1024; // KB
if (size > ${args.threshold}) {
diffs.push({
timestamp: new Date().toISOString(),
size: size.toFixed(2),
type: data.type,
event: data.event
});
}
return originalPush.call(this, data);
};
// Collect for 10 seconds
setTimeout(() => {
window.liveSocket.push = originalPush;
}, 10000);
// Return initial status
return {
monitoring: true,
threshold: ${args.threshold},
message: 'Monitoring DOM diffs for 10 seconds...'
};
})();
`;
const result = await session.page.evaluate(monitoringCode);
if (result.error) {
throw new Error(result.error);
}
// Wait and collect results
await new Promise(resolve => setTimeout(resolve, 11000));
const diffs = await session.page.evaluate(() => {
return window.liveDiffs || [];
});
return this.createTextResponse(this.formatDiffAnalysis(diffs, args.threshold));
}
async liveviewInspectUploads(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const uploadInfo = await session.page.evaluate((uploadName) => {
const liveSocket = window.liveSocket;
if (!liveSocket)
return { error: 'LiveSocket not found' };
// Get upload configuration from the page
const uploads = {};
const uploadInputs = document.querySelectorAll('input[type="file"][data-phx-upload-ref]');
uploadInputs.forEach((input) => {
const ref = input.getAttribute('data-phx-upload-ref');
const name = input.getAttribute('name') || ref;
uploads[name] = {
ref,
multiple: input.multiple,
accept: input.accept,
maxFiles: input.getAttribute('data-phx-max-files'),
maxSize: input.getAttribute('data-phx-max-size')
};
});
return {
uploads,
activeUploads: Object.keys(uploads).length,
targetUpload: uploads[uploadName] || null
};
}, args.uploadName);
if (uploadInfo.error) {
throw new Error(uploadInfo.error);
}
return this.createTextResponse(this.formatUploadInfo(uploadInfo, args.uploadName));
}
async liveviewProfileComponents(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
# Profile LiveComponent rendering
component_timings = %{}
:telemetry.attach(
"liveview-component-profiler",
[:phoenix, :live_component, :render],
fn _event, %{duration: duration}, %{component: component}, _config ->
Agent.update(:component_profiler, fn state ->
Map.update(state, component, [duration], &[duration | &1])
end)
end,
nil
)
Agent.start_link(fn -> %{} end, name: :component_profiler)
# Profile for duration
Process.sleep(${args.duration} * 1000)
# Get results
results = Agent.get(:component_profiler, & &1)
|> Enum.map(fn {component, timings} ->
%{
component: inspect(component),
render_count: length(timings),
avg_time: Enum.sum(timings) / length(timings) / 1_000_000, # ms
min_time: Enum.min(timings) / 1_000_000,
max_time: Enum.max(timings) / 1_000_000
}
end)
|> Enum.sort_by(& &1.avg_time, :desc)
:telemetry.detach("liveview-component-profiler")
Agent.stop(:component_profiler)
results
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to profile components: ${result.error}`);
}
return this.createTextResponse(this.formatComponentProfile(result.data));
}
async liveviewInspectSocket(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
# Find socket process
socket_pid = Process.whereis(String.to_atom("${args.socketId}"))
if socket_pid && Process.alive?(socket_pid) do
state = :sys.get_state(socket_pid)
%{
pid: inspect(socket_pid),
connected?: state.socket.connected?,
assigns: Map.keys(state.socket.assigns),
assign_count: map_size(state.socket.assigns),
memory: Process.info(socket_pid, :memory)[:memory],
message_queue: Process.info(socket_pid, :message_queue_len)[:message_queue_len]
}
else
%{error: "Socket process not found or not alive"}
end
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to inspect socket: ${result.error}`);
}
if (result.data.error) {
throw new Error(result.data.error);
}
return this.createTextResponse(this.formatSocketInfo(result.data));
}
async liveviewStreamAnalysis(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
# Analyze stream usage
# This is a simplified version - real implementation would hook into LiveView internals
%{
stream_name: "${args.streamName}",
estimated_items: "Unknown - requires runtime inspection",
memory_usage: "Requires process inspection",
recommendations: [
"Use limit option to cap stream size",
"Consider pagination for large datasets",
"Use temporary assigns for non-stream data"
]
}
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to analyze stream: ${result.error}`);
}
return this.createTextResponse(`š **Stream Analysis**\n\n` +
`Stream: \`${args.streamName}\`\n\n` +
`š” **Recommendations:**\n` +
result.data.recommendations.map((r) => `⢠${r}`).join('\n'));
}
async liveviewFormStateDebug(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
// Get form state from client
const formState = await session.page.evaluate((formName) => {
const form = document.querySelector(`form[phx-submit="${formName}"], form[name="${formName}"]`);
if (!form)
return { error: 'Form not found' };
const inputs = {};
form.querySelectorAll('input, select, textarea').forEach((el) => {
if (el.name) {
inputs[el.name] = {
value: el.value,
type: el.type,
required: el.required,
validity: el.validity.valid
};
}
});
return {
formName,
action: form.getAttribute('action'),
method: form.getAttribute('method'),
phxSubmit: form.getAttribute('phx-submit'),
phxChange: form.getAttribute('phx-change'),
inputs
};
}, args.formName);
if (formState.error) {
throw new Error(formState.error);
}
return this.createTextResponse(this.formatFormState(formState));
}
async liveviewJsCommandsTrace(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
// Inject JS command tracing
const result = await session.page.evaluate(() => {
if (!window.Phoenix?.LiveView?.JS) {
return { error: 'Phoenix.LiveView.JS not found' };
}
const JS = window.Phoenix.LiveView.JS;
const commands = [];
// Wrap JS methods
['push', 'dispatch', 'toggle', 'show', 'hide', 'add_class', 'remove_class'].forEach(method => {
const original = JS.prototype[method];
if (original) {
JS.prototype[method] = function (...args) {
commands.push({
method,
args,
timestamp: new Date().toISOString()
});
return original.apply(this, args);
};
}
});
return {
tracing: true,
message: 'JS command tracing enabled. Interact with the page to capture commands.'
};
});
if (result.error) {
throw new Error(result.error);
}
return this.createTextResponse(`š **JS Commands Tracing**\n\n` +
`ā
${result.message}\n\n` +
`Monitoring commands: push, dispatch, toggle, show, hide, add_class, remove_class\n\n` +
`š” Commands will be logged to the browser console.`);
}
async liveviewMemoryUsage(args, sessions) {
const session = this.getSession(args.sessionId, sessions);
const code = `
# Get all LiveView processes
liveview_processes = Process.list()
|> Enum.filter(fn pid ->
case Process.info(pid, :dictionary) do
{:dictionary, dict} ->
Keyword.has_key?(dict, :"$initial_call") &&
elem(Keyword.get(dict, :"$initial_call", {nil, nil, nil}), 0) == Phoenix.LiveView.Channel
_ -> false
end
end)
|> Enum.map(fn pid ->
info = Process.info(pid, [:memory, :message_queue_len, :reductions])
%{
pid: inspect(pid),
memory: info[:memory],
message_queue: info[:message_queue_len],
reductions: info[:reductions]
}
end)
|> Enum.sort_by(& &1.memory, :desc)
|> Enum.take(10)
total_memory = Enum.sum(Enum.map(liveview_processes, & &1.memory))
%{
processes: liveview_processes,
total_memory: total_memory,
process_count: length(liveview_processes)
}
`;
const result = await this.tidewave.evaluateCode(session.url, code);
if (!result.success) {
throw new Error(`Failed to analyze memory usage: ${result.error}`);
}
return this.createTextResponse(this.formatMemoryUsage(result.data));
}
// Helper methods
formatMountState(data) {
if (!data.success) {
return `ā **Mount Failed**\n\nError: ${data.error}`;
}
let output = `šÆ **LiveView Mount State**\n\n`;
output += `Connected: ${data.connected ? 'ā
' : 'ā'}\n`;
if (data.root_pid) {
output += `Root PID: ${data.root_pid}\n`;
}
output += '\n';
if (data.assigns && Object.keys(data.assigns).length > 0) {
output += `**Assigns:**\n`;
Object.keys(data.assigns).forEach(key => {
output += `⢠${key}\n`;
});
}
if (data.options) {
output += `\n**Mount Options:**\n`;
output += JSON.stringify(data.options, null, 2);
}
return output;
}
formatDiffAnalysis(diffs, threshold) {
if (!diffs || diffs.length === 0) {
return `ā
**No Large Diffs Detected**\n\nNo DOM diffs exceeded ${threshold}KB during monitoring.`;
}
let output = `ā ļø **Large DOM Diffs Detected**\n\n`;
output += `Found ${diffs.length} diffs exceeding ${threshold}KB:\n\n`;
diffs.forEach((diff, i) => {
output += `${i + 1}. **${diff.size}KB**\n`;
output += ` Time: ${diff.timestamp}\n`;
output += ` Type: ${diff.type}\n`;
output += ` Event: ${diff.event}\n\n`;
});
output += `š” **Optimization Tips:**\n`;
output += `⢠Use temporary assigns for large, infrequently changing data\n`;
output += `⢠Consider pagination or virtualization for large lists\n`;
output += `⢠Move static content outside of changing containers`;
return output;
}
formatUploadInfo(data, uploadName) {
let output = `š¤ **Upload Configuration**\n\n`;
if (uploadName && data.targetUpload) {
output += `**Upload: ${uploadName}**\n`;
output += `⢠Reference: ${data.targetUpload.ref}\n`;
output += `⢠Multiple: ${data.targetUpload.multiple ? 'ā
' : 'ā'}\n`;
output += `⢠Accept: ${data.targetUpload.accept || 'Any'}\n`;
output += `⢠Max Files: ${data.targetUpload.maxFiles || 'Unlimited'}\n`;
output += `⢠Max Size: ${data.targetUpload.maxSize || 'Unlimited'}\n`;
}
else {
output += `Active Uploads: ${data.activeUploads}\n\n`;
const uploadsObj = data.uploads;
if (Object.keys(uploadsObj).length > 0) {
output += `**All Uploads:**\n`;
Object.entries(uploadsObj).forEach(([name, config]) => {
output += `\n⢠**${name}**\n`;
output += ` Accept: ${config.accept || 'Any'}\n`;
output += ` Multiple: ${config.multiple ? 'Yes' : 'No'}\n`;
});
}
}
return output;
}
formatComponentProfile(components) {
if (!components || components.length === 0) {
return `š **No Components Rendered**\n\nNo LiveComponents were rendered during the profiling period.`;
}
let output = `ā” **Component Performance Profile**\n\n`;
components.forEach((comp, i) => {
output += `${i + 1}. **${comp.component}**\n`;
output += ` Renders: ${comp.render_count}\n`;
output += ` Avg: ${comp.avg_time.toFixed(2)}ms\n`;
output += ` Min: ${comp.min_time.toFixed(2)}ms\n`;
output += ` Max: ${comp.max_time.toFixed(2)}ms\n\n`;
});
const slowest = components[0];
if (slowest && slowest.avg_time > 16) {
output += `ā ļø **Performance Warning**\n`;
output += `${slowest.component} averages ${slowest.avg_time.toFixed(2)}ms per render.\n`;
output += `This exceeds the 16ms budget for 60fps.\n`;
}
return output;
}
formatSocketInfo(data) {
return `š **Socket Inspection**\n\n` +
`PID: ${data.pid}\n` +
`Connected: ${data.connected ? 'ā
' : 'ā'}\n` +
`Memory: ${this.formatBytes(data.memory)}\n` +
`Message Queue: ${data.message_queue} messages\n\n` +
`**Assigns:** ${data.assign_count} keys\n` +
data.assigns.slice(0, 10).map((a) => `⢠${a}`).join('\n') +
(data.assigns.length > 10 ? `\n... and ${data.assigns.length - 10} more` : '');
}
formatFormState(data) {
let output = `š **Form State Debug**\n\n`;
output += `Form: ${data.formName}\n`;
if (data.phxSubmit)
output += `Phoenix Submit: ${data.phxSubmit}\n`;
if (data.phxChange)
output += `Phoenix Change: ${data.phxChange}\n`;
if (data.action)
output += `Action: ${data.action}\n`;
if (data.method)
output += `Method: ${data.method}\n`;
output += `\n**Fields:**\n`;
Object.entries(data.inputs).forEach(([name, field]) => {
const icon = field.validity ? 'ā
' : 'ā';
output += `${icon} ${name}: ${field.value || '(empty)'} [${field.type}]`;
if (field.required)
output += ' *required';
output += '\n';
});
return output;
}
formatMemoryUsage(data) {
let output = `š¾ **LiveView Memory Usage**\n\n`;
output += `Total Memory: ${this.formatBytes(data.total_memory)}\n`;
output += `Process Count: ${data.process_count}\n\n`;
if (data.processes.length > 0) {
output += `**Top 10 Processes by Memory:**\n`;
data.processes.forEach((proc, i) => {
output += `${i + 1}. PID: ${proc.pid}\n`;
output += ` Memory: ${this.formatBytes(proc.memory)}\n`;
output += ` Queue: ${proc.message_queue} messages\n`;
output += ` Reductions: ${proc.reductions.toLocaleString()}\n\n`;
});
}
if (data.total_memory > 100 * 1024 * 1024) { // 100MB
output += `ā ļø **Warning**: High memory usage detected!\n`;
output += `Consider investigating large assigns or memory leaks.`;
}
return output;
}
formatBytes(bytes) {
if (bytes < 1024)
return `${bytes} B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
}
//# sourceMappingURL=liveview-advanced-handler.js.map