chrome-devtools-mcp-for-extension 0.8.9 → 0.9.0
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.
|
@@ -478,7 +478,29 @@ export const openExtensionPopup = defineTool({
|
|
|
478
478
|
response.appendResponseLine('💡 You can now use take_snapshot, click, evaluate_script, etc. on the popup');
|
|
479
479
|
return;
|
|
480
480
|
}
|
|
481
|
-
//
|
|
481
|
+
// Check for iframe-embedded popup in current page
|
|
482
|
+
const iframePopups = await page.evaluate(() => {
|
|
483
|
+
return Array.from(document.querySelectorAll('iframe'))
|
|
484
|
+
.filter((iframe) => iframe.src.startsWith('chrome-extension://'))
|
|
485
|
+
.map((iframe) => ({
|
|
486
|
+
src: iframe.src,
|
|
487
|
+
id: iframe.id,
|
|
488
|
+
className: iframe.className,
|
|
489
|
+
}));
|
|
490
|
+
});
|
|
491
|
+
if (iframePopups.length > 0) {
|
|
492
|
+
response.appendResponseLine('✅ Extension popup found (embedded as iframe)');
|
|
493
|
+
response.appendResponseLine(`📄 Popup URL: ${iframePopups[0].src}`);
|
|
494
|
+
response.appendResponseLine('');
|
|
495
|
+
response.appendResponseLine('💡 This popup is embedded in the current page as an iframe.');
|
|
496
|
+
response.appendResponseLine(' You can interact with it using regular page tools:');
|
|
497
|
+
response.appendResponseLine(' - take_snapshot (includes iframe content)');
|
|
498
|
+
response.appendResponseLine(' - click on elements');
|
|
499
|
+
response.appendResponseLine(' - fill forms');
|
|
500
|
+
response.appendResponseLine(' - evaluate_script');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// If not on popup or iframe, try to find any open popup window
|
|
482
504
|
const pages = await browser.pages();
|
|
483
505
|
for (let i = 0; i < pages.length; i++) {
|
|
484
506
|
const p = pages[i];
|
|
@@ -549,6 +571,32 @@ export const openExtensionPopup = defineTool({
|
|
|
549
571
|
}
|
|
550
572
|
}
|
|
551
573
|
if (!popupPage) {
|
|
574
|
+
// Check for iframe-embedded popup with this extension ID
|
|
575
|
+
response.appendResponseLine('🔧 Checking for iframe-embedded popup...');
|
|
576
|
+
// Go back to the original page to check for iframes
|
|
577
|
+
await page.goBack();
|
|
578
|
+
const iframePopups = await page.evaluate((extId) => {
|
|
579
|
+
return Array.from(document.querySelectorAll('iframe'))
|
|
580
|
+
.filter((iframe) => iframe.src.includes(extId))
|
|
581
|
+
.map((iframe) => ({
|
|
582
|
+
src: iframe.src,
|
|
583
|
+
id: iframe.id,
|
|
584
|
+
className: iframe.className,
|
|
585
|
+
}));
|
|
586
|
+
}, extensionInfo.id);
|
|
587
|
+
if (iframePopups.length > 0) {
|
|
588
|
+
response.appendResponseLine('');
|
|
589
|
+
response.appendResponseLine(`✅ Extension popup found (embedded as iframe): ${extensionInfo.name}`);
|
|
590
|
+
response.appendResponseLine(`📄 Popup URL: ${iframePopups[0].src}`);
|
|
591
|
+
response.appendResponseLine('');
|
|
592
|
+
response.appendResponseLine('💡 This popup is embedded in the current page as an iframe.');
|
|
593
|
+
response.appendResponseLine(' You can interact with it using regular page tools:');
|
|
594
|
+
response.appendResponseLine(' - take_snapshot (includes iframe content)');
|
|
595
|
+
response.appendResponseLine(' - click on elements');
|
|
596
|
+
response.appendResponseLine(' - fill forms');
|
|
597
|
+
response.appendResponseLine(' - evaluate_script');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
552
600
|
response.appendResponseLine('❌ Popup window not found.');
|
|
553
601
|
response.appendResponseLine('💡 Please manually click the extension icon to open the popup first.');
|
|
554
602
|
return;
|
|
@@ -664,3 +712,120 @@ export const inspectServiceWorker = defineTool({
|
|
|
664
712
|
});
|
|
665
713
|
},
|
|
666
714
|
});
|
|
715
|
+
// Import iframe popup tools
|
|
716
|
+
import * as iframePopupTools from './iframe-popup-tools.js';
|
|
717
|
+
export const inspectIframePopup = defineTool({
|
|
718
|
+
name: 'inspect_iframe_popup',
|
|
719
|
+
description: `Inspect an iframe-embedded extension popup using CDP. This tool can access iframe content that normal Puppeteer cannot reach. Returns the full HTML of the iframe popup.`,
|
|
720
|
+
annotations: {
|
|
721
|
+
category: ToolCategories.EXTENSION_DEVELOPMENT,
|
|
722
|
+
readOnlyHint: true,
|
|
723
|
+
},
|
|
724
|
+
schema: {
|
|
725
|
+
urlPattern: z
|
|
726
|
+
.string()
|
|
727
|
+
.describe('Regular expression pattern to match the iframe URL (e.g., "chrome-extension://[^/]+/popup\\.html$")'),
|
|
728
|
+
waitMs: z
|
|
729
|
+
.number()
|
|
730
|
+
.optional()
|
|
731
|
+
.describe('Maximum time to wait for iframe (default: 5000ms)'),
|
|
732
|
+
},
|
|
733
|
+
handler: async (request, response, context) => {
|
|
734
|
+
const page = context.getSelectedPage();
|
|
735
|
+
const { urlPattern, waitMs } = request.params;
|
|
736
|
+
await context.waitForEventsAfterAction(async () => {
|
|
737
|
+
try {
|
|
738
|
+
const cdp = await page.createCDPSession();
|
|
739
|
+
const pattern = new RegExp(urlPattern);
|
|
740
|
+
const result = await iframePopupTools.inspectIframe(cdp, pattern, waitMs ?? 5000);
|
|
741
|
+
response.appendResponseLine('✅ Successfully inspected iframe popup');
|
|
742
|
+
response.appendResponseLine('');
|
|
743
|
+
response.appendResponseLine(`📄 Frame URL: ${result.frameUrl}`);
|
|
744
|
+
response.appendResponseLine(`🆔 Frame ID: ${result.frameId}`);
|
|
745
|
+
response.appendResponseLine('');
|
|
746
|
+
response.appendResponseLine('📝 HTML Content:');
|
|
747
|
+
response.appendResponseLine('```html');
|
|
748
|
+
response.appendResponseLine(result.html.length > 2000
|
|
749
|
+
? result.html.substring(0, 2000) + '\n... (truncated)'
|
|
750
|
+
: result.html);
|
|
751
|
+
response.appendResponseLine('```');
|
|
752
|
+
await cdp.detach();
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
response.appendResponseLine(`❌ Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
export const patchIframePopup = defineTool({
|
|
761
|
+
name: 'patch_iframe_popup',
|
|
762
|
+
description: `Patch local extension source files and reload the extension. This allows live editing of iframe-embedded popups. The extension must be loaded from a local directory.`,
|
|
763
|
+
annotations: {
|
|
764
|
+
category: ToolCategories.EXTENSION_DEVELOPMENT,
|
|
765
|
+
readOnlyHint: false,
|
|
766
|
+
},
|
|
767
|
+
schema: {
|
|
768
|
+
extensionPath: z
|
|
769
|
+
.string()
|
|
770
|
+
.describe('Absolute path to the extension directory'),
|
|
771
|
+
patches: z
|
|
772
|
+
.array(z.object({
|
|
773
|
+
file: z
|
|
774
|
+
.string()
|
|
775
|
+
.describe('Relative path to file within extension (e.g., "popup.html")'),
|
|
776
|
+
find: z
|
|
777
|
+
.string()
|
|
778
|
+
.describe('Regular expression pattern to find'),
|
|
779
|
+
replace: z.string().describe('Replacement string'),
|
|
780
|
+
}))
|
|
781
|
+
.describe('Array of patches to apply'),
|
|
782
|
+
},
|
|
783
|
+
handler: async (request, response, context) => {
|
|
784
|
+
const page = context.getSelectedPage();
|
|
785
|
+
const { extensionPath, patches } = request.params;
|
|
786
|
+
await context.waitForEventsAfterAction(async () => {
|
|
787
|
+
try {
|
|
788
|
+
const cdp = await page.createCDPSession();
|
|
789
|
+
response.appendResponseLine(`🔧 Applying ${patches.length} patch(es) to extension...`);
|
|
790
|
+
await iframePopupTools.patchAndReload(cdp, extensionPath, patches);
|
|
791
|
+
response.appendResponseLine('');
|
|
792
|
+
response.appendResponseLine('✅ Patches applied successfully');
|
|
793
|
+
response.appendResponseLine('🔄 Extension reloaded');
|
|
794
|
+
response.appendResponseLine('');
|
|
795
|
+
response.appendResponseLine('Applied patches:');
|
|
796
|
+
for (const p of patches) {
|
|
797
|
+
response.appendResponseLine(` • ${p.file}: "${p.find}" → "${p.replace}"`);
|
|
798
|
+
}
|
|
799
|
+
await cdp.detach();
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
response.appendResponseLine(`❌ Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
export const reloadIframeExtension = defineTool({
|
|
808
|
+
name: 'reload_iframe_extension',
|
|
809
|
+
description: `Reload the extension via its service worker using chrome.runtime.reload(). Useful after manually editing extension files.`,
|
|
810
|
+
annotations: {
|
|
811
|
+
category: ToolCategories.EXTENSION_DEVELOPMENT,
|
|
812
|
+
readOnlyHint: false,
|
|
813
|
+
},
|
|
814
|
+
schema: {},
|
|
815
|
+
handler: async (request, response, context) => {
|
|
816
|
+
const page = context.getSelectedPage();
|
|
817
|
+
await context.waitForEventsAfterAction(async () => {
|
|
818
|
+
try {
|
|
819
|
+
const cdp = await page.createCDPSession();
|
|
820
|
+
response.appendResponseLine('🔄 Reloading extension...');
|
|
821
|
+
await iframePopupTools.reloadExtension(cdp);
|
|
822
|
+
response.appendResponseLine('');
|
|
823
|
+
response.appendResponseLine('✅ Extension reloaded successfully');
|
|
824
|
+
await cdp.detach();
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
response.appendResponseLine(`❌ Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
},
|
|
831
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/tools/iframe-popup-tools.ts
|
|
2
|
+
// Tools for inspecting & editing in-page iframe popups via CDP.
|
|
3
|
+
// These tools enable direct access to iframe-embedded extension popups.
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
export async function findExtensionIdViaTargets(cdp) {
|
|
7
|
+
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
8
|
+
const ext = targetInfos.find((t) => t.type === 'service_worker' && t.url.startsWith('chrome-extension://'));
|
|
9
|
+
if (!ext)
|
|
10
|
+
throw new Error('Extension service worker not found');
|
|
11
|
+
return new URL(ext.url).host;
|
|
12
|
+
}
|
|
13
|
+
export async function waitForFrameByUrlMatch(cdp, pattern, timeoutMs = 5000) {
|
|
14
|
+
await cdp.send('Page.enable');
|
|
15
|
+
// First quick scan
|
|
16
|
+
const tree = await cdp.send('Page.getFrameTree');
|
|
17
|
+
const hit = scanTree(tree.frameTree, pattern);
|
|
18
|
+
if (hit)
|
|
19
|
+
return hit;
|
|
20
|
+
// Then wait for navigation events
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
return await new Promise((resolve, reject) => {
|
|
23
|
+
function onNav(ev) {
|
|
24
|
+
const { frame } = ev;
|
|
25
|
+
if (frame?.url && pattern.test(frame.url)) {
|
|
26
|
+
cleanup();
|
|
27
|
+
resolve({ frameId: frame.id, frameUrl: frame.url });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function onTimeout() {
|
|
31
|
+
cleanup();
|
|
32
|
+
reject(new Error(`Timeout waiting for frame by url match: ${pattern}`));
|
|
33
|
+
}
|
|
34
|
+
function cleanup() {
|
|
35
|
+
cdp.off('Page.frameNavigated', onNav);
|
|
36
|
+
}
|
|
37
|
+
cdp.on('Page.frameNavigated', onNav);
|
|
38
|
+
const left = Math.max(0, timeoutMs - (Date.now() - start));
|
|
39
|
+
setTimeout(onTimeout, left);
|
|
40
|
+
});
|
|
41
|
+
function scanTree(node, rx) {
|
|
42
|
+
if (node?.frame?.url && rx.test(node.frame.url)) {
|
|
43
|
+
return { frameId: node.frame.id, frameUrl: node.frame.url };
|
|
44
|
+
}
|
|
45
|
+
for (const c of node.childFrames ?? []) {
|
|
46
|
+
const r = scanTree(c, rx);
|
|
47
|
+
if (r)
|
|
48
|
+
return r;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function inspectIframe(cdp, urlPattern, waitMs = 5000) {
|
|
54
|
+
await cdp.send('Runtime.enable');
|
|
55
|
+
await cdp.send('DOM.enable');
|
|
56
|
+
await cdp.send('Log.enable');
|
|
57
|
+
const { frameId, frameUrl } = await waitForFrameByUrlMatch(cdp, urlPattern, waitMs);
|
|
58
|
+
const { executionContextId } = await cdp.send('Page.createIsolatedWorld', {
|
|
59
|
+
frameId,
|
|
60
|
+
worldName: 'mcp',
|
|
61
|
+
// grantUniveralAccess is a known CDP option in some builds. It's optional here.
|
|
62
|
+
});
|
|
63
|
+
const { result } = await cdp.send('Runtime.evaluate', {
|
|
64
|
+
contextId: executionContextId,
|
|
65
|
+
expression: 'document.documentElement.outerHTML',
|
|
66
|
+
returnByValue: true,
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
frameId,
|
|
70
|
+
frameUrl,
|
|
71
|
+
html: String(result.value ?? ''),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export async function patchAndReload(cdp, extensionPath, patches) {
|
|
75
|
+
for (const p of patches) {
|
|
76
|
+
const abs = path.join(extensionPath, p.file);
|
|
77
|
+
const src = await fs.readFile(abs, 'utf8');
|
|
78
|
+
const rx = new RegExp(p.find, 'g');
|
|
79
|
+
const out = src.replace(rx, p.replace);
|
|
80
|
+
if (out != src)
|
|
81
|
+
await fs.writeFile(abs, out, 'utf8');
|
|
82
|
+
}
|
|
83
|
+
await reloadExtension(cdp);
|
|
84
|
+
}
|
|
85
|
+
export async function reloadExtension(cdp) {
|
|
86
|
+
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
87
|
+
const sw = targetInfos.find((t) => t.type === 'service_worker' && t.url.startsWith('chrome-extension://'));
|
|
88
|
+
if (!sw)
|
|
89
|
+
throw new Error('Extension service worker not found for reload');
|
|
90
|
+
// Attach to the service worker and execute chrome.runtime.reload()
|
|
91
|
+
const { sessionId } = await cdp.send('Target.attachToTarget', {
|
|
92
|
+
targetId: sw.targetId,
|
|
93
|
+
flatten: true,
|
|
94
|
+
});
|
|
95
|
+
await cdp.send('Target.sendMessageToTarget', {
|
|
96
|
+
sessionId,
|
|
97
|
+
message: JSON.stringify({
|
|
98
|
+
id: 1,
|
|
99
|
+
method: 'Runtime.evaluate',
|
|
100
|
+
params: { expression: 'chrome.runtime.reload()' },
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-devtools-mcp-for-extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "MCP server for Chrome extension development with Web Store automation. Fork of chrome-devtools-mcp with extension-specific tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./build/src/index.js",
|