diff --git a/__tests__/mcp-installer.spec.ts b/__tests__/mcp-installer.spec.ts index 62fda0a..ee9d7c2 100644 --- a/__tests__/mcp-installer.spec.ts +++ b/__tests__/mcp-installer.spec.ts @@ -1,16 +1,180 @@ import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +const INSTALLER = path.resolve("mcp/install-host.js"); +const EXTENSION_ID = "idkjhjggpffolpidfkikidcokdkdaogg"; +const sandboxes: string[] = []; + +function createSandbox(): string { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "xswitch-mcp-test-")); + sandboxes.push(sandbox); + return sandbox; +} + +function runInstaller( + args: string[], + environment: Record +) { + return spawnSync(process.execPath, [INSTALLER, ...args], { + encoding: "utf8", + env: { + ...process.env, + XSWITCH_MCP_MANIFEST_PATH: "", + XSWITCH_MCP_USER_DATA_DIR: "", + ...environment, + }, + }); +} describe("XSwitch MCP installer", () => { - it("requires an explicit extension ID instead of using a stale default", () => { + beforeAll(() => { const result = spawnSync( - process.execPath, - [path.resolve("mcp/install-host.js"), "install"], + process.platform === "win32" ? "npm.cmd" : "npm", + ["--prefix", "mcp", "run", "build"], { encoding: "utf8" } ); + expect(result.status, result.stderr).toBe(0); + }); + + afterEach(() => { + for (const sandbox of sandboxes.splice(0)) { + fs.rmSync(sandbox, { recursive: true, force: true }); + } + }); + + it("requires an explicit extension ID instead of using a stale default", () => { + const result = spawnSync(process.execPath, [INSTALLER, "install"], { + encoding: "utf8", + }); expect(result.status).toBe(1); expect(result.stderr).toContain("--extension-id is required for install"); }); + + it("installs and uninstalls inside a custom user data directory", () => { + const sandbox = createSandbox(); + const installDirectory = path.join(sandbox, "runtime install"); + const userDataDirectory = path.join(sandbox, "Chrome Debug"); + const manifestPath = path.join( + userDataDirectory, + "NativeMessagingHosts", + "com.xswitch.mcp.json" + ); + + const install = runInstaller( + [ + "install", + "--extension-id", + EXTENSION_ID, + "--user-data-dir", + userDataDirectory, + ], + { XSWITCH_MCP_INSTALL_DIR: installDirectory } + ); + + expect(install.status, install.stderr).toBe(0); + expect(install.stdout).toContain(manifestPath); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + expect(manifest.path).toBe(path.join(installDirectory, "native-host")); + expect(manifest.allowed_origins).toEqual([ + `chrome-extension://${EXTENSION_ID}/`, + ]); + + const uninstall = runInstaller( + ["uninstall", "--user-data-dir", userDataDirectory], + { XSWITCH_MCP_INSTALL_DIR: installDirectory } + ); + + expect(uninstall.status, uninstall.stderr).toBe(0); + expect(fs.existsSync(manifestPath)).toBe(false); + }); + + it("supports the --user-data-dir= form", () => { + const sandbox = createSandbox(); + const installDirectory = path.join(sandbox, "install"); + const userDataDirectory = path.join(sandbox, "Chrome Debug"); + const manifestPath = path.join( + userDataDirectory, + "NativeMessagingHosts", + "com.xswitch.mcp.json" + ); + + const result = runInstaller( + [ + "install", + "--extension-id", + EXTENSION_ID, + `--user-data-dir=${userDataDirectory}`, + ], + { XSWITCH_MCP_INSTALL_DIR: installDirectory } + ); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(manifestPath)).toBe(true); + }); + + it("supports XSWITCH_MCP_USER_DATA_DIR for automation", () => { + const sandbox = createSandbox(); + const installDirectory = path.join(sandbox, "install"); + const userDataDirectory = path.join(sandbox, "Automated Chrome"); + const manifestPath = path.join( + userDataDirectory, + "NativeMessagingHosts", + "com.xswitch.mcp.json" + ); + + const result = runInstaller(["install", "--extension-id", EXTENSION_ID], { + XSWITCH_MCP_INSTALL_DIR: installDirectory, + XSWITCH_MCP_USER_DATA_DIR: userDataDirectory, + }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(manifestPath)).toBe(true); + }); + + it("gives the exact manifest path override highest precedence", () => { + const sandbox = createSandbox(); + const installDirectory = path.join(sandbox, "install"); + const userDataDirectory = path.join(sandbox, "Chrome Debug"); + const manifestPath = path.join(sandbox, "exact", "host.json"); + const userDataManifestPath = path.join( + userDataDirectory, + "NativeMessagingHosts", + "com.xswitch.mcp.json" + ); + + const result = runInstaller( + [ + "install", + "--extension-id", + EXTENSION_ID, + "--user-data-dir", + userDataDirectory, + ], + { + XSWITCH_MCP_INSTALL_DIR: installDirectory, + XSWITCH_MCP_MANIFEST_PATH: manifestPath, + } + ); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(manifestPath)).toBe(true); + expect(fs.existsSync(userDataManifestPath)).toBe(false); + }); + + it("rejects an empty --user-data-dir value", () => { + const sandbox = createSandbox(); + const result = runInstaller( + ["install", "--extension-id", EXTENSION_ID, "--user-data-dir="], + { XSWITCH_MCP_INSTALL_DIR: path.join(sandbox, "install") } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "--user-data-dir requires a non-empty path" + ); + }); }); diff --git a/mcp/README.md b/mcp/README.md index 3767acb..6f28fb0 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -9,6 +9,25 @@ npx --yes xswitch-mcp install --extension-id The installer copies a self-contained runtime to `~/.xswitch/runtime` and registers the Native Messaging host for Chrome. A source checkout is not required. +If Chrome is launched with a custom `--user-data-dir`, pass the same directory to +the installer. Chrome looks for per-user Native Messaging manifests inside that +data directory rather than its default application support directory. + +```bash +npx --yes xswitch-mcp install \ + --extension-id \ + --user-data-dir "/path/to/chrome-user-data" +``` + +The manifest is written to +`/NativeMessagingHosts/com.xswitch.mcp.json`. Pass the user data +root itself, not a profile directory such as `Default` or `Profile 1`. + +For automation, `XSWITCH_MCP_USER_DATA_DIR` provides the same setting. +`XSWITCH_MCP_MANIFEST_PATH` remains available as an exact manifest path override +and takes precedence over both the CLI option and the user data directory +environment variable. + Every write creates a local snapshot in the XSwitch extension first. Failed writes are rolled back automatically, and MCP clients can list or restore the 10 most recent snapshots with `list_xswitch_backups` and `restore_xswitch_backup`. @@ -18,3 +37,6 @@ To remove the Chrome registration: ```bash npx --yes xswitch-mcp uninstall ``` + +Use the same `--user-data-dir` option when removing a registration from a custom +browser data directory. diff --git a/mcp/install-host.js b/mcp/install-host.js index 276739e..8eaa584 100644 --- a/mcp/install-host.js +++ b/mcp/install-host.js @@ -16,6 +16,7 @@ function parseArgs(argv) { const options = { browser: "chrome", extensionId: undefined, + userDataDir: undefined, uninstall: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -24,17 +25,22 @@ function parseArgs(argv) { if (arg === "uninstall") options.uninstall = true; else if (arg === "--browser") options.browser = argv[++index]; else if (arg === "--extension-id") options.extensionId = argv[++index]; - else if (arg === "--uninstall") options.uninstall = true; + else if (arg === "--user-data-dir") options.userDataDir = argv[++index]; + else if (arg.startsWith("--user-data-dir=")) { + options.userDataDir = arg.slice("--user-data-dir=".length); + } else if (arg === "--uninstall") options.uninstall = true; else if (arg === "--help" || arg === "-h") { console.log(`Usage: xswitch-mcp install [options] Options: --browser chrome|chromium|edge Browser to register (default: chrome) --extension-id Installed extension ID (required for install) + --user-data-dir Custom browser --user-data-dir --uninstall Remove the native host registration Examples: npx --yes xswitch-mcp install --extension-id + npx --yes xswitch-mcp install --extension-id --user-data-dir npx --yes xswitch-mcp uninstall`); process.exit(0); } else throw new Error(`Unknown argument: ${arg}`); @@ -48,12 +54,24 @@ Examples: if (options.extensionId && !/^[a-p]{32}$/.test(options.extensionId)) { throw new Error("extension-id must be a 32-character Chrome extension ID"); } + if (options.userDataDir !== undefined && !options.userDataDir) { + throw new Error("--user-data-dir requires a non-empty path"); + } return options; } -function manifestLocation(browser) { +function manifestLocation(browser, userDataDir) { if (process.env.XSWITCH_MCP_MANIFEST_PATH) { - return process.env.XSWITCH_MCP_MANIFEST_PATH; + return path.resolve(process.env.XSWITCH_MCP_MANIFEST_PATH); + } + const customUserDataDirectory = + userDataDir || process.env.XSWITCH_MCP_USER_DATA_DIR; + if (customUserDataDirectory) { + return path.join( + path.resolve(customUserDataDirectory), + "NativeMessagingHosts", + `${HOST_NAME}.json` + ); } if (process.platform === "darwin") { const folders = { @@ -126,7 +144,7 @@ function installRuntime() { } const options = parseArgs(process.argv.slice(2)); -const manifestPath = manifestLocation(options.browser); +const manifestPath = manifestLocation(options.browser, options.userDataDir); if (options.uninstall) { if (fs.existsSync(manifestPath)) fs.unlinkSync(manifestPath); diff --git a/mcp/package.json b/mcp/package.json index d6e2ba3..54786ea 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "xswitch-mcp", - "version": "0.2.3", + "version": "0.3.1", "description": "Local MCP bridge for configuring XSwitch rules with AI clients", "type": "module", "license": "MIT",