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
41 changes: 37 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,47 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run check

# `check` only ever sees the source tree. This packs the tarball, installs it elsewhere, and drives
# the installed binary — the only way to catch a `files` allowlist or import-resolution regression.
smoke:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun run smoke
- run: mkdir artifacts
- run: bun pm pack --destination artifacts
- run: bun build scripts/smoke.ts --target=node --format=esm --outfile=artifacts/smoke.mjs
- uses: actions/upload-artifact@v4
with:
name: package
path: artifacts

smoke:
name: smoke (${{ matrix.runtime }})
needs: package
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- runtime: Bun
runner: bunx
command: bun
- runtime: Node
runner: npx
command: node
steps:
- uses: oven-sh/setup-bun@v2
if: matrix.runner == 'bunx'
with:
bun-version: latest
- uses: actions/setup-node@v7
if: matrix.runner == 'npx'
with:
node-version: "22"
- uses: actions/download-artifact@v4
with:
name: package
path: artifacts
- run: ${{ matrix.command }} artifacts/smoke.mjs ${{ matrix.runner }} artifacts
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store
Expand Down
19 changes: 6 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ src/
session.ts on-disk session store (mode 0600)
cookies.ts cookie-jar merge + cURL parsing
ingest.ts normalize creds → verify → persist (+ FORKABLE_COOKIE provisioning)
chrome.ts macOS Chrome cookie decryption
chrome.ts browser cookie import
cli.ts `bun run auth`
login.ts email/password `createSession` login
order/ ordering domain (pure)
Expand All @@ -51,18 +51,11 @@ a session one of:
- **Email/password** (`auth/login.ts`): `bun run auth --login` (`--email`/`--password`/`--mfa`) or
`FORKABLE_EMAIL`/`FORKABLE_PASSWORD` (+ `FORKABLE_MFA`) env. Logs in via the `createSession` mutation;
works headless. A public `identities` pre-check fails fast on SSO-only accounts. Password-capable only.
- **Browser cookie**: `bun run auth --chrome` (macOS Keychain-decrypts the local browser cookie; `--browser`
picks any value in `SUPPORTED_BROWSERS`), `FORKABLE_COOKIE` env, or `bun run auth --file <path>` /
`pbpaste | bun run auth`.

`chrome.ts` searches **every** profile, not just `Default`: `discoverProfiles` unions `Default`, the
dirs in `Local State`'s `profile.info_cache`, any sibling dir holding a `Cookies` DB, and the
user-data root itself (Opera keeps `Cookies` there). Labels come from `info_cache` or the profile's
own `Preferences` (`profile.name`) — Arc doesn't keep `info_cache` current. `pickProfileJar` then
takes the profile whose `_easyorder_session` has the newest `last_access_utc`, so a logged-out
`Default` can't shadow a live `Profile 1`; `--profile <dir>` pins one. Note Arc nests profiles one
level deeper (`Arc/User Data/<Profile>`), and all Google Chrome channels share the single
`Chrome Safe Storage` Keychain account, so `BrowserSpec.label` carries the display name separately.
- **Browser cookie**: `bun run auth --chrome` uses `@steipete/sweet-cookie` to read Chrome and Edge
profiles on macOS, Linux, and Windows. Arc targeting is macOS-only; Brave and Chromium on Linux or
Windows may need an explicit `--profile` path. Matching session candidates are verified until one
succeeds, and the operating system may prompt for credential-store access. `FORKABLE_COOKIE`,
`bun run auth --file <path>`, and `pbpaste | bun run auth` provide manual alternatives.

On startup with no session, `provisionFromEnvIfNeeded` establishes one from env (cookie first, else
email/password). The session is stored at `~/.forkable-mcp/session.json` (mode `0600`, never logged); the
Expand Down
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 9 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@
"bugs": {
"url": "https://github.com/colinds/forkable-mcp/issues"
},
"keywords": ["mcp", "model-context-protocol", "forkable", "lunch", "bun"],
"keywords": ["mcp", "model-context-protocol", "forkable", "lunch", "bun", "node"],
"bin": {
"forkable-mcp": "src/index.ts"
"forkable-mcp": "dist/index.js"
},
"files": ["src", "skills", "tsconfig.json", "README.md", "LICENSE"],
"files": ["dist", "skills", "README.md", "LICENSE"],
"engines": {
"bun": ">=1.3.0"
"node": ">=22"
},
"scripts": {
"build": "bun build src/index.ts --target=node --format=esm --packages=external --outfile=dist/index.js",
"prepack": "bun run build",
"start": "bun run src/index.ts",
"dev": "bun --watch src/index.ts",
"auth": "bun run src/index.ts --auth",
Expand All @@ -38,6 +40,9 @@
"dependencies": {
"@modelcontextprotocol/server": "^2.0.0",
"@modelcontextprotocol/core": "^2.0.0",
"@steipete/sweet-cookie": "^0.4.1",
"cookie": "^2.0.1",
"set-cookie-parser": "^3.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
136 changes: 86 additions & 50 deletions scripts/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@

import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { mkdtemp, rm } from "node:fs/promises";
import { execFile } from "node:child_process";
import { access, mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import pkg from "../package.json" with { type: "json" };

const exec = promisify(execFile);

const EXPECTED_TOOLS = [
"confirm_delivery",
"explain_pick",
Expand All @@ -32,81 +36,113 @@ const EXPECTED_TOOLS = [
];

const log = (msg: string) => console.log(` ${msg}`);
type Runner = "bunx" | "npx";

function fail(msg: string): never {
console.error(`\n✗ ${msg}`);
process.exit(1);
}

async function run(cmd: string[], cwd: string): Promise<void> {
const p = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
const [out, err, code] = await Promise.all([
new Response(p.stdout).text(),
new Response(p.stderr).text(),
p.exited,
]);
if (code !== 0) fail(`\`${cmd.join(" ")}\` exited ${code}\n${err || out}`);
const [command, ...args] = cmd;
try {
await exec(command!, args, { cwd });
} catch (error) {
const result = error as Error & { code?: number; stdout?: string; stderr?: string };
fail(
`\`${cmd.join(" ")}\` exited ${result.code ?? "unknown"}\n${result.stderr || result.stdout || result.message}`,
);
}
}

const tmp = await mkdtemp(join(tmpdir(), "forkable-smoke-"));
try {
log(`packing ${pkg.name}@${pkg.version}`);
await run(["bun", "pm", "pack", "--destination", tmp], process.cwd());
const tgz = [...new Bun.Glob("*.tgz").scanSync(tmp)][0];
if (!tgz) fail("bun pm pack produced no tarball");

const consumer = join(tmp, "consumer");
await Bun.write(
join(consumer, "package.json"),
JSON.stringify({ name: "smoke-consumer", private: true }),
async function exists(path: string): Promise<boolean> {
return access(path).then(
() => true,
() => false,
);
}

log(`installing ${tgz} into a scratch project`);
await run(["bun", "add", join(tmp, tgz)], consumer);

const bin = join(consumer, "node_modules", ".bin", "forkable-mcp");
if (!(await Bun.file(bin).exists())) fail(`no binary at ${bin} — check package.json "bin"`);
async function findTarball(path: string): Promise<string> {
const input = resolve(path);
if ((await stat(input)).isFile()) return input;
const file = (await readdir(input)).find((name) => name.endsWith(".tgz"));
if (!file) fail(`no package tarball found in ${input}`);
return join(input, file);
}

log("connecting a real MCP client to the installed binary");
const client = new Client({ name: "smoke", version: "0" });
async function checkInstalled(runner: Runner, cwd: string, home: string): Promise<void> {
log(`connecting with ${runner}`);
const client = new Client({ name: `smoke-${runner}`, version: "0" });
const transport = new StdioClientTransport({
command: bin,
cwd: consumer,
env: { PATH: process.env.PATH ?? "", FORKABLE_MCP_HOME: join(tmp, "home") },
command: runner,
args: runner === "bunx" ? ["--bun", "forkable-mcp"] : ["forkable-mcp"],
cwd,
env: { PATH: process.env.PATH ?? "", FORKABLE_MCP_HOME: home },
stderr: "pipe",
});
// A startup crash surfaces as a bare "Connection closed", so keep the child's stderr to report
// the actual cause. The stream only exists once connect() has started the transport.
const connecting = client.connect(transport);
let childErr = "";
transport.stderr?.on("data", (d: Buffer) => {
childErr += d.toString();
});
const timer = setTimeout(() => fail("timed out connecting — the server never came up"), 30_000);
await connecting.catch((e: Error) => fail(`connect failed: ${e.message}\n${childErr.trim()}`));
const timer = setTimeout(() => fail(`timed out connecting with ${runner}`), 30_000);
await connecting.catch((e: Error) =>
fail(`${runner} connect failed: ${e.message}\n${childErr.trim()}`),
);
clearTimeout(timer);

const version = client.getServerVersion()?.version;
if (version !== pkg.version)
fail(`server reports v${version}, package.json says v${pkg.version}`);
log(`serverInfo.version = ${version}`);

const names = (await client.listTools()).tools.map((t) => t.name).toSorted();
const missing = EXPECTED_TOOLS.filter((t) => !names.includes(t));
const extra = names.filter((t) => !EXPECTED_TOOLS.includes(t));
if (missing.length) fail(`missing tools: ${missing.join(", ")}`);
if (extra.length) fail(`unexpected tools (update EXPECTED_TOOLS?): ${extra.join(", ")}`);
log(`${names.length} tools registered`);

// Prove a tool actually dispatches. Unauthenticated, so the re-auth message is the pass condition —
// what matters is that the handler ran instead of the process falling over.
fail(`${runner} server reports v${version}, package.json says v${pkg.version}`);
log(`${runner} serverInfo.version = ${version}`);

const names = (await client.listTools()).tools.map((tool) => tool.name).toSorted();
const missing = EXPECTED_TOOLS.filter((tool) => !names.includes(tool));
const extra = names.filter((tool) => !EXPECTED_TOOLS.includes(tool));
if (missing.length) fail(`${runner} missing tools: ${missing.join(", ")}`);
if (extra.length) fail(`${runner} unexpected tools: ${extra.join(", ")}`);
log(`${runner} registered ${names.length} tools`);

const res: any = await client.callTool({ name: "get_profile", arguments: {} });
const text = (res.content ?? []).map((c: any) => c.text ?? "").join("");
if (!text.trim()) fail("get_profile returned no content");
log(`get_profile responded (${text.split("\n")[0].slice(0, 60)}…)`);
const text = (res.content ?? []).map((content: any) => content.text ?? "").join("");
if (!text.trim()) fail(`${runner}: get_profile returned no content`);
log(`${runner} get_profile responded (${text.split("\n")[0].slice(0, 60)}…)`);

await client.close();
console.log("\n✓ packaged install works");
}

async function checkPackage(runner: Runner, root: string, tarball: string): Promise<void> {
const consumer = join(root, `consumer-${runner}`);
await mkdir(consumer, { recursive: true });
await writeFile(join(consumer, "package.json"), JSON.stringify({ private: true }));

log(`installing with ${runner === "bunx" ? "bun" : "npm"}`);
if (runner === "bunx") await run(["bun", "add", tarball], consumer);
else await run(["npm", "install", "--cache", join(root, "npm-cache"), tarball], consumer);

const bin = join(consumer, "node_modules", ".bin", "forkable-mcp");
if (!(await exists(bin))) fail(`no binary at ${bin} — check package.json "bin"`);
await checkInstalled(runner, consumer, join(root, `home-${runner}`));
}

const tmp = await mkdtemp(join(tmpdir(), "forkable-smoke-"));
try {
const requested = process.argv[2];
if (requested && requested !== "bunx" && requested !== "npx") {
fail(`unknown runner ${requested}; expected bunx or npx`);
}
const runners: Runner[] =
requested === "bunx" || requested === "npx" ? [requested] : ["bunx", "npx"];
let tarball: string;
if (process.argv[3]) {
tarball = await findTarball(process.argv[3]);
} else {
log(`packing ${pkg.name}@${pkg.version}`);
await run(["bun", "pm", "pack", "--destination", tmp], process.cwd());
tarball = await findTarball(tmp);
}
await Promise.all(runners.map((runner) => checkPackage(runner, tmp, tarball)));
console.log(`\n✓ packaged install works with ${runners.join(" and ")}`);
} finally {
await rm(tmp, { recursive: true, force: true });
}
Loading
Loading