@extension.dev/mcp 4.1.2 → 4.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.2.0
4
+
5
+ Session lifecycle + determinism release. Tool count 27 -> 28.
6
+
7
+ - New tool `extension_stop`: terminates a dev/start/preview session (dev
8
+ server AND the browser it launched) via a process-group signal with
9
+ SIGTERM -> SIGKILL escalation. Finds the pid in the in-memory session
10
+ registry, falling back to the `ready.json` contract when the MCP server
11
+ restarted since the session began, and removes the stale contract so
12
+ `extension_wait` cannot report a dead session as ready. Supports
13
+ `all: true` to stop everything the server started.
14
+ - Sessions self-clean: dev/start/preview register an exit listener so a
15
+ session that dies on its own is no longer reported as stoppable.
16
+ `extension_preview` sessions are now registered (and stoppable) too.
17
+ - `extension_create` gains `parentDir`: control where the project lands
18
+ instead of inheriting the MCP server's working directory. `nextSteps`
19
+ now reports the full project path.
20
+ - CLI spawns are deterministic: dev/start/preview and the act tools now
21
+ prefer the project's own `node_modules/.bin/extension`, falling back to
22
+ `npx extension@<pinned>` where the pin derives from the vendored
23
+ `extension-develop` version — never a floating `latest`.
24
+ - Session registry keys are path-normalized, so a stop with an absolute
25
+ path matches a session registered with a relative one.
26
+ - Tests: registry suite now asserts against the exported `tools` array
27
+ (the old hand-maintained mirror had drifted to 26 while the server
28
+ registered 27); new stop + CLI-resolution suites (123 tests total).
29
+
3
30
  ## 4.1.2
4
31
 
5
32
  README: restore the `@extension.dev/skill` pairing section (hands +
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  # @extension.dev/mcp [![Version][npm-version-image]][npm-version-url] [![Downloads][npm-downloads-image]][npm-downloads-url] [![Discord][discord-image]][discord-url]
9
9
 
10
- > Give your AI agent hands for browser extension development. 27 MCP tools that scaffold, run, inspect, debug, and publish cross-browser extensions.
10
+ > Give your AI agent hands for browser extension development. 28 MCP tools that scaffold, run, inspect, debug, and publish cross-browser extensions.
11
11
 
12
12
  <img alt="Logo" align="right" src="https://avatars.githubusercontent.com/u/106714027" width="20%" />
13
13
 
@@ -89,6 +89,7 @@ cp node_modules/@extension.dev/mcp/claude/commands/*.md ~/my-extension/.claude/c
89
89
  | run | `extension_start` | Build + preview |
90
90
  | run | `extension_preview` | Preview the production build |
91
91
  | run | `extension_wait` | Poll the dev-server ready contract |
92
+ | run | `extension_stop` | Stop a dev/start/preview session (server + browser) |
92
93
  | see | `extension_manifest_validate` | Cross-browser manifest validation |
93
94
  | see | `extension_inspect` | Build output analysis |
94
95
  | see | `extension_source_inspect` | Live DOM inspection (CDP) |
@@ -108,12 +109,22 @@ cp node_modules/@extension.dev/mcp/claude/commands/*.md ~/my-extension/.claude/c
108
109
  | platform | `extension_publish` | Publish a shareable preview to extension.dev |
109
110
  | platform | `extension_release_promote` | Promote a build to a release channel, headless |
110
111
 
111
- Browser-launching tools (`dev`, `start`, `preview`) shell out to the `extension` CLI; everything else runs in-process.
112
+ Browser-launching tools (`dev`, `start`, `preview`) shell out to the `extension` CLI — the project's own `node_modules/.bin/extension` when present, otherwise `npx extension@<pinned>` at the version this package is verified against; everything else runs in-process.
112
113
 
113
114
  ## From preview to store
114
115
 
115
116
  The four platform tools connect agents to [extension.dev](https://extension.dev): `extension_login` runs a GitHub device-code flow and stores a project-scoped token locally (never returned to the agent), `extension_publish` turns a build into a shareable preview URL, and `extension_release_promote` promotes a tested build to a release channel from CI or an agent session, no browser required. To automate store submission itself (Chrome Web Store, Edge Add-ons, Firefox AMO), pair with [`@extension.dev/deploy`](https://www.npmjs.com/package/@extension.dev/deploy).
116
117
 
118
+ ## The extension.dev stack
119
+
120
+ | Package | Use it to |
121
+ | --- | --- |
122
+ | [`@extension.dev/skill`](https://www.npmjs.com/package/@extension.dev/skill) | Teach AI agents the judgment half: cross-browser rules, gotchas, playbooks |
123
+ | [`@extension.dev/deploy`](https://www.npmjs.com/package/@extension.dev/deploy) | Ship to Chrome, Firefox, and Edge stores from CI |
124
+ | [`@extension.dev/core`](https://www.npmjs.com/package/@extension.dev/core) | Authenticate and publish to the extension.dev platform |
125
+
126
+ All of it rides on [Extension.js](https://github.com/extension-js/extension.js), the open-source cross-browser extension framework.
127
+
117
128
  ## Community
118
129
 
119
130
  - Join the [Discord](https://discord.gg/v9h2RgeTSN) for help and feedback
File without changes
@@ -612,6 +612,47 @@ The `similarTemplates` field lists templates from the catalog with similar surfa
612
612
 
613
613
  ---
614
614
 
615
+ #### `extension_stop`
616
+
617
+ **Source:** MCP `lib/process-manager` + the `ready.json` contract (no CLI verb)
618
+
619
+ **Purpose:** Terminate a running dev/start/preview session — the dev server AND the browser it launched. The lifecycle counterpart to `extension_dev`/`extension_start`.
620
+
621
+ ```json
622
+ {
623
+ "name": "extension_stop",
624
+ "description": "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched.",
625
+ "inputSchema": {
626
+ "type": "object",
627
+ "properties": {
628
+ "projectPath": {
629
+ "type": "string",
630
+ "description": "Path to the extension project root"
631
+ },
632
+ "browser": {
633
+ "type": "string",
634
+ "default": "chrome",
635
+ "description": "Browser of the session to stop"
636
+ },
637
+ "all": {
638
+ "type": "boolean",
639
+ "default": false,
640
+ "description": "Stop every session this server started"
641
+ }
642
+ },
643
+ "required": []
644
+ }
645
+ }
646
+ ```
647
+
648
+ **Returns:** `{ projectPath, browser, pid, stopped, detail }` (or `{ stopped: [...] }` with `all: true`).
649
+
650
+ **How it finds the process:** the in-memory session registry first; if the MCP server restarted since the session began, it falls back to the `pid` recorded in the `ready.json` contract. It signals the whole process group (sessions are spawned detached), escalates SIGTERM → SIGKILL, and removes the stale `ready.json` so a later `extension_wait` cannot report a dead session as ready.
651
+
652
+ **Why this matters for MCP:** without a stop tool, every `extension_dev` call leaks a dev server and a browser window that outlive the agent's task. Agents should stop sessions when verification is done.
653
+
654
+ ---
655
+
615
656
  ### Tier 3 — Browser management tools
616
657
 
617
658
  #### `extension_install_browser`
@@ -721,6 +762,7 @@ The `similarTemplates` field lists templates from the catalog with similar surfa
721
762
  | `extension_source_inspect` | `programs/extension` | CDP client / RDP transport | Live browser via debugging protocol | Wire to running session |
722
763
  | `extension_list_extensions` | MCP `lib/cdp` | `Extensions.getExtensionInfo` (read-only) | Live browser via CDP (Chromium) | MCP tool (no CLI verb) |
723
764
  | `extension_wait` | `programs/extension` | `dev-wait.ts` | `ready.json` contract file | Thin wrapper (exists in CLI) |
765
+ | `extension_stop` | MCP `lib/process-manager` | session registry + group signal | Session registry + `ready.json` pid | MCP tool (no CLI verb) |
724
766
  | `extension_add_feature` | New | `extension_get_template_source` | examples repo patterns | Codegen from examples |
725
767
  | **Agent bridge — act / triggers** | | | | |
726
768
  | `extension_eval` | `programs/extension` | bridge control channel | Live extension context | Wraps `extension eval` (`--allow-eval`) |
package/dist/module.js CHANGED
@@ -2,15 +2,15 @@ import { __webpack_require__ } from "./rslib-runtime.js";
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import node_path, { join } from "node:path";
5
6
  import { extensionCreate } from "extension-create";
6
7
  import node_fs, { existsSync, readdirSync, statSync } from "node:fs";
7
- import node_path, { join } from "node:path";
8
8
  import node_os from "node:os";
9
9
  import { extensionBuild } from "extension-develop";
10
10
  import cross_spawn from "cross-spawn";
11
11
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
12
- import node_net from "node:net";
13
12
  import ws from "ws";
13
+ import node_http from "node:http";
14
14
  import { clearCredentials, exchangeAndPersist, fetchLoginConfig, pollForToken, publish, readCredentials, resolveApiBase, resolveToken, safeApiBase, startDeviceCode } from "@extension.dev/core";
15
15
  import { extensionInstall, getManagedBrowsersCacheRoot } from "extension-install";
16
16
  import { execFile } from "node:child_process";
@@ -31,7 +31,7 @@ var create_namespaceObject = {};
31
31
  __webpack_require__.r(create_namespaceObject);
32
32
  __webpack_require__.d(create_namespaceObject, {
33
33
  handler: ()=>create_handler,
34
- schema: ()=>schema
34
+ schema: ()=>create_schema
35
35
  });
36
36
  var detect_browsers_namespaceObject = {};
37
37
  __webpack_require__.r(detect_browsers_namespaceObject);
@@ -159,6 +159,12 @@ __webpack_require__.d(start_namespaceObject, {
159
159
  handler: ()=>start_handler,
160
160
  schema: ()=>start_schema
161
161
  });
162
+ var stop_namespaceObject = {};
163
+ __webpack_require__.r(stop_namespaceObject);
164
+ __webpack_require__.d(stop_namespaceObject, {
165
+ handler: ()=>stop_handler,
166
+ schema: ()=>stop_schema
167
+ });
162
168
  var storage_namespaceObject = {};
163
169
  __webpack_require__.r(storage_namespaceObject);
164
170
  __webpack_require__.d(storage_namespaceObject, {
@@ -177,10 +183,8 @@ __webpack_require__.d(whoami_namespaceObject, {
177
183
  handler: ()=>whoami_handler,
178
184
  schema: ()=>whoami_schema
179
185
  });
180
- var package_namespaceObject = {
181
- rE: "4.1.2"
182
- };
183
- const schema = {
186
+ var package_namespaceObject = JSON.parse('{"rE":"4.2.1","El":{"OP":"^4.0.8"}}');
187
+ const create_schema = {
184
188
  name: "extension_create",
185
189
  description: "Create a new browser extension project from a template in the extension.dev template catalog. Use extension_list_templates to see available options.",
186
190
  inputSchema: {
@@ -190,6 +194,10 @@ const schema = {
190
194
  type: "string",
191
195
  description: "Name of the extension project (used as directory name)"
192
196
  },
197
+ parentDir: {
198
+ type: "string",
199
+ description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect — pass this explicitly when you care where the project lands."
200
+ },
193
201
  template: {
194
202
  type: "string",
195
203
  default: "typescript",
@@ -208,8 +216,9 @@ const schema = {
208
216
  };
209
217
  async function create_handler(args) {
210
218
  const start = Date.now();
219
+ const projectInput = args.parentDir ? node_path.resolve(args.parentDir, args.projectName) : args.projectName;
211
220
  try {
212
- const result = await extensionCreate(args.projectName, {
221
+ const result = await extensionCreate(projectInput, {
213
222
  template: args.template ?? "typescript",
214
223
  install: args.install ?? true,
215
224
  logger: {
@@ -224,7 +233,7 @@ async function create_handler(args) {
224
233
  depsInstalled: result.depsInstalled,
225
234
  duration: Date.now() - start,
226
235
  nextSteps: [
227
- `cd ${result.projectName}`,
236
+ `cd ${result.projectPath}`,
228
237
  "npm run dev"
229
238
  ]
230
239
  });
@@ -392,7 +401,8 @@ async function build_handler(args) {
392
401
  const summary = await extensionBuild(args.projectPath, {
393
402
  browser: args.browser ?? "chrome",
394
403
  zip: args.zip ?? false,
395
- zipSource: args.zipSource ?? false
404
+ zipSource: args.zipSource ?? false,
405
+ exitOnError: false
396
406
  });
397
407
  return JSON.stringify({
398
408
  success: true,
@@ -407,10 +417,34 @@ async function build_handler(args) {
407
417
  });
408
418
  }
409
419
  }
420
+ const PINNED_CLI_VERSION = String(package_namespaceObject.El.OP ?? "latest").replace(/^[\^~]/, "");
421
+ function pinnedCliVersion() {
422
+ const override = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
423
+ return override || PINNED_CLI_VERSION;
424
+ }
425
+ function resolveExtensionInvocation(projectDir) {
426
+ if (projectDir) {
427
+ const bin = node_path.join(projectDir, "node_modules", ".bin", "win32" === process.platform ? "extension.cmd" : "extension");
428
+ try {
429
+ node_fs.accessSync(bin, node_fs.constants.X_OK);
430
+ return {
431
+ command: bin,
432
+ prefixArgs: []
433
+ };
434
+ } catch {}
435
+ }
436
+ return {
437
+ command: "npx",
438
+ prefixArgs: [
439
+ `extension@${pinnedCliVersion()}`
440
+ ]
441
+ };
442
+ }
410
443
  function runExtensionCli(args, options) {
444
+ const { command, prefixArgs } = resolveExtensionInvocation(options?.cwd);
411
445
  return new Promise((resolve)=>{
412
- const child = cross_spawn("npx", [
413
- "extension",
446
+ const child = cross_spawn(command, [
447
+ ...prefixArgs,
414
448
  ...args
415
449
  ], {
416
450
  cwd: options?.cwd,
@@ -443,8 +477,9 @@ function runExtensionCli(args, options) {
443
477
  });
444
478
  }
445
479
  function spawnExtensionCli(args, options) {
446
- const child = cross_spawn("npx", [
447
- "extension",
480
+ const { command, prefixArgs } = resolveExtensionInvocation(options?.projectDir ?? options?.cwd);
481
+ const child = cross_spawn(command, [
482
+ ...prefixArgs,
448
483
  ...args
449
484
  ], {
450
485
  cwd: options?.cwd,
@@ -463,12 +498,21 @@ function spawnExtensionCli(args, options) {
463
498
  child.unref();
464
499
  return child;
465
500
  }
466
- const sessions = new Map();
501
+ const process_manager_sessions = new Map();
467
502
  function sessionKey(projectPath, browser) {
468
- return `${projectPath}::${browser}`;
503
+ return `${node_path.resolve(projectPath)}::${browser}`;
469
504
  }
470
505
  function registerSession(info) {
471
- sessions.set(sessionKey(info.projectPath, info.browser), info);
506
+ process_manager_sessions.set(sessionKey(info.projectPath, info.browser), info);
507
+ }
508
+ function getSession(projectPath, browser) {
509
+ return process_manager_sessions.get(sessionKey(projectPath, browser));
510
+ }
511
+ function removeSession(projectPath, browser) {
512
+ process_manager_sessions.delete(sessionKey(projectPath, browser));
513
+ }
514
+ function listSessions() {
515
+ return Array.from(process_manager_sessions.values());
472
516
  }
473
517
  const dev_schema = {
474
518
  name: "extension_dev",
@@ -528,7 +572,9 @@ async function dev_handler(args) {
528
572
  if (args.noBrowser) cliArgs.push("--no-browser");
529
573
  if (args.allowControl) cliArgs.push("--allow-control");
530
574
  if (args.allowEval) cliArgs.push("--allow-eval");
531
- const child = spawnExtensionCli(cliArgs);
575
+ const child = spawnExtensionCli(cliArgs, {
576
+ projectDir: args.projectPath
577
+ });
532
578
  const pid = child.pid;
533
579
  registerSession({
534
580
  pid,
@@ -537,6 +583,7 @@ async function dev_handler(args) {
537
583
  projectPath: args.projectPath,
538
584
  command: "dev"
539
585
  });
586
+ child.on("exit", ()=>removeSession(args.projectPath, browser));
540
587
  let earlyOutput = "";
541
588
  const collector = (data)=>{
542
589
  earlyOutput += data.toString();
@@ -552,7 +599,7 @@ async function dev_handler(args) {
552
599
  port: args.port ?? 8080,
553
600
  projectPath: args.projectPath,
554
601
  status: "started",
555
- hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state.",
602
+ hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. When you are done, call extension_stop to shut down the dev server and browser.",
556
603
  earlyOutput: earlyOutput.slice(0, 500)
557
604
  });
558
605
  }
@@ -597,7 +644,9 @@ async function start_handler(args) {
597
644
  browser
598
645
  ];
599
646
  if (false === args.polyfill) cliArgs.push("--no-polyfill");
600
- const child = spawnExtensionCli(cliArgs);
647
+ const child = spawnExtensionCli(cliArgs, {
648
+ projectDir: args.projectPath
649
+ });
601
650
  const pid = child.pid;
602
651
  registerSession({
603
652
  pid,
@@ -605,6 +654,7 @@ async function start_handler(args) {
605
654
  projectPath: args.projectPath,
606
655
  command: "start"
607
656
  });
657
+ child.on("exit", ()=>removeSession(args.projectPath, browser));
608
658
  let earlyOutput = "";
609
659
  const collector = (data)=>{
610
660
  earlyOutput += data.toString();
@@ -619,7 +669,7 @@ async function start_handler(args) {
619
669
  browser,
620
670
  projectPath: args.projectPath,
621
671
  status: "started",
622
- hint: "Use extension_wait to check when the build and browser launch are complete.",
672
+ hint: "Use extension_wait to check when the build and browser launch are complete. When you are done, call extension_stop to shut down the session.",
623
673
  earlyOutput: earlyOutput.slice(0, 500)
624
674
  });
625
675
  }
@@ -658,13 +708,134 @@ async function preview_handler(args) {
658
708
  "--browser",
659
709
  browser
660
710
  ];
661
- const child = spawnExtensionCli(cliArgs);
711
+ const child = spawnExtensionCli(cliArgs, {
712
+ projectDir: args.projectPath
713
+ });
714
+ const pid = child.pid;
715
+ registerSession({
716
+ pid,
717
+ browser,
718
+ projectPath: args.projectPath,
719
+ command: "preview"
720
+ });
721
+ child.on("exit", ()=>removeSession(args.projectPath, browser));
662
722
  return JSON.stringify({
663
- pid: child.pid,
723
+ pid,
664
724
  browser,
665
725
  projectPath: args.projectPath,
666
- status: "launched"
726
+ status: "launched",
727
+ hint: "Call extension_stop when you are done to close the preview browser."
728
+ });
729
+ }
730
+ const stop_schema = {
731
+ name: "extension_stop",
732
+ description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
733
+ inputSchema: {
734
+ type: "object",
735
+ properties: {
736
+ projectPath: {
737
+ type: "string",
738
+ description: "Path to the extension project root"
739
+ },
740
+ browser: {
741
+ type: "string",
742
+ default: "chrome",
743
+ description: "Browser of the session to stop (matches the browser passed to extension_dev/extension_start)"
744
+ },
745
+ all: {
746
+ type: "boolean",
747
+ default: false,
748
+ description: "Stop every session this server started, across projects and browsers. When true, projectPath/browser are ignored."
749
+ }
750
+ },
751
+ required: []
752
+ }
753
+ };
754
+ function isAlive(pid) {
755
+ try {
756
+ process.kill(pid, 0);
757
+ return true;
758
+ } catch {
759
+ return false;
760
+ }
761
+ }
762
+ function signal(pid, sig) {
763
+ try {
764
+ process.kill(-pid, sig);
765
+ return true;
766
+ } catch {
767
+ try {
768
+ process.kill(pid, sig);
769
+ return true;
770
+ } catch {
771
+ return false;
772
+ }
773
+ }
774
+ }
775
+ function readyJsonPath(projectPath, browser) {
776
+ return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
777
+ }
778
+ function pidFromReadyContract(projectPath, browser) {
779
+ try {
780
+ const raw = node_fs.readFileSync(readyJsonPath(projectPath, browser), "utf8");
781
+ const contract = JSON.parse(raw);
782
+ return "number" == typeof contract.pid ? contract.pid : null;
783
+ } catch {
784
+ return null;
785
+ }
786
+ }
787
+ async function stopOne(projectPath, browser) {
788
+ const session = getSession(projectPath, browser);
789
+ const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
790
+ if (null == pid) return {
791
+ projectPath,
792
+ browser,
793
+ pid: null,
794
+ stopped: false,
795
+ detail: "No known session for this project/browser (nothing registered in this server and no ready.json contract found)."
796
+ };
797
+ let detail;
798
+ if (isAlive(pid)) {
799
+ signal(pid, "SIGTERM");
800
+ await new Promise((resolve)=>setTimeout(resolve, 1500));
801
+ if (isAlive(pid)) {
802
+ signal(pid, "SIGKILL");
803
+ await new Promise((resolve)=>setTimeout(resolve, 250));
804
+ }
805
+ detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
806
+ } else detail = "Process was already gone; cleaned up session records.";
807
+ removeSession(projectPath, browser);
808
+ try {
809
+ node_fs.rmSync(readyJsonPath(projectPath, browser), {
810
+ force: true
811
+ });
812
+ } catch {}
813
+ return {
814
+ projectPath,
815
+ browser,
816
+ pid,
817
+ stopped: !isAlive(pid),
818
+ detail
819
+ };
820
+ }
821
+ async function stop_handler(args) {
822
+ if (args.all) {
823
+ const sessions = listSessions();
824
+ if (0 === sessions.length) return JSON.stringify({
825
+ stopped: [],
826
+ message: "No sessions registered in this server."
827
+ });
828
+ const outcomes = [];
829
+ for (const s of sessions)outcomes.push(await stopOne(s.projectPath, s.browser));
830
+ return JSON.stringify({
831
+ stopped: outcomes
832
+ });
833
+ }
834
+ if (!args.projectPath) return JSON.stringify({
835
+ error: "projectPath is required unless all=true. Pass the same projectPath used with extension_dev/extension_start."
667
836
  });
837
+ const outcome = await stopOne(args.projectPath, args.browser ?? "chrome");
838
+ return JSON.stringify(outcome);
668
839
  }
669
840
  const RAW_BASE = "https://raw.githubusercontent.com/extension-js/examples/main/examples";
670
841
  const get_template_source_schema = {
@@ -1405,6 +1576,50 @@ class CDPClient extends CDPConnection {
1405
1576
  return result ?? null;
1406
1577
  }
1407
1578
  }
1579
+ async function resolveCdpPort(projectPath, browser, options) {
1580
+ const waitMs = options?.waitMs ?? 20000;
1581
+ const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1582
+ const deadline = Date.now() + waitMs;
1583
+ let contractSeen = false;
1584
+ for(;;){
1585
+ try {
1586
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1587
+ contractSeen = true;
1588
+ if ("number" == typeof contract.cdpPort) return {
1589
+ port: contract.cdpPort,
1590
+ source: "contract"
1591
+ };
1592
+ } catch {
1593
+ if (!contractSeen) break;
1594
+ }
1595
+ if (Date.now() >= deadline) break;
1596
+ await new Promise((resolve)=>setTimeout(resolve, 500));
1597
+ }
1598
+ if (!contractSeen && await isCdpEndpoint(9222)) return {
1599
+ port: 9222,
1600
+ source: "default-probe"
1601
+ };
1602
+ return null;
1603
+ }
1604
+ const CDP_PORT_MISSING_HINT = "The session's ready contract has no CDP port (the browser may still be binding its debug port, or was launched without one). Confirm the session with extension_wait, give it a moment, and retry.";
1605
+ function isCdpEndpoint(port) {
1606
+ return new Promise((resolve)=>{
1607
+ const req = node_http.get({
1608
+ host: "127.0.0.1",
1609
+ port,
1610
+ path: "/json/version",
1611
+ timeout: 1000
1612
+ }, (res)=>{
1613
+ res.resume();
1614
+ resolve(200 === res.statusCode);
1615
+ });
1616
+ req.on("error", ()=>resolve(false));
1617
+ req.on("timeout", ()=>{
1618
+ req.destroy();
1619
+ resolve(false);
1620
+ });
1621
+ });
1622
+ }
1408
1623
  const source_inspect_schema = {
1409
1624
  name: "extension_source_inspect",
1410
1625
  description: "Inspect a running extension's live state via Chrome DevTools Protocol: full HTML (with shadow DOM), DOM structure, content script injection, console messages, and CSS selector queries. Requires an active dev or start session.",
@@ -1466,28 +1681,6 @@ const source_inspect_schema = {
1466
1681
  ]
1467
1682
  }
1468
1683
  };
1469
- async function findCdpPort(projectPath, browser) {
1470
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1471
- try {
1472
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1473
- if ("number" == typeof contract.cdpPort) return contract.cdpPort;
1474
- } catch {}
1475
- const defaultPort = 9222;
1476
- return new Promise((resolve)=>{
1477
- const socket = new node_net.Socket();
1478
- socket.setTimeout(1000);
1479
- socket.on("connect", ()=>{
1480
- socket.destroy();
1481
- resolve(defaultPort);
1482
- });
1483
- socket.on("error", ()=>resolve(null));
1484
- socket.on("timeout", ()=>{
1485
- socket.destroy();
1486
- resolve(null);
1487
- });
1488
- socket.connect(defaultPort, "127.0.0.1");
1489
- });
1490
- }
1491
1684
  async function source_inspect_handler(args) {
1492
1685
  const browser = args.browser ?? "chrome";
1493
1686
  const include = new Set(args.include ?? [
@@ -1505,11 +1698,12 @@ async function source_inspect_handler(args) {
1505
1698
  error: `Source inspection for ${browser} uses RDP (Remote Debug Protocol). Currently only Chromium CDP is supported.`,
1506
1699
  hint: "Use --browser=chrome for source inspection, or use the CLI: npx extension dev --source"
1507
1700
  });
1508
- const cdpPort = await findCdpPort(args.projectPath, browser);
1509
- if (!cdpPort) return JSON.stringify({
1701
+ const resolved = await resolveCdpPort(args.projectPath, browser);
1702
+ if (!resolved) return JSON.stringify({
1510
1703
  error: "No active dev session found. Cannot connect to Chrome DevTools Protocol.",
1511
- hint: "Start a dev session first with extension_dev, then use extension_wait to confirm it is ready."
1704
+ hint: `Start a dev session first with extension_dev, then use extension_wait to confirm it is ready. ${CDP_PORT_MISSING_HINT}`
1512
1705
  });
1706
+ const cdpPort = resolved.port;
1513
1707
  const cdp = new CDPClient();
1514
1708
  try {
1515
1709
  const allTargets = await CDPClient.discoverTargets(cdpPort);
@@ -1622,16 +1816,27 @@ async function list_extensions_handler(args) {
1622
1816
  error: `Listing extensions for ${browser} uses RDP (Remote Debug Protocol). Currently only Chromium CDP is supported.`,
1623
1817
  hint: "Use --browser=chrome."
1624
1818
  });
1625
- const cdpPort = await list_extensions_findCdpPort(args.projectPath, browser);
1626
- if (!cdpPort) return JSON.stringify({
1819
+ const resolved = await resolveCdpPort(args.projectPath, browser);
1820
+ if (!resolved) return JSON.stringify({
1627
1821
  error: "No active dev session found. Cannot connect to Chrome DevTools Protocol.",
1628
- hint: "Start a dev session first with extension_dev, then use extension_wait to confirm it is ready."
1822
+ hint: `Start a dev session first with extension_dev, then use extension_wait to confirm it is ready. ${CDP_PORT_MISSING_HINT}`
1629
1823
  });
1824
+ const cdpPort = resolved.port;
1630
1825
  const cdp = new CDPClient();
1631
1826
  try {
1632
- const browserWsUrl = await CDPClient.discoverBrowserWsUrl(cdpPort);
1633
- await cdp.connect(browserWsUrl);
1634
- const targets = await cdp.getTargets();
1827
+ let targets = null;
1828
+ let lastError = null;
1829
+ for(let attempt = 0; attempt < 3; attempt++)try {
1830
+ const browserWsUrl = await CDPClient.discoverBrowserWsUrl(cdpPort);
1831
+ await cdp.connect(browserWsUrl);
1832
+ targets = await cdp.getTargets();
1833
+ break;
1834
+ } catch (error) {
1835
+ lastError = error;
1836
+ cdp.disconnect();
1837
+ if (attempt < 2) await new Promise((resolve)=>setTimeout(resolve, 800));
1838
+ }
1839
+ if (!targets) throw lastError;
1635
1840
  const byId = new Map();
1636
1841
  for (const t of targets){
1637
1842
  const url = String(t.url ?? "");
@@ -1683,28 +1888,6 @@ async function list_extensions_handler(args) {
1683
1888
  cdp.disconnect();
1684
1889
  }
1685
1890
  }
1686
- async function list_extensions_findCdpPort(projectPath, browser) {
1687
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1688
- try {
1689
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1690
- if ("number" == typeof contract.cdpPort) return contract.cdpPort;
1691
- } catch {}
1692
- const defaultPort = 9222;
1693
- return new Promise((resolve)=>{
1694
- const socket = new node_net.Socket();
1695
- socket.setTimeout(1000);
1696
- socket.on("connect", ()=>{
1697
- socket.destroy();
1698
- resolve(defaultPort);
1699
- });
1700
- socket.on("error", ()=>resolve(null));
1701
- socket.on("timeout", ()=>{
1702
- socket.destroy();
1703
- resolve(null);
1704
- });
1705
- socket.connect(defaultPort, "127.0.0.1");
1706
- });
1707
- }
1708
1891
  const CONTROL_WS_PATH = "/extjs-control";
1709
1892
  const LEVEL_ORDER = [
1710
1893
  "error",
@@ -3269,6 +3452,70 @@ async function detect_browsers_handler(args) {
3269
3452
  hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}. Use extension_install_browser to install them.` : "All requested browsers are available."
3270
3453
  });
3271
3454
  }
3455
+ function typeOf(value) {
3456
+ if (Array.isArray(value)) return "array";
3457
+ if (null === value) return "null";
3458
+ return typeof value;
3459
+ }
3460
+ function checkPrimitive(path, value, schema, issues) {
3461
+ if (schema.type && [
3462
+ "string",
3463
+ "number",
3464
+ "boolean"
3465
+ ].includes(schema.type)) {
3466
+ if (typeOf(value) !== schema.type) return void issues.push({
3467
+ path,
3468
+ message: `expected ${schema.type}, got ${typeOf(value)}`
3469
+ });
3470
+ }
3471
+ if ("array" === schema.type) {
3472
+ if (!Array.isArray(value)) return void issues.push({
3473
+ path,
3474
+ message: `expected array, got ${typeOf(value)}`
3475
+ });
3476
+ const itemSchema = schema.items;
3477
+ if (itemSchema) value.forEach((item, i)=>checkPrimitive(`${path}[${i}]`, item, itemSchema, issues));
3478
+ return;
3479
+ }
3480
+ if (Array.isArray(schema.enum) && schema.enum.length) {
3481
+ if (!schema.enum.includes(value)) issues.push({
3482
+ path,
3483
+ message: `must be one of: ${schema.enum.map(String).join(", ")}`
3484
+ });
3485
+ }
3486
+ }
3487
+ function validateToolInput(inputSchema, args) {
3488
+ const schema = inputSchema;
3489
+ const issues = [];
3490
+ const properties = schema.properties ?? {};
3491
+ for (const key of schema.required ?? [])if (void 0 === args[key]) issues.push({
3492
+ path: key,
3493
+ message: "required argument is missing"
3494
+ });
3495
+ for (const [key, value] of Object.entries(args)){
3496
+ const propSchema = properties[key];
3497
+ if (!propSchema) {
3498
+ const known = Object.keys(properties);
3499
+ issues.push({
3500
+ path: key,
3501
+ message: `unknown argument${known.length ? ` (known: ${known.join(", ")})` : ""}`
3502
+ });
3503
+ continue;
3504
+ }
3505
+ if (void 0 !== value) checkPrimitive(key, value, propSchema, issues);
3506
+ }
3507
+ return issues;
3508
+ }
3509
+ function inputValidationError(toolName, issues) {
3510
+ return JSON.stringify({
3511
+ ok: false,
3512
+ error: {
3513
+ name: "InputValidationError",
3514
+ message: `Invalid arguments for ${toolName}: ${issues.map((i)=>`${i.path}: ${i.message}`).join("; ")}`,
3515
+ issues
3516
+ }
3517
+ });
3518
+ }
3272
3519
  const tools = [
3273
3520
  create_namespaceObject,
3274
3521
  list_templates_namespaceObject,
@@ -3276,6 +3523,7 @@ const tools = [
3276
3523
  dev_namespaceObject,
3277
3524
  start_namespaceObject,
3278
3525
  preview_namespaceObject,
3526
+ stop_namespaceObject,
3279
3527
  get_template_source_namespaceObject,
3280
3528
  manifest_validate_namespaceObject,
3281
3529
  inspect_namespaceObject,
@@ -3331,6 +3579,16 @@ async function startServer() {
3331
3579
  ],
3332
3580
  isError: true
3333
3581
  };
3582
+ const issues = validateToolInput(tool.schema.inputSchema, args ?? {});
3583
+ if (issues.length) return {
3584
+ content: [
3585
+ {
3586
+ type: "text",
3587
+ text: inputValidationError(name, issues)
3588
+ }
3589
+ ],
3590
+ isError: true
3591
+ };
3334
3592
  try {
3335
3593
  const result = await tool.handler(args ?? {});
3336
3594
  return {
@@ -1,3 +1,12 @@
1
+ export interface ToolModule {
2
+ schema: {
3
+ name: string;
4
+ description: string;
5
+ inputSchema: Record<string, unknown>;
6
+ };
7
+ handler: (args: any) => Promise<string>;
8
+ }
9
+ export declare const tools: ToolModule[];
1
10
  export declare function startServer(): Promise<void>;
2
11
  /**
3
12
  * Human-facing subcommands for `extension-mcp <cmd>`. Unlike the MCP tools
@@ -0,0 +1,7 @@
1
+ export declare function resolveCdpPort(projectPath: string, browser: string, options?: {
2
+ waitMs?: number;
3
+ }): Promise<{
4
+ port: number;
5
+ source: "contract" | "default-probe";
6
+ } | null>;
7
+ export declare const CDP_PORT_MISSING_HINT = "The session's ready contract has no CDP port (the browser may still be binding its debug port, or was launched without one). Confirm the session with extension_wait, give it a moment, and retry.";
@@ -4,10 +4,26 @@ export interface CliResult {
4
4
  stdout: string;
5
5
  stderr: string;
6
6
  }
7
+ /**
8
+ * Resolve how to invoke the `extension` CLI for a given project.
9
+ *
10
+ * Preference order:
11
+ * 1. The project's own `node_modules/.bin/extension` — the version the project
12
+ * pinned is the single source of behavior for that project (lockstep
13
+ * invariant), and spawning it needs no network.
14
+ * 2. `npx extension@<pinned>` — pinned to the extension-develop version this
15
+ * package vendors, never a floating `latest`, so MCP behavior stays
16
+ * reproducible even without a project-local install.
17
+ */
18
+ export declare function resolveExtensionInvocation(projectDir?: string): {
19
+ command: string;
20
+ prefixArgs: string[];
21
+ };
7
22
  export declare function runExtensionCli(args: string[], options?: {
8
23
  cwd?: string;
9
24
  timeoutMs?: number;
10
25
  }): Promise<CliResult>;
11
26
  export declare function spawnExtensionCli(args: string[], options?: {
12
27
  cwd?: string;
28
+ projectDir?: string;
13
29
  }): ChildProcess;
@@ -63,6 +63,6 @@ export interface ProcessInfo {
63
63
  browser: string;
64
64
  port?: number;
65
65
  projectPath: string;
66
- command: "dev" | "start";
66
+ command: "dev" | "start" | "preview";
67
67
  }
68
68
  export type BrowserType = "chrome" | "edge" | "firefox" | "chromium-based" | "gecko-based";
@@ -0,0 +1,11 @@
1
+ export interface InputIssue {
2
+ path: string;
3
+ message: string;
4
+ }
5
+ /**
6
+ * Validate tools/call arguments against a tool inputSchema. Returns an empty
7
+ * array when the args are acceptable.
8
+ */
9
+ export declare function validateToolInput(inputSchema: Record<string, unknown>, args: Record<string, unknown>): InputIssue[];
10
+ /** Frozen-style error envelope for input validation failures. */
11
+ export declare function inputValidationError(toolName: string, issues: InputIssue[]): string;
@@ -8,6 +8,10 @@ export declare const schema: {
8
8
  type: string;
9
9
  description: string;
10
10
  };
11
+ parentDir: {
12
+ type: string;
13
+ description: string;
14
+ };
11
15
  template: {
12
16
  type: string;
13
17
  default: string;
@@ -24,6 +28,7 @@ export declare const schema: {
24
28
  };
25
29
  export declare function handler(args: {
26
30
  projectName: string;
31
+ parentDir?: string;
27
32
  template?: string;
28
33
  install?: boolean;
29
34
  }): Promise<string>;
@@ -0,0 +1,29 @@
1
+ export declare const schema: {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: "object";
6
+ properties: {
7
+ projectPath: {
8
+ type: string;
9
+ description: string;
10
+ };
11
+ browser: {
12
+ type: string;
13
+ default: string;
14
+ description: string;
15
+ };
16
+ all: {
17
+ type: string;
18
+ default: boolean;
19
+ description: string;
20
+ };
21
+ };
22
+ required: never[];
23
+ };
24
+ };
25
+ export declare function handler(args: {
26
+ projectPath?: string;
27
+ browser?: string;
28
+ all?: boolean;
29
+ }): Promise<string>;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@extension.dev/mcp",
3
3
  "type": "module",
4
- "version": "4.1.2",
5
- "description": "MCP server that lets AI agents build, run, inspect, and publish browser extensions. 27 tools for scaffolding, live DOM inspection, log streaming, and store-ready builds across Chrome, Edge, and Firefox.",
4
+ "version": "4.2.1",
5
+ "description": "MCP server that lets AI agents build, run, inspect, and publish browser extensions. 28 tools for scaffolding, live DOM inspection, log streaming, and store-ready builds across Chrome, Edge, and Firefox.",
6
6
  "license": "MIT",
7
7
  "author": {
8
8
  "name": "Cezar Augusto",
@@ -20,6 +20,7 @@
20
20
  "engines": {
21
21
  "node": ">=20.18"
22
22
  },
23
+ "packageManager": "pnpm@10.19.0",
23
24
  "exports": {
24
25
  ".": {
25
26
  "types": "./dist/module.d.ts",
@@ -39,6 +40,15 @@
39
40
  "CHANGELOG.md",
40
41
  "LICENSE"
41
42
  ],
43
+ "scripts": {
44
+ "clean": "rm -rf dist",
45
+ "watch": "rslib build --watch",
46
+ "compile": "rslib build",
47
+ "build": "pnpm run compile",
48
+ "start": "node bin/extension-mcp.js",
49
+ "test": "vitest run",
50
+ "prepublishOnly": "pnpm run test && pnpm run compile"
51
+ },
42
52
  "publishConfig": {
43
53
  "access": "public",
44
54
  "registry": "https://registry.npmjs.org"
@@ -77,13 +87,5 @@
77
87
  "@types/ws": "^8.18.1",
78
88
  "typescript": "6.0.2",
79
89
  "vitest": "^4.1.3"
80
- },
81
- "scripts": {
82
- "clean": "rm -rf dist",
83
- "watch": "rslib build --watch",
84
- "compile": "rslib build",
85
- "build": "pnpm run compile",
86
- "start": "node bin/extension-mcp.js",
87
- "test": "vitest run"
88
90
  }
89
- }
91
+ }