@brutalsystems/muster 1.2.1 → 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 +29 -0
- package/dist/guard.js +15 -8
- package/dist/help.js +15 -6
- package/dist/mcp-bridge.js +4 -3
- package/dist/mcp-install.js +207 -0
- package/dist/mcp-tools.js +17 -0
- package/dist/mcp.js +15 -0
- package/dist/muster.js +32 -4
- package/package.json +2 -2
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/guard.js
CHANGED
|
@@ -428,14 +428,21 @@ export function runtimeArgs(req) {
|
|
|
428
428
|
return result;
|
|
429
429
|
}
|
|
430
430
|
/**
|
|
431
|
-
* The keys of a profile's own settings.json that name its skill set
|
|
432
|
-
* its policy. `--setting-sources ""` is what keeps
|
|
433
|
-
* `hooks` muster's alone — forwarding those would
|
|
434
|
-
* what `assertClaudePolicy` refuses to take on
|
|
435
|
-
* carried across, and muster's keys are spread after
|
|
431
|
+
* The keys of a profile's own settings.json that name its skill set or its
|
|
432
|
+
* layout rather than its policy. `--setting-sources ""` is what keeps
|
|
433
|
+
* `permissions`, `sandbox` and `hooks` muster's alone — forwarding those would
|
|
434
|
+
* let a settings file re-widen what `assertClaudePolicy` refuses to take on
|
|
435
|
+
* trust — so only these are carried across, and muster's keys are spread after
|
|
436
|
+
* them and win regardless. `tui` is how a watched session is drawn (#58).
|
|
437
|
+
* `statusLine` is deliberately absent: Claude Code runs no status line while
|
|
438
|
+
* `disableAllHooks` is true, so it would be inert until that changes.
|
|
436
439
|
*/
|
|
437
|
-
const forwardedSettings = [
|
|
438
|
-
|
|
440
|
+
const forwardedSettings = [
|
|
441
|
+
"enabledPlugins",
|
|
442
|
+
"extraKnownMarketplaces",
|
|
443
|
+
"tui",
|
|
444
|
+
];
|
|
445
|
+
function profileForwardedSettings(configDir) {
|
|
439
446
|
let parsed;
|
|
440
447
|
try {
|
|
441
448
|
parsed = JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8"));
|
|
@@ -573,7 +580,7 @@ musterHome = defaultMusterHome(env)) {
|
|
|
573
580
|
// write root ends up naming a directory nobody is launching in.
|
|
574
581
|
const configDir = claudeConfigDir(env);
|
|
575
582
|
const settings = {
|
|
576
|
-
...
|
|
583
|
+
...profileForwardedSettings(configDir),
|
|
577
584
|
disableAllHooks: true,
|
|
578
585
|
// `--setting-sources ""` means an acceptance recorded in the profile's
|
|
579
586
|
// settings.json is never read, so without this the bypass warning stops
|
package/dist/help.js
CHANGED
|
@@ -176,13 +176,22 @@ export const COMMANDS = [
|
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
name: "mcp",
|
|
179
|
-
summary: "
|
|
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
|
-
"
|
|
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
|
|
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
|
}
|
package/dist/mcp-bridge.js
CHANGED
|
@@ -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(...
|
|
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 (!
|
|
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
|
-
|
|
79
|
-
|
|
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": "
|
|
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": {
|