Skip to content

Commit 46b94bb

Browse files
committed
feat(#7): post-login MCP hints and butter mcp install
After a successful `butter auth login`, print how to connect an AI client to the ButterStack MCP server (Claude Code, OpenCode, the new auto-configurator, and the docs link). The block is skipped under --json. Add `butter mcp install [--opencode] [--claude] [--cursor] [--dry-run]`, which writes the `butterstack` entry into each client's config in that client's own schema. With no flag it configures every client whose config directory exists; a flag limits the install to that client and creates its file if missing. Only the `butterstack` key is written, an already-correct entry is left alone, and a stale one is replaced with the old value printed. OpenCode configs are JSONC, so a small string-aware parser strips comments and trailing commas before JSON.parse. A file that contains comments is never rewritten: the command prints the entry to paste and the file path instead. `butter mcp` alone prints the MCP help.
1 parent 550315c commit 46b94bb

3 files changed

Lines changed: 521 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,32 @@ butter assets approve <asset_id> --project <id> [--comment <text>]
8080
butter assets deny <asset_id> --project <id> [--reason <text>]
8181
```
8282

83+
### MCP (AI clients)
84+
85+
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:
86+
87+
```
88+
butter mcp install [--opencode] [--claude] [--cursor] [--dry-run]
89+
```
90+
91+
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.
92+
93+
| Flag | Client | Config file | Entry |
94+
|---|---|---|---|
95+
| `--opencode` | OpenCode | `~/.config/opencode/opencode.jsonc` (or `opencode.json`) | `mcp.butterstack` with `type: "local"`, `command: ["npx", "-y", "butterstack-mcp"]`, `enabled: true` |
96+
| `--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"]` |
97+
| `--cursor` | Cursor | `~/.cursor/mcp.json` | same as Claude Desktop |
98+
99+
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.
100+
101+
Claude Code registers MCP servers itself:
102+
103+
```
104+
claude mcp add butterstack -- npx -y butterstack-mcp
105+
```
106+
107+
See the [MCP guide](https://butterstack.com/docs/guides/mcp) for other clients.
108+
83109
## Global options
84110

85111
| Flag | Description |

‎bin/butter‎

Lines changed: 304 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ async function authLogin(args) {
356356
if (exchange.expires_at) {
357357
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}`);
358358
}
359+
if (!args.json) printMcpHint();
359360
console.log("");
360361
process.exit(0);
361362
} catch (err) {
@@ -862,6 +863,293 @@ async function assetsDeny(args) {
862863
}
863864
}
864865

866+
// -- MCP client configuration ------------------------------------------------
867+
//
868+
// The ButterStack MCP server (`npx -y butterstack-mcp`) reads the credential
869+
// `butter auth login` writes, so login is step one of every MCP setup. The
870+
// clients disagree on the config schema, and the Claude shape pasted into
871+
// OpenCode makes OpenCode refuse to start, so each client's entry is built
872+
// here and nowhere else.
873+
874+
const MCP_SERVER_NAME = "butterstack";
875+
const MCP_PACKAGE = "butterstack-mcp";
876+
const MCP_DOCS_URL = "https://butterstack.com/docs/guides/mcp";
877+
878+
function printMcpHint() {
879+
console.log(`\n${colors.bold}Connect your AI client to ButterStack:${colors.reset}`);
880+
console.log(` Claude Code: ${colors.accent}claude mcp add ${MCP_SERVER_NAME} -- npx -y ${MCP_PACKAGE}${colors.reset}`);
881+
console.log(
882+
` OpenCode: ${colors.accent}opencode mcp add ${MCP_SERVER_NAME}${colors.reset} ${colors.muted}(interactive; command: npx -y ${MCP_PACKAGE})${colors.reset}`
883+
);
884+
console.log(
885+
` Auto-config: ${colors.accent}butter mcp install${colors.reset} ${colors.muted}(OpenCode, Claude Desktop, Cursor)${colors.reset}`
886+
);
887+
console.log(` Others: ${MCP_DOCS_URL}`);
888+
}
889+
890+
// os.homedir() honors $HOME on macOS and Linux, which is what lets the tests
891+
// point every path below at a throwaway directory.
892+
function mcpClients() {
893+
const home = os.homedir();
894+
let claudeDir;
895+
if (process.platform === "darwin") {
896+
claudeDir = path.join(home, "Library", "Application Support", "Claude");
897+
} else if (process.platform === "win32") {
898+
claudeDir = path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "Claude");
899+
} else {
900+
claudeDir = path.join(home, ".config", "Claude");
901+
}
902+
const opencodeDir = path.join(home, ".config", "opencode");
903+
const cursorDir = path.join(home, ".cursor");
904+
const claudeShape = { command: "npx", args: ["-y", MCP_PACKAGE] };
905+
906+
return [
907+
{
908+
flag: "opencode",
909+
name: "OpenCode",
910+
dir: opencodeDir,
911+
// Either may exist; .jsonc wins when both do. A new file is plain JSON.
912+
files: [path.join(opencodeDir, "opencode.jsonc"), path.join(opencodeDir, "opencode.json")],
913+
createAs: path.join(opencodeDir, "opencode.json"),
914+
key: "mcp",
915+
entry: { type: "local", command: ["npx", "-y", MCP_PACKAGE], enabled: true }
916+
},
917+
{
918+
flag: "claude",
919+
name: "Claude Desktop",
920+
dir: claudeDir,
921+
files: [path.join(claudeDir, "claude_desktop_config.json")],
922+
createAs: path.join(claudeDir, "claude_desktop_config.json"),
923+
key: "mcpServers",
924+
entry: claudeShape
925+
},
926+
{
927+
flag: "cursor",
928+
name: "Cursor",
929+
dir: cursorDir,
930+
files: [path.join(cursorDir, "mcp.json")],
931+
createAs: path.join(cursorDir, "mcp.json"),
932+
key: "mcpServers",
933+
entry: claudeShape
934+
}
935+
];
936+
}
937+
938+
// Tolerant JSONC reader: strips // and /* */ comments and trailing commas
939+
// (never inside strings), then hands the result to JSON.parse. Reports
940+
// whether any comment was seen, because rewriting such a file would silently
941+
// delete the user's comments.
942+
function parseJsonc(text) {
943+
const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
944+
let hadComments = false;
945+
946+
// Index just past the string literal that starts at `i`.
947+
const skipString = (s, i) => {
948+
i++;
949+
while (i < s.length && s[i] !== '"') i += s[i] === "\\" ? 2 : 1;
950+
return i + 1;
951+
};
952+
953+
let noComments = "";
954+
for (let i = 0; i < src.length; ) {
955+
const ch = src[i];
956+
if (ch === '"') {
957+
const end = skipString(src, i);
958+
noComments += src.slice(i, end);
959+
i = end;
960+
} else if (ch === "/" && src[i + 1] === "/") {
961+
hadComments = true;
962+
while (i < src.length && src[i] !== "\n") i++;
963+
} else if (ch === "/" && src[i + 1] === "*") {
964+
hadComments = true;
965+
const end = src.indexOf("*/", i + 2);
966+
if (end === -1) throw new SyntaxError("Unterminated /* comment");
967+
noComments += " ";
968+
i = end + 2;
969+
} else {
970+
noComments += ch;
971+
i++;
972+
}
973+
}
974+
975+
let clean = "";
976+
for (let i = 0; i < noComments.length; ) {
977+
const ch = noComments[i];
978+
if (ch === '"') {
979+
const end = skipString(noComments, i);
980+
clean += noComments.slice(i, end);
981+
i = end;
982+
continue;
983+
}
984+
if (ch === ",") {
985+
let j = i + 1;
986+
while (j < noComments.length && /\s/.test(noComments[j])) j++;
987+
if (noComments[j] === "}" || noComments[j] === "]") {
988+
i++;
989+
continue;
990+
}
991+
}
992+
clean += ch;
993+
i++;
994+
}
995+
996+
return { value: JSON.parse(clean), hadComments };
997+
}
998+
999+
function isPlainObject(v) {
1000+
return v !== null && typeof v === "object" && !Array.isArray(v);
1001+
}
1002+
1003+
// Key order means nothing to any client, so it must not make an existing,
1004+
// correct entry look stale.
1005+
function sameJson(a, b) {
1006+
if (Array.isArray(a) || Array.isArray(b)) {
1007+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => sameJson(v, b[i]));
1008+
}
1009+
if (isPlainObject(a) && isPlainObject(b)) {
1010+
const keys = Object.keys(a);
1011+
return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && sameJson(a[k], b[k]));
1012+
}
1013+
return a === b;
1014+
}
1015+
1016+
function indent(text, pad) {
1017+
return text
1018+
.split("\n")
1019+
.map((l) => pad + l)
1020+
.join("\n");
1021+
}
1022+
1023+
// What to paste by hand. If the file already has the section, only the entry
1024+
// goes inside it; otherwise the whole section goes at the top level.
1025+
function printMcpSnippet(client, data) {
1026+
const hasSection = isPlainObject(data) && isPlainObject(data[client.key]);
1027+
const snippet = hasSection
1028+
? `"${MCP_SERVER_NAME}": ${JSON.stringify(client.entry, null, 2)}`
1029+
: `"${client.key}": ${JSON.stringify({ [MCP_SERVER_NAME]: client.entry }, null, 2)}`;
1030+
const where = hasSection ? `inside the existing "${client.key}" object` : "at the top level";
1031+
console.log(` Add this ${where} (with a comma if it is not the last entry):\n`);
1032+
console.log(indent(snippet, " "));
1033+
console.log("");
1034+
}
1035+
1036+
// Returns "written", "unchanged", "manual" or "error".
1037+
function mcpInstallClient(client, { dryRun }) {
1038+
const existing = client.files.find((f) => fs.existsSync(f));
1039+
const file = existing || client.createAs;
1040+
let data = {};
1041+
let hadComments = false;
1042+
1043+
if (existing) {
1044+
const text = fs.readFileSync(existing, "utf-8");
1045+
if (text.trim() !== "") {
1046+
try {
1047+
({ value: data, hadComments } = parseJsonc(text));
1048+
} catch (err) {
1049+
console.error(`${colors.err}✗ ${client.name}: could not parse ${file}: ${err.message}${colors.reset}`);
1050+
console.error(` Left it untouched. Fix the file, or edit it by hand:`);
1051+
printMcpSnippet(client, null);
1052+
return "error";
1053+
}
1054+
}
1055+
}
1056+
1057+
if (!isPlainObject(data) || (data[client.key] !== undefined && !isPlainObject(data[client.key]))) {
1058+
const what = isPlainObject(data) ? `"${client.key}" is not an object` : "the top level is not an object";
1059+
console.error(`${colors.err}✗ ${client.name}: ${file}: ${what}. Left it untouched.${colors.reset}`);
1060+
return "error";
1061+
}
1062+
1063+
const section = data[client.key] || {};
1064+
const previous = section[MCP_SERVER_NAME];
1065+
if (previous !== undefined && sameJson(previous, client.entry)) {
1066+
console.log(`${colors.ok}✓ ${client.name}: already configured${colors.reset} ${colors.muted}(${file})${colors.reset}`);
1067+
return "unchanged";
1068+
}
1069+
1070+
if (hadComments) {
1071+
console.log(`${colors.warn}! ${client.name}: ${file} contains comments, which a rewrite would delete.${colors.reset}`);
1072+
printMcpSnippet(client, data);
1073+
return "manual";
1074+
}
1075+
1076+
// Spreading keeps every other key, and keeps an existing entry in place.
1077+
const next = { ...data, [client.key]: { ...section, [MCP_SERVER_NAME]: client.entry } };
1078+
1079+
if (dryRun) {
1080+
console.log(`${colors.accent}~ ${client.name}: would ${existing ? "update" : "create"} ${file}${colors.reset}`);
1081+
console.log(` ${client.key}.${MCP_SERVER_NAME} = ${JSON.stringify(client.entry)}`);
1082+
if (previous !== undefined) console.log(` replacing ${JSON.stringify(previous)}`);
1083+
return "unchanged";
1084+
}
1085+
1086+
fs.mkdirSync(path.dirname(file), { recursive: true });
1087+
fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
1088+
console.log(`${colors.ok}✓ ${client.name}: ${existing ? "updated" : "created"} ${file}${colors.reset}`);
1089+
if (previous !== undefined) {
1090+
console.log(` ${colors.muted}Replaced the previous "${MCP_SERVER_NAME}" entry: ${JSON.stringify(previous)}${colors.reset}`);
1091+
}
1092+
return "written";
1093+
}
1094+
1095+
async function mcpInstall(args) {
1096+
const clients = mcpClients();
1097+
const requested = clients.filter((c) => args[c.flag]);
1098+
const dryRun = Boolean(args["dry-run"]);
1099+
1100+
// A client counts as installed when its config directory exists, so a
1101+
// fresh Cursor install with no mcp.json yet is still picked up.
1102+
const targets = requested.length ? requested : clients.filter((c) => fs.existsSync(c.dir));
1103+
if (targets.length === 0) {
1104+
console.error(`${colors.err}✗ No supported AI client found (looked for OpenCode, Claude Desktop, Cursor).${colors.reset}`);
1105+
console.error(`Pass --opencode, --claude or --cursor to create a config anyway, or see ${MCP_DOCS_URL}`);
1106+
process.exit(1);
1107+
}
1108+
1109+
if (dryRun) console.log(`${colors.muted}Dry run: nothing will be written.${colors.reset}`);
1110+
1111+
const results = targets.map((c) => ({ client: c, status: mcpInstallClient(c, { dryRun }) }));
1112+
1113+
const written = results.filter((r) => r.status === "written").map((r) => r.client.name);
1114+
if (written.length) {
1115+
console.log(`\nRestart ${written.join(", ")} to load the ButterStack MCP server.`);
1116+
}
1117+
if (!loadCredentials()) {
1118+
console.log(
1119+
`${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}`
1120+
);
1121+
}
1122+
1123+
if (results.some((r) => r.status === "error")) process.exit(1);
1124+
}
1125+
1126+
function printMcpHelp() {
1127+
console.log(`
1128+
${colors.accent}${colors.bold}butter mcp${colors.reset}
1129+
Connect AI clients to the ButterStack MCP server (npx -y ${MCP_PACKAGE}).
1130+
1131+
${colors.bold}USAGE:${colors.reset}
1132+
butter mcp install [--opencode] [--claude] [--cursor] [--dry-run]
1133+
1134+
With no client flag, every installed client is configured. A client flag
1135+
limits the install to that client, creating its config file if needed.
1136+
Re-running is safe: only the "${MCP_SERVER_NAME}" entry is ever written.
1137+
1138+
--opencode ~/.config/opencode/opencode.jsonc (or opencode.json)
1139+
--claude Claude Desktop's claude_desktop_config.json
1140+
--cursor ~/.cursor/mcp.json
1141+
--dry-run Show what would change without writing anything
1142+
1143+
A config file containing comments is never rewritten; the entry to paste
1144+
is printed instead.
1145+
1146+
Claude Code registers servers itself:
1147+
claude mcp add ${MCP_SERVER_NAME} -- npx -y ${MCP_PACKAGE}
1148+
1149+
Docs: ${MCP_DOCS_URL}
1150+
`);
1151+
}
1152+
8651153
// Flags that are switches, never name/value pairs (#1938).
8661154
//
8671155
// The parser used to treat every `--flag` as taking a value whenever the next
@@ -880,7 +1168,11 @@ const BOOLEAN_FLAGS = new Set([
8801168
"yes",
8811169
"force",
8821170
"no-color",
883-
"steps"
1171+
"steps",
1172+
"dry-run",
1173+
"opencode",
1174+
"claude",
1175+
"cursor"
8841176
]);
8851177

8861178
// `--key=value` is always explicit, so it is honored for any key including
@@ -930,6 +1222,8 @@ ${colors.bold}COMMANDS:${colors.reset}
9301222
${colors.bold}investigation${colors.reset} reads the result back for free.
9311223
${colors.accent}assets${colors.reset} list | approve | deny Review and approve textures, models, and audio
9321224
${colors.bold}show${colors.reset} is coming soon
1225+
${colors.accent}mcp${colors.reset} install Connect OpenCode, Claude Desktop, and Cursor
1226+
to the ButterStack MCP server (see: butter mcp)
9331227
9341228
${colors.bold}GLOBAL OPTIONS:${colors.reset}
9351229
--project <id> Target project ID (e.g. --project 1)
@@ -955,6 +1249,10 @@ async function main() {
9551249
const cmd = args._[0];
9561250
const subcmd = args._[1];
9571251

1252+
if ((args.help || args.h) && cmd === "mcp") {
1253+
printMcpHelp();
1254+
return;
1255+
}
9581256
if (args.help || args.h || !cmd) {
9591257
printHelp();
9601258
return;
@@ -989,6 +1287,10 @@ async function main() {
9891287
if (subcmd === "approve") return assetsApprove(args);
9901288
if (subcmd === "deny") return assetsDeny(args);
9911289
break;
1290+
case "mcp":
1291+
if (!subcmd) return printMcpHelp();
1292+
if (subcmd === "install") return mcpInstall(args);
1293+
break;
9921294
default:
9931295
console.error(`${colors.err}Unknown command: ${cmd}${colors.reset}`);
9941296
printHelp();
@@ -1012,4 +1314,4 @@ if (require.main === module) {
10121314
});
10131315
}
10141316

1015-
module.exports = { request, loadCredentials, saveCredentials, getHost };
1317+
module.exports = { request, loadCredentials, saveCredentials, getHost, parseJsonc };

0 commit comments

Comments
 (0)