@brutalsystems/muster 0.5.0 → 0.6.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
@@ -91,7 +91,10 @@ the resolved `terminal` (a launch event, not a live window-status check).
91
91
  Closing the Terminal window leaves the session running; use `muster stop <id>`
92
92
  to stop it. Terminal.app and iTerm2 may require macOS Automation permission.
93
93
  Ghostty opens a separate app instance that exits when its last window closes;
94
- it uses the [Ghostty macOS launch interface](https://github.com/ghostty-org/ghostty/discussions/9221).
94
+ that instance disables saved-window restoration so previous tabs and windows
95
+ are not duplicated. It uses the [Ghostty macOS launch interface](https://github.com/ghostty-org/ghostty/discussions/9221).
96
+ Terminal.app creates the requested window before activation
97
+ to avoid also opening a default shell window on startup.
95
98
  iTerm2 uses its [documented scripting interface](https://iterm2.com/documentation-scripting.html).
96
99
  If the launch request to the viewer fails,
97
100
  Muster stops the launched session and returns an error.
@@ -99,9 +102,10 @@ Muster stops the launched session and returns an error.
99
102
  Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
100
103
  and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
101
104
 
102
- - **tmux:** a window in the dedicated `muster` session on the `muster` tmux
103
- server. Watchable and attachable; survives the CLI or MCP server exiting.
104
- Use the returned attach hint, or `tmux -L muster attach -t muster`.
105
+ - **tmux:** each agent has its own session on the dedicated `muster` tmux
106
+ server. Opening or exiting one agent does not switch another agent's viewer.
107
+ Watchable and attachable; survives the CLI or MCP server exiting.
108
+ Use the returned attach hint to reconnect to that agent.
105
109
  - **pty:** not watchable or attachable. The CLI prints the peer record and stays
106
110
  running to own the terminal. Ctrl-C stops it. MCP-owned pty sessions stop when
107
111
  the MCP server disconnects. There is no persistent pty daemon.
@@ -124,6 +128,7 @@ max_concurrent = 4
124
128
  permissions = "deny"
125
129
  sandbox = "read-only"
126
130
  allow_dangerous_flags = false
131
+ default_mcp = []
127
132
  ```
128
133
 
129
134
  Choose `sandbox = "workspace-write"` yourself when agents should edit files.
@@ -174,8 +179,8 @@ The concurrency cap is shared by separate CLI/MCP processes, including pending
174
179
  launches. Settings apply to both kinds; tasks are read-only by default.
175
180
 
176
181
  Codex defaults to an explicit sandbox and never-ask approval policy. Muster enumerates
177
- and explicitly disables inherited MCP servers, then verifies the effective
178
- configuration. Hooks, plugins, app connectors, automatic skill-MCP installation
182
+ and explicitly disables inherited MCP servers, enables only the selected
183
+ operator definitions, then verifies the effective server selection. Hooks, plugins, app connectors, automatic skill-MCP installation
179
184
  and external notifications are disabled for the child. Unknown MCP names that
180
185
  cannot be addressed safely are refused.
181
186
 
@@ -189,9 +194,10 @@ arrive after startup; this v1 is not an enterprise policy-enforcement layer.
189
194
  Claude permissions and its command sandbox are different mechanisms; neither
190
195
  claim implies that every external tool is OS-sandboxed.
191
196
 
192
- These child restrictions deliberately prevent propagating spawn authority.
193
- Custom MCP tools—including Tin Can—are not automatically injected into the
194
- child. The child's runtime inbox remains reachable from an external Tin Can.
197
+ These child restrictions prevent automatically propagating spawn authority.
198
+ External tools are enabled only through the operator configuration and launch
199
+ selection described below. The child's runtime inbox remains reachable from an
200
+ external Tin Can even when it has no MCP tools of its own.
195
201
  No user-level runtime configuration is rewritten, and workspace-trust dialogs
196
202
  are never accepted automatically.
197
203
 
@@ -202,6 +208,64 @@ requester, runtime, kind and host. Registry and task outputs also live under
202
208
  closed: verify no Muster operation is running before removing that directory.
203
209
  There is no automatic time-based lock theft.
204
210
 
211
+ ## Optional tools for launched agents
212
+
213
+ MCP servers are optional. Muster does not require or install Tin Can (or any
214
+ other MCP server). Register only servers you want launched agents to use in
215
+ `~/.muster/config.toml`. For example, to use an already-installed Tin Can:
216
+
217
+ ```toml
218
+ # Optional personal defaults for sessions. Omit to keep launches MCP-free.
219
+ default_mcp = ["tincan"]
220
+
221
+ [mcp_servers.tincan]
222
+ command = "tincan"
223
+ tools = ["peers", "send_peer", "message_log"]
224
+ required = false
225
+ ```
226
+
227
+ Then either runtime can use it:
228
+
229
+ ```sh
230
+ muster run codex --mcp tincan --prompt 'Coordinate with the other agent'
231
+ muster run claude --mcp tincan --prompt 'Coordinate with the other agent'
232
+ muster run codex --no-mcp --prompt 'Work without external tools'
233
+ ```
234
+
235
+ `--mcp NAME` is repeatable and **replaces** personal defaults. `--no-mcp` clears
236
+ them and cannot be combined with `--mcp`. MCP `run` takes `mcp: ["tincan"]`;
237
+ `mcp: []` disables all servers. Omitted selection uses `default_mcp` for sessions.
238
+ Tasks have no defaults, but accept an explicit selection.
239
+
240
+ Definitions support either `command` plus optional `args`, `env`, and `env_vars`
241
+ for stdio, or `url` plus optional `bearer_token_env_var` for Streamable HTTP.
242
+ `tools` is a required nonempty list of exact tool names. Use `env_vars` and
243
+ `bearer_token_env_var` to reference credentials in the launch environment;
244
+ credentials are not written to launch logs. HTTP OAuth setup shared across
245
+ runtimes, plugin/app tools, MCP resources and prompts are outside this interface.
246
+ Server and tool names must contain only letters, digits, underscores or hyphens.
247
+ Relative stdio commands run from the requested working directory; prefer an
248
+ absolute executable path or a command on PATH.
249
+
250
+ Before agent launch, Muster initializes each selected server and checks its tool
251
+ catalog. No tool is called during preflight. An explicit `--mcp` selection is
252
+ required: a missing server, missing credential, or missing configured tool fails
253
+ that launch. Defaults are optional unless their definition sets `required = true`;
254
+ an unavailable optional default is skipped with `mcp_warnings`. Each preflight is
255
+ bounded by `startup_timeout_sec` (default 10, maximum 60) and the launch deadline.
256
+ JSON results and `list` report selected logical names in `mcp`; human output shows
257
+ `MCP:` and any warnings. These describe launch configuration, not continuous
258
+ server health.
259
+
260
+ Codex receives isolated definitions, using a fresh runtime name when an inherited
261
+ server has the same name, and its native tool allowlist. Claude receives a private
262
+ Muster stdio connection that filters both tool discovery and calls; newly added
263
+ upstream tools cannot bypass the selection. Only selected tools are preapproved.
264
+ Claude connection specifications are stored with owner-only permissions under
265
+ `~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
266
+ session. MCP server access is separate from filesystem sandbox permissions: a
267
+ selected external tool may have its own write or network capabilities.
268
+
205
269
  ## MCP installation
206
270
 
207
271
  Installed deliberately, in the one session that should hold spawn authority —
@@ -1,7 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import TOML from "@iarna/toml";
4
+ import { codexMcpConfig } from "./mcp.js";
3
5
  const exec = promisify(execFile);
4
- export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
6
+ export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000, selected = []) {
5
7
  const args = [
6
8
  "--disable",
7
9
  "hooks",
@@ -17,24 +19,48 @@ export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
17
19
  "notify=[]",
18
20
  ];
19
21
  async function list(extra) {
20
- const { stdout } = await exec("codex", [...extra, "mcp", "list", "--json"], {
21
- env,
22
- cwd,
23
- timeout: Math.max(1, Math.min(4000, deadline - Date.now())),
24
- maxBuffer: 1024 * 1024,
25
- });
26
- const value = JSON.parse(stdout);
27
- if (!Array.isArray(value))
22
+ try {
23
+ const { stdout } = await exec("codex", [...extra, "mcp", "list", "--json"], {
24
+ env,
25
+ cwd,
26
+ timeout: Math.max(1, Math.min(4000, deadline - Date.now())),
27
+ maxBuffer: 1024 * 1024,
28
+ });
29
+ const value = JSON.parse(stdout);
30
+ if (!Array.isArray(value))
31
+ throw new Error("Cannot verify effective Codex MCP configuration");
32
+ return value;
33
+ }
34
+ catch {
28
35
  throw new Error("Cannot verify effective Codex MCP configuration");
29
- return value;
36
+ }
30
37
  }
31
- for (const server of await list(args)) {
38
+ const inherited = await list(args);
39
+ const usedNames = new Set(inherited.map((s) => s.name));
40
+ for (const server of inherited) {
32
41
  if (typeof server.name !== "string" ||
33
42
  !/^[a-zA-Z0-9_-]+$/.test(server.name))
34
43
  throw new Error("Cannot safely disable an inherited MCP server with a non-simple name");
35
44
  args.push("-c", `mcp_servers.${server.name}.enabled=false`);
36
45
  }
37
- if ((await list(args)).some((s) => s.enabled !== false))
38
- throw new Error("Inherited Codex MCP servers remain enabled; refusing launch");
46
+ const expected = [];
47
+ for (const server of selected) {
48
+ let name = server.name;
49
+ let suffix = 0;
50
+ // Config layers merge nested maps. A fresh key prevents inherited env,
51
+ // credentials or per-tool policy from surviving the requested definition.
52
+ while (usedNames.has(name))
53
+ name = `muster_${server.name}_${++suffix}`;
54
+ usedNames.add(name);
55
+ expected.push(name);
56
+ args.push("-c", `mcp_servers.${name}=${TOML.stringify.value(codexMcpConfig(server))}`);
57
+ }
58
+ const enabled = (await list(args))
59
+ .filter((s) => s.enabled !== false)
60
+ .map((s) => s.name)
61
+ .sort();
62
+ expected.sort();
63
+ if (JSON.stringify(enabled) !== JSON.stringify(expected))
64
+ throw new Error("Inherited Codex MCP servers remain enabled or requested servers are missing; refusing launch");
39
65
  return args;
40
66
  }
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import TOML from "@iarna/toml";
5
5
  import { z } from "zod";
6
+ import { mcpName, mcpServerSchema } from "./mcp.js";
6
7
  export const configSchema = z
7
8
  .object({
8
9
  host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
@@ -17,6 +18,8 @@ export const configSchema = z
17
18
  "danger-full-access",
18
19
  ])
19
20
  .default("read-only"),
21
+ default_mcp: z.array(mcpName).default([]),
22
+ mcp_servers: z.record(mcpName, mcpServerSchema).default({}),
20
23
  allow_dangerous_flags: z.boolean().default(false),
21
24
  })
22
25
  .strict();
package/dist/format.js CHANGED
@@ -40,6 +40,10 @@ function recordHuman(record, color) {
40
40
  lines.push(field("Permissions", clean(record.permissions)));
41
41
  if (record.sandbox)
42
42
  lines.push(field("Sandbox", clean(record.sandbox)));
43
+ if (record.mcp)
44
+ lines.push(field("MCP", record.mcp.length ? record.mcp.map(clean).join(", ") : "none"));
45
+ for (const warning of record.mcp_warnings ?? [])
46
+ lines.push(field("MCP warning", clean(warning)));
43
47
  if (record.host)
44
48
  lines.push(field("Host", `${clean(record.host)}${record.host === "pty" ? " (not watchable or attachable)" : ""}`));
45
49
  if (record.terminal_opened)
package/dist/guard.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { resolve } from "node:path";
3
+ import { mcpName, claudeMcpConfig } from "./mcp.js";
3
4
  export const runSchema = z
4
5
  .object({
5
6
  runtime: z.enum(["codex", "claude"]),
@@ -17,6 +18,7 @@ export const runSchema = z
17
18
  terminal: z.enum(["auto", "terminal", "iterm2", "ghostty"]).optional(),
18
19
  permissions: z.enum(["auto", "deny", "bypass"]).optional(),
19
20
  sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
21
+ mcp: z.array(mcpName).optional(),
20
22
  args: z.array(z.string()).default([]),
21
23
  })
22
24
  .strict();
@@ -53,7 +55,7 @@ function extras(req) {
53
55
  }
54
56
  return result;
55
57
  }
56
- export function launchArgs(req, config) {
58
+ export function launchArgs(req, config, mcp = [], bridgePath) {
57
59
  const extra = extras(req);
58
60
  const policy = resolvePermissions(req, config);
59
61
  if (req.runtime === "codex")
@@ -89,7 +91,11 @@ export function launchArgs(req, config) {
89
91
  disableAllHooks: true,
90
92
  permissions: {
91
93
  defaultMode: mode,
92
- deny: readonly ? ["Edit", "Write", "NotebookEdit"] : [],
94
+ allow: mcp.flatMap((s) => s.config.tools.map((t) => `mcp__${s.name}__${t}`)),
95
+ deny: [
96
+ ...(readonly ? ["Edit", "Write", "NotebookEdit"] : []),
97
+ ...mcp.flatMap((s) => s.excludedTools.map((t) => `mcp__${s.name}__${t}`)),
98
+ ],
93
99
  additionalDirectories: [],
94
100
  },
95
101
  sandbox: {
@@ -112,7 +118,7 @@ export function launchArgs(req, config) {
112
118
  "",
113
119
  "--strict-mcp-config",
114
120
  "--mcp-config",
115
- '{"mcpServers":{}}',
121
+ JSON.stringify(claudeMcpConfig(mcp, bridgePath)),
116
122
  "--settings",
117
123
  JSON.stringify(settings),
118
124
  ...extra,
@@ -56,22 +56,22 @@ export async function openTerminal(tmux, server, hostRef, deadline, terminal = "
56
56
  "Ghostty",
57
57
  "--args",
58
58
  "--quit-after-last-window-closed=true",
59
+ // A separate instance otherwise restores the user's saved tabs/windows.
60
+ "--window-save-state=never",
59
61
  "-e",
60
62
  tmux,
61
63
  "-L",
62
64
  server,
63
65
  "attach-session",
64
66
  "-t",
65
- "muster",
66
- ";",
67
- "select-window",
68
- "-t",
69
67
  hostRef,
70
68
  ], timeout);
71
69
  return;
72
70
  }
73
71
  const quoted = "'" + tmux.replaceAll("'", "'\\''") + "'";
74
- const shellCommand = `${quoted} -L ${server} attach-session -t muster \\; select-window -t ${hostRef}`;
72
+ const shellCommand = `${quoted} -L ${server} attach-session -t ${hostRef}`;
73
+ // Terminal must create the requested window before activation. Activating
74
+ // a cold app first also creates its default shell window.
75
75
  await command("/usr/bin/osascript", [
76
76
  "-e",
77
77
  terminal === "iterm2"
@@ -83,8 +83,9 @@ export async function openTerminal(tmux, server, hostRef, deadline, terminal = "
83
83
  end run`
84
84
  : `on run argv
85
85
  tell application "Terminal"
86
- activate
86
+ launch
87
87
  do script (item 1 of argv)
88
+ activate
88
89
  end tell
89
90
  end run`,
90
91
  shellCommand,
@@ -1,4 +1,5 @@
1
1
  import { mkdtemp, writeFile, rm } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
@@ -52,13 +53,9 @@ export class TmuxHost {
52
53
  file,
53
54
  ];
54
55
  try {
55
- let output;
56
- try {
57
- output = await this.call(["new-session", "-s", "muster", ...common], opts.deadline);
58
- }
59
- catch {
60
- output = await this.call(["new-window", "-t", "muster", ...common], opts.deadline);
61
- }
56
+ // Window selection is session-wide: sharing a session makes every
57
+ // attached terminal follow whichever agent was opened most recently.
58
+ const output = await this.call(["new-session", "-s", `muster-${randomUUID()}`, ...common], opts.deadline);
62
59
  const [hostRef, pid] = output.trim().split("\t");
63
60
  if (!hostRef || !/^@\d+$/.test(hostRef) || !Number(pid))
64
61
  throw new Error("tmux returned an invalid window identity");
@@ -69,8 +66,7 @@ export class TmuxHost {
69
66
  try {
70
67
  const windows = await this.call([
71
68
  "list-windows",
72
- "-t",
73
- "muster",
69
+ "-a",
74
70
  "-F",
75
71
  "#{window_id}\t#{pane_start_command}",
76
72
  ]);
@@ -87,14 +83,16 @@ export class TmuxHost {
87
83
  try {
88
84
  return (await this.call([
89
85
  "list-windows",
90
- "-t",
91
- "muster",
86
+ "-a",
92
87
  "-F",
93
- "#{window_id}\t#{pane_pid}\t#{window_name}",
88
+ "#{window_id}\t#{pane_pid}\t#{window_name}\t#{session_name}",
94
89
  ]))
95
90
  .trim()
96
91
  .split("\n")
97
- .filter(Boolean)
92
+ .filter((l) => {
93
+ const session = l.split("\t")[3];
94
+ return (session === "muster" || /^muster-[0-9a-f-]{36}$/.test(session ?? ""));
95
+ })
98
96
  .map((l) => {
99
97
  const [hostRef, pid, label] = l.split("\t");
100
98
  return { hostRef: hostRef, pid: Number(pid), label: label };
@@ -117,6 +115,6 @@ export class TmuxHost {
117
115
  }
118
116
  }
119
117
  attachHint(hostRef) {
120
- return `tmux -L ${this.server} attach-session -t muster \\; select-window -t ${hostRef}`;
118
+ return `tmux -L ${this.server} attach-session -t ${hostRef}`;
121
119
  }
122
120
  }
@@ -0,0 +1,91 @@
1
+ // Claude has permission rules, but no MCP exposure allowlist. This stdio
2
+ // connection exposes and dispatches only the operator-selected tools, including
3
+ // when the upstream server changes its tool catalog after launch.
4
+ import { readFile } from "node:fs/promises";
5
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
9
+ import { mcpServerSchema, mcpName, mcpTransport } from "./mcp.js";
10
+ async function main() {
11
+ const [path, name] = process.argv.slice(2);
12
+ if (!path || !name || !mcpName.safeParse(name).success)
13
+ throw new Error("Invalid MCP bridge arguments");
14
+ const definitions = JSON.parse(await readFile(path, "utf8"));
15
+ const config = mcpServerSchema.parse(definitions[name]);
16
+ const client = new Client({ name: "muster-tool-bridge", version: "1" }, { capabilities: {} });
17
+ const server = new Server({ name: `muster-${name}`, version: "1" }, { capabilities: { tools: {} } });
18
+ let shutdown;
19
+ const close = () => (shutdown ??= Promise.resolve().then(async () => {
20
+ await client.close().catch(() => { });
21
+ await server.close().catch(() => { });
22
+ }));
23
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
24
+ process.once(signal, () => {
25
+ void close().finally(() => process.exit(0));
26
+ });
27
+ process.stdin.once("end", () => {
28
+ void close();
29
+ });
30
+ process.stdin.once("close", () => {
31
+ void close();
32
+ });
33
+ server.onclose = () => {
34
+ void close();
35
+ };
36
+ client.onclose = () => {
37
+ void close();
38
+ };
39
+ const transport = mcpTransport(config, process.env, process.cwd());
40
+ const timer = setTimeout(() => {
41
+ void close().finally(() => process.exit(1));
42
+ }, config.startup_timeout_sec * 1000);
43
+ try {
44
+ await client.connect(transport);
45
+ if (shutdown) {
46
+ await close();
47
+ return;
48
+ }
49
+ }
50
+ catch (error) {
51
+ await close();
52
+ throw error;
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
58
+ const tools = [];
59
+ let cursor;
60
+ do {
61
+ const page = await client.listTools(cursor ? { cursor } : {});
62
+ tools.push(...page.tools.filter((t) => config.tools.includes(t.name)));
63
+ cursor = page.nextCursor;
64
+ } while (cursor);
65
+ return { tools };
66
+ });
67
+ server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
68
+ if (!config.tools.includes(params.name))
69
+ return {
70
+ isError: true,
71
+ content: [
72
+ { type: "text", text: "Tool not enabled for this Muster launch" },
73
+ ],
74
+ };
75
+ return await client.callTool({
76
+ name: params.name,
77
+ arguments: params.arguments,
78
+ });
79
+ });
80
+ try {
81
+ await server.connect(new StdioServerTransport());
82
+ }
83
+ catch (error) {
84
+ await close();
85
+ throw error;
86
+ }
87
+ }
88
+ main().catch(() => {
89
+ process.stderr.write("Muster MCP connection failed; check the selected server configuration.\n");
90
+ process.exitCode = 1;
91
+ });
package/dist/mcp.js ADDED
@@ -0,0 +1,207 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { z } from "zod";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
+ export const mcpName = z.string().regex(/^[a-zA-Z0-9_-]+$/);
7
+ const environmentName = z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/);
8
+ export const mcpServerSchema = z
9
+ .object({
10
+ command: z.string().min(1).optional(),
11
+ args: z.array(z.string()).default([]),
12
+ url: z
13
+ .string()
14
+ .url()
15
+ .refine((s) => /^https?:\/\//.test(s), "MCP URL must use HTTP(S)")
16
+ .optional(),
17
+ env: z.record(z.string()).default({}),
18
+ env_vars: z.array(environmentName).default([]),
19
+ bearer_token_env_var: environmentName.optional(),
20
+ tools: z.array(mcpName).min(1),
21
+ required: z.boolean().default(false),
22
+ startup_timeout_sec: z.number().positive().max(60).default(10),
23
+ })
24
+ .strict()
25
+ .superRefine((v, ctx) => {
26
+ if (!!v.command === !!v.url)
27
+ ctx.addIssue({
28
+ code: "custom",
29
+ message: "MCP server requires exactly one of command or url",
30
+ });
31
+ if (v.command && v.bearer_token_env_var)
32
+ ctx.addIssue({
33
+ code: "custom",
34
+ message: "bearer_token_env_var requires an HTTP server",
35
+ });
36
+ if (v.url &&
37
+ (v.args.length || Object.keys(v.env).length || v.env_vars.length))
38
+ ctx.addIssue({
39
+ code: "custom",
40
+ message: "args, env and env_vars require a stdio server",
41
+ });
42
+ });
43
+ export function selectMcp(req, config) {
44
+ // Personal defaults apply to sessions, not unattended one-shot tasks.
45
+ const names = req.mcp ?? (req.kind === "session" ? config.default_mcp : []);
46
+ return [...new Set(names)].map((name) => {
47
+ const server = Object.hasOwn(config.mcp_servers, name)
48
+ ? config.mcp_servers[name]
49
+ : undefined;
50
+ if (!server)
51
+ throw new Error(`Unknown MCP server: ${name}`);
52
+ return {
53
+ name,
54
+ config: server,
55
+ required: req.mcp !== undefined || server.required,
56
+ };
57
+ });
58
+ }
59
+ export function mcpEnvironment(servers, base, source) {
60
+ const env = { ...base };
61
+ for (const { config } of servers) {
62
+ for (const key of [
63
+ ...config.env_vars,
64
+ ...(config.bearer_token_env_var ? [config.bearer_token_env_var] : []),
65
+ ]) {
66
+ if (source[key] !== undefined)
67
+ env[key] = source[key];
68
+ }
69
+ }
70
+ return env;
71
+ }
72
+ function serverEnv(config, env) {
73
+ const result = { ...config.env };
74
+ for (const key of config.env_vars) {
75
+ if (env[key] === undefined)
76
+ throw new Error(`Missing MCP environment variable: ${key}`);
77
+ result[key] = env[key];
78
+ }
79
+ return result;
80
+ }
81
+ function headers(config, env) {
82
+ if (!config.bearer_token_env_var)
83
+ return {};
84
+ const value = env[config.bearer_token_env_var];
85
+ if (!value)
86
+ throw new Error("Missing MCP bearer token environment variable");
87
+ return { Authorization: `Bearer ${value}` };
88
+ }
89
+ export function mcpTransport(config, env, cwd) {
90
+ const transport = config.command
91
+ ? new StdioClientTransport({
92
+ command: config.command,
93
+ args: config.args,
94
+ env: { ...env, ...serverEnv(config, env) },
95
+ cwd,
96
+ stderr: "pipe",
97
+ })
98
+ : new StreamableHTTPClientTransport(new URL(config.url), {
99
+ requestInit: { headers: headers(config, env) },
100
+ });
101
+ if (transport instanceof StdioClientTransport)
102
+ transport.stderr?.on("data", () => { });
103
+ return transport;
104
+ }
105
+ export async function prepareMcp(selected, env, cwd, deadline) {
106
+ const servers = [], warnings = [];
107
+ for (const server of selected) {
108
+ const client = new Client({ name: "muster-preflight", version: "1" }, { capabilities: {} });
109
+ let timer;
110
+ let diagnostic = "startup or tool discovery failed (check executable, credentials and server configuration)";
111
+ try {
112
+ const timeout = Math.min(server.config.startup_timeout_sec * 1000, deadline - Date.now());
113
+ if (timeout <= 0)
114
+ throw new Error("deadline");
115
+ const transport = mcpTransport(server.config, env, cwd);
116
+ const discover = async () => {
117
+ await client.connect(transport);
118
+ const names = new Set();
119
+ let cursor;
120
+ do {
121
+ const page = await client.listTools(cursor ? { cursor } : {});
122
+ for (const tool of page.tools) {
123
+ if (!mcpName.safeParse(tool.name).success)
124
+ throw new Error("Unsupported tool name");
125
+ names.add(tool.name);
126
+ }
127
+ cursor = page.nextCursor;
128
+ } while (cursor);
129
+ const missing = server.config.tools.filter((name) => !names.has(name));
130
+ if (missing.length) {
131
+ diagnostic = `configured tools not found: ${missing.join(", ")}`;
132
+ throw new Error("missing tools");
133
+ }
134
+ return [...names].filter((name) => !server.config.tools.includes(name));
135
+ };
136
+ const excludedTools = await Promise.race([
137
+ discover(),
138
+ new Promise((_, reject) => {
139
+ timer = setTimeout(() => reject(new Error("timeout")), timeout);
140
+ }),
141
+ ]);
142
+ servers.push({ ...server, excludedTools });
143
+ }
144
+ catch {
145
+ // Server errors may include tokens or environment values; never log them.
146
+ const message = `MCP server ${server.name}: ${diagnostic}`;
147
+ if (server.required)
148
+ throw new Error(message);
149
+ warnings.push(`${message}; skipped optional default`);
150
+ }
151
+ finally {
152
+ if (timer)
153
+ clearTimeout(timer);
154
+ await client.close().catch(() => { });
155
+ }
156
+ }
157
+ return {
158
+ servers,
159
+ summary: {
160
+ mcp: servers.map((s) => s.name),
161
+ ...(warnings.length ? { mcp_warnings: warnings } : {}),
162
+ },
163
+ };
164
+ }
165
+ export function codexMcpConfig(server) {
166
+ const c = server.config;
167
+ return {
168
+ ...(c.command
169
+ ? { command: c.command, args: c.args, env: c.env, env_vars: c.env_vars }
170
+ : {
171
+ url: c.url,
172
+ ...(c.bearer_token_env_var
173
+ ? { bearer_token_env_var: c.bearer_token_env_var }
174
+ : {}),
175
+ }),
176
+ enabled: true,
177
+ required: server.required,
178
+ enabled_tools: c.tools,
179
+ disabled_tools: [],
180
+ startup_timeout_sec: c.startup_timeout_sec,
181
+ default_tools_approval_mode: "approve",
182
+ };
183
+ }
184
+ export function claudeMcpConfig(servers, bridgePath) {
185
+ if (servers.length && !bridgePath)
186
+ throw new Error("Missing MCP bridge configuration");
187
+ return {
188
+ mcpServers: Object.fromEntries(servers.map(({ name, config }) => [
189
+ name,
190
+ {
191
+ type: "stdio",
192
+ command: process.execPath,
193
+ args: [
194
+ fileURLToPath(new URL("../dist/mcp-bridge.js", import.meta.url)),
195
+ bridgePath,
196
+ name,
197
+ ],
198
+ env: Object.fromEntries([
199
+ ...config.env_vars,
200
+ ...(config.bearer_token_env_var
201
+ ? [config.bearer_token_env_var]
202
+ : []),
203
+ ].map((key) => [key, "${" + key + "}"])),
204
+ },
205
+ ])),
206
+ };
207
+ }
package/dist/muster.js CHANGED
@@ -23,6 +23,8 @@ run also accepts --format json|human, --permissions auto|deny|bypass,
23
23
  and --sandbox read-only|workspace-write|full-access before -- RUNTIME_OPTIONS.
24
24
  --open displays a tmux session on macOS. --terminal accepts auto (Terminal.app),
25
25
  terminal, iterm2, or ghostty, and requires --open.
26
+ --mcp NAME is repeatable and replaces configured session defaults; --no-mcp
27
+ clears them. Tasks use no MCP defaults; select servers explicitly.
26
28
  run, list and stop default to JSON. output prints raw task text.
27
29
 
28
30
  pty launches print their record and keep this process running. Ctrl-C stops the
@@ -54,7 +56,7 @@ async function main() {
54
56
  });
55
57
  });
56
58
  if (mcp) {
57
- const server = new Server({ name: "muster", version: "0.5.0" }, { capabilities: { tools: {} } });
59
+ const server = new Server({ name: "muster", version: "0.6.0" }, { capabilities: { tools: {} } });
58
60
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
59
61
  tools: [
60
62
  {
@@ -94,6 +96,11 @@ async function main() {
94
96
  enum: ["read-only", "workspace-write", "full-access"],
95
97
  description: "Defaults to config (read-only). Full access requires config authorization.",
96
98
  },
99
+ mcp: {
100
+ type: "array",
101
+ items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
102
+ description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
103
+ },
97
104
  args: { type: "array", items: { type: "string" } },
98
105
  },
99
106
  required: ["runtime", "prompt"],
@@ -180,9 +187,17 @@ async function main() {
180
187
  format: { type: "string" },
181
188
  permissions: { type: "string" },
182
189
  sandbox: { type: "string" },
190
+ mcp: { type: "string", multiple: true },
191
+ "no-mcp": { type: "boolean" },
183
192
  },
184
193
  });
185
- const { format = "json", ...values } = parsed;
194
+ const { format = "json", "no-mcp": noMcp, ...values } = parsed;
195
+ if (noMcp !== undefined && command !== "run")
196
+ throw new Error("--no-mcp applies only to run");
197
+ if (noMcp && values.mcp)
198
+ throw new Error("--mcp and --no-mcp cannot be combined");
199
+ if (noMcp)
200
+ values.mcp = [];
186
201
  if (format !== "json" && format !== "human")
187
202
  throw new Error("format must be json or human");
188
203
  if (command === "output" && parsed.format !== undefined)
package/dist/run.js CHANGED
@@ -17,27 +17,30 @@ import { assertClaudePolicy } from "./claude-policy.js";
17
17
  import { codexPolicyArgs } from "./codex-policy.js";
18
18
  import { CodexRpc } from "./codex-rpc.js";
19
19
  import { assignNames, resolvePeer } from "./naming.js";
20
+ import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
20
21
  export class Muster {
21
22
  config;
22
23
  home;
23
24
  env;
24
25
  drivers;
26
+ sourceEnv;
25
27
  registry;
26
28
  log;
27
29
  owned = new Set();
28
30
  closing = false;
29
31
  active = new Set();
30
- constructor(config, home, env, drivers) {
32
+ constructor(config, home, env, drivers, sourceEnv) {
31
33
  this.config = config;
32
34
  this.home = home;
33
35
  this.env = env;
34
36
  this.drivers = drivers;
37
+ this.sourceEnv = sourceEnv;
35
38
  this.registry = new Registry(home);
36
39
  this.log = new LaunchLog(home);
37
40
  }
38
41
  static async create(opts = {}) {
39
42
  const env = launchEnv(opts.env ?? process.env), home = opts.home ?? join(env.HOME ?? homedir(), ".muster");
40
- return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts());
43
+ return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts(), { ...(opts.env ?? process.env) });
41
44
  }
42
45
  run(input, requester = "cli") {
43
46
  if (this.closing)
@@ -60,7 +63,9 @@ export class Muster {
60
63
  (req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
61
64
  throw new Error("--open requires a tmux session on macOS");
62
65
  const permissions = resolvePermissions(req, this.config);
63
- const argv = launchArgs(req, this.config), driver = req.kind === "session"
66
+ const selectedMcp = selectMcp(req, this.config);
67
+ let argv = launchArgs(req, this.config);
68
+ const driver = req.kind === "session"
64
69
  ? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
65
70
  : undefined;
66
71
  if (req.open &&
@@ -87,20 +92,35 @@ export class Muster {
87
92
  requester,
88
93
  ...(req.open ? { open: true, terminal } : {}),
89
94
  args: req.args,
95
+ mcp: selectedMcp.map((s) => s.name),
90
96
  ...permissions,
91
97
  });
98
+ const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, this.env, this.sourceEnv), req.cwd, deadline);
99
+ const runtimeEnv = mcpEnvironment(prepared.servers, this.env, this.sourceEnv);
100
+ entry = await this.registry.update(entry.launchId, prepared.summary);
101
+ let bridgePath;
102
+ if (req.runtime === "claude" && prepared.servers.length) {
103
+ const dir = join(this.home, "mcp");
104
+ await mkdir(dir, { recursive: true, mode: 0o700 });
105
+ bridgePath = join(dir, entry.launchId + ".json");
106
+ entry = await this.registry.update(entry.launchId, {
107
+ mcpConfigPath: bridgePath,
108
+ });
109
+ await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
110
+ }
111
+ argv = launchArgs(req, this.config, prepared.servers, bridgePath);
92
112
  if (req.runtime === "codex") {
93
- policy = await codexPolicyArgs(this.env, req.cwd, deadline);
113
+ policy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers);
94
114
  argv.splice(1, 0, ...policy);
95
115
  }
96
116
  if (this.closing)
97
117
  throw new Error("Launch cancelled: Muster is closing");
98
118
  if (req.kind === "task")
99
- return await this.startTask(entry, argv);
119
+ return await this.startTask(entry, argv, runtimeEnv);
100
120
  const launched = await driver.launch({
101
121
  argv,
102
122
  cwd: req.cwd,
103
- env: this.env,
123
+ env: runtimeEnv,
104
124
  label: req.prompt.slice(0, 48),
105
125
  deadline,
106
126
  });
@@ -115,7 +135,7 @@ export class Muster {
115
135
  ? "no Claude session registry found (check terminal for workspace trust or login prompts)"
116
136
  : "no descendant runtime identity found";
117
137
  if (req.runtime === "codex")
118
- rpc = new CodexRpc(this.env, req.cwd, policy);
138
+ rpc = new CodexRpc(this.env, req.cwd);
119
139
  while (Date.now() < deadline) {
120
140
  if (deadline - Date.now() < 25)
121
141
  break;
@@ -175,6 +195,7 @@ export class Muster {
175
195
  const peer = {
176
196
  kind: "session",
177
197
  ...permissions,
198
+ ...prepared.summary,
178
199
  name: named.display,
179
200
  canonical_id: named.canonicalId,
180
201
  runtime,
@@ -233,19 +254,20 @@ export class Muster {
233
254
  await rpc?.close();
234
255
  }
235
256
  }
236
- async startTask(entry, argv) {
257
+ async startTask(entry, argv, env) {
237
258
  const dir = join(this.home, "tasks", entry.id);
238
259
  await mkdir(dir, { recursive: true, mode: 0o700 });
239
260
  const outputPath = join(dir, "output.txt"), exitPath = join(dir, "exit.json"), startPath = join(dir, "start"), specPath = join(dir, "launch.json");
240
261
  await writeFile(specPath, JSON.stringify({
241
262
  argv,
242
263
  cwd: entry.cwd,
243
- env: this.env,
264
+ env,
244
265
  outputPath,
245
266
  exitPath,
246
267
  startPath,
247
268
  logHome: this.home,
248
269
  launchId: entry.launchId,
270
+ mcpConfigPath: entry.mcpConfigPath,
249
271
  }), { mode: 0o600 });
250
272
  const child = spawn(process.execPath, [
251
273
  fileURLToPath(new URL("../dist/task-worker.js", import.meta.url)),
@@ -273,6 +295,8 @@ export class Muster {
273
295
  kind: "task",
274
296
  permissions: entry.permissions,
275
297
  sandbox: entry.sandbox,
298
+ mcp: entry.mcp ?? [],
299
+ ...(entry.mcp_warnings ? { mcp_warnings: entry.mcp_warnings } : {}),
276
300
  id: entry.id,
277
301
  runtime: entry.runtime,
278
302
  state: "running",
@@ -293,6 +317,15 @@ export class Muster {
293
317
  }
294
318
  }
295
319
  async stopEntry(entry) {
320
+ try {
321
+ await this.stopEntryProcesses(entry);
322
+ }
323
+ finally {
324
+ if (entry.mcpConfigPath)
325
+ await rm(entry.mcpConfigPath, { force: true });
326
+ }
327
+ }
328
+ async stopEntryProcesses(entry) {
296
329
  if (!entry.root)
297
330
  return;
298
331
  if (!(await isSame(entry.root))) {
@@ -396,6 +429,8 @@ export class Muster {
396
429
  if (kind && kind !== original.kind)
397
430
  continue;
398
431
  const e = await this.refreshed(original);
432
+ if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
433
+ await rm(e.mcpConfigPath, { force: true });
399
434
  if (e.kind === "session")
400
435
  result.push({
401
436
  ...e.peer,
@@ -417,6 +452,8 @@ export class Muster {
417
452
  catch { }
418
453
  result.push({
419
454
  kind: "task",
455
+ ...(e.mcp ? { mcp: e.mcp } : {}),
456
+ ...(e.mcp_warnings ? { mcp_warnings: e.mcp_warnings } : {}),
420
457
  ...(e.permissions
421
458
  ? { permissions: e.permissions, sandbox: e.sandbox }
422
459
  : {}),
@@ -33,6 +33,8 @@ async function finish(code, signal, error) {
33
33
  finished = true;
34
34
  try {
35
35
  await output.close();
36
+ if (spec.mcpConfigPath)
37
+ await rm(spec.mcpConfigPath, { force: true });
36
38
  await new LaunchLog(spec.logHome).write({
37
39
  event: "task_exit",
38
40
  launch_id: spec.launchId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {