Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cli/commands/mcp.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""hl mcp — MCP server for AI agent tool discovery."""
from __future__ import annotations

import os
import sys
import urllib.parse
import webbrowser
from pathlib import Path

import typer
Expand All @@ -28,3 +31,30 @@ def mcp_serve(
server = create_mcp_server()
typer.echo(f"Starting MCP server (transport={transport}) ...")
server.run(transport=transport)


@mcp_app.command("buy-more")
def mcp_buy_more(
account_id: str = typer.Option("", "--account-id", help="Nunchi account/workspace to top up."),
plan_id: str = typer.Option("", "--plan-id", help="Subscription plan context for the top-up page."),
credits: int = typer.Option(100, "--credits", min=1, help="Credit pack size to preselect."),
open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the web-auth top-up page in a browser."),
):
"""Open web-auth to buy more MCP subscription credits."""
base = os.environ.get("VITE_PAIR_API_URL", "https://web-auth-opal.vercel.app").rstrip("/")
resolved_account_id = account_id or os.environ.get("NUNCHI_ACCOUNT_ID", "").strip()
resolved_plan_id = plan_id or os.environ.get("NUNCHI_PLAN_ID", "").strip()
query = {
"view": "agent-subscriptions",
"topUp": "credits",
"credits": str(credits),
}
if resolved_account_id:
query["accountId"] = resolved_account_id
if resolved_plan_id:
query["planId"] = resolved_plan_id
url = f"{base}/?{urllib.parse.urlencode(query)}"
typer.echo("Buy more MCP credits in web-auth:")
typer.echo(url)
if open_browser:
webbrowser.open(url)
47 changes: 40 additions & 7 deletions docs/MCP_PRICING_MEASUREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Run:
```bash
python3 scripts/pricing_measure.py --output tmp/pricing-measurement-local.json
railway run --service hosted-trading-mcp --environment production -- python3 scripts/pricing_measure.py --output tmp/pricing-measurement-runner-env.json
python3 scripts/pricing_measure.py --job-profile maker --job-profile taker --job-profile hedge --railway-metrics --output tmp/pricing-job-profiles.json
```

Use `--openrouter-live` only when spending OpenRouter credits is intended.
Expand Down Expand Up @@ -40,18 +41,48 @@ The pricing harness now emits the full Task 7 classification:
- Safety-gated/fund-moving/wallet-write: 7 tools, or 6 if `wallet_auto` is
excluded from the costable 26-tool surface because it is disabled on the
hosted keyless runner.
- Recommended beta free cap: about 20 hosted MCP discovery/read calls before
subscription or upgrade nudges.
- Recommended beta free cap: about 20 hosted MCP discovery/read or
safety-gated execution calls before subscription or upgrade nudges.
`trade` and `funding_hedge_execute` remain safety-gated but are eligible to
spend this 20-call clone/local quota before paid access is required.

## Job Profiles

`pricing_measure.py` now emits deterministic monthly usage estimates for the
three Sam-facing workflow profiles:

- `hedge`: 2 seats, about 8,700 MCP calls, 30 paid-compute calls, about $5
inference, and about 93.5 credits; recommended placeholder plan is
`hosted-mcp-inference-growth`.
- `maker`: 5 seats, about 45,150 MCP calls, 150 paid-compute calls, BYO
inference, and about 459 credits; recommended placeholder plan is
`hosted-mcp-tools-team`.
- `taker`: 2 seats, about 5,500 MCP calls, 500 paid-compute calls, about $15
inference, and about 95 credits; recommended placeholder plan is
`hosted-mcp-inference-growth`.

Credit rates mirror web-auth: 0.01 credits per hosted MCP call, 0.05 credits per
paid-compute call, and 1 credit per $1 of Nunchi/OpenRouter inference spend.
The working OpenRouter starter plan is 100 credits/month; MCP-only tiers stay
cheaper because BYO inference spend is outside Nunchi billing.

These are pricing simulations, not funded-fill proof. The funded maker/taker
fill path is still gated by wallet credentials and testnet funding.

## Economics

Mode 1, hosted MCP tools:

- `C_seat` is not computed yet. Railway metrics expose CPU/memory/network, but not monthly billing cost. Set `RAILWAY_SHARED_RUNTIME_MONTHLY_USD` or pass `--runtime-monthly-usd` once billing data is available.
- `C_seat` is not computed yet. `--railway-metrics` captures Railway
CPU/memory/network/disk summaries, but Railway metrics are not monthly
billing cost. Set `RAILWAY_SHARED_RUNTIME_MONTHLY_USD` or pass
`--runtime-monthly-usd` once billing data is available.

Mode 2, hosted MCP tools plus Nunchi/OpenRouter inference:

- No live OpenRouter spend was measured because `OPENROUTER_API_KEY` is not present in the runner env and `--openrouter-live` was not run.
- Live OpenRouter spend can be measured with `--openrouter-live`. The probe uses
`max_tokens=16` because current OpenRouter providers reject smaller completion
caps.
- Current inference budgets remain inputs only: Starter `$10`, Growth `$50`, Team `$250`.
- Anchor estimates from the Task 7 prompt:
- `openai/gpt-4.1-mini` at about `$0.0002` per heartbeat gives about
Expand All @@ -67,11 +98,13 @@ Mode 3, clone/local plus builder economics:
- Formulaic builder-fee economics at the default `BUILDER_FEE_TENTHS_BPS=100` are `$100` per `$100,000` notional and `$1,000` per `$1,000,000` notional.
- `trade` itself should remain free or low-friction from a pricing standpoint:
it is safety-sensitive, not inference-heavy, and it is the path that can
produce builder-code/builder-fee economics. Gate it with confirmation,
builder-code validation, network consent, size limits, and signing context.
produce builder-code/builder-fee economics. It now spends the clone/local
20-call free quota together with `funding_hedge_execute`, then requires paid
hosted MCP access after that allowance is exhausted.

## Blockers

- Missing Railway monthly billing cost input for Mode 1 `C_seat`.
- Missing `OPENROUTER_API_KEY` for Mode 2 inference spend.
- Missing `OPENROUTER_API_KEY` only in environments where `--openrouter-live`
should run.
- Missing funded-wallet/HL signing credentials for live fills and builder-fee realization.
156 changes: 154 additions & 2 deletions scripts/pricing_measure.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
Expand Down Expand Up @@ -37,6 +38,36 @@
}

FREE_TOOL_CALL_LIMIT_RECOMMENDATION = 20
OPENROUTER_PROBE_MAX_TOKENS = 16
CREDIT_RATES = {
"mcpCallCredits": 0.01,
"paidComputeCallCredits": 0.05,
"inferenceUsdCredits": 1.0,
}

PLAN_LIMITS = {
"hosted-mcp-tools-starter": {"product": "hosted-mcp-tools", "seats": 5, "credits": 50, "mcpCalls": 2_000, "paidComputeCalls": 100, "inferenceUsd": 0.0},
"hosted-mcp-tools-growth": {"product": "hosted-mcp-tools", "seats": 10, "credits": 150, "mcpCalls": 10_000, "paidComputeCalls": 500, "inferenceUsd": 0.0},
"hosted-mcp-tools-team": {"product": "hosted-mcp-tools", "seats": 50, "credits": 500, "mcpCalls": 50_000, "paidComputeCalls": 2_500, "inferenceUsd": 0.0},
"hosted-mcp-inference-starter": {"product": "hosted-mcp-tools-inference", "seats": 5, "credits": 100, "mcpCalls": 2_000, "paidComputeCalls": 100, "inferenceUsd": 10.0},
"hosted-mcp-inference-growth": {"product": "hosted-mcp-tools-inference", "seats": 10, "credits": 300, "mcpCalls": 10_000, "paidComputeCalls": 500, "inferenceUsd": 50.0},
"hosted-mcp-inference-team": {"product": "hosted-mcp-tools-inference", "seats": 50, "credits": 1_000, "mcpCalls": 50_000, "paidComputeCalls": 2_500, "inferenceUsd": 250.0},
}

JOB_PROFILES = {
"hedge": {
"description": "15-minute status/account reads, hedge proposal/backtest, and occasional confirmed hedge execution.",
"usage": {"seats": 2, "freeToolCalls": 8_640, "safetyGatedCalls": 30, "paidComputeCalls": 30, "inferenceUsd": 5.0},
},
"maker": {
"description": "Continuous quoting desk with frequent status/account reads and safety-gated trade submissions.",
"usage": {"seats": 5, "freeToolCalls": 30_000, "safetyGatedCalls": 15_000, "paidComputeCalls": 150, "inferenceUsd": 0.0},
},
"taker": {
"description": "Signal-driven taker using strategy runs plus bursty execution and post-trade checks.",
"usage": {"seats": 2, "freeToolCalls": 4_000, "safetyGatedCalls": 1_000, "paidComputeCalls": 500, "inferenceUsd": 15.0},
},
}

TOOL_CLASSIFICATION = [
{
Expand Down Expand Up @@ -297,6 +328,115 @@ def runtime_c_seat(runtime_monthly_usd: float | None) -> dict[str, Any]:
}


def railway_metrics_probe(args: argparse.Namespace) -> dict[str, Any]:
"""Collect Railway resource metrics when the CLI is available.

Railway metrics corroborate runtime load, but they are not invoices. C_seat
still requires an explicit monthly dollar input.
"""
railway = shutil.which("railway")
if not railway:
return {"ok": False, "blocker": "Railway CLI is not installed."}
command = [railway, "metrics", "--json", "--since", args.railway_metrics_since]
if args.railway_service:
command.extend(["--service", args.railway_service])
if args.railway_environment:
command.extend(["--environment", args.railway_environment])
if args.railway_project:
command.extend(["--project", args.railway_project])
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=30, check=False)
except Exception as exc: # pragma: no cover - CLI/environment dependent
return {"ok": False, "command": command[1:], "blocker": str(exc)}
if result.returncode != 0:
return {
"ok": False,
"command": command[1:],
"blocker": (result.stderr or result.stdout or "railway metrics failed")[:500],
}
try:
payload: Any = json.loads(result.stdout)
except json.JSONDecodeError:
payload = {"raw": result.stdout[:2000]}
return {
"ok": True,
"command": command[1:],
"source": "railway metrics --json",
"note": "Resource metrics are not billing dollars; provide --runtime-monthly-usd or RAILWAY_SHARED_RUNTIME_MONTHLY_USD for C_seat.",
"metrics": payload,
}


def _fits_plan(usage: dict[str, float], limits: dict[str, Any]) -> bool:
if usage["inferenceUsd"] > 0 and limits["inferenceUsd"] <= 0:
return False
return (
usage["seats"] <= limits["seats"]
and usage["creditsUsed"] <= limits["credits"]
and usage["mcpCalls"] <= limits["mcpCalls"]
and usage["paidComputeCalls"] <= limits["paidComputeCalls"]
and usage["inferenceUsd"] <= limits["inferenceUsd"]
)


def credits_used(usage: dict[str, float]) -> float:
credits = (
usage["mcpCalls"] * CREDIT_RATES["mcpCallCredits"]
+ usage["paidComputeCalls"] * CREDIT_RATES["paidComputeCallCredits"]
+ usage["inferenceUsd"] * CREDIT_RATES["inferenceUsdCredits"]
)
return round(credits, 2)


def job_profile_simulation(profile_name: str) -> dict[str, Any]:
profile = JOB_PROFILES[profile_name]
raw_usage = profile["usage"]
usage = {
"seats": raw_usage["seats"],
"freeToolCalls": raw_usage["freeToolCalls"],
"safetyGatedCalls": raw_usage["safetyGatedCalls"],
"paidComputeCalls": raw_usage["paidComputeCalls"],
"inferenceUsd": raw_usage["inferenceUsd"],
}
usage["mcpCalls"] = usage["freeToolCalls"] + usage["safetyGatedCalls"] + usage["paidComputeCalls"]
usage["creditsUsed"] = credits_used(usage)
product_prefix = "hosted-mcp-inference" if usage["inferenceUsd"] > 0 else "hosted-mcp-tools"
candidate_plans = [
(plan_id, limits)
for plan_id, limits in PLAN_LIMITS.items()
if plan_id.startswith(product_prefix)
]
best_plan = next((plan_id for plan_id, limits in candidate_plans if _fits_plan(usage, limits)), None)
if best_plan is None:
best_plan = candidate_plans[-1][0]
limits = PLAN_LIMITS[best_plan]
overage = {
"seats": max(0, usage["seats"] - limits["seats"]),
"credits": max(0, round((usage["creditsUsed"] - limits["credits"]) * 100) / 100),
"mcpCalls": max(0, usage["mcpCalls"] - limits["mcpCalls"]),
"paidComputeCalls": max(0, usage["paidComputeCalls"] - limits["paidComputeCalls"]),
"inferenceUsd": max(0.0, usage["inferenceUsd"] - limits["inferenceUsd"]),
}
return {
"profile": profile_name,
"description": profile["description"],
"monthlyUsage": usage,
"recommendedPlanId": best_plan,
"planLimits": limits,
"creditRates": CREDIT_RATES,
"overageAgainstRecommendedPlan": overage,
"notes": [
"Safety-gated execution currently increments total mcpCalls.",
"Maker profiles are usually constrained by mcpCalls before seats.",
],
}


def job_profile_simulations(selected: list[str]) -> dict[str, Any]:
names = selected or list(JOB_PROFILES.keys())
return {name: job_profile_simulation(name) for name in names}


def measure_subprocess(name: str, command: list[str], timeout: float = 30) -> Measurement:
start = time.perf_counter()
try:
Expand Down Expand Up @@ -393,7 +533,7 @@ def openrouter_probe(model: str) -> dict[str, Any]:
payload = json.dumps({
"model": model,
"messages": [{"role": "user", "content": "Reply with exactly: ok"}],
"max_tokens": 8,
"max_tokens": OPENROUTER_PROBE_MAX_TOKENS,
}).encode()
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
Expand Down Expand Up @@ -421,7 +561,8 @@ def openrouter_probe(model: str) -> dict[str, Any]:
}
except urllib.error.HTTPError as exc:
elapsed_ms = (time.perf_counter() - start) * 1000
return {"ok": False, "elapsedMs": elapsed_ms, "status": exc.code, "blocker": exc.reason}
detail = exc.read().decode("utf-8", errors="replace")[:1000]
return {"ok": False, "elapsedMs": elapsed_ms, "status": exc.code, "blocker": exc.reason, "detail": detail}
except Exception as exc: # pragma: no cover - network dependent
elapsed_ms = (time.perf_counter() - start) * 1000
return {"ok": False, "elapsedMs": elapsed_ms, "blocker": str(exc)}
Expand Down Expand Up @@ -481,6 +622,10 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]:
},
"measurements": [m.__dict__ for m in measurements],
"mode1": runtime_c_seat(runtime_monthly),
"railwayMetricsProbe": railway_metrics_probe(args) if args.railway_metrics else {
"ok": False,
"skipped": "Run with --railway-metrics to collect Railway resource metrics.",
},
"mode2": {
"inferenceBudgetsUsd": HOSTED_INFERENCE_BUDGETS,
"anchorEstimates": inference_anchor_budget_capacity(HOSTED_INFERENCE_BUDGETS),
Expand All @@ -492,6 +637,7 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]:
"builderRevenuePer1mNotionalUsd": builder_revenue_usd(1_000_000, fee_tenths_bps),
"note": "Builder revenue is formulaic until funded-wallet fills are measured.",
},
"jobProfileSimulations": job_profile_simulations(args.job_profile),
"blockers": blockers,
}

Expand All @@ -501,6 +647,12 @@ def main() -> None:
parser.add_argument("--runtime-monthly-usd", type=float, default=None)
parser.add_argument("--openrouter-live", action="store_true")
parser.add_argument("--openrouter-model", default=os.environ.get("NUNCHI_PRICING_OPENROUTER_MODEL", "openai/gpt-4.1-mini"))
parser.add_argument("--job-profile", action="append", choices=sorted(JOB_PROFILES.keys()), default=[])
parser.add_argument("--railway-metrics", action="store_true")
parser.add_argument("--railway-project", default=os.environ.get("RAILWAY_PROJECT_ID", ""))
parser.add_argument("--railway-service", default=os.environ.get("RAILWAY_SERVICE_NAME", ""))
parser.add_argument("--railway-environment", default=os.environ.get("RAILWAY_ENVIRONMENT", "production"))
parser.add_argument("--railway-metrics-since", default=os.environ.get("RAILWAY_METRICS_SINCE", "1h"))
parser.add_argument("--output", type=Path, default=None)
args = parser.parse_args()
report = build_report(args)
Expand Down
21 changes: 21 additions & 0 deletions tests/test_mcp_billing_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typer.testing import CliRunner

from cli.main import app


runner = CliRunner()


def test_mcp_buy_more_prints_web_auth_topup_url(monkeypatch):
monkeypatch.setenv("VITE_PAIR_API_URL", "https://web-auth.example")
monkeypatch.setenv("NUNCHI_ACCOUNT_ID", "acct_123")

result = runner.invoke(app, ["mcp", "buy-more", "--credits", "200", "--no-open"])

assert result.exit_code == 0
assert "Buy more MCP credits in web-auth:" in result.output
assert "https://web-auth.example/" in result.output
assert "view=agent-subscriptions" in result.output
assert "topUp=credits" in result.output
assert "credits=200" in result.output
assert "accountId=acct_123" in result.output
Loading