@brutalsystems/muster 0.5.0 → 0.7.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.
@@ -0,0 +1,86 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { OpenCodeHttp, OpenCodeProtocolError, } from "../opencode-http.js";
3
+ import { command, delay, descendants, processRef } from "./processes.js";
4
+ const POLL_INTERVAL_MS = 50;
5
+ function rawName(session) {
6
+ return session.title.trim() === "" ? null : session.title;
7
+ }
8
+ export async function openCodeEndpointOwnedBy(rootPid, serverUrl, deadline) {
9
+ try {
10
+ const endpoint = new URL(serverUrl);
11
+ const port = Number(endpoint.port);
12
+ if (!Number.isInteger(port) || port <= 0 || deadline <= Date.now())
13
+ return false;
14
+ const family = new Set(await descendants(rootPid, deadline));
15
+ const output = await command("lsof", [
16
+ "-nP",
17
+ "-a",
18
+ `-iTCP@${endpoint.hostname}:${port}`,
19
+ "-sTCP:LISTEN",
20
+ "-Fp",
21
+ ], Math.max(1, Math.min(1000, deadline - Date.now())));
22
+ const listeners = output
23
+ .split("\n")
24
+ .filter((line) => /^p\d+$/.test(line))
25
+ .map((line) => Number(line.slice(1)));
26
+ return listeners.length > 0 && listeners.every((pid) => family.has(pid));
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export async function resolveOpenCode(rootPid, serverUrl, cwd, createdAfter, deadline) {
33
+ const resolvedCwd = await realpath(cwd);
34
+ const client = new OpenCodeHttp(serverUrl);
35
+ let ownershipProven = false;
36
+ while (Date.now() < deadline) {
37
+ if (!(await processRef(rootPid)))
38
+ return;
39
+ if (!ownershipProven &&
40
+ !(await openCodeEndpointOwnedBy(rootPid, serverUrl, deadline))) {
41
+ const remaining = deadline - Date.now();
42
+ if (remaining > 0)
43
+ await delay(Math.min(POLL_INTERVAL_MS, remaining));
44
+ continue;
45
+ }
46
+ ownershipProven = true;
47
+ try {
48
+ const health = await client.health(deadline);
49
+ if (health.healthy) {
50
+ const sessions = await client.sessions(deadline);
51
+ if (!(await processRef(rootPid)))
52
+ return;
53
+ const candidates = sessions.filter((session) => session.directory === resolvedCwd &&
54
+ session.time.created >= createdAfter);
55
+ const statuses = candidates.length
56
+ ? await client.statuses(deadline)
57
+ : {};
58
+ if (!(await processRef(rootPid)))
59
+ return;
60
+ if (!(await openCodeEndpointOwnedBy(rootPid, serverUrl, deadline))) {
61
+ ownershipProven = false;
62
+ continue;
63
+ }
64
+ const matches = candidates.filter((session) => Object.prototype.hasOwnProperty.call(statuses, session.id));
65
+ if (matches.length > 1)
66
+ throw new Error("Multiple OpenCode sessions matched the launch endpoint, cwd, and creation window");
67
+ const match = matches[0];
68
+ if (match)
69
+ return {
70
+ id: match.id,
71
+ pid: rootPid,
72
+ rawName: rawName(match),
73
+ cwd: resolvedCwd,
74
+ serverUrl,
75
+ };
76
+ }
77
+ }
78
+ catch (error) {
79
+ if (!(error instanceof OpenCodeProtocolError))
80
+ throw error;
81
+ }
82
+ const remaining = deadline - Date.now();
83
+ if (remaining > 0)
84
+ await delay(Math.min(POLL_INTERVAL_MS, remaining));
85
+ }
86
+ }
@@ -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
@@ -13,7 +13,7 @@ const listSchema = z
13
13
  .strict();
14
14
  const idSchema = z.object({ id: z.string().min(1) }).strict();
15
15
  const help = `Usage:
16
- muster run <codex|claude> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [--open] [--terminal APP] [-- RUNTIME_OPTIONS]
16
+ muster run <codex|claude|opencode> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [--open] [--terminal APP] [-- RUNTIME_OPTIONS]
17
17
  muster list [--kind session|task] [--format json|human]
18
18
  muster stop <id> [--format json|human]
19
19
  muster output <id>
@@ -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
  {
@@ -63,7 +65,10 @@ async function main() {
63
65
  inputSchema: {
64
66
  type: "object",
65
67
  properties: {
66
- runtime: { type: "string", enum: ["codex", "claude"] },
68
+ runtime: {
69
+ type: "string",
70
+ enum: ["codex", "claude", "opencode"],
71
+ },
67
72
  prompt: { type: "string", minLength: 1 },
68
73
  cwd: { type: "string" },
69
74
  kind: {
@@ -94,6 +99,11 @@ async function main() {
94
99
  enum: ["read-only", "workspace-write", "full-access"],
95
100
  description: "Defaults to config (read-only). Full access requires config authorization.",
96
101
  },
102
+ mcp: {
103
+ type: "array",
104
+ items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
105
+ description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
106
+ },
97
107
  args: { type: "array", items: { type: "string" } },
98
108
  },
99
109
  required: ["runtime", "prompt"],
@@ -180,9 +190,17 @@ async function main() {
180
190
  format: { type: "string" },
181
191
  permissions: { type: "string" },
182
192
  sandbox: { type: "string" },
193
+ mcp: { type: "string", multiple: true },
194
+ "no-mcp": { type: "boolean" },
183
195
  },
184
196
  });
185
- const { format = "json", ...values } = parsed;
197
+ const { format = "json", "no-mcp": noMcp, ...values } = parsed;
198
+ if (noMcp !== undefined && command !== "run")
199
+ throw new Error("--no-mcp applies only to run");
200
+ if (noMcp && values.mcp)
201
+ throw new Error("--mcp and --no-mcp cannot be combined");
202
+ if (noMcp)
203
+ values.mcp = [];
186
204
  if (format !== "json" && format !== "human")
187
205
  throw new Error("format must be json or human");
188
206
  if (command === "output" && parsed.format !== undefined)
package/dist/naming.js CHANGED
@@ -1,5 +1,6 @@
1
- /** Vendored from Tin Can src/naming.ts at fbaaea5842fd4a5c86849d7f51c8e16b8683b058 (MIT). */
2
- /** Peer naming per the handoff §7: slug for humans, canonical id for the log. */
1
+ export function runtimeName(runtime) {
2
+ return runtime === "claude" ? "claude-code" : runtime;
3
+ }
3
4
  export function slugify(raw) {
4
5
  return raw
5
6
  .toLowerCase()