@brutalsystems/muster 1.2.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,6 +114,35 @@ What that verification covers is in [Verification](docs/verification.md).
114
114
  | [MCP servers and plugins](docs/mcp.md) | tools for launched agents, and Muster's own MCP server |
115
115
  | [Verification](docs/verification.md) | what is tested, what is checked by hand |
116
116
 
117
+ ## Use with Claude Code
118
+
119
+ 1. **Install the plugin.** Its `muster` skill teaches a Claude session to use
120
+ Muster well: choosing a kind and level, reaching and stopping what it
121
+ launched, and the traps. It is versioned with Muster, so it describes the
122
+ release it came from.
123
+
124
+ ```sh
125
+ claude plugin marketplace add BrutalSystems/muster
126
+ claude plugin install muster@muster
127
+ ```
128
+
129
+ Or, inside a session, `/plugin marketplace add BrutalSystems/muster` and
130
+ then `/plugin install muster@muster`.
131
+
132
+ 2. **Register the MCP server** for the current Claude profile:
133
+
134
+ ```sh
135
+ muster mcp install
136
+ ```
137
+
138
+ It is read-only by default (`list`, `output`), so registering it at user
139
+ scope grants no launch authority. `muster mcp install --allow-launch` adds
140
+ `run`, `stop` and `title`, and every session in that profile can then
141
+ launch and stop sessions. `muster mcp status` shows which you have.
142
+ Details: [MCP installation](docs/mcp.md#mcp-installation).
143
+
144
+ 3. **Restart Claude Code** (or start a new session) to load it.
145
+
117
146
  ## Keeping it up to date
118
147
 
119
148
  Publishing a release does not touch an installed copy: `muster --version` keeps
package/dist/help.js CHANGED
@@ -176,13 +176,22 @@ export const COMMANDS = [
176
176
  },
177
177
  {
178
178
  name: "mcp",
179
- summary: "start the MCP server on stdio",
180
- synopsis: "muster mcp",
181
- options: [],
179
+ summary: "serve MCP on stdio, or register it with Claude Code",
180
+ synopsis: "muster mcp [--allow-launch] | mcp install|uninstall|status",
181
+ options: [
182
+ ["--allow-launch", "also expose run, stop and title (launch authority)"],
183
+ ["--force", "install: replace a different existing registration"],
184
+ ],
182
185
  notes: [
183
- "Exposes run, list, stop and output. No arguments does the same when stdin is not a terminal, which is what an MCP client provides.",
186
+ "Read-only by default: exposes list and output only. --allow-launch is the deliberate grant. No arguments starts the read-only server when stdin is not a terminal, which is what an MCP client provides.",
187
+ "install registers the server with Claude Code at user scope for the current profile (CLAUDE_CONFIG_DIR) via `claude mcp add`. Without --allow-launch it asks at a terminal, defaulting to no; without a terminal it does not grant. Restart Claude Code afterwards.",
188
+ ],
189
+ examples: [
190
+ "muster mcp",
191
+ "muster mcp install",
192
+ "muster mcp install --allow-launch --force",
193
+ "muster mcp status",
184
194
  ],
185
- examples: ["muster mcp"],
186
195
  },
187
196
  ];
188
197
  export function topLevelHelp() {
@@ -204,7 +213,7 @@ export function topLevelHelp() {
204
213
  // Kept here rather than moved to the README: this is a safety statement
205
214
  // about who gets launch authority, not reference detail, and it is only
206
215
  // load-bearing where someone is about to install.
207
- ...wrap("MCP: install deliberately in the one authorized session, never at user scope.", WIDTH),
216
+ ...wrap("MCP: `muster mcp install` registers a read-only server with Claude Code. Launch authority is granted only by --allow-launch.", WIDTH),
208
217
  ];
209
218
  return lines.join("\n") + "\n";
210
219
  }
@@ -6,13 +6,14 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
9
- import { mcpServerSchema, mcpName, mcpTransport } from "./mcp.js";
9
+ import { mcpServerSchema, mcpName, mcpTransport, toolGate } from "./mcp.js";
10
10
  async function main() {
11
11
  const [path, name] = process.argv.slice(2);
12
12
  if (!path || !name || !mcpName.safeParse(name).success)
13
13
  throw new Error("Invalid MCP bridge arguments");
14
14
  const definitions = JSON.parse(await readFile(path, "utf8"));
15
15
  const config = mcpServerSchema.parse(definitions[name]);
16
+ const gate = toolGate(config.tools);
16
17
  const client = new Client({ name: "muster-tool-bridge", version: "1" }, { capabilities: {} });
17
18
  const server = new Server({ name: `muster-${name}`, version: "1" }, { capabilities: { tools: {} } });
18
19
  let shutdown;
@@ -59,13 +60,13 @@ async function main() {
59
60
  let cursor;
60
61
  do {
61
62
  const page = await client.listTools(cursor ? { cursor } : {});
62
- tools.push(...page.tools.filter((t) => config.tools.includes(t.name)));
63
+ tools.push(...gate.expose(page.tools));
63
64
  cursor = page.nextCursor;
64
65
  } while (cursor);
65
66
  return { tools };
66
67
  });
67
68
  server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
68
- if (!config.tools.includes(params.name))
69
+ if (!gate.allows(params.name))
69
70
  return {
70
71
  isError: true,
71
72
  content: [
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `muster mcp install | uninstall | status` — registering Muster's own MCP
3
+ * server with Claude Code at user scope, for the profile `CLAUDE_CONFIG_DIR`
4
+ * names (or the default one).
5
+ *
6
+ * Every write goes through `claude mcp add` / `claude mcp remove`, so Muster
7
+ * never edits Claude's configuration itself. Reading is different: the current
8
+ * definition is read straight from the profile's `.claude.json`, because
9
+ * `claude mcp get` health-checks a server by starting it and reports whichever
10
+ * scope wins, where this needs the user-scope entry exactly as written.
11
+ */
12
+ import { execFile } from "node:child_process";
13
+ import { access, constants, readFile, realpath } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { delimiter, join } from "node:path";
16
+ import { parseArgs } from "node:util";
17
+ import { LAUNCH_TOOLS, mcpToolNames } from "./mcp-tools.js";
18
+ export const SERVER_NAME = "muster";
19
+ export const ALLOW_LAUNCH = "--allow-launch";
20
+ export const LAUNCH_QUESTION = "Let this Claude launch and stop sessions? [y/N] ";
21
+ export const MCP_SUBCOMMANDS = ["install", "uninstall", "status"];
22
+ /** The first executable called `name` on PATH, as PATH spells it. */
23
+ export async function onPath(name, env) {
24
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
25
+ if (!dir)
26
+ continue;
27
+ const candidate = join(dir, name);
28
+ try {
29
+ await access(candidate, constants.X_OK);
30
+ return candidate;
31
+ }
32
+ catch { }
33
+ }
34
+ return undefined;
35
+ }
36
+ /**
37
+ * The command a client should run to reach this muster. The installed `muster`
38
+ * bin when the one on PATH is this very script — its PATH spelling, not its
39
+ * resolved target, so a package-manager link survives an upgrade. Otherwise
40
+ * (a source checkout, or a different install first on PATH) this node and this
41
+ * script, absolutely, so the registration runs what the person just ran.
42
+ */
43
+ export async function selfInvocation(env, script = process.argv[1], node = process.execPath) {
44
+ const resolved = await realpath(script);
45
+ const found = await onPath("muster", env);
46
+ if (found && (await realpath(found).catch(() => undefined)) === resolved)
47
+ return { command: found, args: [] };
48
+ return { command: node, args: [resolved] };
49
+ }
50
+ export function claudeConfigFile(env) {
51
+ return join(env.CLAUDE_CONFIG_DIR || env.HOME || homedir(), ".claude.json");
52
+ }
53
+ /** The user-scope `muster` entry, or undefined when there is none. */
54
+ export async function registered(env) {
55
+ const file = claudeConfigFile(env);
56
+ let raw;
57
+ try {
58
+ raw = await readFile(file, "utf8");
59
+ }
60
+ catch (e) {
61
+ if (e.code === "ENOENT")
62
+ return undefined;
63
+ throw e;
64
+ }
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ }
69
+ catch {
70
+ throw new Error(`Cannot read ${file}: it is not valid JSON`);
71
+ }
72
+ const entry = parsed?.mcpServers?.[SERVER_NAME];
73
+ if (!entry || typeof entry !== "object")
74
+ return undefined;
75
+ return {
76
+ command: typeof entry.command === "string" ? entry.command : "",
77
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
78
+ };
79
+ }
80
+ export function desired(self, allowLaunch) {
81
+ return {
82
+ command: self.command,
83
+ args: [...self.args, "mcp", ...(allowLaunch ? [ALLOW_LAUNCH] : [])],
84
+ };
85
+ }
86
+ const same = (a, b) => a.command === b.command &&
87
+ a.args.length === b.args.length &&
88
+ a.args.every((arg, i) => arg === b.args[i]);
89
+ const quote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : JSON.stringify(s);
90
+ export const render = (d) => [d.command, ...d.args].map(quote).join(" ");
91
+ const launchAllowed = (d) => d.args.includes(ALLOW_LAUNCH);
92
+ function toolsLine(allowLaunch) {
93
+ return allowLaunch
94
+ ? `Tools: ${mcpToolNames(true).join(", ")} — this Claude can launch and stop sessions.`
95
+ : `Tools: ${mcpToolNames(false).join(", ")} — read-only; it cannot launch or stop anything.`;
96
+ }
97
+ async function defaultRunClaude(binary, args, env) {
98
+ return new Promise((resolve) => {
99
+ execFile(binary, args, { env, timeout: 60_000 }, (error, stdout, stderr) => resolve({
100
+ code: error ? (typeof error.code === "number" ? error.code : 1) : 0,
101
+ stdout: String(stdout),
102
+ stderr: String(stderr),
103
+ }));
104
+ });
105
+ }
106
+ async function claude(deps) {
107
+ const binary = await onPath("claude", deps.env);
108
+ if (!binary)
109
+ throw new Error("`claude` is not on PATH. Install Claude Code, or put it on PATH, and run this again.");
110
+ const run = deps.runClaude ?? defaultRunClaude;
111
+ return async (args, what) => {
112
+ const result = await run(binary, args, deps.env);
113
+ if (result.code !== 0)
114
+ throw new Error(`\`claude ${args.join(" ")}\` failed while ${what}: ${(result.stderr || result.stdout).trim() || `exit ${result.code}`}`);
115
+ return result;
116
+ };
117
+ }
118
+ async function install(opts, deps) {
119
+ const run = await claude(deps);
120
+ let allowLaunch = opts.allowLaunch;
121
+ // Default no, and no question at all without a terminal: the grant is made
122
+ // by a person typing the flag or answering yes, never by a script's silence.
123
+ if (!allowLaunch && deps.ask)
124
+ allowLaunch = /^y(es)?$/i.test((await deps.ask(LAUNCH_QUESTION)).trim());
125
+ const want = desired(await deps.self(), allowLaunch);
126
+ const file = claudeConfigFile(deps.env);
127
+ const have = await registered(deps.env);
128
+ if (have && same(have, want)) {
129
+ deps.write(`Already registered at user scope in ${file}; nothing changed.\n ${render(want)}\n${toolsLine(allowLaunch)}\n`);
130
+ return;
131
+ }
132
+ if (have && !opts.force)
133
+ throw new Error(`A different "${SERVER_NAME}" MCP server is already registered at user scope in ${file}:\n` +
134
+ ` have: ${render(have)}\n want: ${render(want)}\n` +
135
+ `Run again with --force to replace it, or \`muster mcp uninstall\` first.`);
136
+ if (have)
137
+ await run(["mcp", "remove", "--scope", "user", SERVER_NAME], "removing the existing definition");
138
+ await run([
139
+ "mcp",
140
+ "add",
141
+ "--scope",
142
+ "user",
143
+ SERVER_NAME,
144
+ "--",
145
+ want.command,
146
+ ...want.args,
147
+ ], "registering the server");
148
+ deps.write(`${have ? "Replaced" : "Registered"} the "${SERVER_NAME}" MCP server at user scope in ${file}.\n` +
149
+ ` ${render(want)}\n${toolsLine(allowLaunch)}\n` +
150
+ "Restart Claude Code (or start a new session) to load it.\n");
151
+ }
152
+ async function uninstall(deps) {
153
+ const run = await claude(deps);
154
+ const file = claudeConfigFile(deps.env);
155
+ if (!(await registered(deps.env))) {
156
+ deps.write(`No "${SERVER_NAME}" MCP server at user scope in ${file}; nothing to remove.\n`);
157
+ return;
158
+ }
159
+ await run(["mcp", "remove", "--scope", "user", SERVER_NAME], "removing the server");
160
+ deps.write(`Removed the "${SERVER_NAME}" MCP server from user scope in ${file}.\n` +
161
+ "Restart Claude Code (or start a new session) to drop it.\n");
162
+ }
163
+ async function status(deps) {
164
+ const file = claudeConfigFile(deps.env);
165
+ const have = await registered(deps.env);
166
+ if (!have) {
167
+ deps.write(`Not registered: no "${SERVER_NAME}" MCP server at user scope in ${file}.\n` +
168
+ "Run `muster mcp install` to add it.\n");
169
+ return;
170
+ }
171
+ const allowed = launchAllowed(have);
172
+ deps.write(`Registered at user scope in ${file}.\n ${render(have)}\n` +
173
+ `Launch allowed: ${allowed ? "yes" : "no"}\n${toolsLine(allowed)}\n`);
174
+ }
175
+ /** `muster mcp <subcommand> ...`, already known to name a subcommand. */
176
+ export async function mcpCommand(argv, deps) {
177
+ const [sub, ...rest] = argv;
178
+ const { values, positionals } = parseArgs({
179
+ args: rest,
180
+ allowPositionals: true,
181
+ strict: true,
182
+ options: {
183
+ "allow-launch": { type: "boolean" },
184
+ force: { type: "boolean" },
185
+ },
186
+ });
187
+ if (positionals.length)
188
+ throw new Error(`mcp ${sub} takes no positional arguments`);
189
+ if (sub !== "install" && (values["allow-launch"] || values.force))
190
+ throw new Error(`--allow-launch and --force apply only to mcp install`);
191
+ if (sub === "install")
192
+ return install({
193
+ allowLaunch: Boolean(values["allow-launch"]),
194
+ force: Boolean(values.force),
195
+ }, deps);
196
+ if (sub === "uninstall")
197
+ return uninstall(deps);
198
+ if (sub === "status")
199
+ return status(deps);
200
+ throw new Error(`Unknown mcp subcommand: ${sub}`);
201
+ }
202
+ /** The refusal for a tool the running server does not expose. */
203
+ export function notEnabled(name) {
204
+ return LAUNCH_TOOLS.includes(name)
205
+ ? `Tool ${name} is not enabled: this Muster MCP server is read-only. Start it with \`muster mcp --allow-launch\` (or register it with \`muster mcp install --allow-launch\`) to expose ${LAUNCH_TOOLS.join(", ")}.`
206
+ : `Unknown tool ${name}`;
207
+ }
package/dist/mcp-tools.js CHANGED
@@ -165,3 +165,20 @@ export const MCP_TOOLS = [
165
165
  * callers may not set the field, not a place to park an oversight.
166
166
  */
167
167
  export const RUN_MCP_OMITTED = [];
168
+ /**
169
+ * What `muster mcp` serves by default: reading what Muster already owns.
170
+ * Nothing here can start, stop or change a run, so registering the server
171
+ * where every Claude session sees it grants no launch authority.
172
+ */
173
+ export const READ_ONLY_TOOLS = ["list", "output"];
174
+ /**
175
+ * Added by `muster mcp --allow-launch`, the deliberate grant. `title` is here
176
+ * rather than above because it changes a running session, and the read-only
177
+ * set is defined as changing nothing.
178
+ */
179
+ export const LAUNCH_TOOLS = ["run", "stop", "title"];
180
+ export function mcpToolNames(allowLaunch) {
181
+ return allowLaunch
182
+ ? [...READ_ONLY_TOOLS, ...LAUNCH_TOOLS]
183
+ : [...READ_ONLY_TOOLS];
184
+ }
package/dist/mcp.js CHANGED
@@ -39,6 +39,21 @@ export const mcpServerSchema = z
39
39
  message: "args, env and env_vars require a stdio server",
40
40
  });
41
41
  });
42
+ /**
43
+ * One allowlist, applied to both halves of an MCP server: what `tools/list`
44
+ * advertises and what `tools/call` will dispatch. Used by the Claude bridge
45
+ * (the operator's selected tools of an upstream server) and by Muster's own
46
+ * server (read-only unless started with `--allow-launch`). Filtering the
47
+ * listing alone is not enough — a client can call a name it was never shown —
48
+ * so every call is checked against the same set.
49
+ */
50
+ export function toolGate(enabled) {
51
+ const names = new Set(enabled);
52
+ return {
53
+ expose: (tools) => tools.filter((tool) => names.has(tool.name)),
54
+ allows: (name) => names.has(name),
55
+ };
56
+ }
42
57
  export function selectMcp(req, config) {
43
58
  // Personal defaults apply to sessions, not unattended one-shot tasks.
44
59
  const names = req.mcp ?? (req.kind === "session" ? config.default_mcp : []);
package/dist/muster.js CHANGED
@@ -12,7 +12,9 @@ import { checkPlugins, npmRegistry } from "./doctor.js";
12
12
  import { loadConfig, musterHome } from "./config.js";
13
13
  import { describeIdentities, interactiveSetup, setupIdentity, } from "./identity-cli.js";
14
14
  import { IdentityError } from "./identity-store.js";
15
- import { MCP_TOOLS } from "./mcp-tools.js";
15
+ import { MCP_TOOLS, mcpToolNames } from "./mcp-tools.js";
16
+ import { toolGate } from "./mcp.js";
17
+ import { ALLOW_LAUNCH, MCP_SUBCOMMANDS, mcpCommand, notEnabled, selfInvocation, } from "./mcp-install.js";
16
18
  import { normalizeOpenFlag, usesTerminalFlag } from "./guard.js";
17
19
  import { commandHelp, topLevelHelp } from "./help.js";
18
20
  import { promptForMissing, terminalReader } from "./prompt.js";
@@ -75,8 +77,28 @@ async function main() {
75
77
  // driven by hand in a terminal to debug it.
76
78
  const bareAtTerminal = argv.length === 0 && Boolean(process.stdin.isTTY);
77
79
  const mcp = !bareAtTerminal && (argv.length === 0 || argv[0] === "mcp");
78
- if (argv[0] === "mcp" && argv.length > 1)
79
- throw new Error("mcp takes no arguments");
80
+ // `muster mcp install|uninstall|status` registers the server with Claude
81
+ // Code rather than serving it. Handled before Muster.create(): none of them
82
+ // touches a run.
83
+ if (argv[0] === "mcp" &&
84
+ MCP_SUBCOMMANDS.includes(argv[1] ?? "")) {
85
+ if (argv.includes("--help") || argv.includes("-h")) {
86
+ process.stdout.write(commandHelp("mcp"));
87
+ return;
88
+ }
89
+ await mcpCommand(argv.slice(1), {
90
+ env: process.env,
91
+ write: (s) => process.stdout.write(s),
92
+ ...(process.stdin.isTTY ? { ask: terminalReader() } : {}),
93
+ self: () => selfInvocation(process.env),
94
+ });
95
+ return;
96
+ }
97
+ // Read-only unless launching is granted by the flag. Bare `muster` down a
98
+ // pipe is the same server, so it is read-only too.
99
+ const allowLaunch = argv[0] === "mcp" && argv[1] === ALLOW_LAUNCH;
100
+ if (argv[0] === "mcp" && argv.length > (allowLaunch ? 2 : 1))
101
+ throw new Error(`mcp takes ${ALLOW_LAUNCH}, or one of ${MCP_SUBCOMMANDS.join(", ")}`);
80
102
  const muster = await Muster.create();
81
103
  let shutdown;
82
104
  const close = () => (shutdown ??= (async () => {
@@ -107,10 +129,16 @@ async function main() {
107
129
  });
108
130
  if (mcp) {
109
131
  const server = new Server({ name: "muster", version }, { capabilities: { tools: {} } });
132
+ const gate = toolGate(mcpToolNames(allowLaunch));
110
133
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
111
- tools: MCP_TOOLS,
134
+ tools: gate.expose(MCP_TOOLS),
112
135
  }));
113
136
  server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
137
+ if (!gate.allows(params.name))
138
+ return {
139
+ content: [{ type: "text", text: notEnabled(params.name) }],
140
+ isError: true,
141
+ };
114
142
  try {
115
143
  const args = params.arguments ?? {};
116
144
  switch (params.name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "1.2.2",
3
+ "version": "2.0.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -21,7 +21,7 @@
21
21
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
22
22
  "prepack": "npm run build",
23
23
  "prepublishOnly": "npm test",
24
- "version": "node scripts/stamp-release-notes.mjs && git add RELEASE_NOTES.md",
24
+ "version": "node scripts/stamp-release-notes.mjs && node scripts/stamp-plugin-version.mjs && git add RELEASE_NOTES.md plugins/claude-code/.claude-plugin/plugin.json",
25
25
  "postversion": "git push origin HEAD --follow-tags"
26
26
  },
27
27
  "dependencies": {