# MCP tool catalog — yieldhunter

**Identity:** `yieldhunter.eth`  
**How to call (full guide):** [`how-agents-call.md`](./how-agents-call.md)  
**Runtime:** MCP Streamable HTTP + discovery/identity — not an A2A `message/send` runtime.

**Chain scope (v1 by design):** **Ethereum · Base · Arbitrum** stablecoin yields only (mainnet + those L2s). `list_yields` `chains` / `chainIds` must be that set — any unsupported **or blank/whitespace** value (e.g. `solana`, `optimism`, `""`) is **`VALIDATION_ERROR`** (names the value; does **not** silently return the default three-chain universe). Mixed lists like `["base","solana"]` or `["base",""]` reject the whole request. **Omitted** `chains` still defaults to the three supported chains. `sourceChainKey` accepts those keys plus aliases (`eth`/`ETH`/`Ethereum`, `arb`/`ARB`/`Arbitrum`, `BASE`); blank or unknown keys are **`VALIDATION_ERROR`** (bridge cost is **not** estimated). Positioning: risk-adjusted **research shortlist** for capital-preservation-style agents.

### MCP transport (read this first)

| | |
|--|--|
| **Production MCP** | `https://yieldhunter-agent-production.up.railway.app/mcp` |
| **Transport** | MCP **Streamable HTTP** (JSON mode supported as implemented) |
| **Liveness** | `GET https://yieldhunter-agent-production.up.railway.app/health` — not tools |
| **Tools** | **POST** `/mcp` only, via a proper MCP client (`initialize` → `tools/call`) |

Required request headers on tool calls:

```http
Content-Type: application/json
Accept: application/json, text/event-stream
```

**Do not** open `/mcp` as a bare browser **GET** — a **406** response is **expected**, not downtime. Use `/health` for “is it up?” and MCP POST for tools.

**MCP `tools/list` inputSchema:** each tool publishes its real argument properties (e.g. `score_opportunity` / `explain_score`: `id` \| `opportunityId` \| `poolId`, horizon fields; `compare_opportunities`: `poolIds` \| `opportunityIds`; `get_protocol_risk`: `protocol` \| `protocolSlug`). Those alias groups use JSON Schema **`anyOf`** (provide at least one). `health` is `{}`.

**Contract:** All tools return a JSON envelope:

```json
{
  "ok": true,
  "schemaVersion": "1.0.0",
  "requestId": "...",
  "data": {},
  "meta": {
    "asOf": "ISO-8601",
    "degraded": false,
    "warnings": [],
    "sources": [],
    "lastRefreshedAt": "ISO-8601",
    "dataAgeSeconds": 0,
    "sourcesFreshness": [{ "source": "defillama:yields", "lastSuccessAt": "ISO-8601" }],
    "disclaimer": "Research estimates only. ... never holds keys ..."
  }
}
```

**Data freshness (successful research tools):**

| Field | Meaning |
|-------|---------|
| `meta.asOf` | Response generation time |
| `meta.lastRefreshedAt` | Newest **yields or pegs** `asOf` used for this answer |
| `meta.dataAgeSeconds` | Seconds since the **oldest yields or pegs** `asOf` used — prefer this to reject stale composites |
| `meta.sourcesFreshness` | Per-source snapshot time (includes protocol-meta when used) |

`dataAgeSeconds` measures **market-data vintage** (DefiLlama yields + stablecoin pegs), not protocol-meta. Protocol-meta is cached ~6h and still listed in `sourcesFreshness`; it must **not** push composite age over consumer gates (~30 min, e.g. Yieldsentinel) when yields/pegs are fresh. Curated static risk is omitted from age. `get_protocol_risk` ages on protocol-meta because that is the payload. Missing timestamps → `null` age fields. An empty shortlist after **caller filters** is a warning, not `meta.degraded`.

Yields/pegs cache TTL defaults to **10 minutes** (`CACHE_TTL_YIELDS_SECONDS` / `CACHE_TTL_STABLECOINS_SECONDS`). If a cache hit’s `asOf` is older than `YH_STALE_WARN_SECONDS` (30 min), YH revalidates that source on the request instead of serving only the old entry.

**Stale-data policy** (env, does not empty results or change scores):

| Env | Default | Effect when `dataAgeSeconds` ≥ threshold |
|-----|---------|------------------------------------------|
| `YH_STALE_WARN_SECONDS` | `1800` (30 min) | Warning, e.g. `Data age 45 minutes exceeds warn threshold (30 minutes)` |
| `YH_STALE_DEGRADE_SECONDS` | `7200` (2 h) | `meta.degraded=true` **and** degrade warning |

If `dataAgeSeconds` is `null` (unknown), no stale warning/degrade is invented.

### `/health` lastSuccess ≠ data vintage

`/health` **dataSources.lastSuccess** is “last fetch/serve ok” (updates on cache hits too) and can look like “just now” or, when idle, look old. **`dataSources.dataVintage`** is the snapshot `asOf` (what operators should compare to freshness). Prefer tool-envelope **`dataAgeSeconds` / `lastRefreshedAt`** for answer age. Idle + `status: healthy` is not an outage.

When market vintage (oldest yields+pegs `asOf`) is missing or at/over `YH_STALE_DEGRADE_SECONDS` (default 2h), **GET /health and MCP `health` may refresh** DefiLlama yields, pegs, and protocol-meta once via the same adapters/cache path as `list_yields` (not a `list_yields` tool call; `idleMarketRefreshesTotal` on health metrics). Kill switch on: health still answers, refresh skipped. YH healthy ≠ a sibling policy agent treats the same id as fresh.

Non-custodial research only. Never executes transactions.

**Shortlist research, not capital-allocation autopilot.** Tools return timestamped risk-adjusted shortlists and explainability (scores, factors, warnings, data age). yieldhunter does **not** custody funds, build/sign/broadcast transactions, rebalance portfolios, or guarantee safety or returns. Calling agents own policy, position sizing, exit rules, jurisdiction, and final decisions. Data is snapshot-based (DefiLlama + curated priors); exit liquidity, full incident history, and live execution risk are only partially covered — treat outputs as research estimates, not a mandate to act.

**Short-horizon stability (optional fields on opportunities):**

| Field | Scope | Meaning |
|-------|--------|---------|
| `apyChange24h` | **Pool-level** when present | DefiLlama yields `apyPct1D` — APY change in **percentage points** ~1d |
| `tvlChange24hPct` | **Protocol-level only** | DefiLlama protocols `change_1d` — **not** this pool’s TVL flow. All markets under the same `protocolSlug` may share the same value |
| `stabilityConfidence` | derived | `high` / `medium` / `low` from how many of the two signals are present |

**`apyChange24h` null ≠ 0:** A **number** (including `0`) means DefiLlama provided `apyPct1D` for that pool — `0` is an observed ~1d flat change. **`null` / missing** means unknown / not provided for that pool (common on some structured products, e.g. Pendle) — **not** “no move.” Do **not** invent `0` when the source field is absent.

Do **not** treat `tvlChange24hPct` as pool-specific inflow/outflow (protocol-level only). Missing protocol TVL deltas stay **`null`** too. Large `|apyChange24h|` (≥5 pp) may add a **warning only** — ranking unchanged.

**ExitRisk v1 (`exitRisk` / `exitRiskReason`) — size-vs-pool friction heuristic only:**

| Field | Values / notes |
|-------|----------------|
| `exitRisk` | `low` \| `medium` \| `high` \| `unknown` |
| `exitRiskReason` | Short reason string (read it — often names confidence vs size) |

**What it is:** a heuristic estimate of **size vs pool friction**. Inputs only: **pool TVL**, **liquidity confidence**, request **`notionalUsd`**.

**What it is not:**

- **Not** an on-chain withdraw simulation  
- **Not** withdraw-queue, daily-cap, pause, timelock, or protocol-freeze status  
- **Not** transfer whitelist, eligibility, lockups, or redemption queues  
- **Not** a hard execution gate or a guarantee you can exit  

- **`medium` ≠ “small pool” only** — often **liquidity confidence** is medium even when TVL ≫ notional. Read `exitRiskReason`.  
- **`unknown` = missing/invalid inputs** — **not** “safe.” Never invent `low`.  
- **`low` is strict:** high liquidity confidence **and** a large TVL multiple (≥50× notional). Many quality L2 pools stay **medium**.  
- Larger **`notionalUsd`** mainly stresses **thinner** pools. Deep blue chips often do not flip between 10k and 250k.  
- Soft **warnings** for `high` / `unknown` (liquidity heuristic). When protocol `knownIssues` mention transfer / eligibility / redemption / permissioned constraints, `low`/`medium` also get a soft warning: *exitRisk is liquidity-only; protocol notes transfer/eligibility restrictions*. Do **not** treat `exitRisk=low` as free exit in that case (e.g. BUIDL).  
- Default ranking stays **risk-adjusted**. No `maxExitRisk` filter in v1.

Real withdraw constraints (queue / cap / pause / lockup / whitelist) are **out of scope** for yieldhunter core. Callers should treat those as **separate policy** (e.g. future yieldsentinel / curated `withdrawHint` — not wired).

---

## health

Liveness, feature flags, scoring version, tool list.

```json
{}
```

---

## list_yields

Rank stablecoin yields. **Default = risk-adjusted ranking with balanced (v1.1) weights.** Optional params are policy knobs for visiting agents — omit them for the same behaviour as v1.1.

**Default ranking is risk-adjusted score, not highest APY.** For headline yield order, use `sortBy: "net_apy"`.

### Defaults (when new params are omitted)

| Param | Default |
|-------|---------|
| `sortBy` | `risk_adjusted` |
| `sortOrder` | `desc` |
| `riskAppetite` | `balanced` (v1.1 weights) |
| `minScore` | none (no filter) |
| `categories` / `excludeCategories` | none (no category filter) |
| `diversity` | `soft` (max **2** markets per `protocolSlug` in the presented top-k) |
| `includeFactors` | `false` (no nested `scores.factors`; heavy `factors[]` rationales omitted) |
| `maxRewardShare` | none (filter off) |

### Optional policy params

| Param | Values | Role |
|-------|--------|------|
| `sortBy` | `risk_adjusted` \| `net_apy` \| `gross_apy` \| `tvl` | Primary sort key after scoring |
| `sortOrder` | `asc` \| `desc` | Sort direction |
| `riskAppetite` | `conservative` \| `balanced` \| `aggressive` | Reweights factor blend before filter/sort (factors themselves unchanged) |
| `minScore` | `0`–`100` | Drop rows whose composite is below threshold **after** appetite weights |
| `categories` | `lending` \| `rwa` \| `other`[] | Include-only list (matches `category`) |
| `excludeCategories` | same enum[] | Exclude after include — e.g. `["rwa"]` for treasury-style shortlists |
| `diversity` | `soft` \| `off` | Presentation only: prefer protocol diversity in returned top-k |
| `includeFactors` | `true` \| `false` | When `true`, each opportunity includes compact `scores.factors` (no second tool call) |
| `maxRewardShare` | `0`–`1` or omit | Max **reward/total** incentive share only; omit/null = no filter |

**Category / RWA (no separate `isRwa` field):**

| Field | Meaning |
|-------|---------|
| `category` | Normalized **`lending` \| `rwa` \| `other`** — this is the practical RWA flag (`category === "rwa"`) |
| `categoryRaw` | Upstream DefiLlama-style label (e.g. `"RWA"`, `"RWA Lending"`, `"Lending"`, `"Dexs"`, `"Insurance"`) — informational only |

Filter with `categories` (include) and `excludeCategories` (e.g. `["rwa"]`). There are **no RWA sub-types** in v1 (T-bill vs private credit). `excludeCategories: ["rwa"]` does **not** remove all non-blue-chip risk — e.g. Insurance often maps to **`other`**. Maple Syrup USDC/USDT/USDG (and other Maple syrup USD* wrappers) is categorized as **rwa** (institutional credit), not blue-chip lending — so treasury exclude-rwa filters drop it (`categoryRaw` stays the upstream label, e.g. Uncollateralized Lending). Research only: listed RWA still carries issuer / legal / transfer risk.

**Reward share:** `apy.rewardShare = apy.reward / apy.total` when `total` is meaningful (not ~0). Both ~0 → share `0`. Pathological/unknown → `null` (never invent 0 when reward is positive but gross is ~0). Each opportunity always exposes `apy.base`, `apy.reward`, `apy.total`, and `apy.rewardShare`.

**`maxRewardShare` scope (incentive gate, not a farm filter):** caps only the **incentive share** of gross APY (`reward/total`). It does **not** remove high **base-only** APY markets (organic yield spikes, mis-tagged base, or exotic products with `reward ≈ 0`). Capital-preservation still needs **`minScore` + `riskAppetite` + `excludeCategories`** (and usually `minTvlUsd`) together — do not rely on `maxRewardShare` alone.

**Soft diversity (default):** after score sort, the returned list takes at most **2** opportunities per protocol so research top-k is not an Aave mirror. Scoring is unchanged. Set `"diversity": "off"` if you need every market from one protocol in pure score order. Meta echoes `diversity`, `truncatedDueToDiversity`, `protocolsRepresented`.

**Factor breakdown (`includeFactors`):** default `false` keeps list payloads slim (flat `scores.*` still include each factor number + `compositeRiskAdjusted`; heavy `factors[]` rationales are omitted). Set `"includeFactors": true` for:

```json
"scores": {
  "protocolRisk": 95.8,
  "depegCounterparty": 92.8,
  "liquidity": 86.4,
  "yieldQuality": 90.3,
  "costAdjusted": 88.7,
  "compositeRiskAdjusted": 91.3,
  "factors": {
    "protocolRisk": 95.8,
    "depegCounterparty": 92.8,
    "liquidity": 86.4,
    "yieldQuality": 90.3,
    "costAdjusted": 88.7
  }
}
```

Ids match scoring factors used elsewhere. Ranking / composite math unchanged. For full rationales use `score_opportunity` or `explain_score`.

Also still supported: `chains`, `assets`, `minTvlUsd`, `limit`, `horizonDays`, `notionalUsd`, `protocols`, etc.

Response `meta` echoes applied controls when relevant: `sortBy`, `sortOrder`, `riskAppetite`, `weights`, `minScore`, `categoriesApplied`, `excludeCategoriesApplied`, `diversity`, plus data freshness (`lastRefreshedAt`, `dataAgeSeconds`, `sourcesFreshness`).  
`data.ranking` remains `compositeRiskAdjusted` for the default risk-adjusted path.

**`meta.suggestedNextSteps`:** 3–5 compact follow-ups (`tool`, `hint`, optional `argsHint`) so calling agents can offer explain / compare / protocol-risk / score / stricter `list_yields` without a second discovery pass. When rows exist, `argsHint` includes real `opportunities[0].id` (and `[1]` for compare). Hints are written for host bots to offer **humans**; `data` remains the canonical agent payload. Research only — not an execution plan. Ranking is unchanged.

**`meta.humanLeadIn`:** optional 1–2 sentence host lead-in when the list is non-empty (real protocol/chain from #1; wording follows `sortBy` — risk-adjusted score, net/gross APY, or TVL; research only). **Omitted** when there are no opportunities. For UIs talking to people — do not treat it as a ranking field.

**`meta.rankContrastNote`:** one extra sentence when a **nearby** returned row has a meaningfully higher APY (net if present, else gross) than #1 but ranks lower. Prefers **#2**, else the highest-APY row in the **top 5** (not the whole-list max-APY farm). Names both protocols/chains and one honest reason from existing fields (thinner liquidity, more incentive share, lower net after gas, higher protocol risk, or “combined risk factors”). **Omitted** if #1 already leads APY in that window or the gap is &lt; 0.25 pp. Does not change ranking.

**`meta.scoreLegend`:** always present on `list_yields` success — one/two sentences explaining that the score is a 0–100 **risk-adjusted** rating (not APY, not a safety guarantee). Hosts should show it **once** near the first shortlist. Exact key: **`meta.scoreLegend`**.

Human-facing hosts should surface `scoreLegend`, `suggestedNextSteps`, `humanLeadIn`, and `rankContrastNote` to end users (see [`how-agents-call.md`](./how-agents-call.md) § Human-facing hosts). `data` remains the canonical agent payload. The tool may return up to `limit` (matrix / default research often `10`); **A2H hosts present ≤5** unless the human asks for more. QA matrix: [`yieldhunter-test-catalog.md`](./yieldhunter-test-catalog.md).

Pipeline: score factors → reweight composite by appetite → **category** filters → **maxRewardShare** → **minScore** → sort → **soft diversity top-k** (or plain slice if `diversity: "off"`).

When `maxRewardShare` is set: keep rows with `rewardShare ≤ maxRewardShare`; **unknown** share is **excluded** (with a meta warning) — not treated as 0. Omitted/null = filter off (default ranking unchanged). Rows with high gross APY but **low/zero reward share** still pass this filter.

### Practical minScore bands

Scores **compress high** (strong blue-chip lending often ~85–92 after v2.1 liquidity trend). Use bands as policy, not a classroom grade:

| minScore | Intent |
|----------|--------|
| **85** | Strict / **treasury default** (recommended high bar) |
| **80** | Institutional / selective |
| **&lt; 70** filter not typical | Below ~70 is speculative territory if you *include* it |

**`minScore: 90` is rare / very strict** — not a normal bar. A few top L2 blue-chip markets may clear it after v2.1; still prefer **85** for treasury (pattern a below). Details: `docs/scoring-methodology.md`.

### Three example patterns

**a) Treasury / conservative** — capital preservation, high bar (~85), no RWA, mostly organic yield:

```json
{
  "chains": ["ethereum", "base", "arbitrum"],
  "assets": ["USDC", "USDT"],
  "minTvlUsd": 10000000,
  "limit": 10,
  "riskAppetite": "conservative",
  "minScore": 85,
  "excludeCategories": ["rwa"],
  "maxRewardShare": 0.3
}
```

Tip: `"maxRewardShare": 0.25` is a stricter **incentive-share** gate (not a generic farm filter). Default remains **omit** (filter off). Pair with minScore / appetite / excludeCategories for treasury policy.

**b) Default research** — omit new params (v1.1 behaviour):

```json
{
  "chains": ["base", "ethereum"],
  "assets": ["USDC", "USDT"],
  "minTvlUsd": 5000000,
  "limit": 10,
  "horizonDays": 30,
  "notionalUsd": 10000
}
```

**c) Yield-seeking** — sort by net APY with aggressive weights:

```json
{
  "chains": ["base", "ethereum"],
  "assets": ["USDC"],
  "minTvlUsd": 1000000,
  "limit": 15,
  "sortBy": "net_apy",
  "riskAppetite": "aggressive"
}
```

**d) With compact factor breakdown** — avoid a second round-trip:

```json
{
  "assets": ["USDC"],
  "chains": ["base", "ethereum"],
  "limit": 5,
  "includeFactors": true
}
```

Weights and methodology: `docs/scoring-methodology.md`.

---

## score_opportunity

Full multi-factor score for one opportunity.

**Id chaining:** `list_yields` returns `opportunities[].id` (canonical DefiLlama pool UUID). Pass that value as **`id`**, **`opportunityId`**, or **`poolId`** (aliases for the same key). Prefer copying `id` from the list response.

```json
{
  "id": "<opportunities[].id from list_yields>",
  "horizonDays": 30
}
```

Also valid: `{ "poolId": "…" }` or `{ "opportunityId": "…" }`.

---

## compare_opportunities

Compare 2–5 opportunities; returns winner + rationale (still ranked by composite risk-adjusted; no query-control knobs on this tool).

Pass `list_yields` ids as **`poolIds`** or **`opportunityIds`**.

```json
{
  "poolIds": ["id-a", "id-b"],
  "horizonDays": 30
}
```

---

## explain_score

**Human-readable narrative** (`summary` + `bullets` with short glosses) plus **unchanged structured** `factors[]` / scores for agents. Same id aliases as `score_opportunity`: **`id` | `opportunityId` | `poolId`** = `list_yields` `opportunities[].id`.

Same id aliases as `score_opportunity`: **`id` | `opportunityId` | `poolId`** = `list_yields` `opportunities[].id`.

```json
{
  "opportunityId": "<opportunities[].id from list_yields>",
  "horizonDays": 30
}
```

---

## get_protocol_risk

Protocol risk card by DefiLlama/curated slug.

**Chaining:** use `list_yields` `opportunities[].protocolSlug` as **`protocol`** or **`protocolSlug`**.

```json
{
  "protocol": "aave-v3"
}
```

---

## MCP Inspector

```bash
pnpm dev
# In another terminal (if Inspector installed):
npx @modelcontextprotocol/inspector http://127.0.0.1:8787/mcp
```

Or run the project smoke test:

```bash
pnpm test
pnpm smoke:mcp
```
