diff --git a/cli/commands/hedge.py b/cli/commands/hedge.py index 9f83dd7..e3136cf 100644 --- a/cli/commands/hedge.py +++ b/cli/commands/hedge.py @@ -115,6 +115,23 @@ def _build_proposal( Returns (proposal, snapshot) or raises typer.Exit if no position open. """ + state = hl.get_account_state() + return _build_proposal_from_state( + state, + coin, + mainnet=mainnet, + hedge_instrument=hedge_instrument, + ) + + +def _build_proposal_from_state( + state: Optional[dict], + coin: str, + *, + mainnet: bool = False, + hedge_instrument: Optional[str] = None, +): + """Build a hedge proposal from an already-fetched account state.""" from strategies.cfi_hedge import build_cfi_hedge_proposal, get_cfi_profile from strategies.cfi_funding import ( fetch_cfi_funding_snapshot, @@ -130,7 +147,6 @@ def _build_proposal( typer.echo(f"Error: no deployed CFI v2 profile for coin '{coin}'", err=True) raise typer.Exit(2) - state = hl.get_account_state() if not state: typer.echo("Error: could not fetch HL account state", err=True) raise typer.Exit(1) @@ -162,6 +178,24 @@ def _build_proposal( return proposal, snapshot +def _build_view_only_proposal( + address: str, + coin: str, + *, + mainnet: bool = False, + hedge_instrument: Optional[str] = None, +): + from cli.hl_adapter import read_only_account_state + + state = read_only_account_state(address, testnet=not mainnet) + return _build_proposal_from_state( + state, + coin, + mainnet=mainnet, + hedge_instrument=hedge_instrument, + ) + + # ─── propose ───────────────────────────────────────────────────────────────── @@ -169,6 +203,12 @@ def _build_proposal( def propose_cmd( coin: str = typer.Argument("BTC", help="Coin to hedge (BTC, ETH)"), mainnet: bool = typer.Option(False, "--mainnet", help="Use mainnet (default: testnet)"), + address: Optional[str] = typer.Option( + None, + "--address", + "-a", + help="View-only: build proposal for this address without loading a signing key.", + ), ): """Show a CFI v2 hedge proposal without executing.""" _boot_cli() @@ -176,14 +216,18 @@ def propose_cmd( from cli.config import TradingConfig from cli.hedge_display import hedge_proposal_block from cli.hl_adapter import DirectHLProxy + from cli.view_mode import view_address from parent.hl_proxy import HLProxy - cfg = TradingConfig() - private_key = cfg.get_private_key() - raw_hl = HLProxy(private_key=private_key, testnet=not mainnet) - hl = DirectHLProxy(raw_hl) - - proposal, snapshot = _build_proposal(hl, coin, mainnet=mainnet) + view_only_address = view_address(address) + if view_only_address: + proposal, snapshot = _build_view_only_proposal(view_only_address, coin, mainnet=mainnet) + else: + cfg = TradingConfig() + private_key = cfg.get_private_key() + raw_hl = HLProxy(private_key=private_key, testnet=not mainnet) + hl = DirectHLProxy(raw_hl) + proposal, snapshot = _build_proposal(hl, coin, mainnet=mainnet) typer.echo(hedge_proposal_block(proposal, snapshot, mainnet=mainnet)) @@ -196,6 +240,12 @@ def execute_cmd( dry_run: bool = typer.Option(False, "--dry-run", help="Preview only; do not sign or submit"), yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirm"), mainnet: bool = typer.Option(False, "--mainnet", help="Use mainnet (default: testnet)"), + address: Optional[str] = typer.Option( + None, + "--address", + "-a", + help="View-only dry-run: preview this address without loading a signing key.", + ), ): """Build the proposal and optionally sign + submit a real yex:{COIN}SWP order. @@ -207,14 +257,19 @@ def execute_cmd( from cli.display import BOLD, GREEN, RESET from cli.hedge_display import hedge_proposal_block from cli.hl_adapter import DirectHLProxy + from cli.view_mode import view_address from parent.hl_proxy import HLProxy - cfg = TradingConfig() - private_key = cfg.get_private_key() - raw_hl = HLProxy(private_key=private_key, testnet=not mainnet) - hl = DirectHLProxy(raw_hl) - - proposal, snapshot = _build_proposal(hl, coin, mainnet=mainnet) + view_only_address = view_address(address) + if view_only_address and dry_run: + proposal, snapshot = _build_view_only_proposal(view_only_address, coin, mainnet=mainnet) + hl = None + else: + cfg = TradingConfig() + private_key = cfg.get_private_key() + raw_hl = HLProxy(private_key=private_key, testnet=not mainnet) + hl = DirectHLProxy(raw_hl) + proposal, snapshot = _build_proposal(hl, coin, mainnet=mainnet) typer.echo(hedge_proposal_block(proposal, snapshot, mainnet=mainnet)) # Size the order in CFI v2 (BTCSWP) units. SDK rounds to szDecimals. @@ -417,12 +472,35 @@ def backtest_cmd( / "hedge_calculator.py" ) if not script_path.exists(): + from strategies.cfi_hedge import get_cfi_profile, hourly_to_apy + from strategies.cfi_funding import fetch_cfi_funding_snapshot + + profile = get_cfi_profile(coin, mainnet=False) + if profile is None: + typer.echo(f"Error: no CFI profile for coin '{coin}'", err=True) + raise typer.Exit(2) + snapshot = fetch_cfi_funding_snapshot(profile) + hedge_notional = notional / profile.vol_mult_l + fixed_cost = notional * snapshot.k_fixed_hr * 24 * days typer.echo( - f"Error: hedge_calculator.py not found at {script_path}. " - f"Pass --script to override.", - err=True, + json.dumps( + { + "mode": "builtin_cfi_projection", + "note": "Reference hedge_calculator.py was not packaged; using built-in CFI v2 math.", + "coin": coin.upper(), + "days": days, + "perp_notional_usd": notional, + "hedge_notional_usd": hedge_notional, + "vol_mult_l": profile.vol_mult_l, + "k_fixed_hr": snapshot.k_fixed_hr, + "k_fixed_apy": hourly_to_apy(snapshot.k_fixed_hr), + "estimated_fixed_leg_cost_usd": fixed_cost, + "script_path_missing": str(script_path), + }, + indent=2, + ) ) - raise typer.Exit(2) + return cmd = [ sys.executable, diff --git a/cli/mcp_server.py b/cli/mcp_server.py index d2476ad..f55d5d5 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -54,6 +54,8 @@ "x-nunchi-secret-nunchi-web-auth-pair-token": "NUNCHI_WEB_AUTH_PAIR_TOKEN", "x-nunchi-web-auth-address": "NUNCHI_WEB_AUTH_ADDRESS", "x-nunchi-secret-nunchi-web-auth-address": "NUNCHI_WEB_AUTH_ADDRESS", + "x-nunchi-account-id": "NUNCHI_ACCOUNT_ID", + "x-nunchi-secret-nunchi-account-id": "NUNCHI_ACCOUNT_ID", "x-nunchi-trading-permission-tier": "NUNCHI_TRADING_PERMISSION_TIER", "x-nunchi-secret-nunchi-trading-permission-tier": "NUNCHI_TRADING_PERMISSION_TIER", "x-nunchi-trading-network": "NUNCHI_TRADING_NETWORK", @@ -227,6 +229,11 @@ def _trusted_context_env_overrides(ctx: Any) -> dict[str, str]: policy = _policy_from_context_env(overrides) if policy is not None: overrides["NUNCHI_SESSION_POLICY"] = policy + view_address = _clean_context_value( + overrides.get("NUNCHI_WEB_AUTH_ADDRESS") or overrides.get("NUNCHI_ACCOUNT_ID") + ) + if view_address and "HL_VIEW_AS_USER" not in overrides: + overrides["HL_VIEW_AS_USER"] = view_address return overrides @@ -487,11 +494,15 @@ def setup_check(ctx: FastMCPContext = None) -> str: has_web_auth = bool(env_overrides.get("NUNCHI_WEB_AUTH_PAIR_TOKEN")) and bool( env_overrides.get("NUNCHI_WEB_AUTH_ADDRESS") ) + has_view_only = bool(env_overrides.get("HL_VIEW_AS_USER")) + permission_tier = str(env_overrides.get("NUNCHI_TRADING_PERMISSION_TIER") or "").strip().lower() keystores = list_keystores() if has_env_key: ok_items.append("HL_PRIVATE_KEY set") elif has_web_auth: ok_items.append("web-auth pairing context provided") + elif has_view_only and permission_tier == "read_only": + ok_items.append(f"view-only context provided ({env_overrides.get('HL_VIEW_AS_USER')})") elif keystores: ok_items.append(f"Keystore found ({len(keystores)} keys)") else: diff --git a/scripts/entrypoint.py b/scripts/entrypoint.py index a500158..1538bca 100644 --- a/scripts/entrypoint.py +++ b/scripts/entrypoint.py @@ -355,7 +355,7 @@ def handle_mcp_json_rpc(raw_body: bytes, headers: Any) -> tuple[int, dict[str, A if method == "tools/call": name = str(params.get("name", "")) arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} - return 200, _json_rpc_result(request_id, {"content": [{"type": "text", "text": call_mcp_tool(name, arguments, headers)}]}) + return 200, _json_rpc_result(request_id, _mcp_tool_result(call_mcp_tool(name, arguments, headers))) return 200, _json_rpc_error(request_id, -32601, f"method not found: {method}") except Exception as exc: log.exception("MCP JSON-RPC error") @@ -522,6 +522,22 @@ def call_mcp_tool(name: str, arguments: dict[str, Any], headers: Any) -> str: return _json_error(f"unknown tool: {name}") +def _mcp_tool_result(text: str) -> dict[str, Any]: + result: dict[str, Any] = {"content": [{"type": "text", "text": text}]} + try: + parsed = json.loads(text) + except (TypeError, json.JSONDecodeError): + return result + if isinstance(parsed, dict) and parsed.get("error"): + result["isError"] = True + result["structuredContent"] = { + "ok": False, + "error": parsed.get("error"), + **({"code": parsed.get("code")} if parsed.get("code") else {}), + } + return result + + def _context_from_headers(headers: Any) -> Any: normalized = {str(key).lower(): str(value) for key, value in headers.items()} request = SimpleNamespace(headers=normalized) @@ -542,11 +558,15 @@ def _setup_check_text(env_overrides: dict[str, str]) -> str: has_env_key = bool(env_overrides.get("HL_PRIVATE_KEY") or os.environ.get("HL_PRIVATE_KEY")) has_web_auth = bool(env_overrides.get("NUNCHI_WEB_AUTH_PAIR_TOKEN")) and bool(env_overrides.get("NUNCHI_WEB_AUTH_ADDRESS")) + has_view_only = bool(env_overrides.get("HL_VIEW_AS_USER")) + permission_tier = str(env_overrides.get("NUNCHI_TRADING_PERMISSION_TIER") or "").strip().lower() keystores = list_keystores() if has_env_key: ok_items.append("HL_PRIVATE_KEY set") elif has_web_auth: ok_items.append("web-auth pairing context provided") + elif has_view_only and permission_tier == "read_only": + ok_items.append(f"view-only context provided ({env_overrides.get('HL_VIEW_AS_USER')})") elif keystores: ok_items.append(f"Keystore found ({len(keystores)} keys)") else: diff --git a/scripts/mcp_workload_experiment.py b/scripts/mcp_workload_experiment.py new file mode 100644 index 0000000..4af6758 --- /dev/null +++ b/scripts/mcp_workload_experiment.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Controlled live MCP workload experiment. + +Seeds internal experiment subscriptions in web-auth, mints real hosted MCP +gateway tokens, runs hedge/maker/taker tool mixes, and fetches the internal +margin dashboard for evidence-backed tier analysis. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +WEB_AUTH_URL = "https://web-auth-production-4d1b.up.railway.app" +GATEWAY_URL = "https://agent.nunchi.trade" +SERVER_ID = "nunchi_trading" +ADDRESSES = [ + "0xEb1Ba7Fc58b3416361a0EE07d140c91410c0AA8c", + "0x2f0ba6686208Cb31a319F3F54587b1eF1EF0F87e", +] + +PROFILES: dict[str, dict[str, Any]] = { + "hedge": { + "planId": "hosted-mcp-inference-growth", + "tier": "Growth inference tier", + "monthly": {"seats": 2, "mcpCalls": 8700, "paidComputeCalls": 30, "safetyGatedCalls": 30, "inferenceUsd": 5.0}, + "calls": [ + ("setup_check", {}), + ("account", {"mainnet": False}), + ("status", {}), + ("funding_hedge_propose", {"coin": "BTC", "mainnet": False}), + ("funding_hedge_backtest", {"coin": "BTC", "days": 7, "notional": 100000}), + ("funding_hedge_execute", {"coin": "BTC", "dry_run": True, "mainnet": False, "confirmed": True}), + ("openrouter_chat", {"prompt": "Summarize the risk posture for a small funding hedge experiment in one sentence.", "max_tokens": 24, "temperature": 0.1}), + ], + }, + "maker": { + "planId": "hosted-mcp-tools-team", + "tier": "Team BYO-inference tier", + "monthly": {"seats": 5, "mcpCalls": 45150, "paidComputeCalls": 150, "safetyGatedCalls": 15000, "inferenceUsd": 0.0}, + "calls": [ + ("setup_check", {}), + ("strategies", {}), + ("account", {"mainnet": False}), + ("status", {}), + ("radar_run", {"mock": True}), + ("reflect_run", {"since": "2026-07-01"}), + ("run_strategy", {"strategy": "maker", "instrument": "ETH-PERP", "max_ticks": 1, "mock": True, "dry_run": True}), + ("trade", {"instrument": "ETH-PERP", "side": "buy", "size": 0.001, "mainnet": False, "confirmed": True}), + ("trade", {"instrument": "ETH-PERP", "side": "sell", "size": 0.001, "mainnet": False, "confirmed": True}), + ], + }, + "taker": { + "planId": "hosted-mcp-inference-growth", + "tier": "Growth inference tier", + "monthly": {"seats": 2, "mcpCalls": 5500, "paidComputeCalls": 500, "safetyGatedCalls": 1000, "inferenceUsd": 15.0}, + "calls": [ + ("setup_check", {}), + ("account", {"mainnet": False}), + ("status", {}), + ("radar_run", {"mock": True}), + ("openrouter_chat", {"prompt": "Choose one cautious taker setup from mocked risk context and explain why in one sentence.", "max_tokens": 24, "temperature": 0.1}), + ("trade", {"instrument": "BTC-PERP", "side": "buy", "size": 0.001, "mainnet": False, "confirmed": True}), + ], + }, +} + + +class HttpError(RuntimeError): + def __init__(self, status: int, body: Any) -> None: + super().__init__(str(body)) + self.status = status + self.body = body + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--web-auth-url", default=os.environ.get("WEB_AUTH_PAIR_API_URL") or WEB_AUTH_URL) + parser.add_argument("--gateway-url", default=os.environ.get("NUNCHI_MCP_GATEWAY_URL") or GATEWAY_URL) + parser.add_argument("--connect-token", default=os.environ.get("NUNCHI_CONNECT_API_TOKEN", "")) + parser.add_argument("--metering-token", default=os.environ.get("INTERNAL_MCP_EXPERIMENT_TOKEN") or os.environ.get("INTERNAL_COSTING_DASHBOARD_TOKEN", "")) + parser.add_argument("--costing-token", default=os.environ.get("INTERNAL_COSTING_DASHBOARD_TOKEN", "")) + parser.add_argument("--addresses", nargs="+", default=ADDRESSES) + parser.add_argument("--profiles", nargs="+", default=list(PROFILES)) + parser.add_argument("--cycles", type=int, default=1) + parser.add_argument("--sleep-after", type=float, default=10.0) + parser.add_argument("--run-id", default=datetime.now(timezone.utc).strftime("mcp-workload-%Y%m%dT%H%M%SZ")) + parser.add_argument("--output", default="") + parser.add_argument("--strict", action="store_true") + parser.add_argument("--cleanup", action="store_true", help="Delete internal experiment subscriptions after collecting results.") + args = parser.parse_args() + + for name, value in { + "NUNCHI_CONNECT_API_TOKEN": args.connect_token, + "INTERNAL_MCP_EXPERIMENT_TOKEN": args.metering_token, + "INTERNAL_COSTING_DASHBOARD_TOKEN": args.costing_token, + }.items(): + if not value: + raise SystemExit(f"missing {name}") + + results: dict[str, Any] = { + "runId": args.run_id, + "generatedAt": datetime.now(timezone.utc).isoformat(), + "addresses": args.addresses, + "inputs": {"cycles": args.cycles, "webAuthUrl": args.web_auth_url, "gatewayUrl": args.gateway_url}, + "profiles": [], + } + for profile_name in args.profiles: + profile = PROFILES[profile_name] + for index, address in enumerate(args.addresses): + results["profiles"].append(run_profile(args, profile_name, profile, address, index)) + + if args.sleep_after: + time.sleep(args.sleep_after) + results["marginDashboard"] = http_json("GET", f"{args.web_auth_url.rstrip('/')}/api/billing/subscription/margin-dashboard", token=args.costing_token) + results["tierExpectations"] = derive_expectations(results) + if args.cleanup: + results["cleanup"] = cleanup_experiment(args) + + out = Path(args.output or f"tmp/{args.run_id}.json") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(results, indent=2, sort_keys=True), encoding="utf-8") + print(json.dumps({"ok": True, "output": str(out), "profileRuns": len(results["profiles"])}, indent=2)) + failures = [call for profile in results["profiles"] for call in profile["calls"] if not call["ok"]] + if failures and args.strict: + print(json.dumps({"failedCalls": failures[:20]}, indent=2), file=sys.stderr) + return 1 + return 0 + + +def run_profile(args: argparse.Namespace, profile_name: str, profile: dict[str, Any], address: str, index: int) -> dict[str, Any]: + user_id = f"mcp-exp-{profile_name}-{address[-8:].lower()}" + subscription_id = f"{args.run_id}-{profile_name}-{index}" + seed_subscription(args, profile, user_id, address, subscription_id) + minted = connect_gateway(args, profile_name, profile, user_id, address, subscription_id) + calls = [] + for cycle in range(args.cycles): + for tool, body in profile["calls"]: + started = time.perf_counter() + try: + response = http_json("POST", f"{args.gateway_url.rstrip('/')}/v1/servers/{SERVER_ID}/tools/{tool}/call", token=minted["token"], body=body, timeout=60) + content_error = tool_content_error(response) + calls.append({ + "cycle": cycle, + "tool": tool, + "ok": content_error is None, + "status": 200, + "elapsedMs": round((time.perf_counter() - started) * 1000, 3), + "error": content_error, + "preview": preview(response), + }) + except HttpError as exc: + calls.append({"cycle": cycle, "tool": tool, "ok": False, "status": exc.status, "elapsedMs": round((time.perf_counter() - started) * 1000, 3), "error": preview(exc.body)}) + return { + "profile": profile_name, + "address": address, + "userId": user_id, + "accountId": address, + "subscriptionId": subscription_id, + "planId": profile["planId"], + "expectedTier": profile["tier"], + "monthlyExpectation": profile["monthly"], + "gatewayTokenId": minted.get("token_id"), + "calls": calls, + } + + +def seed_subscription(args: argparse.Namespace, profile: dict[str, Any], user_id: str, account_id: str, subscription_id: str) -> None: + http_json( + "POST", + f"{args.web_auth_url.rstrip('/')}/api/internal/costing/experiment-subscription", + token=args.costing_token, + body={ + "userId": user_id, + "accountId": account_id, + "subscriptionId": subscription_id, + "planId": profile["planId"], + "status": "active", + "experimentName": args.run_id, + }, + ) + + +def connect_gateway(args: argparse.Namespace, profile_name: str, profile: dict[str, Any], user_id: str, account_id: str, subscription_id: str) -> dict[str, Any]: + web = args.web_auth_url.rstrip("/") + return http_json( + "POST", + f"{args.gateway_url.rstrip('/')}/v1/connect/hosted-trading", + token=args.connect_token, + headers={"x-nunchi-user-id": user_id}, + body={ + "workspace_id": args.run_id, + "agent_id": f"experiment-{profile_name}", + "permission_tier": "testnet_trading", + "network": "testnet", + "ttl_seconds": 3600, + "max_order_size": 0.01, + "max_strategy_ticks": 5, + "require_confirmation": False, + "account_id": account_id, + "subscription_id": subscription_id, + "plan_id": profile["planId"], + "metering_status_url": f"{web}/api/metering/status", + "metering_usage_url": f"{web}/api/metering/usage", + "metering_seats_register_url": f"{web}/api/metering/seats/register", + "metering_seats_release_url": f"{web}/api/metering/seats/release", + "metering_token": args.metering_token, + }, + ) + + +def derive_expectations(results: dict[str, Any]) -> dict[str, Any]: + account_rows = results.get("marginDashboard", {}).get("rows", []) + wanted = {run["subscriptionId"] for run in results["profiles"]} + experiment_rows = [row for row in account_rows if row.get("subscriptionId") in wanted] + return { + "source": "Real gateway tool calls plus web-auth internal margin dashboard", + "profileGuidance": [ + { + "profile": run["profile"], + "address": run["address"], + "subscriptionId": run["subscriptionId"], + "planId": run["planId"], + "expectedTier": run["expectedTier"], + "successfulCalls": sum(1 for call in run["calls"] if call["ok"]), + "failedCalls": sum(1 for call in run["calls"] if not call["ok"]), + "monthlyExpectation": run["monthlyExpectation"], + } + for run in results["profiles"] + ], + "experimentAccounts": experiment_rows, + "byTier": results.get("marginDashboard", {}).get("byTier", []), + } + + +def cleanup_experiment(args: argparse.Namespace) -> dict[str, Any]: + return http_json( + "POST", + f"{args.web_auth_url.rstrip('/')}/api/internal/costing/experiment-subscriptions/cleanup", + token=args.costing_token, + body={"experimentName": args.run_id}, + ) + + +def http_json(method: str, url: str, *, token: str = "", headers: dict[str, str] | None = None, body: Any = None, timeout: int = 30) -> dict[str, Any]: + data = json.dumps(body).encode("utf-8") if body is not None else None + request = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "accept": "application/json", + **({"content-type": "application/json"} if body is not None else {}), + **({"authorization": f"Bearer {token}"} if token else {}), + **(headers or {}), + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = raw + raise HttpError(exc.code, parsed) from exc + + +def preview(value: Any, limit: int = 500) -> str: + text = value if isinstance(value, str) else json.dumps(value, sort_keys=True) + return text[:limit] + + +def tool_content_error(response: dict[str, Any]) -> str | None: + result = response.get("result") + if not isinstance(result, dict): + return None + structured = result.get("structuredContent") + if result.get("isError") is True and isinstance(structured, dict) and structured.get("error"): + return preview(str(structured.get("error")), 300) + contents = result.get("content") + if not isinstance(contents, list): + return None + text = "\n".join( + str(item.get("text", "")) + for item in contents + if isinstance(item, dict) + ) + lowered = text.lower() + markers = [ + '"error"', + "traceback", + "requires a signing context", + "no signing context", + "unknown tool", + ] + if any(marker in lowered for marker in markers): + return preview(text, 300) + return None + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_mcp_gateway_context.py b/tests/test_mcp_gateway_context.py index 4281594..102962f 100644 --- a/tests/test_mcp_gateway_context.py +++ b/tests/test_mcp_gateway_context.py @@ -39,6 +39,7 @@ def test_trusted_gateway_headers_become_scoped_env(monkeypatch): assert env["NUNCHI_WEB_AUTH_PAIR_TOKEN"] == "pair-token" assert env["NUNCHI_WEB_AUTH_ADDRESS"] == "0x" + "2" * 40 + assert env["HL_VIEW_AS_USER"] == "0x" + "2" * 40 assert env["NUNCHI_MAX_ORDER_SIZE"] == "0.5" assert env["NUNCHI_MAX_STRATEGY_TICKS"] == "12" policy = json.loads(env["NUNCHI_SESSION_POLICY"]) @@ -47,6 +48,24 @@ def test_trusted_gateway_headers_become_scoped_env(monkeypatch): assert "trade" in policy["allowed_actions"] +def test_trusted_gateway_account_id_becomes_view_only_address(monkeypatch): + from cli.mcp_server import _trusted_context_env_overrides + + account_id = "0x" + "6" * 40 + monkeypatch.setenv("NUNCHI_RUNNER_CONTEXT_SECRET", "shared-secret") + ctx = _ctx({ + "x-nunchi-runner-context-secret": "shared-secret", + "x-nunchi-account-id": account_id, + "x-nunchi-trading-permission-tier": "read_only", + "x-nunchi-trading-network": "testnet", + }) + + env = _trusted_context_env_overrides(ctx) + + assert env["NUNCHI_ACCOUNT_ID"] == account_id + assert env["HL_VIEW_AS_USER"] == account_id + + def test_context_limits_fail_closed_without_signing_context(monkeypatch, tmp_path): from cli.mcp_server import _context_limit_error @@ -145,6 +164,22 @@ def test_entrypoint_trade_fails_closed_without_signing_context(monkeypatch, tmp_ assert status == 200 assert "requires a signing context" in response["result"]["content"][0]["text"] + assert response["result"]["isError"] is True + assert "requires a signing context" in response["result"]["structuredContent"]["error"] + + +def test_entrypoint_structures_json_tool_errors(): + from scripts.entrypoint import _mcp_tool_result + + result = _mcp_tool_result(json.dumps({"error": "boom", "code": "example_error"})) + + assert result["isError"] is True + assert result["structuredContent"] == { + "ok": False, + "error": "boom", + "code": "example_error", + } + assert result["content"][0]["text"] == '{"error": "boom", "code": "example_error"}' def test_entrypoint_trade_forwards_trusted_context_to_subprocess(monkeypatch, tmp_path): @@ -206,6 +241,8 @@ def test_entrypoint_funding_hedge_execute_refuses_without_confirm(): assert status == 200 assert "confirmed=true" in response["result"]["content"][0]["text"] + assert response["result"]["isError"] is True + assert "confirmed=true" in response["result"]["structuredContent"]["error"] def test_entrypoint_funding_hedge_execute_confirmed_dry_run_forwards_to_cli(monkeypatch, tmp_path): @@ -249,6 +286,7 @@ def fake_run_hl(*args, timeout=30, env_overrides=None): assert captured["timeout"] == 120 assert captured["env_overrides"]["NUNCHI_WEB_AUTH_PAIR_TOKEN"] == "pair-token" assert captured["env_overrides"]["NUNCHI_WEB_AUTH_ADDRESS"] == "0x" + "5" * 40 + assert captured["env_overrides"]["HL_VIEW_AS_USER"] == "0x" + "5" * 40 def test_entrypoint_funding_hedge_execute_confirmed_dry_run_allows_keyless_preview(monkeypatch, tmp_path):