@muhaven/mcp 0.1.6 → 0.2.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/CHANGELOG.md +195 -0
- package/dist/broker.cjs +103 -13
- package/dist/broker.js +103 -13
- package/dist/index.cjs +162 -120
- package/dist/index.d.cts +1 -21
- package/dist/index.d.ts +1 -21
- package/dist/index.js +162 -120
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/tool-hashes.json +9 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,201 @@ 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
|
+
|
|
126
|
+
## [0.1.7] — 2026-05-18
|
|
127
|
+
|
|
128
|
+
`position.*` tools can now drive real on-chain action via @muhaven/mcp
|
|
129
|
+
— Path C of MCP Option A (dashboard URL elicitation → existing passkey
|
|
130
|
+
ceremony). Pre-0.1.7, position tools returned a placeholder UserOp
|
|
131
|
+
envelope + broker signature that no host could submit; the path was
|
|
132
|
+
attestation-only despite implying buy/sell/claim. 0.1.7 swaps the
|
|
133
|
+
envelope for a pre-filled dashboard deep-link URL the user opens to
|
|
134
|
+
review + tap their passkey through the existing dashboard flow.
|
|
135
|
+
|
|
136
|
+
### Added
|
|
137
|
+
|
|
138
|
+
- **`muhaven.cash.wrap`** — new tool. Returns a `/cash?amount=` deep-
|
|
139
|
+
link for USDC → mhUSDC conversion. Common LLM chain: `read.portfolio`
|
|
140
|
+
→ notice 0 mhUSDC → `cash.wrap` → then `position.buy` (each is its
|
|
141
|
+
own user-confirmed deep-link). Input is human-readable USDC ("100" =
|
|
142
|
+
$100). 23 tools total now (was 22).
|
|
143
|
+
|
|
144
|
+
- **Token identifier accepts symbols OR addresses.** Every `position.*`
|
|
145
|
+
tool's `token` field used to require a 0x-address. Now accepts either
|
|
146
|
+
a symbol ("TBILL1") or a 0x-address. The dashboard pages resolve the
|
|
147
|
+
symbol via the marketplace store; unknown identifiers leave the form
|
|
148
|
+
blank for the user to fill in. Saves the LLM a round-trip through
|
|
149
|
+
`read.tokens` for the common "buy 5 of TBILL1" flow.
|
|
150
|
+
|
|
151
|
+
- **Exported pure helpers** (`buildPositionDeeplink`,
|
|
152
|
+
`formatUsdc6ToDecimal`) so third-party MCP servers + tests can reason
|
|
153
|
+
about the URL shape without spawning anything.
|
|
154
|
+
|
|
155
|
+
### Changed
|
|
156
|
+
|
|
157
|
+
- **`position.buy/sell/claim` return shape** is now `{ dashboardUrl,
|
|
158
|
+
action, instructions, echo }` instead of `{ intentHash,
|
|
159
|
+
unsignedUserOp, brokerSignature, signerAddress }`. The `instructions`
|
|
160
|
+
field is a pre-formatted two-line string the LLM can show the user
|
|
161
|
+
verbatim ("Open this link to review and authorize..."). The `echo`
|
|
162
|
+
field mirrors input for LLM self-verification. **Breaking** for any
|
|
163
|
+
consumer that pinned the 0.1.6 response shape — the prior shape was
|
|
164
|
+
itself never end-to-end usable (placeholder envelope), so the
|
|
165
|
+
practical impact is "MCP buy now actually works" rather than
|
|
166
|
+
regression.
|
|
167
|
+
|
|
168
|
+
- **`position.rebalance`** returns `not_implemented` with a clear
|
|
169
|
+
next-step hint pointing at single-leg `position.buy` / `position.sell`
|
|
170
|
+
or the dashboard. Multi-leg `execute_plan` (one URL, one passkey,
|
|
171
|
+
one batched UserOp) lands in Wave 5 with composite preview UI.
|
|
172
|
+
|
|
173
|
+
- **Broker dep no longer required for position tools.** Previously, a
|
|
174
|
+
`position.buy` call without a running `muhaven-broker` daemon
|
|
175
|
+
returned `broker.unavailable`. Now position tools talk only to the
|
|
176
|
+
dashboard URL — the broker is still needed for `read.*` / governance
|
|
177
|
+
/ issuer / policy tools (those use the JWT-authed path).
|
|
178
|
+
|
|
179
|
+
### Removed
|
|
180
|
+
|
|
181
|
+
- `signEnvelope` + `PositionEnvelopeData` + the per-process
|
|
182
|
+
`hasSessionKey` probe cache + `__resetSessionKeyProbeCacheForTests`'s
|
|
183
|
+
cache (the function is retained as a no-op for back-compat with any
|
|
184
|
+
test harness importing it). The whole broker-attestation path for
|
|
185
|
+
position tools is gone — they don't need a signing key at all.
|
|
186
|
+
|
|
187
|
+
### Internal
|
|
188
|
+
|
|
189
|
+
- 21 new vitest cases in `__tests__/position-deeplink.test.ts`,
|
|
190
|
+
replacing `session-key-required.test.ts` (deleted — covered a
|
|
191
|
+
removed code path). Total `@muhaven/mcp` suite: **262/262 passing**
|
|
192
|
+
(was 241). Tool-hash count: **23** (was 22).
|
|
193
|
+
|
|
194
|
+
### Operator notes
|
|
195
|
+
|
|
196
|
+
- Once installed, the fresh-install ritual is unchanged: `muhaven-broker
|
|
197
|
+
setup --register claude-code` still wires the MCP server into Claude
|
|
198
|
+
Code via `claude mcp add-json`.
|
|
199
|
+
- Existing 0.1.6 installs: `npm install -g @muhaven/mcp@latest` picks up
|
|
200
|
+
the new bin; no setup re-run needed.
|
|
201
|
+
- `MUHAVEN_DASHBOARD_URL` env var (defaults to `https://muhaven.app`)
|
|
202
|
+
now drives the deep-link URL prefix; staging operators set it to
|
|
203
|
+
`https://muhaven-staging.example` and the URLs flow through.
|
|
204
|
+
|
|
10
205
|
## [0.1.6] — 2026-05-17
|
|
11
206
|
|
|
12
207
|
Adds `muhaven-broker setup --register HOST` so a fresh install no longer
|
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 =
|
|
56
|
-
|
|
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 =
|
|
87
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1843
|
+
return "0.2.0";
|
|
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 =
|
|
58
|
-
|
|
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 =
|
|
89
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1845
|
+
return "0.2.0";
|
|
1756
1846
|
}
|
|
1757
1847
|
}
|
|
1758
1848
|
function printVersion() {
|