mayan-mcp
by sand0vvv
README.md
# mayan-mcp
An MCP server for [Mayan Finance](https://mayan.finance) cross-chain swaps.
Ask an AI assistant what a swap will cost, then get the exact transaction to sign — between Solana
and seven EVM chains, through Mayan's Swift and MCTP routes.
**This server holds no private keys. It cannot sign and it cannot broadcast.** `build_swap` returns
an unsigned transaction; the caller signs it in their own wallet. There is no tool here that can
move funds.

Before any transaction is handed over, its own encoded arguments are read back and compared against
the quote you were shown — the token, the amount, and the contract it forwards to. A payload that
spends more than it quoted is refused rather than returned.
Those are real numbers, not a mockup: `scripts/capture-demo.ts` drives the built server over stdio
against the live API and writes what came back to [`docs/captured-output.json`](docs/captured-output.json),
which is what the image above renders. Run it yourself and compare — a tool whose claim is "check
what you are signing" should be checkable itself.
---
## Custody model
| | |
|---|---|
| Reads quotes, tokens, swap status | yes |
| Builds transactions | yes, unsigned |
| Holds private keys or seed phrases | **no** |
| Signs transactions | **no** |
| Broadcasts to a chain | **no** |
The guarantee is structural rather than procedural: there is no key material in the process, so
there is no code path from a compromised prompt to a moved balance. The signing step belongs to the
wallet, which is also where the user sees the recipient address one last time.
Two consequences worth stating plainly:
- **Tool annotations are not the guarantee.** This server sets `readOnlyHint`, but the MCP
specification requires clients to treat annotations from an untrusted server as untrusted. The
claim that matters is "no keys in the process", which you can check by reading `src/`.
- **A prompt injection cannot move funds through this server, but it can still propose a bad swap.**
Every response echoes the recipient address, the minimum received and the slippage next to the
payload, so the terms are visible before anyone signs.
See [SECURITY.md](SECURITY.md) for the threat model, the invariant checks, and a reachability
analysis of the current `npm audit` output.
---
## Install
Requires Node.js 20 or newer.
```bash
npx @sand0vvv/mayan-mcp --help
```
### Claude Desktop / Claude Code
```json
{
"mcpServers": {
"mayan": {
"command": "npx",
"args": ["-y", "@sand0vvv/mayan-mcp"],
"env": {
"SOLANA_RPC_URL": "https://your-solana-rpc-endpoint"
}
}
}
}
```
### Cursor / other MCP clients
Same command, over stdio: `npx -y @sand0vvv/mayan-mcp`.
### Streamable HTTP
```bash
npx @sand0vvv/mayan-mcp --http --port 8787
```
Stateless — a fresh server per request, no session state. It binds to `127.0.0.1` and validates the
`Host` and `Origin` headers, so a page in your browser cannot reach it by pointing its own hostname at
loopback. There is no authentication: exposing it on another interface (`--host 0.0.0.0`) is a
deliberate act that belongs behind a proxy providing auth and rate limiting.
Holding no session state is what MCP's [2026-07-28 revision](https://modelcontextprotocol.io/specification/2026-07-28/changelog)
made mandatory; this server was built that way before it was required. It speaks the protocol
versions its SDK implements, which is not yet that revision — the claim is about the property, not
the version.
---
## Configuration
Everything comes from the environment. Nothing is required to get a quote.
| Variable | Required | Purpose |
|---|:--:|---|
| `SOLANA_RPC_URL` | for Solana builds | Building a Solana-source swap needs a live RPC connection. Without it the server falls back to the public mainnet endpoint, which throttles hard. |
| `MAYAN_RPC_URL_<CHAIN>` | for allowance reads | Per-chain EVM endpoint, e.g. `MAYAN_RPC_URL_BASE`. Lets the server read your existing allowance instead of assuming an approve is needed. Read-only: the only call made is `eth_call`. |
| `MAYAN_API_KEY` | no | Lifts Mayan's per-IP rate limit. Request one from `support@mayan.finance`. |
| `MAYAN_HTTP_TIMEOUT_MS` | no | Default `30000`. |
| `MAYAN_MAX_RETRIES` | no | Default `3`. Retries are full-jitter backoff, reads only. |
| `MAYAN_TOKEN_CACHE_TTL_MS` | no | Default `600000`. |
| `MAYAN_REFERRER_EVM` / `MAYAN_REFERRER_SOLANA` | no | Attaches a fee-split referrer to quotes. **Unset by default, and left unset in this project** — see [SECURITY.md](SECURITY.md#fees). |
| `MAYAN_MCP_DEBUG` | no | `1` for verbose logging on stderr. |
There is no variable for a private key, and there never will be.
---
## Tools
| Tool | What it does |
|---|---|
| `get_quote` | Every available route for a swap: expected output, guaranteed minimum, slippage, fees, ETA. |
| `build_swap` | The unsigned transaction, plus the quote terms and any ERC20 approval needed first. |
| `list_tokens` | Tokens Mayan can route on a chain, with addresses and decimals. |
| `track_order` | Status of a swap by its source-chain transaction hash. |
| `check_allowance` | How much the Mayan forwarder is currently allowed to spend, and whether that covers a swap. |
| `list_chains` | Supported chains and their EVM chain ids. |
| `estimate_gas` | Gas estimate for a built EVM transaction. |
Supported chains: `solana`, `ethereum`, `base`, `arbitrum`, `optimism`, `polygon`, `bsc`,
`avalanche`.
---
## Worked example
> **"What do I get for 0.5 SOL into USDC on Base?"**
`get_quote` → two routes:
```
0.5 SOL on solana -> USDC on base
SWIFT V2 · Swift
expected 37.137748 USDC
minimum 36.933584 USDC (slippage 55 bps)
eta 3s
price GOOD (ratio 0.9996)
quote id 0xa4c71a85927b104fe039e05d56efe944
FAST_MCTP · Best Price
expected 37.155133 USDC
minimum 36.954199 USDC (slippage 50 bps)
eta 35s
price GOOD (ratio 0.9996)
```
> **"Build it."**
`build_swap` → the transaction, unsigned:
```
Unsigned swap built: 10 USDC on base -> SOL on solana via SWIFT
You receive at least 0.130172143 SOL (expected 0.134198086, slippage 300 bps, ETA 3s)
Recipient: 9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM
Transaction to sign:
to 0x337685fdaB40D39bd02028545a4FfA7D287cC3E2
chainId 8453
value 0x0
data 932 bytes (forwardERC20)
Checked before returning it:
ok target is the Mayan forwarder from the SDK constant
ok chain id matches the quote
ok the call spends USDC, the token quoted
ok the call spends 10000000 base units, the amount quoted
ok forwards to 0x40fFE85A28DC9993541449464d7529a922142960, the SWIFT contract the quote declared
Approval required BEFORE this transaction:
approve 10000000 (base units) of USDC
spender 0x337685fdaB40D39bd02028545a4FfA7D287cC3E2 (Mayan forwarder)
Signers returned: 0. Unsigned. This server holds no private keys and cannot sign or broadcast.
```
Those checks are not decoration. The transaction is assembled from data Mayan's backend served, so the
SDK's own encoded arguments are read back and compared against the quote you were shown: the token,
the amount, and the protocol contract. A payload that spends more than it quoted, or routes through a
substituted contract, is refused rather than returned. See [SECURITY.md](SECURITY.md).
`structuredContent` carries the same thing machine-readably: `evm_transaction`, `approval`, `route`
and a `verification` block recording which invariants were checked.
### Solana output
Two things are different when the source chain is Solana, both stated here rather than left to be
discovered:
- **The cross-check above does not apply.** It reads an EVM call's encoded arguments; a Solana build
is a list of instructions with no such envelope. Those fields are therefore *absent* from the
verification block rather than reported as failures. What still holds is the route allow-list and
the empty signer set — the custody claim itself. Review the instructions in your wallet.
- **`MCTP` from Solana cannot be built here.** That route needs an ephemeral keypair to carry its
CCTP message, and this server does not emit key material of any kind. `SWIFT` from Solana builds
without one and is what an unpinned `build_swap` selects.
A Solana-source swap returns instructions in the SDK's own `InstructionInfo` shape, so the output
feeds straight back into `deserializeInstructionInfo()` without a translation step:
```json
{
"programId": "ComputeBudget111111111111111111111111111111",
"accounts": [],
"data": "AsBcFQA="
}
```
Address lookup tables come back as base58 addresses, ready for
`connection.getMultipleAccountsInfo()`.
---
## What the caller still has to do
1. **Send the approval first** on EVM sources, or the swap reverts. `build_swap` tells you the
token, the amount and the spender. With `MAYAN_RPC_URL_<CHAIN>` set it reads your current
allowance and says nothing when you have already approved enough. (USDT on Ethereum additionally
requires zeroing an existing allowance before raising it — the response says so when it applies.)
2. **Sign and submit.** Not this server's job, by design.
3. **Check the recipient address** in your wallet before signing.
---
## Development
```bash
npm install
npm run build
npm test # unit tests: invariants, serialisation round-trip, schemas, layering
npm run typecheck
npx tsx scripts/smoke.ts # live: quotes and builds against Mayan's API
npx tsx scripts/e2e.ts # live: spawns the built server and speaks MCP over stdio
```
The layout keeps one boundary strictly:
```
src/client/ the only code that talks to @mayanfinance/swap-sdk
src/tools/ MCP tool definitions — Zod in, structuredContent out
src/transport/ stdio and Streamable HTTP
```
`test/layering.test.ts` fails the build if anything above `src/client` imports the SDK or a chain
library directly, and it verifies its own detector fires before trusting the result.
---
## License
MIT. See [LICENSE](LICENSE).
Not affiliated with Mayan Finance. This is client software for their public API; it makes no
warranty about routing, pricing or settlement, and it is not financial advice.
TDQS
A4.4/5.0
Scored across 7 tools
Disambiguation5/5
Each tool targets a distinct operation: quoting, building, listing tokens/chains, tracking, gas estimation, and allowance checking. No overlap in functionality.
Naming Consistency5/5
All tools follow a consistent verb_noun pattern with underscores (e.g., get_quote, build_swap, list_tokens), making them predictable.
Tool Count5/5
7 tools is well within the ideal range for a focused cross-chain swap server, each tool serving a necessary role without bloat.
Completeness5/5
Covers all essential operations for cross-chain swaps: chain/token discovery, quoting, transaction building, order tracking, gas estimation, and allowance checks. No obvious gaps.
Maintenance
ActivitySlowing
ResponsivenessNo issues