mcp-optimizer 0.0.1-alpha.1

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.
@@ -0,0 +1,335 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { runLighthouseAudit } from "./runner/lighthouseRunner";
4
+ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
6
+ import * as http from 'http';
7
+ import { IncomingMessage, ServerResponse } from 'http';
8
+ import { autoFixFromReport } from './fix/fixer';
9
+
10
+ export class LighthouseMcpServer {
11
+ private readonly server: McpServer;
12
+ private readonly reports: Map<string, any> = new Map();
13
+
14
+ constructor() {
15
+ this.server = new McpServer({ name: "Lighthouse MCP Server", version: "0.1.0" });
16
+ this.registerTools();
17
+ }
18
+
19
+ /**
20
+ * Run an audit and store the report. Returns stored record.
21
+ */
22
+ public async runAudit(options: { url: string; categories?: string[]; formFactor?: 'mobile' | 'desktop' }) {
23
+ const { url, categories, formFactor } = options;
24
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
25
+
26
+ const opts: any = {};
27
+ if (categories && categories.length > 0) opts.categories = categories;
28
+ if (formFactor === 'mobile') opts.emulateMobile = true;
29
+
30
+ const runnerResult = await runLighthouseAudit(url, opts);
31
+ const reportJson = runnerResult.report;
32
+ const lhr = runnerResult.lhr;
33
+ const reportObj = typeof reportJson === 'string' ? JSON.parse(reportJson) : reportJson;
34
+
35
+ const record = {
36
+ id,
37
+ url,
38
+ fetchedAt: new Date().toISOString(),
39
+ lhr,
40
+ report: reportObj
41
+ };
42
+
43
+ this.reports.set(id, record);
44
+ return record;
45
+ }
46
+
47
+ private registerTools(): void {
48
+ this.server.tool(
49
+ "lighthouse_run_audit",
50
+ "Run a Lighthouse audit against a URL and store the report",
51
+ {
52
+ url: z.string().describe("The URL to audit, including protocol (http:// or https://)"),
53
+ categories: z.array(z.string()).optional().describe("Optional Lighthouse categories to run, e.g. ['performance','accessibility']"),
54
+ formFactor: z.enum(["mobile", "desktop"]).optional().describe("Emulated form factor")
55
+ },
56
+ async ({ url, categories, formFactor }) => {
57
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
58
+ try {
59
+ const opts: any = {};
60
+ if (categories && categories.length > 0) opts.categories = categories;
61
+ if (formFactor === 'mobile') opts.emulateMobile = true;
62
+ const runnerResult = await runLighthouseAudit(url, opts);
63
+ const reportJson = runnerResult.report;
64
+ const lhr = runnerResult.lhr;
65
+ const reportObj = typeof reportJson === 'string' ? JSON.parse(reportJson) : reportJson;
66
+ this.reports.set(id, {
67
+ id,
68
+ url,
69
+ fetchedAt: new Date().toISOString(),
70
+ lhr,
71
+ report: reportObj
72
+ });
73
+ const perf = lhr.categories?.performance?.score ?? null;
74
+ const accessibility = lhr.categories?.accessibility?.score ?? null;
75
+ const summary = {
76
+ reportId: id,
77
+ url,
78
+ fetchedAt: new Date().toISOString(),
79
+ performance: perf !== null ? Math.round(perf * 100) : undefined,
80
+ accessibility: accessibility !== null ? Math.round(accessibility * 100) : undefined
81
+ };
82
+ // 返回所有信息,便于 HTTP 路由复用
83
+ return {
84
+ content: [
85
+ { type: "text", text: JSON.stringify(summary, null, 2) },
86
+ { type: "text", text: JSON.stringify(lhr, null, 2) },
87
+ { type: "text", text: JSON.stringify(reportObj, null, 2) }
88
+ ]
89
+ };
90
+ } catch (error) {
91
+ return {
92
+ content: [
93
+ { type: "text", text: `Lighthouse audit failed: ${error}` }
94
+ ]
95
+ };
96
+ }
97
+ }
98
+ );
99
+ this.server.tool(
100
+ "lighthouse_get_report",
101
+ "Retrieve a previously-run Lighthouse report by reportId",
102
+ {
103
+ reportId: z.string().describe("The report id returned by `lighthouse_run_audit`")
104
+ },
105
+ async ({ reportId }) => {
106
+ const record = this.reports.get(reportId);
107
+ if (!record) {
108
+ return {
109
+ content: [
110
+ { type: "text", text: `Report not found: ${reportId}` }
111
+ ]
112
+ };
113
+ }
114
+ return {
115
+ content: [
116
+ { type: "text", text: JSON.stringify(record.report, null, 2) }
117
+ ]
118
+ };
119
+ }
120
+ );
121
+
122
+ // 新增:从用户 Prompt 中识别 URL 并自动运行 Lighthouse 审计
123
+ this.server.tool(
124
+ "lighthouse_analyze_prompt",
125
+ "Scan a text prompt for a URL, run Lighthouse, and return a summary",
126
+ {
127
+ prompt: z.string().describe("A text prompt that may contain a URL to analyze")
128
+ },
129
+ async ({ prompt }) => {
130
+ try {
131
+ const urlMatch = prompt.match(/https?:\/\/[^\s"'<>]+/i);
132
+ if (!urlMatch) {
133
+ return {
134
+ content: [{ type: "text", text: "No URL found in prompt." }]
135
+ };
136
+ }
137
+ const url = urlMatch[0];
138
+ const result = await this.runAuditViaTool({ url });
139
+ let fix = null;
140
+ if (result && result.lhr) {
141
+ fix = await autoFixFromReport({ lhr: result.lhr, report: JSON.stringify(result.report) });
142
+ }
143
+ const perf = result.lhr?.categories?.performance?.score ?? null;
144
+ const accessibility = result.lhr?.categories?.accessibility?.score ?? null;
145
+ const summary = {
146
+ reportId: result.id,
147
+ url: result.url,
148
+ fetchedAt: result.fetchedAt,
149
+ performance: perf !== null ? Math.round(perf * 100) : undefined,
150
+ accessibility: accessibility !== null ? Math.round(accessibility * 100) : undefined
151
+ };
152
+ return {
153
+ content: [
154
+ { type: "text", text: JSON.stringify(summary, null, 2) },
155
+ { type: "text", text: JSON.stringify(result.lhr || {}, null, 2) },
156
+ { type: "text", text: JSON.stringify(result.report || {}, null, 2) },
157
+ { type: "text", text: JSON.stringify({ fix }, null, 2) }
158
+ ]
159
+ };
160
+ } catch (err: any) {
161
+ return { content: [{ type: "text", text: `Error analyzing prompt: ${String(err)}` }] };
162
+ }
163
+ }
164
+ );
165
+ }
166
+
167
+ // 新增:暴露一个直接调用 MCP 工具的接口
168
+ async runAuditViaTool(params: { url: string; categories?: string[]; formFactor?: 'mobile' | 'desktop' }) {
169
+ // Some versions of the MCP SDK don't expose an `invokeTool` helper.
170
+ // Call the internal runner directly and return a shape similar to the
171
+ // tool's output so HTTP callers can use `result.summary` / `result.lhr`.
172
+ const record = await this.runAudit(params);
173
+ const perf = record.lhr?.categories?.performance?.score ?? null;
174
+ const accessibility = record.lhr?.categories?.accessibility?.score ?? null;
175
+ const summary = {
176
+ reportId: record.id,
177
+ url: record.url,
178
+ fetchedAt: record.fetchedAt,
179
+ performance: perf !== null ? Math.round(perf * 100) : undefined,
180
+ accessibility: accessibility !== null ? Math.round(accessibility * 100) : undefined
181
+ };
182
+ // return record plus a `summary` to match existing HTTP handler expectations
183
+ return { ...record, summary };
184
+ }
185
+
186
+ async connect(transport: Transport): Promise<void> {
187
+ await this.server.connect(transport);
188
+ }
189
+ }
190
+
191
+ export default LighthouseMcpServer;
192
+
193
+ export async function startMcpServer(): Promise<void> {
194
+ // Run HTTP/SSE server
195
+ const port = Number(process.env.PORT || process.env.AUDIT_PORT || 5000);
196
+ const mcp = new LighthouseMcpServer();
197
+ let sseTransport: SSEServerTransport | null = null;
198
+ const pendingPosts: Array<{ body: string; url: string | undefined; headers: any }> = [];
199
+
200
+ const { Readable, Writable } = await (async () => {
201
+ const mod = await import('stream');
202
+ return { Readable: mod.Readable, Writable: mod.Writable };
203
+ })();
204
+
205
+ function makeMockReq(body: string, url?: string, headers?: any) {
206
+ const r = new Readable({ read() { this.push(body); this.push(null); } }) as any;
207
+ r.method = 'POST';
208
+ r.url = url || '/sse';
209
+ r.headers = headers || { 'content-type': 'application/json' };
210
+ return r as IncomingMessage;
211
+ }
212
+
213
+ function makeMockRes() {
214
+ const w = new Writable({ write(chunk, _enc, cb) { cb(); } }) as any;
215
+ w.writeHead = (status: number, headers?: any) => { w.statusCode = status; w._headers = headers; };
216
+ w.end = (data?: any) => { if (data) { try { /* consume */ } catch (_) {} } };
217
+ return w as unknown as ServerResponse<IncomingMessage>;
218
+ }
219
+
220
+ const server = http.createServer(async (req, res) => {
221
+ try {
222
+ if (req.method === 'GET' && req.url && req.url.startsWith('/sse')) {
223
+ // SSE handshake: set headers and create transport
224
+ // Ensure we send proper SSE headers so clients don't fallback to polling.
225
+ // Register the transport using '/sse' as the message POST path
226
+ sseTransport = new SSEServerTransport('/sse', res as unknown as ServerResponse<IncomingMessage>);
227
+ try {
228
+ await mcp.connect(sseTransport);
229
+ console.info('SSE: new connection established');
230
+ } catch (err) {
231
+ console.error('SSE: failed to start transport:', err);
232
+ // Ensure client receives an error
233
+ try {
234
+ res.writeHead(500, { 'Content-Type': 'application/json' });
235
+ res.end(JSON.stringify({ error: 'failed to start sse transport' }));
236
+ } catch (_) { }
237
+ return;
238
+ }
239
+ // replay any pending POSTs that arrived before the GET
240
+ if (pendingPosts.length > 0) {
241
+ console.info(`SSE: replaying ${pendingPosts.length} pending POST(s)`);
242
+ for (const p of pendingPosts.splice(0)) {
243
+ try {
244
+ const mockReq = makeMockReq(p.body, p.url, p.headers);
245
+ const mockRes = makeMockRes();
246
+ // don't await to avoid blocking the handshake
247
+ sseTransport.handlePostMessage(mockReq as unknown as IncomingMessage, mockRes as unknown as ServerResponse<IncomingMessage>).catch((err: any) => {
248
+ console.error('SSE: replay handlePostMessage failed:', err);
249
+ });
250
+ } catch (err) {
251
+ console.error('SSE: failed to replay pending POST:', err);
252
+ }
253
+ }
254
+ }
255
+ return;
256
+ }
257
+
258
+ if (req.method === 'POST' && req.url && (req.url === '/messages' || req.url.startsWith('/sse'))) {
259
+ console.info(`SSE: received POST to ${req.url}`);
260
+ if (!sseTransport) {
261
+ // Buffer the POST body so it can be processed once the GET arrives.
262
+ try {
263
+ let body = '';
264
+ for await (const chunk of req) {
265
+ body += chunk;
266
+ }
267
+ console.info('SSE: POST received before GET; buffering');
268
+ pendingPosts.push({ body, url: req.url, headers: req.headers });
269
+ res.writeHead(200, { 'Content-Type': 'application/json' });
270
+ res.end(JSON.stringify({ ok: true }));
271
+ return;
272
+ } catch (e) {
273
+ console.error('SSE: error reading POST body before GET:', e);
274
+ res.writeHead(500, { 'Content-Type': 'application/json' });
275
+ res.end(JSON.stringify({ error: String(e) }));
276
+ return;
277
+ }
278
+ }
279
+ try {
280
+ await sseTransport.handlePostMessage(req as unknown as IncomingMessage, res as unknown as ServerResponse<IncomingMessage>);
281
+ return;
282
+ } catch (err) {
283
+ console.error('SSE: handlePostMessage failed:', err);
284
+ res.writeHead(500, { 'Content-Type': 'application/json' });
285
+ res.end(JSON.stringify({ error: String(err) }));
286
+ return;
287
+ }
288
+ }
289
+
290
+ if (req.method === 'POST' && req.url === '/audit') {
291
+ let body = '';
292
+ for await (const chunk of req) {
293
+ body += chunk;
294
+ }
295
+ const parsed = JSON.parse(body || '{}');
296
+ const url = parsed.url as string | undefined;
297
+ if (!url) {
298
+ res.writeHead(400, { 'Content-Type': 'application/json' });
299
+ res.end(JSON.stringify({ error: 'missing url' }));
300
+ return;
301
+ }
302
+ try {
303
+ const result = await mcp.runAuditViaTool({ url, categories: parsed.categories, formFactor: parsed.formFactor });
304
+ let fix = null;
305
+ if (result && result.lhr) {
306
+ fix = await autoFixFromReport({ lhr: result.lhr, report: JSON.stringify(result.report) });
307
+ }
308
+ res.writeHead(200, { 'Content-Type': 'application/json' });
309
+ res.end(JSON.stringify({ summary: result.summary, fix, error: (result as any).error }));
310
+ } catch (err: any) {
311
+ res.writeHead(500, { 'Content-Type': 'application/json' });
312
+ res.end(JSON.stringify({ error: String(err) }));
313
+ }
314
+ return;
315
+ }
316
+
317
+ // fallback
318
+ res.writeHead(200, { 'Content-Type': 'application/json' });
319
+ res.end(JSON.stringify({ message: 'MCP Optimizer running — POST /audit { "url": "https://..." }' }));
320
+ } catch (outerErr: any) {
321
+ // ensure no plain-text stdout noise
322
+ try {
323
+ res.writeHead(500, { 'Content-Type': 'application/json' });
324
+ res.end(JSON.stringify({ error: String(outerErr) }));
325
+ } catch (_) {
326
+ // ignore
327
+ }
328
+ }
329
+ });
330
+
331
+ return new Promise((resolve, reject) => {
332
+ server.listen(port, () => resolve());
333
+ server.on('error', reject);
334
+ });
335
+ }
@@ -0,0 +1,66 @@
1
+ export async function runLighthouseAudit(
2
+ url: string,
3
+ opts?: { emulateMobile?: boolean; categories?: string[] }
4
+ ): Promise<any> {
5
+ // Use the Lighthouse CLI via `npx` to avoid importing the ESM-only
6
+ // Lighthouse package into this CommonJS runtime.
7
+ const dynamicImport = new Function('specifier', 'return import(specifier)');
8
+ const chromeLauncherModule = await (dynamicImport as any)('chrome-launcher');
9
+ const chromeLauncher = (chromeLauncherModule && (chromeLauncherModule.default ?? chromeLauncherModule)) as any;
10
+ const { execFile } = await (dynamicImport as any)('node:child_process');
11
+
12
+ const os = await (dynamicImport as any)('node:os');
13
+ const pathMod = await (dynamicImport as any)('node:path');
14
+ const tmpDir = pathMod.join(os.tmpdir(), `lighthouse-${Date.now()}-${Math.random().toString(36).slice(2,6)}`);
15
+ const fs = await (dynamicImport as any)('node:fs');
16
+ fs.mkdirSync(tmpDir, { recursive: true });
17
+ const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'], userDataDir: tmpDir });
18
+ try {
19
+ const port = chrome.port;
20
+ const args: string[] = [
21
+ 'lighthouse',
22
+ url,
23
+ `--port=${port}`,
24
+ '--output=json',
25
+ '--quiet'
26
+ ];
27
+ if (opts?.emulateMobile) args.push('--preset=mobile');
28
+ if (opts?.categories && opts.categories.length) args.push(`--only-categories=${opts.categories.join(',')}`);
29
+
30
+ // Run via npx to ensure local package is used
31
+ const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
32
+
33
+ const reportJson: string = await new Promise((resolve, reject) => {
34
+ execFile(cmd, args, { maxBuffer: 10 * 1024 * 1024 }, (err: any, stdout: string, stderr: string) => {
35
+ if (err) {
36
+ const message = stderr || (err && err.message) || String(err);
37
+ return reject(new Error(message));
38
+ }
39
+ resolve(stdout);
40
+ });
41
+ });
42
+
43
+ // The CLI returns a JSON string containing the report and LHR; parse it
44
+ const parsed = JSON.parse(reportJson);
45
+ // Lighthouse CLI places the LHR inside `lhr` when output=json
46
+ const lhr = parsed.lhr ?? parsed;
47
+ return { lhr, report: parsed };
48
+ } catch (err: any) {
49
+ // If anything goes wrong launching Chrome or running Lighthouse,
50
+ // return a minimal error-shaped report so the MCP server can continue
51
+ // to respond and surface the error to callers instead of crashing.
52
+ const message = (err && (err.stack || err.message)) || String(err);
53
+ return {
54
+ lhr: { categories: {} },
55
+ report: { error: message },
56
+ error: message
57
+ };
58
+ } finally {
59
+ try {
60
+ await chrome.kill();
61
+ }
62
+ catch (_) {
63
+ // ignore cleanup errors
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,4 @@
1
+ declare module '@modelcontextprotocol/sdk' {
2
+ const content: any;
3
+ export = content;
4
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true
11
+ },
12
+ "include": ["src/**/*"]
13
+ }