@muhaven/mcp 0.1.7 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,122 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] — 2026-05-18
11
+
12
+ **Minor bump signals a breaking change to `position.buy`'s input
13
+ shape.** This release consolidates the pre-Codex review of the 0.1.7
14
+ Path C bundle: 4 parallel agents (Code Reviewer, Frontend Developer,
15
+ Security Engineer, Reality Checker) surfaced ≥5 cross-confirmed HIGH
16
+ findings and several MEDIUM/LOW items. 0.2.0 lands all of them.
17
+
18
+ ### Breaking
19
+
20
+ - **`muhaven.position.buy.amountUsdc6` → `amountUsdc` (human decimal).**
21
+ Pre-0.2.0, the field was base-6 integer string ("5000000" = $5).
22
+ An LLM hearing "buy 5 dollars" would naively emit `"5"` and silently
23
+ produce a URL with `amount=0.000005` — user buys $5e-6 instead of $5.
24
+ 0.2.0 unifies the unit convention across the whole Path C surface
25
+ (matches `cash.wrap.amountUsdc` shape): `"5"` means 5 mhUSDC. Max
26
+ 6 fractional digits, 48-char length cap. Server-side schema rejects
27
+ scientific notation, leading +, thousands separators, leading zeros,
28
+ bare leading/trailing dots.
29
+
30
+ - **`muhaven.position.sell.amountShares` rejects fractional input.**
31
+ fhERC-20 shares are integer base units (memory:
32
+ `project_decimals_lie_wave4_p0`). Pre-0.2.0 accepted "2.5" which
33
+ silently floored to 2 on the on-chain submit. 0.2.0 schema regex
34
+ `^[1-9]\d*$` rejects any fractional or leading-zero input at the
35
+ MCP-server boundary.
36
+
37
+ - **Position tool response shape** stays the `{ dashboardUrl, action,
38
+ instructions, echo }` format from 0.1.7 — no change here, but the
39
+ schema breaking changes above are what trigger the minor bump per
40
+ semver.
41
+
42
+ ### Removed (cleanup of 0.1.7 deprecation candidates)
43
+
44
+ - `__resetSessionKeyProbeCacheForTests` — was retained as a no-op in
45
+ 0.1.7 for "back-compat with downstream test consumers." Verified
46
+ empirically (Security Engineer review) that no consumer outside our
47
+ own tests imported it. Deleted entirely.
48
+ - `formatUsdc6ToDecimal` + `computeIntentHash` +
49
+ `PLACEHOLDER_INTENT_DOMAIN` — orphaned helpers tied to the
50
+ pre-0.1.7 attestation path. No external consumers; deleted.
51
+
52
+ ### Fixed
53
+
54
+ - **`MUHAVEN_DASHBOARD_URL` env-poisoning** (Security M-2): the URL
55
+ is used to build every position deep-link. Pre-0.2.0, a malicious
56
+ npm dep or attacker with write access to `~/.claude.json` could
57
+ set `MUHAVEN_DASHBOARD_URL=https://muhaven-app.com` and have the
58
+ MCP server route every user click to a typosquat phishing clone.
59
+ Validation now happens at boot in `loadMcpConfig` + `loadBrokerConfig`
60
+ using the same `https-or-loopback` rule as the existing
61
+ `--dashboard-base-url` CLI flag. Hard-fails at server start with a
62
+ clear error message if invalid.
63
+
64
+ - **`buildRegisterEnv` shell-metachar sanitization** (Security M-1):
65
+ the JSON config blob passed via argv to `claude mcp add-json` could
66
+ carry a crafted `MUHAVEN_KEYRING='file" & calc.exe &"'` past
67
+ Windows's `shell: true` invocation, reaching `cmd.exe`'s parser as
68
+ a command-injection vector. 0.2.0 rejects (not escapes) any value
69
+ containing shell metacharacters (`"` `\` newline `&` `|` `;`
70
+ `` ` `` `<` `>` `(` `)` `%` `$`) and restricts `MUHAVEN_KEYRING` to
71
+ the recognized values (`file` / `os`). Rejected values surface as
72
+ warnings on stderr; setup continues with the cleaned env.
73
+
74
+ - **`claude mcp remove` exit-code capture** (Code Reviewer H2 +
75
+ Security M-3): pre-0.2.0 swallowed every exit code from the
76
+ idempotent remove step. A perm-locked scope or stale lockfile that
77
+ failed remove + then failed add showed an opaque error attributing
78
+ the failure only to add. 0.2.0 captures remove's exit + stderr,
79
+ swallows only the expected "no such server" pattern, and surfaces
80
+ any anomaly as a warning on the success path or folds it into the
81
+ failure reason on the error path. Closes the split-brain
82
+ `~/.claude.json` operator-confusion class.
83
+
84
+ - **`decimalUsdcAmountSchema.max(48)` length cap** (Security L-5):
85
+ defense-in-depth against URL bloat (LLM emits a 10MB digit string).
86
+
87
+ ### Sibling commits (same hotfix bundle, deployed in lockstep)
88
+
89
+ - Backend `pg-portfolio.repository.findByUserId` now orders by
90
+ `last_synced_at DESC NULLS LAST` so the frontend's first-seen Map
91
+ dedup picks the freshest row — aligns the tiebreak across backend +
92
+ frontend + dedup script (Code Reviewer N2).
93
+ - `backend/scripts/dedup-portfolios.ts` moved the discovery SELECT
94
+ inside the dedup transaction + added `SELECT FOR UPDATE` row locks
95
+ + `pg_advisory_xact_lock`. Closes the TOCTOU window where a
96
+ concurrent backend write could lose data (Security H-1).
97
+ - Frontend TradePage / YieldsPage now render an inline AlertTriangle
98
+ banner when `?token=` doesn't resolve instead of silently falling
99
+ back to `marketplace.filtered[0]`. Closes the LLM-token-swap footgun
100
+ (Frontend H-1).
101
+ - TradePage marketplace.load() failure surfaces a Retry CTA instead
102
+ of half-rendering (Frontend H-2).
103
+ - TradePage / CashPage `?amount=` pre-fill uses a shared
104
+ `sanitizePrefillAmount` helper that bounds precision to 6 dp and
105
+ rejects fractional shares (Frontend H-4 / Code Reviewer L1).
106
+ - YieldsPage scroll-into-view moved into a watch keyed on
107
+ `epochsStore.items.length` so deep-link landings on disconnected
108
+ wallets still scroll once the items render (Frontend H-3).
109
+ - YieldsPage `selectedToken` declaration hoisted above onMounted so
110
+ the deep-link path sets the ref synchronously before the
111
+ `selectableTokens` watcher (`immediate: true`) snaps it to `list[0]`
112
+ (Frontend H-5).
113
+
114
+ ### Internal
115
+
116
+ - `__tests__/position-deeplink.test.ts`: rewrote amount tests for the
117
+ decimal-string schema; added schema-level corpus tests pinning the
118
+ reject list (negative, scientific notation, leading +, thousands
119
+ separator, leading zeros, etc.); added a regression test for the
120
+ base-6 footgun (`position.buy({amountUsdc: '5'})` must produce
121
+ `amount=5`, NOT `0.000005`).
122
+ - `__tests__/mcp-redteam.test.ts`: updated field name from `amountUsdc6`
123
+ to `amountUsdc` in 4 call sites.
124
+ - Total tool count unchanged at 23.
125
+
10
126
  ## [0.1.7] — 2026-05-18
11
127
 
12
128
  `position.*` tools can now drive real on-chain action via @muhaven/mcp
package/dist/broker.cjs CHANGED
@@ -51,9 +51,38 @@ function deriveAllowedHosts(baseUrl) {
51
51
  function trimTrailingSlash(s) {
52
52
  return s.endsWith("/") ? s.slice(0, -1) : s;
53
53
  }
54
+ function validatePublicUrlEnv(name, value) {
55
+ let parsed;
56
+ try {
57
+ parsed = new URL(value);
58
+ } catch {
59
+ return `${name} is not a valid URL: ${value}`;
60
+ }
61
+ if (parsed.protocol === "https:") return null;
62
+ if (parsed.protocol === "http:") {
63
+ const host = parsed.hostname;
64
+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return null;
65
+ return `${name} must use https:// (got http:// to ${host} \u2014 refusing to route MCP deep-links over cleartext to a non-loopback host)`;
66
+ }
67
+ return `${name} must use https:// (got ${parsed.protocol})`;
68
+ }
69
+ function resolvePublicUrlEnv(name, rawValue, defaultValue) {
70
+ const value = rawValue ?? defaultValue;
71
+ const err = validatePublicUrlEnv(name, value);
72
+ if (err) throw new Error(err);
73
+ return trimTrailingSlash(value);
74
+ }
54
75
  function loadMcpConfig(env = process.env) {
55
- const backendBaseUrl = trimTrailingSlash(env.MUHAVEN_BACKEND_URL ?? DEFAULT_BACKEND_URL);
56
- const dashboardBaseUrl = trimTrailingSlash(env.MUHAVEN_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL);
76
+ const backendBaseUrl = resolvePublicUrlEnv(
77
+ "MUHAVEN_BACKEND_URL",
78
+ env.MUHAVEN_BACKEND_URL,
79
+ DEFAULT_BACKEND_URL
80
+ );
81
+ const dashboardBaseUrl = resolvePublicUrlEnv(
82
+ "MUHAVEN_DASHBOARD_URL",
83
+ env.MUHAVEN_DASHBOARD_URL,
84
+ DEFAULT_DASHBOARD_URL
85
+ );
57
86
  const brokerEndpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
58
87
  const readOnly = readEnvBool("MUHAVEN_READ_ONLY", false, env);
59
88
  const requestTimeoutMs = readEnvInt("MUHAVEN_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, env);
@@ -83,8 +112,16 @@ function loadBrokerConfig(env = process.env) {
83
112
  const endpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
84
113
  const maxRequestBytes = readEnvInt("MUHAVEN_BROKER_MAX_BYTES", DEFAULT_BROKER_MAX_BYTES, env);
85
114
  const requestTimeoutMs = readEnvInt("MUHAVEN_BROKER_TIMEOUT_MS", DEFAULT_BROKER_TIMEOUT_MS, env);
86
- const backendBaseUrl = trimTrailingSlash(env.MUHAVEN_BACKEND_URL ?? DEFAULT_BACKEND_URL);
87
- const dashboardBaseUrl = trimTrailingSlash(env.MUHAVEN_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL);
115
+ const backendBaseUrl = resolvePublicUrlEnv(
116
+ "MUHAVEN_BACKEND_URL",
117
+ env.MUHAVEN_BACKEND_URL,
118
+ DEFAULT_BACKEND_URL
119
+ );
120
+ const dashboardBaseUrl = resolvePublicUrlEnv(
121
+ "MUHAVEN_DASHBOARD_URL",
122
+ env.MUHAVEN_DASHBOARD_URL,
123
+ DEFAULT_DASHBOARD_URL
124
+ );
88
125
  return {
89
126
  endpoint,
90
127
  sessionKeyHex,
@@ -1105,12 +1142,34 @@ function parseSetupFlags(argv) {
1105
1142
  registerScope
1106
1143
  };
1107
1144
  }
1145
+ var SHELL_METACHAR_RE = /["\\\n\r&|;`<>()%$]/;
1146
+ var SAFE_KEYRING_VALUES = /* @__PURE__ */ new Set(["file", "os"]);
1108
1147
  function buildRegisterEnv(effectiveEnv) {
1109
1148
  const env = {};
1110
- if (effectiveEnv.MUHAVEN_BACKEND_URL) env.MUHAVEN_BACKEND_URL = effectiveEnv.MUHAVEN_BACKEND_URL;
1111
- if (effectiveEnv.MUHAVEN_DASHBOARD_URL) env.MUHAVEN_DASHBOARD_URL = effectiveEnv.MUHAVEN_DASHBOARD_URL;
1112
- if (effectiveEnv.MUHAVEN_KEYRING) env.MUHAVEN_KEYRING = effectiveEnv.MUHAVEN_KEYRING;
1113
- return env;
1149
+ const warnings = [];
1150
+ function acceptOrWarn(name, value) {
1151
+ if (!value) return;
1152
+ if (SHELL_METACHAR_RE.test(value)) {
1153
+ warnings.push(
1154
+ `${name} contains shell metacharacters and was dropped from the host config \u2014 set a clean value in your env if you need a non-default.`
1155
+ );
1156
+ return;
1157
+ }
1158
+ env[name] = value;
1159
+ }
1160
+ acceptOrWarn("MUHAVEN_BACKEND_URL", effectiveEnv.MUHAVEN_BACKEND_URL);
1161
+ acceptOrWarn("MUHAVEN_DASHBOARD_URL", effectiveEnv.MUHAVEN_DASHBOARD_URL);
1162
+ const keyring = effectiveEnv.MUHAVEN_KEYRING;
1163
+ if (keyring) {
1164
+ if (!SAFE_KEYRING_VALUES.has(keyring)) {
1165
+ warnings.push(
1166
+ `MUHAVEN_KEYRING="${keyring}" is not one of the recognized values (file, os) \u2014 dropped from the host config.`
1167
+ );
1168
+ } else {
1169
+ env.MUHAVEN_KEYRING = keyring;
1170
+ }
1171
+ }
1172
+ return { env, warnings };
1114
1173
  }
1115
1174
  function buildClaudeMcpRegisterJson(registerEnv) {
1116
1175
  const payload = {
@@ -1128,6 +1187,7 @@ function buildClaudeMcpAddJsonArgv(serverName, json, scope) {
1128
1187
  function buildClaudeMcpRemoveArgv(serverName, scope) {
1129
1188
  return ["mcp", "remove", serverName, "--scope", scope];
1130
1189
  }
1190
+ var CLAUDE_REMOVE_NOT_FOUND_RE = /(no.*server|not found|does not exist|no MCP server)/i;
1131
1191
  async function registerWithHost(deps, options) {
1132
1192
  if (options.host === "claude-code") {
1133
1193
  return registerWithClaudeCode(deps, options);
@@ -1152,7 +1212,28 @@ async function registerWithClaudeCode(deps, options) {
1152
1212
  cmd: "claude --version"
1153
1213
  };
1154
1214
  }
1155
- await deps.shellOut("claude", buildClaudeMcpRemoveArgv(options.serverName, options.scope));
1215
+ const warnings = [];
1216
+ let removeResult = null;
1217
+ try {
1218
+ removeResult = await deps.shellOut(
1219
+ "claude",
1220
+ buildClaudeMcpRemoveArgv(options.serverName, options.scope)
1221
+ );
1222
+ } catch (err) {
1223
+ warnings.push(
1224
+ `claude mcp remove threw before exit: ${err.message}. If the add below succeeds but you see duplicates in ~/.claude.json, inspect file permissions.`
1225
+ );
1226
+ }
1227
+ if (removeResult && removeResult.exitCode !== 0) {
1228
+ const stderr = removeResult.stderr.trim();
1229
+ const stdout = removeResult.stdout.trim();
1230
+ const combined = [stderr, stdout].filter((s) => s.length > 0).join(" | ");
1231
+ if (!CLAUDE_REMOVE_NOT_FOUND_RE.test(combined)) {
1232
+ warnings.push(
1233
+ `claude mcp remove returned exit ${removeResult.exitCode}: ${combined || "(no stderr)"}. Continuing with add. If you see duplicate muhaven entries afterwards, inspect ~/.claude.json or the project's .mcp.json manually.`
1234
+ );
1235
+ }
1236
+ }
1156
1237
  const json = buildClaudeMcpRegisterJson(options.registerEnv);
1157
1238
  const addArgv = buildClaudeMcpAddJsonArgv(options.serverName, json, options.scope);
1158
1239
  let add;
@@ -1166,10 +1247,11 @@ async function registerWithClaudeCode(deps, options) {
1166
1247
  };
1167
1248
  }
1168
1249
  if (add.exitCode !== 0) {
1169
- const reason = [add.stderr, add.stdout].map((s) => s.trim()).filter((s) => s.length > 0).join(" | ") || `exit ${add.exitCode}`;
1250
+ const addReason = [add.stderr, add.stdout].map((s) => s.trim()).filter((s) => s.length > 0).join(" | ") || `exit ${add.exitCode}`;
1251
+ const reason = warnings.length > 0 ? `${addReason} (preceding remove also surfaced: ${warnings.join("; ")})` : addReason;
1170
1252
  return { status: "failed", host: options.host, reason };
1171
1253
  }
1172
- return { status: "registered", host: options.host, scope: options.scope };
1254
+ return warnings.length > 0 ? { status: "registered", host: options.host, scope: options.scope, warnings } : { status: "registered", host: options.host, scope: options.scope };
1173
1255
  }
1174
1256
  async function runSetup(argv, deps) {
1175
1257
  let flags;
@@ -1342,7 +1424,10 @@ async function runSetup(argv, deps) {
1342
1424
  }
1343
1425
  }
1344
1426
  if (flags.register.length > 0) {
1345
- const registerEnv = buildRegisterEnv(effectiveEnv);
1427
+ const { env: registerEnv, warnings: envWarnings } = buildRegisterEnv(effectiveEnv);
1428
+ for (const w of envWarnings) {
1429
+ deps.printErr(`Host register env: ${w}`);
1430
+ }
1346
1431
  for (const host of flags.register) {
1347
1432
  const outcome = await registerWithHost(deps, {
1348
1433
  host,
@@ -1355,6 +1440,11 @@ async function runSetup(argv, deps) {
1355
1440
  deps.print(
1356
1441
  `Host register: ${outcome.host} wired (scope: ${outcome.scope}). Restart the host to pick up the new MCP server.`
1357
1442
  );
1443
+ if (outcome.warnings) {
1444
+ for (const w of outcome.warnings) {
1445
+ deps.printErr(`Host register warning: ${w}`);
1446
+ }
1447
+ }
1358
1448
  break;
1359
1449
  case "cli_missing":
1360
1450
  deps.printErr(
@@ -1750,7 +1840,7 @@ function printUsage() {
1750
1840
  }
1751
1841
  function getBrokerPackageVersion() {
1752
1842
  {
1753
- return "0.1.7";
1843
+ return "0.2.1";
1754
1844
  }
1755
1845
  }
1756
1846
  function printVersion() {
package/dist/broker.js CHANGED
@@ -53,9 +53,38 @@ function deriveAllowedHosts(baseUrl) {
53
53
  function trimTrailingSlash(s) {
54
54
  return s.endsWith("/") ? s.slice(0, -1) : s;
55
55
  }
56
+ function validatePublicUrlEnv(name, value) {
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(value);
60
+ } catch {
61
+ return `${name} is not a valid URL: ${value}`;
62
+ }
63
+ if (parsed.protocol === "https:") return null;
64
+ if (parsed.protocol === "http:") {
65
+ const host = parsed.hostname;
66
+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return null;
67
+ return `${name} must use https:// (got http:// to ${host} \u2014 refusing to route MCP deep-links over cleartext to a non-loopback host)`;
68
+ }
69
+ return `${name} must use https:// (got ${parsed.protocol})`;
70
+ }
71
+ function resolvePublicUrlEnv(name, rawValue, defaultValue) {
72
+ const value = rawValue ?? defaultValue;
73
+ const err = validatePublicUrlEnv(name, value);
74
+ if (err) throw new Error(err);
75
+ return trimTrailingSlash(value);
76
+ }
56
77
  function loadMcpConfig(env = process.env) {
57
- const backendBaseUrl = trimTrailingSlash(env.MUHAVEN_BACKEND_URL ?? DEFAULT_BACKEND_URL);
58
- const dashboardBaseUrl = trimTrailingSlash(env.MUHAVEN_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL);
78
+ const backendBaseUrl = resolvePublicUrlEnv(
79
+ "MUHAVEN_BACKEND_URL",
80
+ env.MUHAVEN_BACKEND_URL,
81
+ DEFAULT_BACKEND_URL
82
+ );
83
+ const dashboardBaseUrl = resolvePublicUrlEnv(
84
+ "MUHAVEN_DASHBOARD_URL",
85
+ env.MUHAVEN_DASHBOARD_URL,
86
+ DEFAULT_DASHBOARD_URL
87
+ );
59
88
  const brokerEndpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
60
89
  const readOnly = readEnvBool("MUHAVEN_READ_ONLY", false, env);
61
90
  const requestTimeoutMs = readEnvInt("MUHAVEN_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, env);
@@ -85,8 +114,16 @@ function loadBrokerConfig(env = process.env) {
85
114
  const endpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
86
115
  const maxRequestBytes = readEnvInt("MUHAVEN_BROKER_MAX_BYTES", DEFAULT_BROKER_MAX_BYTES, env);
87
116
  const requestTimeoutMs = readEnvInt("MUHAVEN_BROKER_TIMEOUT_MS", DEFAULT_BROKER_TIMEOUT_MS, env);
88
- const backendBaseUrl = trimTrailingSlash(env.MUHAVEN_BACKEND_URL ?? DEFAULT_BACKEND_URL);
89
- const dashboardBaseUrl = trimTrailingSlash(env.MUHAVEN_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL);
117
+ const backendBaseUrl = resolvePublicUrlEnv(
118
+ "MUHAVEN_BACKEND_URL",
119
+ env.MUHAVEN_BACKEND_URL,
120
+ DEFAULT_BACKEND_URL
121
+ );
122
+ const dashboardBaseUrl = resolvePublicUrlEnv(
123
+ "MUHAVEN_DASHBOARD_URL",
124
+ env.MUHAVEN_DASHBOARD_URL,
125
+ DEFAULT_DASHBOARD_URL
126
+ );
90
127
  return {
91
128
  endpoint,
92
129
  sessionKeyHex,
@@ -1107,12 +1144,34 @@ function parseSetupFlags(argv) {
1107
1144
  registerScope
1108
1145
  };
1109
1146
  }
1147
+ var SHELL_METACHAR_RE = /["\\\n\r&|;`<>()%$]/;
1148
+ var SAFE_KEYRING_VALUES = /* @__PURE__ */ new Set(["file", "os"]);
1110
1149
  function buildRegisterEnv(effectiveEnv) {
1111
1150
  const env = {};
1112
- if (effectiveEnv.MUHAVEN_BACKEND_URL) env.MUHAVEN_BACKEND_URL = effectiveEnv.MUHAVEN_BACKEND_URL;
1113
- if (effectiveEnv.MUHAVEN_DASHBOARD_URL) env.MUHAVEN_DASHBOARD_URL = effectiveEnv.MUHAVEN_DASHBOARD_URL;
1114
- if (effectiveEnv.MUHAVEN_KEYRING) env.MUHAVEN_KEYRING = effectiveEnv.MUHAVEN_KEYRING;
1115
- return env;
1151
+ const warnings = [];
1152
+ function acceptOrWarn(name, value) {
1153
+ if (!value) return;
1154
+ if (SHELL_METACHAR_RE.test(value)) {
1155
+ warnings.push(
1156
+ `${name} contains shell metacharacters and was dropped from the host config \u2014 set a clean value in your env if you need a non-default.`
1157
+ );
1158
+ return;
1159
+ }
1160
+ env[name] = value;
1161
+ }
1162
+ acceptOrWarn("MUHAVEN_BACKEND_URL", effectiveEnv.MUHAVEN_BACKEND_URL);
1163
+ acceptOrWarn("MUHAVEN_DASHBOARD_URL", effectiveEnv.MUHAVEN_DASHBOARD_URL);
1164
+ const keyring = effectiveEnv.MUHAVEN_KEYRING;
1165
+ if (keyring) {
1166
+ if (!SAFE_KEYRING_VALUES.has(keyring)) {
1167
+ warnings.push(
1168
+ `MUHAVEN_KEYRING="${keyring}" is not one of the recognized values (file, os) \u2014 dropped from the host config.`
1169
+ );
1170
+ } else {
1171
+ env.MUHAVEN_KEYRING = keyring;
1172
+ }
1173
+ }
1174
+ return { env, warnings };
1116
1175
  }
1117
1176
  function buildClaudeMcpRegisterJson(registerEnv) {
1118
1177
  const payload = {
@@ -1130,6 +1189,7 @@ function buildClaudeMcpAddJsonArgv(serverName, json, scope) {
1130
1189
  function buildClaudeMcpRemoveArgv(serverName, scope) {
1131
1190
  return ["mcp", "remove", serverName, "--scope", scope];
1132
1191
  }
1192
+ var CLAUDE_REMOVE_NOT_FOUND_RE = /(no.*server|not found|does not exist|no MCP server)/i;
1133
1193
  async function registerWithHost(deps, options) {
1134
1194
  if (options.host === "claude-code") {
1135
1195
  return registerWithClaudeCode(deps, options);
@@ -1154,7 +1214,28 @@ async function registerWithClaudeCode(deps, options) {
1154
1214
  cmd: "claude --version"
1155
1215
  };
1156
1216
  }
1157
- await deps.shellOut("claude", buildClaudeMcpRemoveArgv(options.serverName, options.scope));
1217
+ const warnings = [];
1218
+ let removeResult = null;
1219
+ try {
1220
+ removeResult = await deps.shellOut(
1221
+ "claude",
1222
+ buildClaudeMcpRemoveArgv(options.serverName, options.scope)
1223
+ );
1224
+ } catch (err) {
1225
+ warnings.push(
1226
+ `claude mcp remove threw before exit: ${err.message}. If the add below succeeds but you see duplicates in ~/.claude.json, inspect file permissions.`
1227
+ );
1228
+ }
1229
+ if (removeResult && removeResult.exitCode !== 0) {
1230
+ const stderr = removeResult.stderr.trim();
1231
+ const stdout = removeResult.stdout.trim();
1232
+ const combined = [stderr, stdout].filter((s) => s.length > 0).join(" | ");
1233
+ if (!CLAUDE_REMOVE_NOT_FOUND_RE.test(combined)) {
1234
+ warnings.push(
1235
+ `claude mcp remove returned exit ${removeResult.exitCode}: ${combined || "(no stderr)"}. Continuing with add. If you see duplicate muhaven entries afterwards, inspect ~/.claude.json or the project's .mcp.json manually.`
1236
+ );
1237
+ }
1238
+ }
1158
1239
  const json = buildClaudeMcpRegisterJson(options.registerEnv);
1159
1240
  const addArgv = buildClaudeMcpAddJsonArgv(options.serverName, json, options.scope);
1160
1241
  let add;
@@ -1168,10 +1249,11 @@ async function registerWithClaudeCode(deps, options) {
1168
1249
  };
1169
1250
  }
1170
1251
  if (add.exitCode !== 0) {
1171
- const reason = [add.stderr, add.stdout].map((s) => s.trim()).filter((s) => s.length > 0).join(" | ") || `exit ${add.exitCode}`;
1252
+ const addReason = [add.stderr, add.stdout].map((s) => s.trim()).filter((s) => s.length > 0).join(" | ") || `exit ${add.exitCode}`;
1253
+ const reason = warnings.length > 0 ? `${addReason} (preceding remove also surfaced: ${warnings.join("; ")})` : addReason;
1172
1254
  return { status: "failed", host: options.host, reason };
1173
1255
  }
1174
- return { status: "registered", host: options.host, scope: options.scope };
1256
+ return warnings.length > 0 ? { status: "registered", host: options.host, scope: options.scope, warnings } : { status: "registered", host: options.host, scope: options.scope };
1175
1257
  }
1176
1258
  async function runSetup(argv, deps) {
1177
1259
  let flags;
@@ -1344,7 +1426,10 @@ async function runSetup(argv, deps) {
1344
1426
  }
1345
1427
  }
1346
1428
  if (flags.register.length > 0) {
1347
- const registerEnv = buildRegisterEnv(effectiveEnv);
1429
+ const { env: registerEnv, warnings: envWarnings } = buildRegisterEnv(effectiveEnv);
1430
+ for (const w of envWarnings) {
1431
+ deps.printErr(`Host register env: ${w}`);
1432
+ }
1348
1433
  for (const host of flags.register) {
1349
1434
  const outcome = await registerWithHost(deps, {
1350
1435
  host,
@@ -1357,6 +1442,11 @@ async function runSetup(argv, deps) {
1357
1442
  deps.print(
1358
1443
  `Host register: ${outcome.host} wired (scope: ${outcome.scope}). Restart the host to pick up the new MCP server.`
1359
1444
  );
1445
+ if (outcome.warnings) {
1446
+ for (const w of outcome.warnings) {
1447
+ deps.printErr(`Host register warning: ${w}`);
1448
+ }
1449
+ }
1360
1450
  break;
1361
1451
  case "cli_missing":
1362
1452
  deps.printErr(
@@ -1752,7 +1842,7 @@ function printUsage() {
1752
1842
  }
1753
1843
  function getBrokerPackageVersion() {
1754
1844
  {
1755
- return "0.1.7";
1845
+ return "0.2.1";
1756
1846
  }
1757
1847
  }
1758
1848
  function printVersion() {