@qianexchange/mcp: MCP server for the Qian multichain DEX and launchpad
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
.env
|
||||
*.tgz
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Qian
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,42 @@
|
||||
# @qianexchange/mcp
|
||||
|
||||
MCP server for [Qian](https://qian.ag), the multichain DEX and launchpad. Any MCP-capable agent can launch tokens, trade, and read market data on Robinhood Chain, Base, and Ethereum.
|
||||
|
||||
Non-custodial: the server runs on your machine and signs with your key. Qian never holds keys or funds. Every launched token's liquidity is locked forever in QianLocker.
|
||||
|
||||
## Setup
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"qian": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@qianexchange/mcp"],
|
||||
"env": { "QIAN_KEY": "0x<your-agent-wallet-private-key>" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Omit `QIAN_KEY` for read-only mode (market data still works). Use a dedicated hot wallet funded with only what the agent may spend.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_tokens` | Tokens on Qian, all chains, with price/FDV/volume |
|
||||
| `get_token` | Full detail: price, FDV, hook, metadata |
|
||||
| `get_trades` / `get_holders` | Market activity |
|
||||
| `my_wallet` | Your address + ETH balance per chain |
|
||||
| `quote_buy` | Simulate a buy (no gas) |
|
||||
| `buy` / `sell` | Trade via QianSwapRouter (plain ETH in/out, auto-approve, slippage-protected) |
|
||||
| `launch_token` | Deploy a token: name/symbol/image/socials, FDV tier 1–4, creator fee 0.5–20%, optional dev buy |
|
||||
| `collect_fees` | Claim your 70% creator share of trading fees |
|
||||
|
||||
## Notes
|
||||
|
||||
- V2 has no launch-time trading restrictions. Liquidity is locked forever; there is no dead window or max-wallet cap.
|
||||
- Trades route through QianSwapRouter, which takes a 1% fee on the ETH side.
|
||||
- Env overrides: `QIAN_API`, `RPC_ROBINHOOD`, `RPC_BASE`, `RPC_ETHEREUM`.
|
||||
|
||||
MIT. Contact: dev@qian.ag
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Security
|
||||
|
||||
The Qian MCP server is non-custodial. It runs on your own machine and signs transactions locally with the private key you place in `QIAN_KEY`. That key never leaves your environment, and Qian's servers never receive it.
|
||||
|
||||
Guidance:
|
||||
|
||||
- Use a dedicated hot wallet for the agent. Fund it with only what the agent is allowed to spend.
|
||||
- Never commit `QIAN_KEY` or an `.env` holding it. The included `.gitignore` excludes `.env`.
|
||||
- Read tools (market data) work without `QIAN_KEY`. Only set the key when the agent needs to launch, trade, or collect fees.
|
||||
- Every launched token's liquidity is locked forever in QianLocker; the contracts have no owner, mint, pause, or blacklist functions.
|
||||
|
||||
To report a vulnerability, email dev@qian.ag.
|
||||
Generated
+1288
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@qianexchange/mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for the Qian multichain DEX & launchpad (qian.ag) — launch and trade tokens from any MCP-capable agent",
|
||||
"type": "module",
|
||||
"bin": { "qian-mcp": "server.js" },
|
||||
"author": "[email protected]",
|
||||
"license": "MIT",
|
||||
"engines": { "node": ">=20" },
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.0",
|
||||
"ethers": "^6.13.0",
|
||||
"zod": "^3.24.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Qian MCP server — launch & trade tokens on the Qian multichain DEX (qian.ag).
|
||||
*
|
||||
* Read tools proxy the public API (api.qian.ag). Write tools sign with the key
|
||||
* in QIAN_KEY (the agent's own hot wallet — Qian never holds keys or funds).
|
||||
* Chains: robinhood (4663), base (8453), ethereum (1).
|
||||
*
|
||||
* Env: QIAN_KEY (hex private key, required for write tools),
|
||||
* QIAN_API (default https://api.qian.ag),
|
||||
* RPC_ROBINHOOD / RPC_BASE / RPC_ETHEREUM (optional RPC overrides).
|
||||
*/
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { z } from 'zod'
|
||||
import { ethers } from 'ethers'
|
||||
|
||||
const API = process.env.QIAN_API ?? 'https://api.qian.ag'
|
||||
|
||||
// V2 stack (2026-07-16): core addresses are IDENTICAL on every chain (one fresh
|
||||
// key at nonce 0 each). No feature hooks — the only approved hook is the GuardHook,
|
||||
// applied automatically when deployCoin passes hookAddress=0, and the creator's tax
|
||||
// is the V4 pool fee. Only rpc/chainId/explorer differ per chain.
|
||||
const CORE = {
|
||||
factoryV4: '0x750d0bc66baa12CF1710a8ba939B97DB4aD005c3',
|
||||
feeRouter: '0xDe598E5F0b428B38EFaA6e887458FEF70bFA9436',
|
||||
swapRouter: '0xEE540d2496cfFDf17E29a04bcd7b6dc795C61d66',
|
||||
}
|
||||
const CHAINS = {
|
||||
robinhood: {
|
||||
...CORE,
|
||||
chainId: 4663,
|
||||
rpc: process.env.RPC_ROBINHOOD ?? 'https://rpc.mainnet.chain.robinhood.com',
|
||||
explorer: 'https://robinhoodchain.blockscout.com',
|
||||
},
|
||||
base: {
|
||||
...CORE,
|
||||
chainId: 8453,
|
||||
rpc: process.env.RPC_BASE ?? 'https://base-rpc.publicnode.com',
|
||||
explorer: 'https://basescan.org',
|
||||
},
|
||||
ethereum: {
|
||||
...CORE,
|
||||
chainId: 1,
|
||||
rpc: process.env.RPC_ETHEREUM ?? 'https://ethereum-rpc.publicnode.com',
|
||||
explorer: 'https://etherscan.io',
|
||||
},
|
||||
}
|
||||
const chainSlug = z.enum(['robinhood', 'base', 'ethereum'])
|
||||
const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/, 'EVM address')
|
||||
const SUPPLY = 1_000_000_000n * 10n ** 18n
|
||||
|
||||
const FACTORY_ABI = [
|
||||
'function deployCoin(string _name, string _symbol, string _metadata, bytes32 salt, uint256 configId, address hookAddress, bytes hookConfig, uint256 maxDeployFee, uint24 poolFee) payable',
|
||||
'function deployFee() view returns (uint256)',
|
||||
'event ERC20TokenCreated(address tokenAddress)',
|
||||
]
|
||||
const ROUTER_ABI = [
|
||||
'function buy(address token, uint256 minTokensOut) payable returns (uint256)',
|
||||
'function sell(address token, uint256 tokensIn, uint256 minEthOut) returns (uint256)',
|
||||
]
|
||||
const FEE_ROUTER_ABI = ['function collectFees(address token)']
|
||||
const ERC20_ABI = [
|
||||
'function balanceOf(address) view returns (uint256)',
|
||||
'function allowance(address,address) view returns (uint256)',
|
||||
'function approve(address,uint256) returns (bool)',
|
||||
]
|
||||
|
||||
const providers = {}
|
||||
const provider = (c) => (providers[c] ??= new ethers.JsonRpcProvider(CHAINS[c].rpc, CHAINS[c].chainId, { staticNetwork: true }))
|
||||
function wallet(c) {
|
||||
if (!process.env.QIAN_KEY) throw new Error('QIAN_KEY not set — write tools need a private key in the environment')
|
||||
return new ethers.Wallet(process.env.QIAN_KEY, provider(c))
|
||||
}
|
||||
|
||||
async function api(path, init) {
|
||||
const res = await fetch(`${API}${path}`, init)
|
||||
if (!res.ok) throw new Error(`Qian API ${path} -> ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
||||
return res.json()
|
||||
}
|
||||
const json = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] })
|
||||
|
||||
// Trim a raw API token to what an agent needs; price derived from X96 sqrt-price.
|
||||
function slim(t) {
|
||||
const priceEth = t.last_price_x96 ? priceEthPerToken(t.last_price_x96, t.address, t.chain) : null
|
||||
return {
|
||||
chain: t.chain, address: t.address, name: t.name, symbol: t.symbol,
|
||||
pool: t.pool_kind, creator: t.creator, deploy_time: t.deploy_time,
|
||||
trade_count: t.trade_count, volume_eth_24h: t.volume_eth ? ethers.formatEther(BigInt(t.volume_eth)) : '0',
|
||||
price_eth: priceEth, fdv_eth: priceEth === null ? null : priceEth * 1e9,
|
||||
description: t.description, website: t.website, telegram: t.telegram, x: t.x,
|
||||
url: `https://qian.ag/${t.chain}/token/${t.address}`,
|
||||
}
|
||||
}
|
||||
const WETH = { robinhood: '0x0bd7d308f8e1639fab988df18a8011f41eacad73', base: '0x4200000000000000000000000000000000000006', ethereum: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' }
|
||||
function priceEthPerToken(x96, token, chain) {
|
||||
// sqrtPriceX96 is token1-per-token0; flip when the token sorts after WETH
|
||||
const p = Number(BigInt(x96)) / 2 ** 96
|
||||
const tokenIs0 = token.toLowerCase() < WETH[chain]
|
||||
return tokenIs0 ? p * p : 1 / (p * p)
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: 'qian', version: '0.1.0' })
|
||||
|
||||
server.registerTool('list_tokens', {
|
||||
description: 'List tokens launched on Qian (all chains, newest first). Optionally filter by chain.',
|
||||
inputSchema: { chain: chainSlug.optional(), limit: z.number().int().min(1).max(200).default(50) },
|
||||
}, async ({ chain, limit }) => {
|
||||
let { tokens } = await api('/tokens')
|
||||
if (chain) tokens = tokens.filter((t) => t.chain === chain)
|
||||
return json(tokens.slice(0, limit).map(slim))
|
||||
})
|
||||
|
||||
server.registerTool('get_token', {
|
||||
description: 'Full detail for one Qian token: price, FDV, hook, metadata.',
|
||||
inputSchema: { chain: chainSlug, address },
|
||||
}, async ({ chain, address: addr }) => {
|
||||
const { token } = await api(`/tokens/${chain}/${addr}`)
|
||||
return json({
|
||||
...slim(token),
|
||||
hook: token.hook, hook_config: token.hook_config,
|
||||
})
|
||||
})
|
||||
|
||||
server.registerTool('get_trades', {
|
||||
description: 'Recent trades for a Qian token (newest first).',
|
||||
inputSchema: { chain: chainSlug, address, limit: z.number().int().min(1).max(200).default(50) },
|
||||
}, async ({ chain, address: addr, limit }) => {
|
||||
const { trades } = await api(`/tokens/${chain}/${addr}/trades?limit=${limit}`)
|
||||
return json(trades)
|
||||
})
|
||||
|
||||
server.registerTool('get_holders', {
|
||||
description: 'Top holders for a Qian token.',
|
||||
inputSchema: { chain: chainSlug, address },
|
||||
}, async ({ chain, address: addr }) => json((await api(`/tokens/${chain}/${addr}/holders`)).holders))
|
||||
|
||||
server.registerTool('my_wallet', {
|
||||
description: 'Your wallet address (from QIAN_KEY) and native ETH balance on each chain.',
|
||||
inputSchema: {},
|
||||
}, async () => {
|
||||
const w = wallet('robinhood')
|
||||
const balances = {}
|
||||
for (const c of Object.keys(CHAINS)) balances[c] = ethers.formatEther(await provider(c).getBalance(w.address))
|
||||
return json({ address: w.address, eth: balances })
|
||||
})
|
||||
|
||||
server.registerTool('quote_buy', {
|
||||
description: 'Simulate buying a token with ETH via QianSwapRouter (1% router fee included). Returns expected tokens out. Your wallet must hold at least eth_amount for the simulation.',
|
||||
inputSchema: { chain: chainSlug, address, eth_amount: z.string().describe('ETH to spend, e.g. "0.05"') },
|
||||
}, async ({ chain, address: addr, eth_amount }) => {
|
||||
const router = new ethers.Contract(CHAINS[chain].swapRouter, ROUTER_ABI, wallet(chain))
|
||||
const out = await router.buy.staticCall(addr, 0n, { value: ethers.parseEther(eth_amount) })
|
||||
return json({ eth_in: eth_amount, tokens_out: ethers.formatEther(out), pct_of_supply: Number((out * 10000n) / SUPPLY) / 100 })
|
||||
})
|
||||
|
||||
async function sendAndWait(txPromise, chain, label) {
|
||||
const tx = await txPromise
|
||||
const rc = await tx.wait()
|
||||
if (rc.status !== 1) throw new Error(`${label} tx reverted: ${tx.hash}`)
|
||||
return { hash: tx.hash, explorer: `${CHAINS[chain].explorer}/tx/${tx.hash}`, rc }
|
||||
}
|
||||
|
||||
server.registerTool('buy', {
|
||||
description: 'Buy a Qian token with plain ETH via QianSwapRouter. Simulates first, applies slippage to set min-out, then sends. No launch-time trading restrictions in V2 (no dead window, no max-wallet).',
|
||||
inputSchema: {
|
||||
chain: chainSlug, address,
|
||||
eth_amount: z.string().describe('ETH to spend, e.g. "0.05"'),
|
||||
slippage_bps: z.number().int().min(1).max(2000).default(100),
|
||||
},
|
||||
}, async ({ chain, address: addr, eth_amount, slippage_bps }) => {
|
||||
const w = wallet(chain)
|
||||
const router = new ethers.Contract(CHAINS[chain].swapRouter, ROUTER_ABI, w)
|
||||
const value = ethers.parseEther(eth_amount)
|
||||
const expected = await router.buy.staticCall(addr, 0n, { value })
|
||||
const minOut = (expected * BigInt(10_000 - slippage_bps)) / 10_000n
|
||||
const { hash, explorer } = await sendAndWait(router.buy(addr, minOut, { value }), chain, 'buy')
|
||||
const bal = await new ethers.Contract(addr, ERC20_ABI, provider(chain)).balanceOf(w.address)
|
||||
return json({ hash, explorer, spent_eth: eth_amount, tokens_received: ethers.formatEther(expected), wallet_balance: ethers.formatEther(bal) })
|
||||
})
|
||||
|
||||
server.registerTool('sell', {
|
||||
description: 'Sell a Qian token for ETH via QianSwapRouter (approves the router if needed). amount "all" sells your full balance.',
|
||||
inputSchema: {
|
||||
chain: chainSlug, address,
|
||||
amount: z.string().describe('Token amount in whole tokens, or "all"'),
|
||||
slippage_bps: z.number().int().min(1).max(2000).default(100),
|
||||
},
|
||||
}, async ({ chain, address: addr, amount, slippage_bps }) => {
|
||||
const w = wallet(chain)
|
||||
const erc = new ethers.Contract(addr, ERC20_ABI, w)
|
||||
const router = new ethers.Contract(CHAINS[chain].swapRouter, ROUTER_ABI, w)
|
||||
const tokens = amount === 'all' ? await erc.balanceOf(w.address) : ethers.parseEther(amount)
|
||||
if (tokens === 0n) throw new Error('nothing to sell — token balance is 0')
|
||||
if ((await erc.allowance(w.address, CHAINS[chain].swapRouter)) < tokens)
|
||||
await sendAndWait(erc.approve(CHAINS[chain].swapRouter, ethers.MaxUint256), chain, 'approve')
|
||||
const expected = await router.sell.staticCall(addr, tokens, 0n)
|
||||
const minOut = (expected * BigInt(10_000 - slippage_bps)) / 10_000n
|
||||
const { hash, explorer } = await sendAndWait(router.sell(addr, tokens, minOut), chain, 'sell')
|
||||
return json({ hash, explorer, tokens_sold: ethers.formatEther(tokens), eth_received: ethers.formatEther(expected) })
|
||||
})
|
||||
|
||||
server.registerTool('launch_token', {
|
||||
description: [
|
||||
'Launch a new token on Qian: 1B supply, full supply as locked one-sided liquidity (LP owned forever by QianLocker — no rug possible), fees stream to you as creator (70%) and the platform (30%).',
|
||||
'Costs the deploy fee (~0.0005 ETH) + optional dev_buy_eth. Tiers set starting FDV: 1=1 ETH, 2=2, 3=5, 4=10.',
|
||||
'creator_fee_pct is the trading tax (0.5%–20%) you earn on every swap — it is the V4 pool fee, immutable once launched. There are no anti-snipe / dead-window / max-wallet options in V2.',
|
||||
].join(' '),
|
||||
inputSchema: {
|
||||
chain: chainSlug,
|
||||
name: z.string().min(1).max(64),
|
||||
symbol: z.string().min(1).max(16),
|
||||
description: z.string().max(280).default(''),
|
||||
image_url: z.string().url().optional().describe('http(s) or ipfs image; pinned to IPFS for you'),
|
||||
website: z.string().default(''), telegram: z.string().default(''), x: z.string().default(''),
|
||||
tier: z.number().int().min(1).max(4).default(1),
|
||||
creator_fee_pct: z.number().min(0.5).max(20).default(1).describe('trading fee you earn, 0.5–20% (the immutable V4 pool fee)'),
|
||||
dev_buy_eth: z.string().default('0').describe('ETH for an initial creator buy in the deploy tx'),
|
||||
},
|
||||
}, async (p) => {
|
||||
const w = wallet(p.chain)
|
||||
const C = CHAINS[p.chain]
|
||||
|
||||
let imageCid = ''
|
||||
if (p.image_url) {
|
||||
const src = p.image_url.startsWith('ipfs://') ? `https://ipfs.qian.ag/ipfs/${p.image_url.slice(7)}` : p.image_url
|
||||
const img = await fetch(src)
|
||||
if (!img.ok) throw new Error(`image fetch failed: ${img.status}`)
|
||||
const body = new FormData()
|
||||
body.append('file', new Blob([await img.arrayBuffer()], { type: img.headers.get('content-type') ?? 'image/png' }), 'logo')
|
||||
imageCid = (await api('/launch/upload-image', { method: 'POST', body })).cid
|
||||
}
|
||||
const { uri } = await api('/launch/prepare', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: p.name, symbol: p.symbol, description: p.description, website: p.website, telegram: p.telegram, x: p.x, imageCid }),
|
||||
})
|
||||
|
||||
// hookAddress=0 resolves to the GuardHook (front-run protection only); no feature hooks in V2.
|
||||
const hookAddr = ethers.ZeroAddress, hookConfig = '0x'
|
||||
const poolFee = Math.round(p.creator_fee_pct * 10_000) // uint24 hundredths of a bip (0.5%–20% → 5000–200000)
|
||||
|
||||
const factory = new ethers.Contract(C.factoryV4, FACTORY_ABI, w)
|
||||
const fee = await factory.deployFee()
|
||||
const value = fee + ethers.parseEther(p.dev_buy_eth)
|
||||
const salt = ethers.hexlify(ethers.randomBytes(32))
|
||||
// maxDeployFee = fee just read → the controller can't raise deployFee under our pending launch.
|
||||
await factory.deployCoin.staticCall(p.name, p.symbol, uri, salt, p.tier, hookAddr, hookConfig, fee, poolFee, { value })
|
||||
const { hash, explorer, rc } = await sendAndWait(
|
||||
factory.deployCoin(p.name, p.symbol, uri, salt, p.tier, hookAddr, hookConfig, fee, poolFee, { value }), p.chain, 'deploy')
|
||||
const created = rc.logs.map((l) => { try { return factory.interface.parseLog(l) } catch { return null } }).find((l) => l?.name === 'ERC20TokenCreated')
|
||||
if (!created) throw new Error(`deploy succeeded but no ERC20TokenCreated event found in ${hash}`)
|
||||
const token = created.args[0]
|
||||
return json({ token, hash, explorer, page: `https://qian.ag/${p.chain}/token/${token}`, metadata_uri: uri, deploy_fee_eth: ethers.formatEther(fee) })
|
||||
})
|
||||
|
||||
server.registerTool('collect_fees', {
|
||||
description: 'Collect accrued trading fees for a token via QianFeeRouter (permissionless — anyone can trigger; payout always goes 70% to the token creator, 30% to the platform).',
|
||||
inputSchema: { chain: chainSlug, address },
|
||||
}, async ({ chain, address: addr }) => {
|
||||
const feeRouter = new ethers.Contract(CHAINS[chain].feeRouter, FEE_ROUTER_ABI, wallet(chain))
|
||||
const { hash, explorer } = await sendAndWait(feeRouter.collectFees(addr), chain, 'collectFees')
|
||||
return json({ hash, explorer })
|
||||
})
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
console.error(`qian-mcp ready — api=${API} write=${process.env.QIAN_KEY ? 'enabled' : 'DISABLED (no QIAN_KEY)'}`)
|
||||
Reference in New Issue
Block a user