Imported from Ellenp2p/apy-mcp (
AGENTS.md). Install upstream withnpx skills add Ellenp2p/apy-mcp. Copyright stays with the author.
AGENTS.md
Rust server aggregating DeFi lending/borrowing rates. It serves three surfaces
from one binary: an embedded web UI (/, static assets), a Web API
(/api/v1/*), and an optional MCP query_rates tool over MCP Streamable
HTTP at /mcp. Auth for /api/* and /mcp is GitHub OAuth (or API keys).
Project goals
- Aggregate lending/borrowing APY across chains and protocols behind one
unified query surface (filter by chain, asset, protocol, APY range,
utilization), exposed both as REST (
/api/v1/rates) and as the MCPquery_ratestool — a single query core (RateService) drives both. - Coverage today: Aave V3 (11 EVM chains), Spark Savings (spUSDC/spUSDT),
Blend (Stellar). New protocols should plug in as a
LendingProviderimplementation and be wired intosrc/service/rates.rs, not special-cased. - Rate data must be fresh and cheap to serve: rely on official protocol data sources (contract reads / official APIs), cache aggressively (120s SQLite), and keep fetch paths resilient (timeouts, per-source isolation so one failing source doesn't fail the whole query).
- Production deployment is a public HTTPS server with GitHub OAuth — auth and OAuth flows are invariants, not optional features.
Commands
cargo check
cargo test # unit tests (fast, offline)
cargo clippy
Cargo features (both on by default):
cargo build # mcp + web
cargo build --no-default-features # core API only (no /mcp, no web UI)
cargo build --no-default-features --features web
cargo build --no-default-features --features mcp
Live network tests are #[ignore]d and hit real RPCs/APIs (may be flaky):
cargo test test_live_ -- --ignored --nocapture
Architecture
src/chains/{evm,stellar}/— protocol providers; each implements theLendingProvidertrait (src/chains/mod.rs):chain_name,protocol_name,get_pool_rates,list_pools.evm/aave.rs— Aave V3 contract reads (getAllReservesTokens/getReserveDataviaeth_call, hand-rolled ABI decode).evm/savings.rs+SparkSavingsProvider— Spark Savings (spUSDC/spUSDT) via the official REST APIapi.spark.fi/v1/savings/{protocol}/{chain}/{token}.stellar/blend.rs— Blend pools via SorobangetLedgerEntries(XDR decode).
src/service/— protocol-agnostic query core.rates.rs—RateService: owns the providers +monitored_pools+ DB cache;query/add_pool/list_pools(filters, 120s SQLite cache).types.rs— sharedPoolRates/AssetRate/AllRatesResponseandQueryRatesParams(used by both REST and MCP).
src/api.rs— REST handlers for/api/v1/{rates,chains,protocols,pools}.src/mcp/tools.rs—#[tool_router]shell overRateService(only with themcpfeature; the JSON output shape ofquery_ratesmust stay compatible).src/web.rs— the/page, rendered with Dioxus SSR (#[component]s +dioxus::ssr::render_element;webfeature only). Initial visit serves straight from the 120s cache (never blocks on RPC; cold cache kicks a background refresh and shows a "refresh shortly" notice). The filter form submits GET to/and the handler re-renders with fresh results (8s timeout, falls back to cached data). Sort-header clicks and filter-form submits go through htmx (embedded asweb/htmx.min.js, ~50KB, rust-embedded) and swap the<div id="results-region">fragment in place — no full reload, no scroll jump, filter form state preserved. Server detectsHX-Request: true(or?_fragment=resultsfor curl/no-JS) and returns only the swap region instead of the full document. The form carries a hiddenforce_refresh=1so htmx form submits run a fresh query (with 8s timeout + cache fallback) while sort-header clicks — which don't includeforce_refresh— re-render straight from the 120s cache.src/http.rsprewarms the cache in a background task at startup.web/keepsstyle.css,htmx.min.js, and a tinyapp.jsfor the OAuth redirect token capture.src/http.rs— axum server: public routes (web assets, health, OAuth, admin), authenticated/api/v1/*,/mcp(feature-gated +--enable-mcpruntime switch), OAuth callback.src/main.rs— CLI (stdio[mcp feature only],http,daemon,admin); HTTP bootstrap.
Cargo features
mcp(default) — the MCP tool +/mcpendpoint +stdiosubcommand. Without it,GET /mcpreturns 404mcp_not_available.web(default) — the embedded frontend. Without it,/serves a minimal placeholder page.- With
mcpbuilt in,--enable-mcp=false(orENABLE_MCP=false) disables/mcpat runtime (404mcp_disabled).daemonrespawns preserve the flag.
Protocol names (protocol= / ?protocol=)
aave_v3— Aave V3 (all supported EVM chains)spark— Spark Savings vaults (spUSDC/spUSDT), via the Spark Savings APIblend— Stellar Blend poolsall(default) — everything
Spark Savings data is Cloudflare-cached by the upstream API (~5 min refresh);
we additionally cache 120s in SQLite (use_cache=false forces fresh).
HTTP / OAuth invariants (don't break)
/api/v1/*is public read-only (rate data is public), served from the shared 120s SQLite cache./mcprequiresAuthorization: Bearer(OAuth token, GitHub token, or API key). Web pages (/, static assets) are public.- 401 responses MUST carry
WWW-Authenticate: Bearer resource=..., authorization_servers=...(RFC 9728) — clients use it to start OAuth. Browsers (Accept: text/html) get an HTML hint page linking to/docsinstead of an empty body;/docsis a public MCP usage guide (Dioxus SSR withweb, static page without). - Access tokens TTL 24h with rotating refresh tokens; PKCE (S256) is enforced
on
/oauth/token; client secrets are stored sha256 + constant-time compared. --base-urlMUST be the real public HTTPS domain (used for OAuth callback,.well-knowndiscovery endpoints, and the MCP Host allowlist). Wrong base-url breaks GitHub login and client OAuth discovery.- GitHub web login redirects to
/?oauth=success&...&token=<github token>; the SPA stores that token and uses it as the Bearer for/api/v1/*(stored GitHub tokens are valid Bearer credentials, see auth middleware).
Deployment / release
daemonsubcommand runs the HTTP server in the background: same args ashttp, plus required--log <file>; returns immediately.pkill -f apy-mcpto stop (on Windows:taskkill //F //IM apy-mcp.exe).- Production HTTP requires
--base-url,--admin-token, and GitHub OAuth client id/secret (+ optional--allowed-github-users). - Release = push a
vX.Y.Ztag;.github/workflows/release.ymlbuilds 4 platforms. Linux must stay static musl (x86_64-unknown-linux-musl): keepreqwestonrustls-tls+default-features = false— switching back to native-tls breaks the static build (glibc version errors on old servers). New dependencies must not pull in OpenSSL. - The release build auto-injects the tag into the binary as
APY_MCP_RELEASE, so/healthreports the real deployed version. Cargo.toml'sversionstays at 0.1.0 and is NOT the deployed version — read it from/healthinstead.
Conventions
.envis gitignored and.opencode/opencode.jsondenies agents reading it — never read or commit.env.- SQLite database lives in
data/(gitignored). web/assets are embedded byrust-embedat compile time — after editing them,cargo buildis required for changes to show up in the binary.