@muhaven/mcp 0.1.7 → 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 +116 -0
- package/dist/broker.cjs +103 -13
- package/dist/broker.js +103 -13
- package/dist/index.cjs +71 -35
- package/dist/index.js +71 -35
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/tool-hashes.json +3 -3
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 =
|
|
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() {
|
package/dist/index.cjs
CHANGED
|
@@ -11,7 +11,6 @@ var zod = require('zod');
|
|
|
11
11
|
var os = require('os');
|
|
12
12
|
var net = require('net');
|
|
13
13
|
var crypto = require('crypto');
|
|
14
|
-
require('viem');
|
|
15
14
|
var accounts = require('viem/accounts');
|
|
16
15
|
|
|
17
16
|
// ../../node_modules/.pnpm/tsup@8.5.1_postcss@8.5.14_tsx@4.21.0_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js
|
|
@@ -61,9 +60,38 @@ function deriveAllowedHosts(baseUrl) {
|
|
|
61
60
|
function trimTrailingSlash(s) {
|
|
62
61
|
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
63
62
|
}
|
|
63
|
+
function validatePublicUrlEnv(name, value) {
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = new URL(value);
|
|
67
|
+
} catch {
|
|
68
|
+
return `${name} is not a valid URL: ${value}`;
|
|
69
|
+
}
|
|
70
|
+
if (parsed.protocol === "https:") return null;
|
|
71
|
+
if (parsed.protocol === "http:") {
|
|
72
|
+
const host = parsed.hostname;
|
|
73
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return null;
|
|
74
|
+
return `${name} must use https:// (got http:// to ${host} \u2014 refusing to route MCP deep-links over cleartext to a non-loopback host)`;
|
|
75
|
+
}
|
|
76
|
+
return `${name} must use https:// (got ${parsed.protocol})`;
|
|
77
|
+
}
|
|
78
|
+
function resolvePublicUrlEnv(name, rawValue, defaultValue) {
|
|
79
|
+
const value = rawValue ?? defaultValue;
|
|
80
|
+
const err2 = validatePublicUrlEnv(name, value);
|
|
81
|
+
if (err2) throw new Error(err2);
|
|
82
|
+
return trimTrailingSlash(value);
|
|
83
|
+
}
|
|
64
84
|
function loadMcpConfig(env = process.env) {
|
|
65
|
-
const backendBaseUrl =
|
|
66
|
-
|
|
85
|
+
const backendBaseUrl = resolvePublicUrlEnv(
|
|
86
|
+
"MUHAVEN_BACKEND_URL",
|
|
87
|
+
env.MUHAVEN_BACKEND_URL,
|
|
88
|
+
DEFAULT_BACKEND_URL
|
|
89
|
+
);
|
|
90
|
+
const dashboardBaseUrl = resolvePublicUrlEnv(
|
|
91
|
+
"MUHAVEN_DASHBOARD_URL",
|
|
92
|
+
env.MUHAVEN_DASHBOARD_URL,
|
|
93
|
+
DEFAULT_DASHBOARD_URL
|
|
94
|
+
);
|
|
67
95
|
const brokerEndpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
|
|
68
96
|
const readOnly = readEnvBool("MUHAVEN_READ_ONLY", false, env);
|
|
69
97
|
const requestTimeoutMs = readEnvInt("MUHAVEN_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, env);
|
|
@@ -93,8 +121,16 @@ function loadBrokerConfig(env = process.env) {
|
|
|
93
121
|
const endpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
|
|
94
122
|
const maxRequestBytes = readEnvInt("MUHAVEN_BROKER_MAX_BYTES", DEFAULT_BROKER_MAX_BYTES, env);
|
|
95
123
|
const requestTimeoutMs = readEnvInt("MUHAVEN_BROKER_TIMEOUT_MS", DEFAULT_BROKER_TIMEOUT_MS, env);
|
|
96
|
-
const backendBaseUrl =
|
|
97
|
-
|
|
124
|
+
const backendBaseUrl = resolvePublicUrlEnv(
|
|
125
|
+
"MUHAVEN_BACKEND_URL",
|
|
126
|
+
env.MUHAVEN_BACKEND_URL,
|
|
127
|
+
DEFAULT_BACKEND_URL
|
|
128
|
+
);
|
|
129
|
+
const dashboardBaseUrl = resolvePublicUrlEnv(
|
|
130
|
+
"MUHAVEN_DASHBOARD_URL",
|
|
131
|
+
env.MUHAVEN_DASHBOARD_URL,
|
|
132
|
+
DEFAULT_DASHBOARD_URL
|
|
133
|
+
);
|
|
98
134
|
return {
|
|
99
135
|
endpoint,
|
|
100
136
|
sessionKeyHex,
|
|
@@ -467,13 +503,13 @@ var TOOL_DESCRIPTORS = [
|
|
|
467
503
|
{
|
|
468
504
|
name: "muhaven.position.buy",
|
|
469
505
|
group: "position",
|
|
470
|
-
description:
|
|
506
|
+
description: 'Prepare a Subscription buy. Returns a dashboard deep-link URL (muhaven.app/trade?mode=buy&...) the user opens to review the pre-filled form, then taps Authorize. The user\'s passkey + ZeroDev kernel sign on the dashboard \u2014 this MCP tool never holds or submits a signing key. Use after the user names a clear amount + token (e.g. "Buy 5 mhUSDC of TBILL1" \u2192 `amountUsdc: "5"`). Token accepts either a symbol ("TBILL1") or 0x-address. The `amountUsdc` field is HUMAN-DECIMAL mhUSDC ("5" = 5 mhUSDC, "0.5" = half a mhUSDC) \u2014 NOT base-6 integer. Max 6 fractional digits. Settlement is NOT observable from MCP \u2014 verify by calling muhaven.read.portfolio after the user confirms done.',
|
|
471
507
|
sensitive: true
|
|
472
508
|
},
|
|
473
509
|
{
|
|
474
510
|
name: "muhaven.position.sell",
|
|
475
511
|
group: "position",
|
|
476
|
-
description: "Prepare a Subscription sell. Returns a dashboard deep-link URL (muhaven.app/trade?mode=sell&...) with the form pre-filled. Same passkey + verify-after pattern as muhaven.position.buy. Input is amountShares (raw share count
|
|
512
|
+
description: "Prepare a Subscription sell. Returns a dashboard deep-link URL (muhaven.app/trade?mode=sell&...) with the form pre-filled. Same passkey + verify-after pattern as muhaven.position.buy. Input is amountShares (raw POSITIVE INTEGER share count, NOT mhUSDC notional) \u2014 fhERC-20 shares have no decimals so fractional inputs are rejected.",
|
|
477
513
|
sensitive: true
|
|
478
514
|
},
|
|
479
515
|
{
|
|
@@ -660,16 +696,30 @@ var ReadAuditInputSchema = zod.z.object({
|
|
|
660
696
|
cursor: zod.z.string().min(1).max(512).optional(),
|
|
661
697
|
limit: zod.z.number().int().min(1).max(200).optional()
|
|
662
698
|
}).strict();
|
|
699
|
+
var decimalUsdcAmountSchema = zod.z.string().regex(
|
|
700
|
+
/^(0|[1-9]\d*)(\.\d{1,6})?$/,
|
|
701
|
+
'must be a positive decimal mhUSDC amount with at most 6 fractional digits (e.g. "5", "0.5", "1234.567")'
|
|
702
|
+
).max(48, "must be at most 48 characters");
|
|
663
703
|
var PositionBuyInputSchema = zod.z.object({
|
|
664
704
|
/** Symbol (e.g. "TBILL1") or 0x-address. Path C dashboard resolves either. */
|
|
665
705
|
token: tokenIdentifierSchema,
|
|
666
|
-
/**
|
|
667
|
-
|
|
706
|
+
/**
|
|
707
|
+
* mhUSDC amount in human-decimal units ("5" = 5 mhUSDC, "0.5" =
|
|
708
|
+
* half a mhUSDC). Forwarded verbatim to the dashboard form via
|
|
709
|
+
* `/trade?amount=`. Replaces the prior `amountUsdc6` base-6 integer
|
|
710
|
+
* field — see schema doc above for rationale (LLM-footgun fix).
|
|
711
|
+
*/
|
|
712
|
+
amountUsdc: decimalUsdcAmountSchema
|
|
668
713
|
}).strict();
|
|
669
714
|
var PositionSellInputSchema = zod.z.object({
|
|
670
715
|
token: tokenIdentifierSchema,
|
|
671
|
-
/**
|
|
672
|
-
|
|
716
|
+
/**
|
|
717
|
+
* Share count to redeem. fhERC-20 shares are integer base units
|
|
718
|
+
* (no decimals — see memory `project_decimals_lie_wave4_p0`).
|
|
719
|
+
* Regex rejects any fractional input so a deep-link can't pre-fill
|
|
720
|
+
* "2.5 shares" that would silently floor on the on-chain submit.
|
|
721
|
+
*/
|
|
722
|
+
amountShares: zod.z.string().regex(/^[1-9]\d*$/, "must be a positive integer share count")
|
|
673
723
|
}).strict();
|
|
674
724
|
var PositionClaimInputSchema = zod.z.object({
|
|
675
725
|
token: tokenIdentifierSchema,
|
|
@@ -687,11 +737,13 @@ var PositionRebalanceInputSchema = zod.z.object({
|
|
|
687
737
|
}).strict();
|
|
688
738
|
var CashWrapInputSchema = zod.z.object({
|
|
689
739
|
/**
|
|
690
|
-
* USDC amount in human-
|
|
691
|
-
* $1.50).
|
|
692
|
-
*
|
|
740
|
+
* USDC amount in human-decimal units ("100" for $100, "1.5" for
|
|
741
|
+
* $1.50). Same shape + same regex as `PositionBuyInputSchema.amountUsdc`
|
|
742
|
+
* so the LLM doesn't have to learn two different unit conventions
|
|
743
|
+
* across the Path C surface. Max 6 fractional digits (USDC's base
|
|
744
|
+
* unit floor); 48-char length cap is URL-bloat defense.
|
|
693
745
|
*/
|
|
694
|
-
amountUsdc:
|
|
746
|
+
amountUsdc: decimalUsdcAmountSchema
|
|
695
747
|
}).strict();
|
|
696
748
|
var PolicySetTierInputSchema = zod.z.object({
|
|
697
749
|
targetTier: tierSchema,
|
|
@@ -841,39 +893,23 @@ function buildPositionDeeplink(dashboardBaseUrl, action, params) {
|
|
|
841
893
|
search.set("from", "mcp");
|
|
842
894
|
return `${base}${path}?${search.toString()}`;
|
|
843
895
|
}
|
|
844
|
-
function formatUsdc6ToDecimal(amountUsdc6) {
|
|
845
|
-
if (!/^\d+$/.test(amountUsdc6)) {
|
|
846
|
-
throw new Error(`amountUsdc6 must be a non-negative integer string: ${amountUsdc6}`);
|
|
847
|
-
}
|
|
848
|
-
const padded = amountUsdc6.padStart(7, "0");
|
|
849
|
-
const intPart = padded.slice(0, -6).replace(/^0+(?=\d)/, "");
|
|
850
|
-
const fracPart = padded.slice(-6).replace(/0+$/, "");
|
|
851
|
-
return fracPart === "" ? intPart : `${intPart}.${fracPart}`;
|
|
852
|
-
}
|
|
853
896
|
function resolveDashboardBaseUrl(deps) {
|
|
854
897
|
return deps.dashboardBaseUrl ?? "https://muhaven.app";
|
|
855
898
|
}
|
|
856
899
|
async function positionBuy(input, deps) {
|
|
857
|
-
const amount = formatUsdc6ToDecimal(input.amountUsdc6);
|
|
858
900
|
const dashboardUrl = buildPositionDeeplink(resolveDashboardBaseUrl(deps), "buy", {
|
|
859
901
|
token: input.token,
|
|
860
|
-
amount
|
|
902
|
+
amount: input.amountUsdc
|
|
861
903
|
});
|
|
862
904
|
return ok({
|
|
863
905
|
dashboardUrl,
|
|
864
906
|
action: "buy",
|
|
865
|
-
instructions: `Open this link to review and authorize the buy of ${
|
|
907
|
+
instructions: `Open this link to review and authorize the buy of ${input.amountUsdc} mhUSDC of ${input.token}:
|
|
866
908
|
${dashboardUrl}`,
|
|
867
|
-
echo: { action: "buy", token: input.token, amount }
|
|
909
|
+
echo: { action: "buy", token: input.token, amount: input.amountUsdc }
|
|
868
910
|
});
|
|
869
911
|
}
|
|
870
912
|
async function positionSell(input, deps) {
|
|
871
|
-
if (!/^\d+(\.\d+)?$/.test(input.amountShares)) {
|
|
872
|
-
return err(
|
|
873
|
-
"invalid_input",
|
|
874
|
-
`amountShares must be a non-negative number string: ${JSON.stringify(input.amountShares)}`
|
|
875
|
-
);
|
|
876
|
-
}
|
|
877
913
|
const dashboardUrl = buildPositionDeeplink(resolveDashboardBaseUrl(deps), "sell", {
|
|
878
914
|
token: input.token,
|
|
879
915
|
shares: input.amountShares
|
|
@@ -1216,7 +1252,7 @@ var SERVER_NAME = "@muhaven/mcp";
|
|
|
1216
1252
|
var SERVER_VERSION = resolveServerVersion();
|
|
1217
1253
|
function resolveServerVersion() {
|
|
1218
1254
|
{
|
|
1219
|
-
return "0.
|
|
1255
|
+
return "0.2.0";
|
|
1220
1256
|
}
|
|
1221
1257
|
}
|
|
1222
1258
|
function toJsonInputSchema(schema) {
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,6 @@ import { z, ZodError } from 'zod';
|
|
|
9
9
|
import { platform, homedir } from 'os';
|
|
10
10
|
import { connect, createServer } from 'net';
|
|
11
11
|
import { createHash } from 'crypto';
|
|
12
|
-
import 'viem';
|
|
13
12
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
14
13
|
|
|
15
14
|
// src/server.ts
|
|
@@ -57,9 +56,38 @@ function deriveAllowedHosts(baseUrl) {
|
|
|
57
56
|
function trimTrailingSlash(s) {
|
|
58
57
|
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
59
58
|
}
|
|
59
|
+
function validatePublicUrlEnv(name, value) {
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = new URL(value);
|
|
63
|
+
} catch {
|
|
64
|
+
return `${name} is not a valid URL: ${value}`;
|
|
65
|
+
}
|
|
66
|
+
if (parsed.protocol === "https:") return null;
|
|
67
|
+
if (parsed.protocol === "http:") {
|
|
68
|
+
const host = parsed.hostname;
|
|
69
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return null;
|
|
70
|
+
return `${name} must use https:// (got http:// to ${host} \u2014 refusing to route MCP deep-links over cleartext to a non-loopback host)`;
|
|
71
|
+
}
|
|
72
|
+
return `${name} must use https:// (got ${parsed.protocol})`;
|
|
73
|
+
}
|
|
74
|
+
function resolvePublicUrlEnv(name, rawValue, defaultValue) {
|
|
75
|
+
const value = rawValue ?? defaultValue;
|
|
76
|
+
const err2 = validatePublicUrlEnv(name, value);
|
|
77
|
+
if (err2) throw new Error(err2);
|
|
78
|
+
return trimTrailingSlash(value);
|
|
79
|
+
}
|
|
60
80
|
function loadMcpConfig(env = process.env) {
|
|
61
|
-
const backendBaseUrl =
|
|
62
|
-
|
|
81
|
+
const backendBaseUrl = resolvePublicUrlEnv(
|
|
82
|
+
"MUHAVEN_BACKEND_URL",
|
|
83
|
+
env.MUHAVEN_BACKEND_URL,
|
|
84
|
+
DEFAULT_BACKEND_URL
|
|
85
|
+
);
|
|
86
|
+
const dashboardBaseUrl = resolvePublicUrlEnv(
|
|
87
|
+
"MUHAVEN_DASHBOARD_URL",
|
|
88
|
+
env.MUHAVEN_DASHBOARD_URL,
|
|
89
|
+
DEFAULT_DASHBOARD_URL
|
|
90
|
+
);
|
|
63
91
|
const brokerEndpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
|
|
64
92
|
const readOnly = readEnvBool("MUHAVEN_READ_ONLY", false, env);
|
|
65
93
|
const requestTimeoutMs = readEnvInt("MUHAVEN_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, env);
|
|
@@ -89,8 +117,16 @@ function loadBrokerConfig(env = process.env) {
|
|
|
89
117
|
const endpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
|
|
90
118
|
const maxRequestBytes = readEnvInt("MUHAVEN_BROKER_MAX_BYTES", DEFAULT_BROKER_MAX_BYTES, env);
|
|
91
119
|
const requestTimeoutMs = readEnvInt("MUHAVEN_BROKER_TIMEOUT_MS", DEFAULT_BROKER_TIMEOUT_MS, env);
|
|
92
|
-
const backendBaseUrl =
|
|
93
|
-
|
|
120
|
+
const backendBaseUrl = resolvePublicUrlEnv(
|
|
121
|
+
"MUHAVEN_BACKEND_URL",
|
|
122
|
+
env.MUHAVEN_BACKEND_URL,
|
|
123
|
+
DEFAULT_BACKEND_URL
|
|
124
|
+
);
|
|
125
|
+
const dashboardBaseUrl = resolvePublicUrlEnv(
|
|
126
|
+
"MUHAVEN_DASHBOARD_URL",
|
|
127
|
+
env.MUHAVEN_DASHBOARD_URL,
|
|
128
|
+
DEFAULT_DASHBOARD_URL
|
|
129
|
+
);
|
|
94
130
|
return {
|
|
95
131
|
endpoint,
|
|
96
132
|
sessionKeyHex,
|
|
@@ -463,13 +499,13 @@ var TOOL_DESCRIPTORS = [
|
|
|
463
499
|
{
|
|
464
500
|
name: "muhaven.position.buy",
|
|
465
501
|
group: "position",
|
|
466
|
-
description:
|
|
502
|
+
description: 'Prepare a Subscription buy. Returns a dashboard deep-link URL (muhaven.app/trade?mode=buy&...) the user opens to review the pre-filled form, then taps Authorize. The user\'s passkey + ZeroDev kernel sign on the dashboard \u2014 this MCP tool never holds or submits a signing key. Use after the user names a clear amount + token (e.g. "Buy 5 mhUSDC of TBILL1" \u2192 `amountUsdc: "5"`). Token accepts either a symbol ("TBILL1") or 0x-address. The `amountUsdc` field is HUMAN-DECIMAL mhUSDC ("5" = 5 mhUSDC, "0.5" = half a mhUSDC) \u2014 NOT base-6 integer. Max 6 fractional digits. Settlement is NOT observable from MCP \u2014 verify by calling muhaven.read.portfolio after the user confirms done.',
|
|
467
503
|
sensitive: true
|
|
468
504
|
},
|
|
469
505
|
{
|
|
470
506
|
name: "muhaven.position.sell",
|
|
471
507
|
group: "position",
|
|
472
|
-
description: "Prepare a Subscription sell. Returns a dashboard deep-link URL (muhaven.app/trade?mode=sell&...) with the form pre-filled. Same passkey + verify-after pattern as muhaven.position.buy. Input is amountShares (raw share count
|
|
508
|
+
description: "Prepare a Subscription sell. Returns a dashboard deep-link URL (muhaven.app/trade?mode=sell&...) with the form pre-filled. Same passkey + verify-after pattern as muhaven.position.buy. Input is amountShares (raw POSITIVE INTEGER share count, NOT mhUSDC notional) \u2014 fhERC-20 shares have no decimals so fractional inputs are rejected.",
|
|
473
509
|
sensitive: true
|
|
474
510
|
},
|
|
475
511
|
{
|
|
@@ -656,16 +692,30 @@ var ReadAuditInputSchema = z.object({
|
|
|
656
692
|
cursor: z.string().min(1).max(512).optional(),
|
|
657
693
|
limit: z.number().int().min(1).max(200).optional()
|
|
658
694
|
}).strict();
|
|
695
|
+
var decimalUsdcAmountSchema = z.string().regex(
|
|
696
|
+
/^(0|[1-9]\d*)(\.\d{1,6})?$/,
|
|
697
|
+
'must be a positive decimal mhUSDC amount with at most 6 fractional digits (e.g. "5", "0.5", "1234.567")'
|
|
698
|
+
).max(48, "must be at most 48 characters");
|
|
659
699
|
var PositionBuyInputSchema = z.object({
|
|
660
700
|
/** Symbol (e.g. "TBILL1") or 0x-address. Path C dashboard resolves either. */
|
|
661
701
|
token: tokenIdentifierSchema,
|
|
662
|
-
/**
|
|
663
|
-
|
|
702
|
+
/**
|
|
703
|
+
* mhUSDC amount in human-decimal units ("5" = 5 mhUSDC, "0.5" =
|
|
704
|
+
* half a mhUSDC). Forwarded verbatim to the dashboard form via
|
|
705
|
+
* `/trade?amount=`. Replaces the prior `amountUsdc6` base-6 integer
|
|
706
|
+
* field — see schema doc above for rationale (LLM-footgun fix).
|
|
707
|
+
*/
|
|
708
|
+
amountUsdc: decimalUsdcAmountSchema
|
|
664
709
|
}).strict();
|
|
665
710
|
var PositionSellInputSchema = z.object({
|
|
666
711
|
token: tokenIdentifierSchema,
|
|
667
|
-
/**
|
|
668
|
-
|
|
712
|
+
/**
|
|
713
|
+
* Share count to redeem. fhERC-20 shares are integer base units
|
|
714
|
+
* (no decimals — see memory `project_decimals_lie_wave4_p0`).
|
|
715
|
+
* Regex rejects any fractional input so a deep-link can't pre-fill
|
|
716
|
+
* "2.5 shares" that would silently floor on the on-chain submit.
|
|
717
|
+
*/
|
|
718
|
+
amountShares: z.string().regex(/^[1-9]\d*$/, "must be a positive integer share count")
|
|
669
719
|
}).strict();
|
|
670
720
|
var PositionClaimInputSchema = z.object({
|
|
671
721
|
token: tokenIdentifierSchema,
|
|
@@ -683,11 +733,13 @@ var PositionRebalanceInputSchema = z.object({
|
|
|
683
733
|
}).strict();
|
|
684
734
|
var CashWrapInputSchema = z.object({
|
|
685
735
|
/**
|
|
686
|
-
* USDC amount in human-
|
|
687
|
-
* $1.50).
|
|
688
|
-
*
|
|
736
|
+
* USDC amount in human-decimal units ("100" for $100, "1.5" for
|
|
737
|
+
* $1.50). Same shape + same regex as `PositionBuyInputSchema.amountUsdc`
|
|
738
|
+
* so the LLM doesn't have to learn two different unit conventions
|
|
739
|
+
* across the Path C surface. Max 6 fractional digits (USDC's base
|
|
740
|
+
* unit floor); 48-char length cap is URL-bloat defense.
|
|
689
741
|
*/
|
|
690
|
-
amountUsdc:
|
|
742
|
+
amountUsdc: decimalUsdcAmountSchema
|
|
691
743
|
}).strict();
|
|
692
744
|
var PolicySetTierInputSchema = z.object({
|
|
693
745
|
targetTier: tierSchema,
|
|
@@ -837,39 +889,23 @@ function buildPositionDeeplink(dashboardBaseUrl, action, params) {
|
|
|
837
889
|
search.set("from", "mcp");
|
|
838
890
|
return `${base}${path}?${search.toString()}`;
|
|
839
891
|
}
|
|
840
|
-
function formatUsdc6ToDecimal(amountUsdc6) {
|
|
841
|
-
if (!/^\d+$/.test(amountUsdc6)) {
|
|
842
|
-
throw new Error(`amountUsdc6 must be a non-negative integer string: ${amountUsdc6}`);
|
|
843
|
-
}
|
|
844
|
-
const padded = amountUsdc6.padStart(7, "0");
|
|
845
|
-
const intPart = padded.slice(0, -6).replace(/^0+(?=\d)/, "");
|
|
846
|
-
const fracPart = padded.slice(-6).replace(/0+$/, "");
|
|
847
|
-
return fracPart === "" ? intPart : `${intPart}.${fracPart}`;
|
|
848
|
-
}
|
|
849
892
|
function resolveDashboardBaseUrl(deps) {
|
|
850
893
|
return deps.dashboardBaseUrl ?? "https://muhaven.app";
|
|
851
894
|
}
|
|
852
895
|
async function positionBuy(input, deps) {
|
|
853
|
-
const amount = formatUsdc6ToDecimal(input.amountUsdc6);
|
|
854
896
|
const dashboardUrl = buildPositionDeeplink(resolveDashboardBaseUrl(deps), "buy", {
|
|
855
897
|
token: input.token,
|
|
856
|
-
amount
|
|
898
|
+
amount: input.amountUsdc
|
|
857
899
|
});
|
|
858
900
|
return ok({
|
|
859
901
|
dashboardUrl,
|
|
860
902
|
action: "buy",
|
|
861
|
-
instructions: `Open this link to review and authorize the buy of ${
|
|
903
|
+
instructions: `Open this link to review and authorize the buy of ${input.amountUsdc} mhUSDC of ${input.token}:
|
|
862
904
|
${dashboardUrl}`,
|
|
863
|
-
echo: { action: "buy", token: input.token, amount }
|
|
905
|
+
echo: { action: "buy", token: input.token, amount: input.amountUsdc }
|
|
864
906
|
});
|
|
865
907
|
}
|
|
866
908
|
async function positionSell(input, deps) {
|
|
867
|
-
if (!/^\d+(\.\d+)?$/.test(input.amountShares)) {
|
|
868
|
-
return err(
|
|
869
|
-
"invalid_input",
|
|
870
|
-
`amountShares must be a non-negative number string: ${JSON.stringify(input.amountShares)}`
|
|
871
|
-
);
|
|
872
|
-
}
|
|
873
909
|
const dashboardUrl = buildPositionDeeplink(resolveDashboardBaseUrl(deps), "sell", {
|
|
874
910
|
token: input.token,
|
|
875
911
|
shares: input.amountShares
|
|
@@ -1212,7 +1248,7 @@ var SERVER_NAME = "@muhaven/mcp";
|
|
|
1212
1248
|
var SERVER_VERSION = resolveServerVersion();
|
|
1213
1249
|
function resolveServerVersion() {
|
|
1214
1250
|
{
|
|
1215
|
-
return "0.
|
|
1251
|
+
return "0.2.0";
|
|
1216
1252
|
}
|
|
1217
1253
|
}
|
|
1218
1254
|
function toJsonInputSchema(schema) {
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.2",
|
|
4
4
|
"name": "muhaven-mcp",
|
|
5
5
|
"display_name": "MuHaven (RWA portfolio)",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.2.0",
|
|
7
7
|
"description": "Confidential RWA portfolio management on Fhenix CoFHE. Read your encrypted balances, propose yield claims and policy changes — all signing happens in a sibling broker daemon, the LLM never sees your private key.",
|
|
8
8
|
"long_description": "MuHaven MCP exposes 22 tools across read.* / position.* / policy.* / issuer.* / governance.* groups for managing real-world asset (RWA) tokens with FHE-encrypted balances. Authentication uses a one-time device-code ceremony (run `muhaven-broker login`); subsequent tool calls fetch the JWT from the broker over a Unix socket. Position / governance tools return unsigned UserOps + broker signatures — they NEVER auto-submit to a bundler. The companion `muhaven-broker` daemon must be running before tools can be invoked. See README for setup.",
|
|
9
9
|
"author": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhaven/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MuHaven MCP server — read/position/policy toolsets bridging Claude Desktop / Cursor / Claude Code to the MuHaven backend, with a sibling muhaven-broker daemon holding the session-key private half over a local IPC socket",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/tool-hashes.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-05-
|
|
2
|
+
"generatedAt": "2026-05-17T19:03:52.417Z",
|
|
3
3
|
"tools": [
|
|
4
4
|
{
|
|
5
5
|
"name": "muhaven.read.portfolio",
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"name": "muhaven.position.buy",
|
|
26
|
-
"sha256": "
|
|
26
|
+
"sha256": "4266f9d91df2086729709e3b1e76c34cb598fd90265aa24de90c256a3a6c217a"
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
"name": "muhaven.position.sell",
|
|
30
|
-
"sha256": "
|
|
30
|
+
"sha256": "58c5090b04ecf4068dafe1f6a62e60b0befc9e0ad1cbbaedbab57e623c0194ce"
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"name": "muhaven.position.claim",
|