playwright-mcp/src/tools/requests.ts
Ryan Malloy 9257404ba3 feat: fix video recording session persistence and add HTTP request monitoring
Video Recording Fixes:
- Fix session persistence issues where recording state was lost between tool calls
- Improve page video object handling by triggering navigation when needed
- Add browser_reveal_artifact_paths tool to show exact file locations
- Enhance browser_recording_status with detailed debugging info and file listings
- Add clearVideoRecordingState() method for proper state management
- Keep recording config available for debugging until new session starts

Request Monitoring System:
- Add comprehensive RequestInterceptor class for HTTP traffic capture
- Implement 5 new MCP tools for request monitoring and analysis
- Support multiple export formats: JSON, HAR, CSV, and summary reports
- Add filtering by domain, method, status codes, and response timing
- Integrate with artifact storage for organized session-based file management
- Enhance browser_network_requests with rich intercepted data

Additional Improvements:
- Add getBaseDirectory/getSessionDirectory methods to ArtifactManager
- Fix floating promise in tab.ts extension console message polling
- Add debug script for comprehensive video recording workflow testing
- Update README with new tool documentation

Resolves video recording workflow issues and adds powerful HTTP traffic
analysis capabilities for web application debugging and security testing.
2025-09-05 07:17:11 -06:00

561 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import { RequestInterceptorOptions } from '../requestInterceptor.js';
import type { Context } from '../context.js';
const startMonitoringSchema = z.object({
urlFilter: z.union([
z.string(),
z.object({
type: z.enum(['regex', 'function']),
value: z.string()
})
]).optional().describe('Filter URLs to capture. Can be a string (contains match), regex pattern, or custom function. Examples: "/api/", ".*\\.json$", or custom logic'),
captureBody: z.boolean().optional().default(true).describe('Whether to capture request and response bodies (default: true)'),
maxBodySize: z.number().optional().default(10485760).describe('Maximum body size to capture in bytes (default: 10MB). Larger bodies will be truncated'),
autoSave: z.boolean().optional().default(false).describe('Automatically save captured requests after each response (default: false for performance)'),
outputPath: z.string().optional().describe('Custom output directory path. If not specified, uses session artifact directory')
});
const getRequestsSchema = z.object({
filter: z.enum(['all', 'failed', 'slow', 'errors', 'success']).optional().default('all').describe('Filter requests by type: all, failed (network failures), slow (>1s), errors (4xx/5xx), success (2xx/3xx)'),
domain: z.string().optional().describe('Filter requests by domain hostname'),
method: z.string().optional().describe('Filter requests by HTTP method (GET, POST, etc.)'),
status: z.number().optional().describe('Filter requests by HTTP status code'),
limit: z.number().optional().default(100).describe('Maximum number of requests to return (default: 100)'),
format: z.enum(['summary', 'detailed', 'stats']).optional().default('summary').describe('Response format: summary (basic info), detailed (full data), stats (statistics only)'),
slowThreshold: z.number().optional().default(1000).describe('Threshold in milliseconds for considering requests "slow" (default: 1000ms)')
});
const exportRequestsSchema = z.object({
format: z.enum(['json', 'har', 'csv', 'summary']).optional().default('json').describe('Export format: json (full data), har (HTTP Archive), csv (spreadsheet), summary (human-readable report)'),
filename: z.string().optional().describe('Custom filename for export. Auto-generated if not specified with timestamp'),
filter: z.enum(['all', 'failed', 'slow', 'errors', 'success']).optional().default('all').describe('Filter which requests to export'),
includeBody: z.boolean().optional().default(false).describe('Include request/response bodies in export (warning: may create large files)')
});
/**
* Start comprehensive request monitoring and interception
*
* This tool enables deep HTTP traffic analysis during browser automation.
* Perfect for API reverse engineering, security testing, and performance analysis.
*
* Use Cases:
* - Security testing: Capture all API calls for vulnerability analysis
* - Performance monitoring: Identify slow endpoints and optimize
* - API documentation: Generate comprehensive API usage reports
* - Debugging: Analyze failed requests and error patterns
*/
const startRequestMonitoring = defineTool({
capability: 'core',
schema: {
name: 'browser_start_request_monitoring',
title: 'Start request monitoring',
description: 'Enable comprehensive HTTP request/response interception and analysis. Captures headers, bodies, timing, and failure information for all browser traffic. Essential for security testing, API analysis, and performance debugging.',
inputSchema: startMonitoringSchema,
type: 'destructive',
},
handle: async (context: Context, params: z.output<typeof startMonitoringSchema>, response) => {
try {
await context.ensureTab();
// Parse URL filter
let urlFilter: RequestInterceptorOptions['urlFilter'];
if (params.urlFilter) {
if (typeof params.urlFilter === 'string') {
urlFilter = params.urlFilter;
} else {
// Handle regex or function
if (params.urlFilter.type === 'regex') {
urlFilter = new RegExp(params.urlFilter.value);
} else {
// Function - evaluate safely
try {
urlFilter = eval(`(${params.urlFilter.value})`);
} catch (error: any) {
throw new Error(`Invalid filter function: ${error.message}`);
}
}
}
}
// Get output path from artifact manager or use default
let outputPath = params.outputPath;
if (!outputPath && context.sessionId) {
const artifactManager = context.getArtifactManager();
if (artifactManager)
outputPath = artifactManager.getSubdirectory('requests');
}
if (!outputPath)
outputPath = context.config.outputDir + '/requests';
const options: RequestInterceptorOptions = {
urlFilter,
captureBody: params.captureBody,
maxBodySize: params.maxBodySize,
autoSave: params.autoSave,
outputPath
};
// Start monitoring
await context.startRequestMonitoring(options);
response.addResult('✅ **Request monitoring started successfully**');
response.addResult('');
response.addResult('📊 **Configuration:**');
response.addResult(`• URL Filter: ${params.urlFilter || 'All requests'}`);
response.addResult(`• Capture Bodies: ${params.captureBody ? 'Yes' : 'No'}`);
response.addResult(`• Max Body Size: ${(params.maxBodySize! / 1024 / 1024).toFixed(1)}MB`);
response.addResult(`• Auto Save: ${params.autoSave ? 'Yes' : 'No'}`);
response.addResult(`• Output Path: ${outputPath}`);
response.addResult('');
response.addResult('🎯 **Next Steps:**');
response.addResult('1. Navigate to pages and interact with the application');
response.addResult('2. Use `browser_get_requests` to view captured traffic');
response.addResult('3. Use `browser_export_requests` to save analysis results');
response.addResult('4. Use `browser_clear_requests` to clear captured data');
} catch (error: any) {
throw new Error(`Failed to start request monitoring: ${error.message}`);
}
},
});
/**
* Retrieve and analyze captured HTTP requests
*
* Access comprehensive request data including timing, headers, bodies,
* and failure information. Supports advanced filtering and analysis.
*/
const getRequests = defineTool({
capability: 'core',
schema: {
name: 'browser_get_requests',
title: 'Get captured requests',
description: 'Retrieve and analyze captured HTTP requests with advanced filtering. Shows timing, status codes, headers, and bodies. Perfect for identifying performance issues, failed requests, or analyzing API usage patterns.',
inputSchema: getRequestsSchema,
type: 'readOnly',
},
handle: async (context: Context, params: z.output<typeof getRequestsSchema>, response) => {
try {
const interceptor = context.getRequestInterceptor();
if (!interceptor) {
response.addResult('❌ **Request monitoring not active**');
response.addResult('');
response.addResult('💡 Start monitoring first with `browser_start_request_monitoring`');
return;
}
let requests = interceptor.getData();
// Apply filters
if (params.filter !== 'all') {
switch (params.filter) {
case 'failed':
requests = interceptor.getFailedRequests();
break;
case 'slow':
requests = interceptor.getSlowRequests(params.slowThreshold);
break;
case 'errors':
requests = requests.filter(r => r.response && r.response.status >= 400);
break;
case 'success':
requests = requests.filter(r => r.response && r.response.status < 400);
break;
}
}
if (params.domain) {
requests = requests.filter(r => {
try {
return new URL(r.url).hostname === params.domain;
} catch {
return false;
}
});
}
if (params.method)
requests = requests.filter(r => r.method.toLowerCase() === params.method!.toLowerCase());
if (params.status)
requests = requests.filter(r => r.response?.status === params.status);
// Limit results
const limitedRequests = requests.slice(0, params.limit);
if (params.format === 'stats') {
// Return statistics only
const stats = interceptor.getStats();
response.addResult('📊 **Request Statistics**');
response.addResult('');
response.addResult(`• Total Requests: ${stats.totalRequests}`);
response.addResult(`• Successful: ${stats.successfulRequests} (${((stats.successfulRequests / stats.totalRequests) * 100).toFixed(1)}%)`);
response.addResult(`• Failed: ${stats.failedRequests} (${((stats.failedRequests / stats.totalRequests) * 100).toFixed(1)}%)`);
response.addResult(`• Errors: ${stats.errorResponses} (${((stats.errorResponses / stats.totalRequests) * 100).toFixed(1)}%)`);
response.addResult(`• Average Response Time: ${stats.averageResponseTime}ms`);
response.addResult(`• Slow Requests (>1s): ${stats.slowRequests}`);
response.addResult('');
response.addResult('**By Method:**');
Object.entries(stats.requestsByMethod).forEach(([method, count]) => {
response.addResult(`${method}: ${count}`);
});
response.addResult('');
response.addResult('**By Status Code:**');
Object.entries(stats.requestsByStatus).forEach(([status, count]) => {
response.addResult(`${status}: ${count}`);
});
response.addResult('');
response.addResult('**Top Domains:**');
const topDomains = Object.entries(stats.requestsByDomain)
.sort(([, a], [, b]) => b - a)
.slice(0, 5);
topDomains.forEach(([domain, count]) => {
response.addResult(`${domain}: ${count}`);
});
return;
}
// Return request data
if (limitedRequests.length === 0) {
response.addResult(' **No requests found matching the criteria**');
response.addResult('');
response.addResult('💡 Try different filters or ensure the page has made HTTP requests');
return;
}
response.addResult(`📋 **Captured Requests (${limitedRequests.length} of ${requests.length} total)**`);
response.addResult('');
limitedRequests.forEach((req, index) => {
const duration = req.duration ? `${req.duration}ms` : 'pending';
const status = req.failed ? 'FAILED' : req.response?.status || 'pending';
const size = req.response?.bodySize ? ` (${(req.response.bodySize / 1024).toFixed(1)}KB)` : '';
response.addResult(`**${index + 1}. ${req.method} ${status}** - ${duration}`);
response.addResult(` ${req.url}${size}`);
if (params.format === 'detailed') {
response.addResult(` 📅 ${req.timestamp}`);
if (req.response) {
response.addResult(` 📊 Status: ${req.response.status} ${req.response.statusText}`);
response.addResult(` ⏱️ Duration: ${req.response.duration}ms`);
response.addResult(` 🔄 From Cache: ${req.response.fromCache ? 'Yes' : 'No'}`);
// Show key headers
const contentType = req.response.headers['content-type'];
if (contentType)
response.addResult(` 📄 Content-Type: ${contentType}`);
}
if (req.failed && req.failure)
response.addResult(` ❌ Failure: ${req.failure.errorText}`);
response.addResult('');
}
});
if (requests.length > params.limit)
response.addResult(`💡 Showing first ${params.limit} results. Use higher limit or specific filters to see more.`);
} catch (error: any) {
throw new Error(`Failed to get requests: ${error.message}`);
}
},
});
/**
* Export captured requests to various formats for external analysis
*/
const exportRequests = defineTool({
capability: 'core',
schema: {
name: 'browser_export_requests',
title: 'Export captured requests',
description: 'Export captured HTTP requests to various formats (JSON, HAR, CSV, or summary report). Perfect for sharing analysis results, importing into other tools, or creating audit reports.',
inputSchema: exportRequestsSchema,
type: 'readOnly',
},
handle: async (context: Context, params: z.output<typeof exportRequestsSchema>, response) => {
try {
const interceptor = context.getRequestInterceptor();
if (!interceptor) {
response.addResult('❌ **Request monitoring not active**');
response.addResult('');
response.addResult('💡 Start monitoring first with `browser_start_request_monitoring`');
return;
}
const requests = interceptor.getData();
if (requests.length === 0) {
response.addResult(' **No requests to export**');
response.addResult('');
response.addResult('💡 Navigate to pages and interact with the application first');
return;
}
let exportPath: string;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const defaultFilename = `requests-${timestamp}`;
switch (params.format) {
case 'har':
exportPath = await interceptor.exportHAR(params.filename || `${defaultFilename}.har`);
break;
case 'json':
exportPath = await interceptor.save(params.filename || `${defaultFilename}.json`);
break;
case 'csv':
// Create CSV export
const csvData = requests.map(req => ({
timestamp: req.timestamp,
method: req.method,
url: req.url,
status: req.response?.status || (req.failed ? 'FAILED' : 'PENDING'),
duration: req.duration || '',
size: req.response?.bodySize || '',
contentType: req.response?.headers['content-type'] || '',
fromCache: req.response?.fromCache || false
}));
const csvHeaders = Object.keys(csvData[0]).join(',');
const csvRows = csvData.map(row => Object.values(row).join(','));
const csvContent = [csvHeaders, ...csvRows].join('\n');
const csvFilename = params.filename || `${defaultFilename}.csv`;
const csvPath = `${interceptor.getStatus().options.outputPath}/${csvFilename}`;
await require('fs/promises').writeFile(csvPath, csvContent);
exportPath = csvPath;
break;
case 'summary':
// Create human-readable summary
const stats = interceptor.getStats();
const summaryLines = [
'# HTTP Request Analysis Summary',
`Generated: ${new Date().toISOString()}`,
'',
'## Overview',
`- Total Requests: ${stats.totalRequests}`,
`- Successful: ${stats.successfulRequests}`,
`- Failed: ${stats.failedRequests}`,
`- Errors: ${stats.errorResponses}`,
`- Average Response Time: ${stats.averageResponseTime}ms`,
'',
'## Performance',
`- Fast Requests (<1s): ${stats.fastRequests}`,
`- Slow Requests (>1s): ${stats.slowRequests}`,
'',
'## Request Methods',
...Object.entries(stats.requestsByMethod).map(([method, count]) => `- ${method}: ${count}`),
'',
'## Status Codes',
...Object.entries(stats.requestsByStatus).map(([status, count]) => `- ${status}: ${count}`),
'',
'## Top Domains',
...Object.entries(stats.requestsByDomain)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([domain, count]) => `- ${domain}: ${count}`),
'',
'## Slow Requests (>1s)',
...interceptor.getSlowRequests().map(req =>
`- ${req.method} ${req.url} (${req.duration}ms)`
),
'',
'## Failed Requests',
...interceptor.getFailedRequests().map(req =>
`- ${req.method} ${req.url} (${req.response?.status || 'NETWORK_FAILED'})`
)
];
const summaryFilename = params.filename || `${defaultFilename}-summary.md`;
const summaryPath = `${interceptor.getStatus().options.outputPath}/${summaryFilename}`;
await require('fs/promises').writeFile(summaryPath, summaryLines.join('\n'));
exportPath = summaryPath;
break;
default:
throw new Error(`Unsupported export format: ${params.format}`);
}
response.addResult('✅ **Export completed successfully**');
response.addResult('');
response.addResult(`📁 **File saved:** ${exportPath}`);
response.addResult(`📊 **Exported:** ${requests.length} requests`);
response.addResult(`🗂️ **Format:** ${params.format.toUpperCase()}`);
response.addResult('');
if (params.format === 'har') {
response.addResult('💡 **HAR files** can be imported into:');
response.addResult(' • Chrome DevTools (Network tab)');
response.addResult(' • Insomnia or Postman');
response.addResult(' • Online HAR viewers');
} else if (params.format === 'json') {
response.addResult('💡 **JSON files** contain full request/response data');
response.addResult(' • Perfect for programmatic analysis');
response.addResult(' • Includes headers, bodies, timing info');
}
} catch (error: any) {
throw new Error(`Failed to export requests: ${error.message}`);
}
},
});
/**
* Clear all captured request data from memory
*/
const clearRequests = defineTool({
capability: 'core',
schema: {
name: 'browser_clear_requests',
title: 'Clear captured requests',
description: 'Clear all captured HTTP request data from memory. Useful for freeing up memory during long sessions or when starting fresh analysis.',
inputSchema: z.object({}),
type: 'destructive',
},
handle: async (context: Context, params, response) => {
try {
const interceptor = context.getRequestInterceptor();
if (!interceptor) {
response.addResult(' **Request monitoring not active**');
response.addResult('');
response.addResult('💡 Start monitoring first with `browser_start_request_monitoring`');
return;
}
const clearedCount = interceptor.clear();
response.addResult('✅ **Request data cleared successfully**');
response.addResult('');
response.addResult(`🗑️ **Cleared:** ${clearedCount} captured requests`);
response.addResult('');
response.addResult('💡 **Memory freed** - Ready for new monitoring session');
} catch (error: any) {
throw new Error(`Failed to clear requests: ${error.message}`);
}
},
});
/**
* Get current request monitoring status and configuration
*/
const getMonitoringStatus = defineTool({
capability: 'core',
schema: {
name: 'browser_request_monitoring_status',
title: 'Get request monitoring status',
description: 'Check if request monitoring is active and view current configuration. Shows capture statistics, filter settings, and output paths.',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (context: Context, params, response) => {
try {
const interceptor = context.getRequestInterceptor();
if (!interceptor) {
response.addResult('❌ **Request monitoring is not active**');
response.addResult('');
response.addResult('💡 **To start monitoring:**');
response.addResult('1. Use `browser_start_request_monitoring` to enable');
response.addResult('2. Navigate to pages and perform actions');
response.addResult('3. Use `browser_get_requests` to view captured data');
return;
}
const status = interceptor.getStatus();
const stats = interceptor.getStats();
response.addResult('✅ **Request monitoring is active**');
response.addResult('');
response.addResult('📊 **Current Statistics:**');
response.addResult(`• Total Captured: ${stats.totalRequests} requests`);
response.addResult(`• Successful: ${stats.successfulRequests}`);
response.addResult(`• Failed: ${stats.failedRequests}`);
response.addResult(`• Average Response Time: ${stats.averageResponseTime}ms`);
response.addResult('');
response.addResult('⚙️ **Configuration:**');
response.addResult(`• Attached to Page: ${status.isAttached ? 'Yes' : 'No'}`);
response.addResult(`• Current Page: ${status.pageUrl || 'None'}`);
response.addResult(`• Capture Bodies: ${status.options.captureBody ? 'Yes' : 'No'}`);
response.addResult(`• Max Body Size: ${status.options.maxBodySize ? (status.options.maxBodySize / 1024 / 1024).toFixed(1) + 'MB' : 'Unlimited'}`);
response.addResult(`• Auto Save: ${status.options.autoSave ? 'Yes' : 'No'}`);
response.addResult(`• Output Path: ${status.options.outputPath || 'Default'}`);
if (stats.totalRequests > 0) {
response.addResult('');
response.addResult('📈 **Recent Activity:**');
const recentRequests = interceptor.getData().slice(-3);
recentRequests.forEach((req, index) => {
const duration = req.duration ? ` (${req.duration}ms)` : '';
const status = req.failed ? 'FAILED' : req.response?.status || 'pending';
response.addResult(` ${index + 1}. ${req.method} ${status} - ${new URL(req.url).pathname}${duration}`);
});
}
} catch (error: any) {
throw new Error(`Failed to get monitoring status: ${error.message}`);
}
},
});
export default [
startRequestMonitoring,
getRequests,
exportRequests,
clearRequests,
getMonitoringStatus,
];