lighthouse-mcp 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,7 +101,7 @@ Run a comprehensive Lighthouse audit on a URL.
101
101
  **Parameters:**
102
102
  - `url` (required): The URL to audit
103
103
  - `categories` (optional): Array of categories to audit (defaults to all)
104
- - Options: "performance", "accessibility", "best-practices", "seo", "pwa"
104
+ - Options: "performance", "accessibility", "best-practices", "seo"
105
105
  - `device` (optional): Device to emulate (defaults to "mobile")
106
106
  - Options: "mobile", "desktop"
107
107
  - `throttling` (optional): Whether to apply network throttling (defaults to true)
@@ -145,10 +145,16 @@ Claude will use the `get_performance_score` tool to analyze the website and retu
145
145
 
146
146
  ## Requirements
147
147
 
148
- - Node.js 16+
148
+ - Node.js 22.19+
149
149
  - Chrome/Chromium browser (for Lighthouse)
150
150
 
151
151
  ## Endorsements
152
152
  <a href="https://glama.ai/mcp/servers/@priyankark/lighthouse-mcp">
153
153
  <img width="380" height="200" src="https://glama.ai/mcp/servers/@priyankark/lighthouse-mcp/badge" />
154
154
  </a>
155
+
156
+ ## Security
157
+
158
+ Chrome runs with its sandbox enabled. Localhost, IPv4 loopback, and IPv6 loopback remain supported by default. Private, link-local, cloud metadata, and other non-public destinations are blocked, including requests made by redirects and page resources. Connections use validated IP addresses to prevent DNS rebinding. Because loopback access is intentional, audit only pages you trust to access services on your own machine. For hostile sites, use a separate environment with network isolation.
159
+
160
+ Audits are limited to one at a time and time out after 120 seconds. Chrome and the audit proxy are cleaned up after success or failure.
package/build/index.js CHANGED
@@ -7,123 +7,15 @@ import * as chromeLauncher from 'chrome-launcher';
7
7
  import os from 'os';
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
- import dns from 'dns/promises';
11
- import net from 'net';
12
- // Workaround for modelcontextprotocol/typescript-sdk#1380
13
- // In some Zod runtimes, the method literal is stored under `_def.values[0]`
14
- // instead of `_def.value` / `.value`, causing "Schema method literal must be a string"
15
- // during Server initialization. This patches setRequestHandler to handle both cases.
16
- const originalSetRequestHandler = Server.prototype.setRequestHandler;
17
- Server.prototype.setRequestHandler = function patchedSetRequestHandler(requestSchema, handler) {
18
- try {
19
- return originalSetRequestHandler.call(this, requestSchema, handler);
20
- }
21
- catch (err) {
22
- if (err?.message !== 'Schema method literal must be a string')
23
- throw err;
24
- // Attempt to fix the schema by copying values[0] to value
25
- try {
26
- const shape = requestSchema?.shape ?? requestSchema?._def?.shape?.();
27
- const methodSchema = shape?.method;
28
- const def = methodSchema?._def;
29
- const maybeValue = Array.isArray(def?.values) ? def.values[0] : undefined;
30
- if (typeof maybeValue === 'string') {
31
- if (def && def.value === undefined)
32
- def.value = maybeValue;
33
- if (methodSchema && methodSchema.value === undefined)
34
- methodSchema.value = maybeValue;
35
- }
36
- }
37
- catch {
38
- // If patching fails, rethrow the original error
39
- }
40
- return originalSetRequestHandler.call(this, requestSchema, handler);
41
- }
42
- };
43
- // ---------------------------------------------------------------------------
44
- // SSRF protection — validate URLs before passing to Lighthouse
45
- // Allows localhost/loopback (needed for local dev servers) but blocks
46
- // cloud metadata endpoints, RFC 1918 private ranges, and link-local IPs.
47
- // ---------------------------------------------------------------------------
48
- const BLOCKED_IP_RANGES = [
49
- // RFC 1918 private networks
50
- { prefix: '10.', mask: null },
51
- { prefix: '172.', mask: (ip) => { const b = parseInt(ip.split('.')[1], 10); return b >= 16 && b <= 31; } },
52
- { prefix: '192.168.', mask: null },
53
- // Link-local (includes AWS metadata 169.254.169.254)
54
- { prefix: '169.254.', mask: null },
55
- ];
56
- const BLOCKED_HOSTNAMES = [
57
- 'metadata.google.internal',
58
- 'metadata.goog',
59
- ];
60
- function isBlockedIP(ip) {
61
- for (const range of BLOCKED_IP_RANGES) {
62
- if (ip.startsWith(range.prefix)) {
63
- if (range.mask === null || range.mask(ip))
64
- return true;
65
- }
66
- }
67
- return false;
68
- }
69
- function isLoopback(ip) {
70
- if (ip === '::1')
71
- return true;
72
- if (ip.startsWith('127.'))
73
- return true;
74
- return false;
75
- }
76
- async function validateUrl(url) {
77
- let parsed;
78
- try {
79
- parsed = new URL(url);
80
- }
81
- catch {
82
- throw new McpError(ErrorCode.InvalidParams, `Invalid URL: ${url}`);
83
- }
84
- // Only allow http and https schemes
85
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
86
- throw new McpError(ErrorCode.InvalidParams, `Unsupported URL scheme "${parsed.protocol}" — only http: and https: are allowed`);
87
- }
88
- const hostname = parsed.hostname;
89
- // Block known cloud metadata hostnames
90
- if (BLOCKED_HOSTNAMES.includes(hostname.toLowerCase())) {
91
- throw new McpError(ErrorCode.InvalidParams, `URL hostname "${hostname}" is blocked (cloud metadata endpoint)`);
92
- }
93
- // If the hostname is an IP literal, validate it directly
94
- if (net.isIP(hostname)) {
95
- if (isLoopback(hostname))
96
- return; // allow localhost
97
- if (isBlockedIP(hostname)) {
98
- throw new McpError(ErrorCode.InvalidParams, `URL resolves to a blocked internal IP address (${hostname})`);
99
- }
100
- return;
101
- }
102
- // Resolve the hostname and check every returned address
103
- let addresses;
104
- try {
105
- const results = await dns.resolve4(hostname);
106
- addresses = results;
107
- }
108
- catch {
109
- // If DNS resolution fails, let Lighthouse handle the error naturally
110
- return;
111
- }
112
- for (const ip of addresses) {
113
- if (isLoopback(ip))
114
- continue; // allow localhost
115
- if (isBlockedIP(ip)) {
116
- throw new McpError(ErrorCode.InvalidParams, `URL hostname "${hostname}" resolves to blocked internal IP address (${ip})`);
117
- }
118
- }
119
- }
10
+ import { createAuditProxy, parseUrl, resolveTarget } from './network.js';
120
11
  const isValidAuditArgs = (args) => {
121
12
  return (typeof args === 'object' &&
122
13
  args !== null &&
123
14
  typeof args.url === 'string' &&
124
15
  (args.categories === undefined ||
125
16
  (Array.isArray(args.categories) &&
126
- args.categories.every((cat) => typeof cat === 'string'))) &&
17
+ args.categories.length > 0 && args.categories.length <= 4 &&
18
+ args.categories.every((cat) => ['performance', 'accessibility', 'best-practices', 'seo'].includes(cat)))) &&
127
19
  (args.device === undefined ||
128
20
  args.device === 'mobile' ||
129
21
  args.device === 'desktop') &&
@@ -131,9 +23,10 @@ const isValidAuditArgs = (args) => {
131
23
  };
132
24
  class LighthouseServer {
133
25
  constructor() {
26
+ this.auditRunning = false;
134
27
  this.server = new Server({
135
28
  name: 'lighthouse-mcp',
136
- version: '0.1.0',
29
+ version: '0.1.16',
137
30
  }, {
138
31
  capabilities: {
139
32
  tools: {},
@@ -169,7 +62,6 @@ class LighthouseServer {
169
62
  'accessibility',
170
63
  'best-practices',
171
64
  'seo',
172
- 'pwa',
173
65
  ],
174
66
  },
175
67
  description: 'Categories to audit (defaults to all)',
@@ -223,9 +115,17 @@ class LighthouseServer {
223
115
  if (!isValidAuditArgs(args)) {
224
116
  throw new McpError(ErrorCode.InvalidParams, 'Invalid audit arguments');
225
117
  }
226
- // SSRF protection: validate the URL before launching Chrome
227
- await validateUrl(args.url);
118
+ if (this.auditRunning)
119
+ throw new McpError(ErrorCode.InvalidRequest, 'An audit is already running');
120
+ this.auditRunning = true;
121
+ let chrome;
122
+ let proxy;
123
+ let timer;
228
124
  try {
125
+ const allowLoopback = true;
126
+ const url = parseUrl(args.url);
127
+ await resolveTarget(url.hostname, allowLoopback);
128
+ proxy = await createAuditProxy(allowLoopback);
229
129
  // Ensure temp directory exists and is writable (fixes #19 - Windows EPERM)
230
130
  // On Windows, os.tmpdir() reads TEMP -> TMP -> USERPROFILE, so we verify
231
131
  // the resolved path is usable before launching Chrome.
@@ -237,7 +137,7 @@ class LighthouseServer {
237
137
  // If the default temp dir isn't writable, create a fallback in the user's home
238
138
  const fallbackTmp = path.join(os.homedir(), '.lighthouse-tmp');
239
139
  if (!fs.existsSync(fallbackTmp)) {
240
- fs.mkdirSync(fallbackTmp, { recursive: true });
140
+ fs.mkdirSync(fallbackTmp, { recursive: true, mode: 0o700 });
241
141
  }
242
142
  process.env.TEMP = fallbackTmp;
243
143
  process.env.TMP = fallbackTmp;
@@ -245,12 +145,14 @@ class LighthouseServer {
245
145
  }
246
146
  // Explicitly pass process.env so MCP-configured env vars (TEMP, TMP, TMPDIR)
247
147
  // propagate to the Chrome child process on all platforms.
248
- const chrome = await chromeLauncher.launch({
249
- chromeFlags: ['--headless', '--no-sandbox'],
148
+ chrome = await chromeLauncher.launch({
149
+ chromeFlags: ['--headless', `--proxy-server=http://127.0.0.1:${proxy.port}`,
150
+ '--proxy-bypass-list=<-loopback>', '--disable-quic',
151
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'],
250
152
  envVars: process.env,
251
153
  });
252
154
  const options = {
253
- logLevel: 'info',
155
+ logLevel: 'error',
254
156
  output: 'json',
255
157
  onlyCategories: args.categories,
256
158
  port: chrome.port,
@@ -272,8 +174,12 @@ class LighthouseServer {
272
174
  cpuSlowdownMultiplier: 1,
273
175
  },
274
176
  };
275
- const runnerResult = await lighthouse(args.url, options);
276
- await chrome.kill();
177
+ const runnerResult = await Promise.race([
178
+ lighthouse(args.url, options),
179
+ new Promise((_resolve, reject) => {
180
+ timer = setTimeout(() => reject(new Error('Audit timed out after 120 seconds')), 120000);
181
+ }),
182
+ ]);
277
183
  if (!runnerResult) {
278
184
  throw new McpError(ErrorCode.InternalError, 'Failed to run Lighthouse audit');
279
185
  }
@@ -342,6 +248,21 @@ class LighthouseServer {
342
248
  isError: true,
343
249
  };
344
250
  }
251
+ finally {
252
+ if (timer)
253
+ clearTimeout(timer);
254
+ try {
255
+ await chrome?.kill();
256
+ }
257
+ finally {
258
+ try {
259
+ await proxy?.close();
260
+ }
261
+ finally {
262
+ this.auditRunning = false;
263
+ }
264
+ }
265
+ }
345
266
  }
346
267
  async handleGetPerformanceScore(args) {
347
268
  if (!isValidAuditArgs(args)) {
@@ -356,6 +277,8 @@ class LighthouseServer {
356
277
  throttling: true,
357
278
  };
358
279
  const result = await this.handleRunAudit(auditArgs);
280
+ if (result.isError)
281
+ return result;
359
282
  // Extract just the performance data
360
283
  const resultData = JSON.parse(result.content[0].text);
361
284
  const performanceData = {
@@ -0,0 +1,11 @@
1
+ import dns from 'node:dns/promises';
2
+ export declare function isAllowedAddress(address: string, allowLoopback?: boolean): boolean;
3
+ export declare function parseUrl(input: string): URL;
4
+ export declare function resolveTarget(host: string, allowLoopback?: boolean, lookup?: typeof dns.lookup): Promise<{
5
+ address: string;
6
+ family: number;
7
+ }>;
8
+ export declare function createAuditProxy(allowLoopback?: boolean): Promise<{
9
+ port: number;
10
+ close: () => Promise<void>;
11
+ }>;
@@ -0,0 +1,107 @@
1
+ import dns from 'node:dns/promises';
2
+ import http from 'node:http';
3
+ import net from 'node:net';
4
+ import ipaddr from 'ipaddr.js';
5
+ export function isAllowedAddress(address, allowLoopback = true) {
6
+ try {
7
+ const parsed = ipaddr.process(address);
8
+ return parsed.range() === 'unicast' || (allowLoopback && parsed.range() === 'loopback');
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export function parseUrl(input) {
15
+ const url = new URL(input);
16
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
17
+ throw new Error('Only HTTP(S) URLs without embedded credentials are allowed');
18
+ }
19
+ return url;
20
+ }
21
+ export async function resolveTarget(host, allowLoopback = true, lookup = dns.lookup) {
22
+ const hostname = host.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
23
+ if (['metadata.google.internal', 'metadata.goog'].includes(hostname)) {
24
+ throw new Error('Cloud metadata endpoints are blocked');
25
+ }
26
+ const addresses = net.isIP(hostname)
27
+ ? [{ address: hostname, family: net.isIP(hostname) }]
28
+ : await lookup(hostname, { all: true, verbatim: true });
29
+ if (!addresses.length || addresses.some(a => !isAllowedAddress(a.address, allowLoopback))) {
30
+ throw new Error('Non-public network addresses are blocked');
31
+ }
32
+ return addresses[0];
33
+ }
34
+ // Resolve and validate each connection, then connect to that exact IP. Chrome
35
+ // must use this proxy for redirects and subresources too (no loopback bypass).
36
+ export async function createAuditProxy(allowLoopback = true) {
37
+ const sockets = new Set();
38
+ const track = (socket) => {
39
+ sockets.add(socket);
40
+ socket.setTimeout(120000, () => socket.destroy());
41
+ socket.once('close', () => sockets.delete(socket));
42
+ return socket;
43
+ };
44
+ const server = http.createServer(async (req, res) => {
45
+ try {
46
+ const url = parseUrl(req.url || '');
47
+ if (url.protocol !== 'http:')
48
+ throw new Error('Use CONNECT for HTTPS');
49
+ const target = await resolveTarget(url.hostname, allowLoopback);
50
+ if (res.destroyed)
51
+ return;
52
+ const headers = { ...req.headers, host: url.host };
53
+ delete headers['proxy-authorization'];
54
+ delete headers['proxy-connection'];
55
+ const upstream = http.request({
56
+ hostname: target.address, family: target.family,
57
+ port: url.port || 80, path: url.pathname + url.search,
58
+ method: req.method, headers, agent: false,
59
+ }, response => { res.writeHead(response.statusCode || 502, response.headers); response.pipe(res); });
60
+ upstream.on('socket', track);
61
+ upstream.on('error', () => { if (!res.headersSent)
62
+ res.writeHead(502); res.end(); });
63
+ res.on('close', () => upstream.destroy());
64
+ req.pipe(upstream);
65
+ }
66
+ catch {
67
+ res.writeHead(403);
68
+ res.end('Destination blocked');
69
+ }
70
+ });
71
+ server.on('connection', track);
72
+ server.on('connect', async (req, client, head) => {
73
+ try {
74
+ const url = parseUrl(`https://${req.url}`);
75
+ const target = await resolveTarget(url.hostname, allowLoopback);
76
+ if (client.destroyed)
77
+ return;
78
+ const upstream = track(net.connect({ host: target.address, family: target.family, port: Number(url.port || 443) }));
79
+ upstream.on('error', () => client.destroy());
80
+ client.on('error', () => upstream.destroy());
81
+ client.on('close', () => upstream.destroy());
82
+ upstream.once('connect', () => {
83
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
84
+ upstream.write(head);
85
+ client.pipe(upstream);
86
+ upstream.pipe(client);
87
+ });
88
+ }
89
+ catch {
90
+ client.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
91
+ }
92
+ });
93
+ server.on('clientError', (_error, socket) => socket.destroy());
94
+ await new Promise((resolve, reject) => {
95
+ server.once('error', reject);
96
+ server.listen(0, '127.0.0.1', resolve);
97
+ });
98
+ const address = server.address();
99
+ return {
100
+ port: address.port,
101
+ close: async () => {
102
+ for (const socket of sockets)
103
+ socket.destroy();
104
+ await new Promise(resolve => server.close(() => resolve()));
105
+ },
106
+ };
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lighthouse-mcp",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "MCP server for Google Lighthouse performance metrics",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -15,8 +15,9 @@
15
15
  "build": "tsc && chmod +x build/index.js",
16
16
  "start": "node build/index.js",
17
17
  "dev": "tsc -w",
18
- "prepublishOnly": "npm run build",
19
- "update-server-json": "node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));const server=JSON.parse(fs.readFileSync('server.json'));server.version=pkg.version;server.packages[0].version=pkg.version;fs.writeFileSync('server.json',JSON.stringify(server,null,2));\""
18
+ "prepublishOnly": "npm test",
19
+ "update-server-json": "node -e \"const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('package.json'));const server=JSON.parse(fs.readFileSync('server.json'));server.version=pkg.version;server.packages[0].version=pkg.version;fs.writeFileSync('server.json',JSON.stringify(server,null,2));\"",
20
+ "test": "npm run build && node --test test/*.test.mjs"
20
21
  },
21
22
  "keywords": [
22
23
  "lighthouse",
@@ -32,23 +33,19 @@
32
33
  "license": "MIT",
33
34
  "repository": {
34
35
  "type": "git",
35
- "url": ""
36
+ "url": "https://github.com/priyankark/lighthouse-mcp.git"
36
37
  },
37
38
  "dependencies": {
38
- "@modelcontextprotocol/sdk": "^1.27.1",
39
- "chrome-launcher": "^0.15.2",
40
- "lighthouse": "^12.5.1"
41
- },
42
- "overrides": {
43
- "basic-ftp": ">=5.3.0",
44
- "lodash-es": ">=4.18.1",
45
- "path-to-regexp": ">=8.4.0",
46
- "brace-expansion": ">=5.0.5",
47
- "hono": ">=4.12.14",
48
- "@hono/node-server": ">=1.19.13"
39
+ "@modelcontextprotocol/sdk": "^1.30.0",
40
+ "chrome-launcher": "^1.2.1",
41
+ "ipaddr.js": "^2.5.0",
42
+ "lighthouse": "^13.4.1"
49
43
  },
50
44
  "devDependencies": {
51
45
  "@types/node": "^20.4.5",
52
46
  "typescript": "^5.1.6"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.19.0"
53
50
  }
54
51
  }