playwright-min-network-mcp
Version:
Minimal network monitoring MCP tool for Playwright browser automation
467 lines (466 loc) • 22.9 kB
JavaScript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { chromium } from 'playwright';
import { z } from 'zod';
import { launchBrowserServer } from './browser.js';
import { connectToCdp, startNetworkMonitoring } from './monitor.js';
import { GetRecentRequestsSchema, GetRequestDetailSchema, StartMonitorSchema, } from './types.js';
export class NetworkMonitorMCP {
server;
isMonitoring = false;
cdpWebSocketUrl = null;
cdpPort = 9222;
networkBuffer = [];
pendingRequests = new Map();
cdpWebSocket = null;
browserServer = null;
constructor() {
this.server = new Server({
name: 'network-monitor',
version: '0.1.0',
}, {
capabilities: {
tools: {},
},
});
this.setupHandlers();
}
setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'start_monitor',
description: 'Start network monitoring with a new browser instance. Default: captures API and form data only (JSON, form submissions). Use "all" to include static files.',
inputSchema: {
type: 'object',
properties: {
max_buffer_size: {
type: 'number',
description: 'Maximum buffer size for storing requests',
default: 30,
},
cdp_port: {
type: 'number',
description: 'Chrome DevTools Protocol port number',
default: 9222,
},
filter: {
type: 'object',
description: 'Content-type filtering configuration. Controls which types of network requests to capture.',
properties: {
content_types: {
oneOf: [
{
type: 'array',
items: { type: 'string' },
description: 'Array of content-type patterns to include. Example: ["application/json", "text/css"] to capture JSON and CSS files.',
},
{
type: 'string',
enum: ['all'],
description: 'Special value "all" to capture all content types including static files (CSS, JS, images).',
},
],
default: [
'application/json',
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
],
description: 'Content types to capture. Default: API and form data only. Use "all" for everything including static files, or [] to capture nothing.',
},
url_include_patterns: {
oneOf: [
{
type: 'array',
items: { type: 'string' },
description: 'Array of URL patterns to include. Example: ["api/", "/graphql"] to capture only API endpoints.',
},
{
type: 'string',
enum: ['all'],
description: 'Special value "all" to include all URLs (no URL filtering).',
},
],
default: 'all',
description: 'URL patterns to include. Default: "all" for no filtering. Use array of patterns to filter specific URLs.',
},
methods: {
type: 'array',
items: { type: 'string' },
description: 'Array of HTTP methods to include. Example: ["GET", "POST"] to only capture GET and POST requests.',
},
},
},
},
},
},
{
name: 'update_filter',
description: 'Update network monitoring filter settings without restarting the browser. Preserves the current browsing session.',
inputSchema: {
type: 'object',
properties: {
filter: {
type: 'object',
description: 'Content-type filtering configuration. Controls which types of network requests to capture.',
properties: {
content_types: {
oneOf: [
{
type: 'array',
items: { type: 'string' },
description: 'Array of content-type patterns to include. Example: ["application/json", "text/css"] to capture JSON and CSS files.',
},
{
type: 'string',
enum: ['all'],
description: 'Special value "all" to capture all content types including static files (CSS, JS, images).',
},
],
default: [
'application/json',
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
],
description: 'Content types to capture. Default: API and form data only. Use "all" for everything including static files, or [] to capture nothing.',
},
url_include_patterns: {
oneOf: [
{
type: 'array',
items: { type: 'string' },
description: 'Array of URL patterns to include. Example: ["api/", "/graphql"] to capture only API endpoints.',
},
{
type: 'string',
enum: ['all'],
description: 'Special value "all" to include all URLs (no URL filtering).',
},
],
default: 'all',
description: 'URL patterns to include. Default: "all" for no filtering. Use array of patterns to filter specific URLs.',
},
methods: {
type: 'array',
items: { type: 'string' },
description: 'Array of HTTP methods to include. Example: ["GET", "POST"] to only capture GET and POST requests.',
},
},
required: ['content_types'],
},
},
required: ['filter'],
},
},
{
name: 'stop_monitor',
description: 'Stop network monitoring',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'get_recent_requests',
description: 'Get recent network requests compact overview with 512B request/response body previews. Shows both request and response body previews separately.',
inputSchema: {
type: 'object',
properties: {
count: {
type: 'number',
description: 'Number of requests to return',
default: 10,
},
include_headers: {
type: 'boolean',
description: 'Include request/response headers',
default: false,
},
},
},
},
{
name: 'get_request_detail',
description: 'Get full details for a specific request by UUID. Returns complete request/response data with 50KB body limit and optional headers to prevent MCP context overflow.',
inputSchema: {
type: 'object',
properties: {
uuid: {
type: 'string',
description: 'UUID of the request to retrieve details for',
pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
},
include_headers: {
type: 'boolean',
description: 'Include request/response headers (default: false for context efficiency)',
default: false,
},
},
required: ['uuid'],
},
},
],
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'start_monitor':
return this.startMonitor(args);
case 'update_filter':
return this.updateFilter(args);
case 'stop_monitor':
return this.stopMonitor();
case 'get_recent_requests':
return this.getRecentRequests(args);
case 'get_request_detail':
return this.getRequestDetail(args);
default:
throw new Error(`Unknown tool: ${name}`);
}
});
}
async startMonitor(args) {
try {
const options = StartMonitorSchema.parse(args || {});
this.cdpPort = options.cdp_port;
// Launch browser server only if not already running
if (!this.browserServer) {
this.browserServer = await launchBrowserServer(chromium, this.cdpPort);
}
// Get CDP WebSocket URL from the debugging port
const listResponse = await fetch(`http://localhost:${this.cdpPort}/json/list`);
const pages = await listResponse.json();
this.cdpWebSocketUrl = pages[0].webSocketDebuggerUrl;
// Connect to CDP and start monitoring
if (!this.cdpWebSocketUrl) {
throw new Error('Failed to get WebSocket URL from browser server');
}
// Close existing WebSocket connection if switching to a different page
if (this.cdpWebSocket && this.cdpWebSocket.readyState === WebSocket.OPEN) {
this.cdpWebSocket.close();
}
this.cdpWebSocket = await connectToCdp(this.cdpWebSocketUrl);
await startNetworkMonitoring(this.cdpWebSocket, this.networkBuffer, {
contentTypes: options.filter.content_types,
urlIncludePatterns: options.filter.url_include_patterns,
methods: options.filter.methods,
}, this.pendingRequests, options.max_buffer_size);
this.isMonitoring = true;
const status = {
status: 'started',
buffer_size: options.max_buffer_size,
filter: {
contentTypes: options.filter.content_types,
urlIncludePatterns: options.filter.url_include_patterns,
},
cdp_endpoint: this.cdpWebSocketUrl,
cdp_port: this.cdpPort,
};
return {
content: [
{
type: 'text',
text: JSON.stringify(status, null, 2),
},
],
};
}
catch (error) {
throw new Error(`Failed to start monitor: ${error}`);
}
}
async stopMonitor() {
if (this.cdpWebSocket) {
this.cdpWebSocket.close();
this.cdpWebSocket = null;
}
// Close browser server properly
if (this.browserServer) {
await this.browserServer.close();
this.browserServer = null;
}
this.isMonitoring = false;
this.cdpWebSocketUrl = null;
return {
content: [
{
type: 'text',
text: 'Network monitoring stopped',
},
],
};
}
async updateFilter(args) {
if (!this.isMonitoring || !this.cdpWebSocket) {
throw new Error('Network monitoring is not active. Use start_monitor first.');
}
// Parse filter options only
const filterSchema = z.object({
filter: z.object({
content_types: z
.union([z.array(z.string()), z.literal('all')])
.optional()
.default([
'application/json',
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
]),
url_include_patterns: z
.union([z.array(z.string()), z.literal('all')])
.optional()
.default('all'),
methods: z.array(z.string()).optional(),
}),
});
const options = filterSchema.parse(args || {});
// Clear existing buffer
this.networkBuffer.length = 0;
// Close existing WebSocket and create new one with updated filter
this.cdpWebSocket.close();
this.cdpWebSocket = await connectToCdp(this.cdpWebSocketUrl);
await startNetworkMonitoring(this.cdpWebSocket, this.networkBuffer, {
contentTypes: options.filter.content_types,
urlIncludePatterns: options.filter.url_include_patterns,
methods: options.filter.methods,
}, this.pendingRequests, 30 // Use default buffer size for filter updates
);
const status = {
status: 'updated',
buffer_size: 30,
filter: {
contentTypes: options.filter.content_types,
urlIncludePatterns: options.filter.url_include_patterns,
},
cdp_endpoint: this.cdpWebSocketUrl,
cdp_port: this.cdpPort,
};
return {
content: [
{
type: 'text',
text: JSON.stringify(status, null, 2),
},
],
};
}
async getRecentRequests(args) {
try {
const options = GetRecentRequestsSchema.parse(args || {});
// Sort by timestamp (newest first) and limit count
const sortedRequests = [...this.networkBuffer].sort((a, b) => b.timestamp - a.timestamp);
const limitedRequests = sortedRequests.slice(0, options.count);
// Convert to compact format with 512B body previews
const compactRequests = limitedRequests.map((req) => {
const compact = {
uuid: req.uuid,
method: req.method,
url: req.url,
timestamp: req.timestamp,
};
// Add response data if available
if (req.response) {
compact.status = req.response.status;
compact.mimeType = req.response.mimeType;
compact.responseTimestamp = req.responseTimestamp;
// Add 512B response body preview if body exists
if (req.response.body) {
compact.responseBodyPreview = req.response.body.substring(0, 512);
compact.responseBodySize = req.response.body.length;
}
}
// Add request body preview if exists
if (req.body) {
compact.requestBodyPreview = req.body.substring(0, 512);
compact.requestBodySize = req.body.length;
}
return compact;
});
const response = {
total_captured: this.networkBuffer.length,
showing: compactRequests.length,
requests: compactRequests,
};
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2),
},
],
};
}
catch (error) {
throw new Error(`Failed to get recent requests: ${error}`);
}
}
async getRequestDetail(args) {
try {
const options = GetRequestDetailSchema.parse(args || {});
// Find request by UUID
const request = this.networkBuffer.find((req) => req.uuid === options.uuid);
if (!request) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: 'Request not found',
uuid: options.uuid,
message: 'No request found with the specified UUID in the current buffer',
}, null, 2),
},
],
};
}
// Return request details (headers optional, body limited to 50KB)
const requestCopy = { ...request };
if (!options.include_headers) {
requestCopy.headers = undefined;
if (requestCopy.response) {
requestCopy.response = { ...requestCopy.response };
requestCopy.response.headers = undefined;
}
}
// Limit body sizes to 50KB to prevent MCP context overflow
const MAX_BODY_SIZE = 50 * 1024; // 50KB
if (requestCopy.body && requestCopy.body.length > MAX_BODY_SIZE) {
const originalSize = requestCopy.body.length;
requestCopy.body =
requestCopy.body.substring(0, MAX_BODY_SIZE) +
`\n... [truncated from ${originalSize} bytes]`;
}
if (requestCopy.response?.body && requestCopy.response.body.length > MAX_BODY_SIZE) {
const originalSize = requestCopy.response.body.length;
requestCopy.response.body =
requestCopy.response.body.substring(0, MAX_BODY_SIZE) +
`\n... [truncated from ${originalSize} bytes]`;
}
return {
content: [
{
type: 'text',
text: JSON.stringify(requestCopy, null, 2),
},
],
};
}
catch (error) {
throw new Error(`Failed to get request detail: ${error}`);
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Network Monitor MCP server running on stdio');
}
}
// Start the server
const server = new NetworkMonitorMCP();
server.run().catch(console.error);