diff --git a/README.md b/README.md index 34f36a1..af58ea6 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,32 @@ butter assets approve --project [--comment ] butter assets deny --project [--reason ] ``` +### MCP (AI clients) + +The ButterStack MCP server (`npx -y butterstack-mcp`) uses the credential `butter auth login` stores, so log in first. Then write the MCP entry into your AI client's config: + +``` +butter mcp install [--opencode] [--claude] [--cursor] [--dry-run] +``` + +With no client flag, every installed client is configured. A client flag limits the install to that client and creates its config file if it does not exist yet. Each client gets the entry in its own schema, which matters because they differ: a Claude-style entry in an OpenCode config stops OpenCode from starting. + +| Flag | Client | Config file | Entry | +|---|---|---|---| +| `--opencode` | OpenCode | `~/.config/opencode/opencode.jsonc` (or `opencode.json`) | `mcp.butterstack` with `type: "local"`, `command: ["npx", "-y", "butterstack-mcp"]`, `enabled: true` | +| `--claude` | Claude Desktop | macOS `~/Library/Application Support/Claude/claude_desktop_config.json`, Windows `%APPDATA%\Claude\claude_desktop_config.json`, Linux `~/.config/Claude/claude_desktop_config.json` | `mcpServers.butterstack` with `command: "npx"`, `args: ["-y", "butterstack-mcp"]` | +| `--cursor` | Cursor | `~/.cursor/mcp.json` | same as Claude Desktop | + +Re-running is safe. Only the `butterstack` entry is ever written; every other key and server is left as it was, and an entry that is already correct is not rewritten. A config file that contains comments is never rewritten, because that would delete the comments; the command prints the entry to paste and the file to paste it into instead. `--dry-run` shows what would change without writing anything. + +Claude Code registers MCP servers itself: + +``` +claude mcp add butterstack -- npx -y butterstack-mcp +``` + +See the [MCP guide](https://butterstack.com/docs/guides/mcp) for other clients. + ## Global options | Flag | Description | diff --git a/bin/butter b/bin/butter index e8b9f20..a61559e 100755 --- a/bin/butter +++ b/bin/butter @@ -356,6 +356,7 @@ async function authLogin(args) { if (exchange.expires_at) { console.log(`${colors.muted}Token expires ${new Date(exchange.expires_at).toLocaleDateString()} -- run ${colors.bold}butter auth login${colors.reset}${colors.muted} again after that to renew.${colors.reset}`); } + if (!args.json) printMcpHint(); console.log(""); process.exit(0); } catch (err) { @@ -862,6 +863,293 @@ async function assetsDeny(args) { } } +// -- MCP client configuration ------------------------------------------------ +// +// The ButterStack MCP server (`npx -y butterstack-mcp`) reads the credential +// `butter auth login` writes, so login is step one of every MCP setup. The +// clients disagree on the config schema, and the Claude shape pasted into +// OpenCode makes OpenCode refuse to start, so each client's entry is built +// here and nowhere else. + +const MCP_SERVER_NAME = "butterstack"; +const MCP_PACKAGE = "butterstack-mcp"; +const MCP_DOCS_URL = "https://butterstack.com/docs/guides/mcp"; + +function printMcpHint() { + console.log(`\n${colors.bold}Connect your AI client to ButterStack:${colors.reset}`); + console.log(` Claude Code: ${colors.accent}claude mcp add ${MCP_SERVER_NAME} -- npx -y ${MCP_PACKAGE}${colors.reset}`); + console.log( + ` OpenCode: ${colors.accent}opencode mcp add ${MCP_SERVER_NAME}${colors.reset} ${colors.muted}(interactive; command: npx -y ${MCP_PACKAGE})${colors.reset}` + ); + console.log( + ` Auto-config: ${colors.accent}butter mcp install${colors.reset} ${colors.muted}(OpenCode, Claude Desktop, Cursor)${colors.reset}` + ); + console.log(` Others: ${MCP_DOCS_URL}`); +} + +// os.homedir() honors $HOME on macOS and Linux, which is what lets the tests +// point every path below at a throwaway directory. +function mcpClients() { + const home = os.homedir(); + let claudeDir; + if (process.platform === "darwin") { + claudeDir = path.join(home, "Library", "Application Support", "Claude"); + } else if (process.platform === "win32") { + claudeDir = path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "Claude"); + } else { + claudeDir = path.join(home, ".config", "Claude"); + } + const opencodeDir = path.join(home, ".config", "opencode"); + const cursorDir = path.join(home, ".cursor"); + const claudeShape = { command: "npx", args: ["-y", MCP_PACKAGE] }; + + return [ + { + flag: "opencode", + name: "OpenCode", + dir: opencodeDir, + // Either may exist; .jsonc wins when both do. A new file is plain JSON. + files: [path.join(opencodeDir, "opencode.jsonc"), path.join(opencodeDir, "opencode.json")], + createAs: path.join(opencodeDir, "opencode.json"), + key: "mcp", + entry: { type: "local", command: ["npx", "-y", MCP_PACKAGE], enabled: true } + }, + { + flag: "claude", + name: "Claude Desktop", + dir: claudeDir, + files: [path.join(claudeDir, "claude_desktop_config.json")], + createAs: path.join(claudeDir, "claude_desktop_config.json"), + key: "mcpServers", + entry: claudeShape + }, + { + flag: "cursor", + name: "Cursor", + dir: cursorDir, + files: [path.join(cursorDir, "mcp.json")], + createAs: path.join(cursorDir, "mcp.json"), + key: "mcpServers", + entry: claudeShape + } + ]; +} + +// Tolerant JSONC reader: strips // and /* */ comments and trailing commas +// (never inside strings), then hands the result to JSON.parse. Reports +// whether any comment was seen, because rewriting such a file would silently +// delete the user's comments. +function parseJsonc(text) { + const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + let hadComments = false; + + // Index just past the string literal that starts at `i`. + const skipString = (s, i) => { + i++; + while (i < s.length && s[i] !== '"') i += s[i] === "\\" ? 2 : 1; + return i + 1; + }; + + let noComments = ""; + for (let i = 0; i < src.length; ) { + const ch = src[i]; + if (ch === '"') { + const end = skipString(src, i); + noComments += src.slice(i, end); + i = end; + } else if (ch === "/" && src[i + 1] === "/") { + hadComments = true; + while (i < src.length && src[i] !== "\n") i++; + } else if (ch === "/" && src[i + 1] === "*") { + hadComments = true; + const end = src.indexOf("*/", i + 2); + if (end === -1) throw new SyntaxError("Unterminated /* comment"); + noComments += " "; + i = end + 2; + } else { + noComments += ch; + i++; + } + } + + let clean = ""; + for (let i = 0; i < noComments.length; ) { + const ch = noComments[i]; + if (ch === '"') { + const end = skipString(noComments, i); + clean += noComments.slice(i, end); + i = end; + continue; + } + if (ch === ",") { + let j = i + 1; + while (j < noComments.length && /\s/.test(noComments[j])) j++; + if (noComments[j] === "}" || noComments[j] === "]") { + i++; + continue; + } + } + clean += ch; + i++; + } + + return { value: JSON.parse(clean), hadComments }; +} + +function isPlainObject(v) { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +// Key order means nothing to any client, so it must not make an existing, +// correct entry look stale. +function sameJson(a, b) { + if (Array.isArray(a) || Array.isArray(b)) { + return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => sameJson(v, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && sameJson(a[k], b[k])); + } + return a === b; +} + +function indent(text, pad) { + return text + .split("\n") + .map((l) => pad + l) + .join("\n"); +} + +// What to paste by hand. If the file already has the section, only the entry +// goes inside it; otherwise the whole section goes at the top level. +function printMcpSnippet(client, data) { + const hasSection = isPlainObject(data) && isPlainObject(data[client.key]); + const snippet = hasSection + ? `"${MCP_SERVER_NAME}": ${JSON.stringify(client.entry, null, 2)}` + : `"${client.key}": ${JSON.stringify({ [MCP_SERVER_NAME]: client.entry }, null, 2)}`; + const where = hasSection ? `inside the existing "${client.key}" object` : "at the top level"; + console.log(` Add this ${where} (with a comma if it is not the last entry):\n`); + console.log(indent(snippet, " ")); + console.log(""); +} + +// Returns "written", "unchanged", "manual" or "error". +function mcpInstallClient(client, { dryRun }) { + const existing = client.files.find((f) => fs.existsSync(f)); + const file = existing || client.createAs; + let data = {}; + let hadComments = false; + + if (existing) { + const text = fs.readFileSync(existing, "utf-8"); + if (text.trim() !== "") { + try { + ({ value: data, hadComments } = parseJsonc(text)); + } catch (err) { + console.error(`${colors.err}✗ ${client.name}: could not parse ${file}: ${err.message}${colors.reset}`); + console.error(` Left it untouched. Fix the file, or edit it by hand:`); + printMcpSnippet(client, null); + return "error"; + } + } + } + + if (!isPlainObject(data) || (data[client.key] !== undefined && !isPlainObject(data[client.key]))) { + const what = isPlainObject(data) ? `"${client.key}" is not an object` : "the top level is not an object"; + console.error(`${colors.err}✗ ${client.name}: ${file}: ${what}. Left it untouched.${colors.reset}`); + return "error"; + } + + const section = data[client.key] || {}; + const previous = section[MCP_SERVER_NAME]; + if (previous !== undefined && sameJson(previous, client.entry)) { + console.log(`${colors.ok}✓ ${client.name}: already configured${colors.reset} ${colors.muted}(${file})${colors.reset}`); + return "unchanged"; + } + + if (hadComments) { + console.log(`${colors.warn}! ${client.name}: ${file} contains comments, which a rewrite would delete.${colors.reset}`); + printMcpSnippet(client, data); + return "manual"; + } + + // Spreading keeps every other key, and keeps an existing entry in place. + const next = { ...data, [client.key]: { ...section, [MCP_SERVER_NAME]: client.entry } }; + + if (dryRun) { + console.log(`${colors.accent}~ ${client.name}: would ${existing ? "update" : "create"} ${file}${colors.reset}`); + console.log(` ${client.key}.${MCP_SERVER_NAME} = ${JSON.stringify(client.entry)}`); + if (previous !== undefined) console.log(` replacing ${JSON.stringify(previous)}`); + return "unchanged"; + } + + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n"); + console.log(`${colors.ok}✓ ${client.name}: ${existing ? "updated" : "created"} ${file}${colors.reset}`); + if (previous !== undefined) { + console.log(` ${colors.muted}Replaced the previous "${MCP_SERVER_NAME}" entry: ${JSON.stringify(previous)}${colors.reset}`); + } + return "written"; +} + +async function mcpInstall(args) { + const clients = mcpClients(); + const requested = clients.filter((c) => args[c.flag]); + const dryRun = Boolean(args["dry-run"]); + + // A client counts as installed when its config directory exists, so a + // fresh Cursor install with no mcp.json yet is still picked up. + const targets = requested.length ? requested : clients.filter((c) => fs.existsSync(c.dir)); + if (targets.length === 0) { + console.error(`${colors.err}✗ No supported AI client found (looked for OpenCode, Claude Desktop, Cursor).${colors.reset}`); + console.error(`Pass --opencode, --claude or --cursor to create a config anyway, or see ${MCP_DOCS_URL}`); + process.exit(1); + } + + if (dryRun) console.log(`${colors.muted}Dry run: nothing will be written.${colors.reset}`); + + const results = targets.map((c) => ({ client: c, status: mcpInstallClient(c, { dryRun }) })); + + const written = results.filter((r) => r.status === "written").map((r) => r.client.name); + if (written.length) { + console.log(`\nRestart ${written.join(", ")} to load the ButterStack MCP server.`); + } + if (!loadCredentials()) { + console.log( + `${colors.muted}The MCP server uses your CLI login. Run ${colors.bold}butter auth login${colors.reset}${colors.muted} if you have not yet.${colors.reset}` + ); + } + + if (results.some((r) => r.status === "error")) process.exit(1); +} + +function printMcpHelp() { + console.log(` +${colors.accent}${colors.bold}butter mcp${colors.reset} +Connect AI clients to the ButterStack MCP server (npx -y ${MCP_PACKAGE}). + +${colors.bold}USAGE:${colors.reset} + butter mcp install [--opencode] [--claude] [--cursor] [--dry-run] + +With no client flag, every installed client is configured. A client flag +limits the install to that client, creating its config file if needed. +Re-running is safe: only the "${MCP_SERVER_NAME}" entry is ever written. + + --opencode ~/.config/opencode/opencode.jsonc (or opencode.json) + --claude Claude Desktop's claude_desktop_config.json + --cursor ~/.cursor/mcp.json + --dry-run Show what would change without writing anything + +A config file containing comments is never rewritten; the entry to paste +is printed instead. + +Claude Code registers servers itself: + claude mcp add ${MCP_SERVER_NAME} -- npx -y ${MCP_PACKAGE} + +Docs: ${MCP_DOCS_URL} +`); +} + // Flags that are switches, never name/value pairs (#1938). // // The parser used to treat every `--flag` as taking a value whenever the next @@ -880,7 +1168,11 @@ const BOOLEAN_FLAGS = new Set([ "yes", "force", "no-color", - "steps" + "steps", + "dry-run", + "opencode", + "claude", + "cursor" ]); // `--key=value` is always explicit, so it is honored for any key including @@ -930,6 +1222,8 @@ ${colors.bold}COMMANDS:${colors.reset} ${colors.bold}investigation${colors.reset} reads the result back for free. ${colors.accent}assets${colors.reset} list | approve | deny Review and approve textures, models, and audio ${colors.bold}show${colors.reset} is coming soon + ${colors.accent}mcp${colors.reset} install Connect OpenCode, Claude Desktop, and Cursor + to the ButterStack MCP server (see: butter mcp) ${colors.bold}GLOBAL OPTIONS:${colors.reset} --project Target project ID (e.g. --project 1) @@ -955,6 +1249,10 @@ async function main() { const cmd = args._[0]; const subcmd = args._[1]; + if ((args.help || args.h) && cmd === "mcp") { + printMcpHelp(); + return; + } if (args.help || args.h || !cmd) { printHelp(); return; @@ -989,6 +1287,10 @@ async function main() { if (subcmd === "approve") return assetsApprove(args); if (subcmd === "deny") return assetsDeny(args); break; + case "mcp": + if (!subcmd) return printMcpHelp(); + if (subcmd === "install") return mcpInstall(args); + break; default: console.error(`${colors.err}Unknown command: ${cmd}${colors.reset}`); printHelp(); @@ -1012,4 +1314,4 @@ if (require.main === module) { }); } -module.exports = { request, loadCredentials, saveCredentials, getHost }; +module.exports = { request, loadCredentials, saveCredentials, getHost, parseJsonc }; diff --git a/test/butter.test.js b/test/butter.test.js index a6d000c..8853559 100644 --- a/test/butter.test.js +++ b/test/butter.test.js @@ -859,3 +859,194 @@ test("without the flag, auth login says it is opening a browser and hands it the rmHome(home); } }); + +// -- #7: MCP client setup ---------------------------------------------------- +// +// Every `mcp install` case runs against a temp HOME, so no test can touch a +// real client config. + +const { parseJsonc } = require(BUTTER_BIN); + +const OPENCODE_ENTRY = { type: "local", command: ["npx", "-y", "butterstack-mcp"], enabled: true }; +const CLAUDE_SHAPE_ENTRY = { command: "npx", args: ["-y", "butterstack-mcp"] }; + +function opencodeDir(home) { + return path.join(home, ".config", "opencode"); +} + +test("#7: parseJsonc strips comments and trailing commas, but never inside strings", () => { + const text = [ + "{", + " // a line comment", + ' "url": "https://example.com/a//b", /* inline */', + ' "glob": "src/**/*.ts",', + ' "quote": "say \\"hi\\" // not a comment",', + ' "comma": ",}",', + ' "list": [1, 2, 3,],', + ' "nested": { "a": 1, },', + " /* a", + " block */", + "}" + ].join("\n"); + const { value, hadComments } = parseJsonc(text); + assert.equal(hadComments, true); + assert.deepEqual(value, { + url: "https://example.com/a//b", + glob: "src/**/*.ts", + quote: 'say "hi" // not a comment', + comma: ",}", + list: [1, 2, 3], + nested: { a: 1 } + }); + + assert.equal(parseJsonc('{"a": [1,],}').hadComments, false, "trailing commas alone are not comments"); + assert.throws(() => parseJsonc('{"a": 1 /* never closed'), /Unterminated/); + assert.throws(() => parseJsonc('{"a": }'), SyntaxError); +}); + +test("#7: mcp install fixes a wrong-shaped OpenCode entry, keeps everything else, and is idempotent", async () => { + const home = mkHome(); + try { + const file = path.join(opencodeDir(home), "opencode.json"); + fs.mkdirSync(opencodeDir(home), { recursive: true }); + // The Claude-shaped entry that makes OpenCode refuse to start. + fs.writeFileSync( + file, + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + theme: "dark", + mcp: { other: { type: "local", command: ["other-mcp"], enabled: true }, butterstack: { command: "butterstack-mcp" } } + }) + ); + + const first = await runButter(["mcp", "install", "--opencode"], { home }); + assert.equal(first.success, true, first.stderr); + assert.ok(stripAnsi(first.stdout).includes(file), `the written file should be printed:\n${first.stdout}`); + + const written = JSON.parse(fs.readFileSync(file, "utf-8")); + assert.deepEqual(written.mcp.butterstack, OPENCODE_ENTRY); + assert.deepEqual(written.mcp.other, { type: "local", command: ["other-mcp"], enabled: true }, "other servers must survive"); + assert.equal(written.theme, "dark"); + assert.equal(written.$schema, "https://opencode.ai/config.json"); + assert.deepEqual(Object.keys(written.mcp), ["other", "butterstack"], "no duplicate, and the entry stays in place"); + + const before = fs.readFileSync(file, "utf-8"); + const second = await runButter(["mcp", "install", "--opencode"], { home }); + assert.equal(second.success, true); + assert.match(stripAnsi(second.stdout), /OpenCode: already configured/); + assert.equal(fs.readFileSync(file, "utf-8"), before, "a re-run must not rewrite the file"); + } finally { + rmHome(home); + } +}); + +test("#7: mcp install --cursor creates ~/.cursor/mcp.json when it does not exist", async () => { + const home = mkHome(); + try { + const file = path.join(home, ".cursor", "mcp.json"); + const { stdout, success } = await runButter(["mcp", "install", "--cursor"], { home }); + assert.equal(success, true); + assert.ok(stripAnsi(stdout).includes(`created ${file}`), stdout); + assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf-8")), { mcpServers: { butterstack: CLAUDE_SHAPE_ENTRY } }); + assert.equal(fs.existsSync(opencodeDir(home)), false, "a client flag limits the install to that client"); + } finally { + rmHome(home); + } +}); + +test("#7: with no flag, mcp install configures only the clients that are installed", async () => { + const home = mkHome(); + try { + fs.mkdirSync(path.join(home, ".cursor")); + const { success } = await runButter(["mcp", "install"], { home }); + assert.equal(success, true); + assert.ok(fs.existsSync(path.join(home, ".cursor", "mcp.json"))); + assert.equal(fs.existsSync(opencodeDir(home)), false, "OpenCode is not installed, so nothing is created for it"); + + } finally { + rmHome(home); + } +}); + +test("#7: with no flag and no client installed, mcp install fails loudly", async () => { + const home = mkHome(); + try { + const { stderr, success } = await runButter(["mcp", "install"], { home }); + assert.equal(success, false); + assert.match(stderr, /No supported AI client found/); + } finally { + rmHome(home); + } +}); + +test("#7: a config with comments is never rewritten; the snippet and path are printed instead", async () => { + const home = mkHome(); + try { + const file = path.join(opencodeDir(home), "opencode.jsonc"); + fs.mkdirSync(opencodeDir(home), { recursive: true }); + const original = '{\n // keep me\n "theme": "dark",\n "mcp": {},\n}\n'; + fs.writeFileSync(file, original); + + const { stdout, success } = await runButter(["mcp", "install", "--opencode"], { home }); + const out = stripAnsi(stdout); + assert.equal(success, true, "printing the snippet is a normal outcome, not a failure"); + assert.equal(fs.readFileSync(file, "utf-8"), original, "the commented file must be left byte-for-byte"); + assert.ok(out.includes(file), `the path to edit should be printed:\n${out}`); + assert.ok(out.includes('inside the existing "mcp" object'), out); + assert.ok(out.includes('"type": "local"') && out.includes('"enabled": true'), `the OpenCode shape should be printed:\n${out}`); + } finally { + rmHome(home); + } +}); + +test("#7: mcp install --dry-run writes nothing", async () => { + const home = mkHome(); + try { + const { stdout, success } = await runButter(["mcp", "install", "--cursor", "--opencode", "--dry-run"], { home }); + assert.equal(success, true); + assert.match(stripAnsi(stdout), /would create .*mcp\.json/); + assert.equal(fs.existsSync(path.join(home, ".cursor")), false); + assert.equal(fs.existsSync(opencodeDir(home)), false); + } finally { + rmHome(home); + } +}); + +async function loginOutput(extraArgs) { + const { server, port } = await startFakeExchangeServer(["ping"]); + const home = mkHome(); + try { + const child = spawn( + process.execPath, + [BUTTER_BIN, "auth", "login", "--host", `http://127.0.0.1:${port}`, ...extraArgs], + { env: buildEnv({ home }) } + ); + const readOut = captureStdout(child); + const parsed = new URL(cleanUrl(await readAuthUrl(child))); + await hitCallback( + `http://127.0.0.1:${parsed.searchParams.get("port")}/callback?code=c&state=${parsed.searchParams.get("state")}` + ); + const { success } = await waitForExit(child); + assert.equal(success, true); + return stripAnsi(readOut()); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +} + +test("#7: a successful login prints how to connect an AI client", async () => { + const out = await loginOutput([]); + assert.ok(out.includes("Successfully authenticated"), out); + assert.ok(out.includes("Connect your AI client to ButterStack:"), out); + assert.ok(out.includes("claude mcp add butterstack -- npx -y butterstack-mcp"), out); + assert.ok(out.includes("opencode mcp add butterstack"), out); + assert.ok(out.includes("butter mcp install"), out); + assert.ok(out.includes("https://butterstack.com/docs/guides/mcp"), out); +}); + +test("#7: the MCP hint is skipped under --json", async () => { + const out = await loginOutput(["--json"]); + assert.ok(out.includes("Successfully authenticated"), out); + assert.ok(!out.includes("Connect your AI client"), out); +});