Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 168 additions & 4 deletions __tests__/mcp-installer.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
) {
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=<path> 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"
);
});
});
22 changes: 22 additions & 0 deletions mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ npx --yes xswitch-mcp install --extension-id <your-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 <your-extension-id> \
--user-data-dir "/path/to/chrome-user-data"
```

The manifest is written to
`<user-data-dir>/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`.
Expand All @@ -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.
26 changes: 22 additions & 4 deletions mcp/install-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 <id> Installed extension ID (required for install)
--user-data-dir <path> Custom browser --user-data-dir
--uninstall Remove the native host registration

Examples:
npx --yes xswitch-mcp install --extension-id <extension-id>
npx --yes xswitch-mcp install --extension-id <extension-id> --user-data-dir <path>
npx --yes xswitch-mcp uninstall`);
process.exit(0);
} else throw new Error(`Unknown argument: ${arg}`);
Expand All @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion mcp/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading