intent-swap-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +244 -0
- package/dist/intent-parser.d.ts +25 -0
- package/dist/intent-parser.js +157 -0
- package/dist/price-feed.d.ts +9 -0
- package/dist/price-feed.js +64 -0
- package/dist/uniswap.d.ts +44 -0
- package/dist/uniswap.js +196 -0
- package/package.json +34 -0
- package/src/index.ts +269 -0
- package/src/intent-parser.ts +194 -0
- package/src/price-feed.ts +72 -0
- package/src/uniswap.ts +270 -0
- package/tsconfig.json +15 -0
package/dist/uniswap.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uniswap V3 quote + tx builder.
|
|
3
|
+
* Uses public RPC — no API key required.
|
|
4
|
+
*
|
|
5
|
+
* Quote: calls QuoterV2 on-chain
|
|
6
|
+
* Build: generates SwapRouter02 calldata via viem
|
|
7
|
+
*/
|
|
8
|
+
import { createPublicClient, http, parseUnits, formatUnits, encodeFunctionData } from "viem";
|
|
9
|
+
import { mainnet, arbitrum } from "viem/chains";
|
|
10
|
+
// ─── Chain config ──────────────────────────────────────────────────────────
|
|
11
|
+
const CHAINS = {
|
|
12
|
+
mainnet: {
|
|
13
|
+
chain: mainnet,
|
|
14
|
+
rpc: "https://ethereum.publicnode.com",
|
|
15
|
+
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
|
|
16
|
+
swapRouter: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45",
|
|
17
|
+
},
|
|
18
|
+
arbitrum: {
|
|
19
|
+
chain: arbitrum,
|
|
20
|
+
rpc: "https://arbitrum-one.publicnode.com",
|
|
21
|
+
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
|
|
22
|
+
swapRouter: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45",
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const TOKENS_MAINNET = {
|
|
26
|
+
ETH: { address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", decimals: 18, isNative: true },
|
|
27
|
+
WETH: { address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", decimals: 18 },
|
|
28
|
+
USDC: { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6 },
|
|
29
|
+
USDT: { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", decimals: 6 },
|
|
30
|
+
DAI: { address: "0x6B175474E89094C44Da98b954EedeAC495271d0F", decimals: 18 },
|
|
31
|
+
WBTC: { address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", decimals: 8 },
|
|
32
|
+
UNI: { address: "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", decimals: 18 },
|
|
33
|
+
LINK: { address: "0x514910771AF9Ca656af840dff83E8264EcF986CA", decimals: 18 },
|
|
34
|
+
ARB: { address: "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1", decimals: 18 },
|
|
35
|
+
AAVE: { address: "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", decimals: 18 },
|
|
36
|
+
CRV: { address: "0xD533a949740bb3306d119CC777fa900bA034cd52", decimals: 18 },
|
|
37
|
+
};
|
|
38
|
+
// ─── ABIs (minimal) ────────────────────────────────────────────────────────
|
|
39
|
+
const QUOTER_V2_ABI = [
|
|
40
|
+
{
|
|
41
|
+
name: "quoteExactInputSingle",
|
|
42
|
+
type: "function",
|
|
43
|
+
stateMutability: "nonpayable",
|
|
44
|
+
inputs: [
|
|
45
|
+
{
|
|
46
|
+
name: "params",
|
|
47
|
+
type: "tuple",
|
|
48
|
+
components: [
|
|
49
|
+
{ name: "tokenIn", type: "address" },
|
|
50
|
+
{ name: "tokenOut", type: "address" },
|
|
51
|
+
{ name: "amountIn", type: "uint256" },
|
|
52
|
+
{ name: "fee", type: "uint24" },
|
|
53
|
+
{ name: "sqrtPriceLimitX96", type: "uint160" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
outputs: [
|
|
58
|
+
{ name: "amountOut", type: "uint256" },
|
|
59
|
+
{ name: "sqrtPriceX96After", type: "uint160" },
|
|
60
|
+
{ name: "initializedTicksCrossed", type: "uint32" },
|
|
61
|
+
{ name: "gasEstimate", type: "uint256" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
const SWAP_ROUTER_ABI = [
|
|
66
|
+
{
|
|
67
|
+
name: "exactInputSingle",
|
|
68
|
+
type: "function",
|
|
69
|
+
stateMutability: "payable",
|
|
70
|
+
inputs: [
|
|
71
|
+
{
|
|
72
|
+
name: "params",
|
|
73
|
+
type: "tuple",
|
|
74
|
+
components: [
|
|
75
|
+
{ name: "tokenIn", type: "address" },
|
|
76
|
+
{ name: "tokenOut", type: "address" },
|
|
77
|
+
{ name: "fee", type: "uint24" },
|
|
78
|
+
{ name: "recipient", type: "address" },
|
|
79
|
+
{ name: "amountIn", type: "uint256" },
|
|
80
|
+
{ name: "amountOutMinimum", type: "uint256" },
|
|
81
|
+
{ name: "sqrtPriceLimitX96", type: "uint160" },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
outputs: [{ name: "amountOut", type: "uint256" }],
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
// ─── Fee tiers to try ──────────────────────────────────────────────────────
|
|
89
|
+
const FEE_TIERS = [500, 3000, 10000]; // 0.05%, 0.3%, 1%
|
|
90
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
91
|
+
function getTokenInfo(symbol) {
|
|
92
|
+
const info = TOKENS_MAINNET[symbol.toUpperCase()];
|
|
93
|
+
if (!info)
|
|
94
|
+
throw new Error(`Unsupported token: ${symbol}`);
|
|
95
|
+
return info;
|
|
96
|
+
}
|
|
97
|
+
function slippageBpsToMultiplier(bps) {
|
|
98
|
+
// amountOutMinimum = amountOut * (10000 - bps) / 10000
|
|
99
|
+
return BigInt(10000 - bps);
|
|
100
|
+
}
|
|
101
|
+
export async function getSwapQuote(params) {
|
|
102
|
+
const { fromToken, toToken, amount } = params;
|
|
103
|
+
const tokenIn = getTokenInfo(fromToken);
|
|
104
|
+
const tokenOut = getTokenInfo(toToken);
|
|
105
|
+
const client = createPublicClient({
|
|
106
|
+
chain: CHAINS.mainnet.chain,
|
|
107
|
+
transport: http(CHAINS.mainnet.rpc),
|
|
108
|
+
});
|
|
109
|
+
const amountIn = parseUnits(amount.toString(), tokenIn.decimals);
|
|
110
|
+
// Try fee tiers, pick best output
|
|
111
|
+
let bestOut = null;
|
|
112
|
+
let bestFee = 3000;
|
|
113
|
+
let bestGas = 0;
|
|
114
|
+
for (const fee of FEE_TIERS) {
|
|
115
|
+
try {
|
|
116
|
+
const result = await client.readContract({
|
|
117
|
+
address: CHAINS.mainnet.quoterV2,
|
|
118
|
+
abi: QUOTER_V2_ABI,
|
|
119
|
+
functionName: "quoteExactInputSingle",
|
|
120
|
+
args: [
|
|
121
|
+
{
|
|
122
|
+
tokenIn: tokenIn.address,
|
|
123
|
+
tokenOut: tokenOut.address,
|
|
124
|
+
amountIn,
|
|
125
|
+
fee,
|
|
126
|
+
sqrtPriceLimitX96: 0n,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
const [amountOut, , , gasEstimate] = result;
|
|
131
|
+
if (bestOut === null || amountOut > bestOut) {
|
|
132
|
+
bestOut = amountOut;
|
|
133
|
+
bestFee = fee;
|
|
134
|
+
bestGas = Number(gasEstimate);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// fee tier not available, try next
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (bestOut === null) {
|
|
142
|
+
throw new Error(`No liquidity found for ${fromToken}→${toToken}`);
|
|
143
|
+
}
|
|
144
|
+
const amountOut = Number(formatUnits(bestOut, tokenOut.decimals));
|
|
145
|
+
const rate = amountOut / amount;
|
|
146
|
+
return {
|
|
147
|
+
fromToken: fromToken.toUpperCase(),
|
|
148
|
+
toToken: toToken.toUpperCase(),
|
|
149
|
+
amountIn: amount,
|
|
150
|
+
amountOut,
|
|
151
|
+
feeTier: bestFee,
|
|
152
|
+
priceImpactPct: null, // would need pool state to calculate precisely
|
|
153
|
+
gasEstimate: bestGas,
|
|
154
|
+
rateFormatted: `1 ${fromToken.toUpperCase()} = ${rate.toFixed(6)} ${toToken.toUpperCase()}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export async function buildSwapTx(params) {
|
|
158
|
+
const { fromToken, toToken, amount, recipientAddress, slippageBps = 50 } = params;
|
|
159
|
+
// Get quote first
|
|
160
|
+
const quote = await getSwapQuote({ fromToken, toToken, amount, slippageBps });
|
|
161
|
+
const tokenIn = getTokenInfo(fromToken);
|
|
162
|
+
const tokenOut = getTokenInfo(toToken);
|
|
163
|
+
const amountIn = parseUnits(amount.toString(), tokenIn.decimals);
|
|
164
|
+
const amountOutRaw = parseUnits(quote.amountOut.toString(), tokenOut.decimals);
|
|
165
|
+
const amountOutMinimum = (amountOutRaw * slippageBpsToMultiplier(slippageBps)) / 10000n;
|
|
166
|
+
const calldata = encodeFunctionData({
|
|
167
|
+
abi: SWAP_ROUTER_ABI,
|
|
168
|
+
functionName: "exactInputSingle",
|
|
169
|
+
args: [
|
|
170
|
+
{
|
|
171
|
+
tokenIn: tokenIn.address,
|
|
172
|
+
tokenOut: tokenOut.address,
|
|
173
|
+
fee: quote.feeTier,
|
|
174
|
+
recipient: recipientAddress,
|
|
175
|
+
amountIn,
|
|
176
|
+
amountOutMinimum,
|
|
177
|
+
sqrtPriceLimitX96: 0n,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
const isNativeIn = tokenIn.isNative === true;
|
|
182
|
+
return {
|
|
183
|
+
to: CHAINS.mainnet.swapRouter,
|
|
184
|
+
data: calldata,
|
|
185
|
+
value: isNativeIn ? `0x${amountIn.toString(16)}` : "0x0",
|
|
186
|
+
fromToken: fromToken.toUpperCase(),
|
|
187
|
+
toToken: toToken.toUpperCase(),
|
|
188
|
+
amountIn: amount,
|
|
189
|
+
estimatedAmountOut: quote.amountOut,
|
|
190
|
+
feeTier: quote.feeTier,
|
|
191
|
+
slippageBps,
|
|
192
|
+
summary: `Swap ${amount} ${fromToken.toUpperCase()} → ~${quote.amountOut.toFixed(4)} ${toToken.toUpperCase()} (slippage: ${slippageBps / 100}%)`,
|
|
193
|
+
warning: "This transaction must be signed and broadcast by the user's wallet. " +
|
|
194
|
+
"If fromToken is an ERC-20 (not ETH), the user must first approve the SwapRouter to spend their tokens.",
|
|
195
|
+
};
|
|
196
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "intent-swap-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for intent-based DeFi swaps — swap tokens with natural language",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"intent-swap-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"start": "node dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
16
|
+
"openai": "^6.37.0",
|
|
17
|
+
"viem": "^2.13.0",
|
|
18
|
+
"zod": "^3.23.8"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^20",
|
|
22
|
+
"tsx": "^4.11.0",
|
|
23
|
+
"typescript": "^5"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"defi",
|
|
28
|
+
"swap",
|
|
29
|
+
"ethereum",
|
|
30
|
+
"uniswap",
|
|
31
|
+
"ai-agent"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT"
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* intent-swap MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes DeFi swap capabilities as MCP tools so AI agents (Claude, GPT, etc.)
|
|
6
|
+
* can execute on-chain swaps via natural language.
|
|
7
|
+
*
|
|
8
|
+
* Tools:
|
|
9
|
+
* - parse_swap_intent : NL → structured swap params
|
|
10
|
+
* - get_token_price : real-time price via CoinGecko
|
|
11
|
+
* - get_swap_quote : Uniswap V3 on-chain quote
|
|
12
|
+
* - build_swap_tx : build calldata for wallet signing
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
|
+
import {
|
|
18
|
+
CallToolRequestSchema,
|
|
19
|
+
ListToolsRequestSchema,
|
|
20
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { parseIntent } from "./intent-parser.js";
|
|
23
|
+
import { getTokenPrice, getSupportedTokens } from "./price-feed.js";
|
|
24
|
+
import { getSwapQuote, buildSwapTx } from "./uniswap.js";
|
|
25
|
+
|
|
26
|
+
// ─── Server setup ──────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const server = new Server(
|
|
29
|
+
{
|
|
30
|
+
name: "intent-swap",
|
|
31
|
+
version: "0.1.0",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
capabilities: {
|
|
35
|
+
tools: {},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// ─── Tool definitions ──────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
43
|
+
tools: [
|
|
44
|
+
{
|
|
45
|
+
name: "parse_swap_intent",
|
|
46
|
+
description:
|
|
47
|
+
"Parse a natural language swap request into structured parameters. " +
|
|
48
|
+
"Supports English and Chinese. Examples: 'swap 1 ETH for USDC', " +
|
|
49
|
+
"'把0.5个以太换成USDT', 'buy WBTC with all my ETH'.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
intent: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "Natural language swap request from the user",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
required: ["intent"],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "get_token_price",
|
|
63
|
+
description:
|
|
64
|
+
"Get the current USD price of a token. Supported: ETH, WETH, WBTC, BTC, USDC, USDT, DAI, ARB, UNI, LINK, MATIC, OP.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
token: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Token symbol (e.g. ETH, USDC, WBTC)",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ["token"],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "get_swap_quote",
|
|
78
|
+
description:
|
|
79
|
+
"Get a real-time swap quote from Uniswap V3. Returns expected output amount, price impact, and gas estimate.",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
fromToken: {
|
|
84
|
+
type: "string",
|
|
85
|
+
description: "Source token symbol (e.g. ETH)",
|
|
86
|
+
},
|
|
87
|
+
toToken: {
|
|
88
|
+
type: "string",
|
|
89
|
+
description: "Destination token symbol (e.g. USDC)",
|
|
90
|
+
},
|
|
91
|
+
amount: {
|
|
92
|
+
type: "number",
|
|
93
|
+
description: "Amount of fromToken to swap",
|
|
94
|
+
},
|
|
95
|
+
slippageBps: {
|
|
96
|
+
type: "number",
|
|
97
|
+
description: "Slippage tolerance in basis points (default: 50 = 0.5%)",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
required: ["fromToken", "toToken", "amount"],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "build_swap_tx",
|
|
105
|
+
description:
|
|
106
|
+
"Build the transaction calldata for a swap. The user must sign and broadcast this transaction themselves via their wallet. " +
|
|
107
|
+
"Returns: to (contract address), data (calldata), value (ETH to send), and a human-readable summary.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
fromToken: {
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "Source token symbol",
|
|
114
|
+
},
|
|
115
|
+
toToken: {
|
|
116
|
+
type: "string",
|
|
117
|
+
description: "Destination token symbol",
|
|
118
|
+
},
|
|
119
|
+
amount: {
|
|
120
|
+
type: "number",
|
|
121
|
+
description: "Amount of fromToken to swap",
|
|
122
|
+
},
|
|
123
|
+
recipientAddress: {
|
|
124
|
+
type: "string",
|
|
125
|
+
description: "Wallet address to receive the output tokens (0x...)",
|
|
126
|
+
},
|
|
127
|
+
slippageBps: {
|
|
128
|
+
type: "number",
|
|
129
|
+
description: "Slippage tolerance in basis points (default: 50)",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
required: ["fromToken", "toToken", "amount", "recipientAddress"],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "list_supported_tokens",
|
|
137
|
+
description: "List all supported tokens with their addresses and current prices.",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: {},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
// ─── Tool handlers ─────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
149
|
+
const { name, arguments: args } = request.params;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
switch (name) {
|
|
153
|
+
case "parse_swap_intent": {
|
|
154
|
+
const { intent } = z.object({ intent: z.string() }).parse(args);
|
|
155
|
+
const result = await parseIntent(intent);
|
|
156
|
+
return {
|
|
157
|
+
content: [
|
|
158
|
+
{
|
|
159
|
+
type: "text",
|
|
160
|
+
text: JSON.stringify(result, null, 2),
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case "get_token_price": {
|
|
167
|
+
const { token } = z.object({ token: z.string() }).parse(args);
|
|
168
|
+
const price = await getTokenPrice(token.toUpperCase());
|
|
169
|
+
if (price === null) {
|
|
170
|
+
return {
|
|
171
|
+
content: [
|
|
172
|
+
{
|
|
173
|
+
type: "text",
|
|
174
|
+
text: `Token ${token} is not supported or price unavailable.`,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
isError: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
content: [
|
|
182
|
+
{
|
|
183
|
+
type: "text",
|
|
184
|
+
text: JSON.stringify({ token: token.toUpperCase(), priceUsd: price }, null, 2),
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case "get_swap_quote": {
|
|
191
|
+
const params = z
|
|
192
|
+
.object({
|
|
193
|
+
fromToken: z.string(),
|
|
194
|
+
toToken: z.string(),
|
|
195
|
+
amount: z.number().positive(),
|
|
196
|
+
slippageBps: z.number().optional().default(50),
|
|
197
|
+
})
|
|
198
|
+
.parse(args);
|
|
199
|
+
const quote = await getSwapQuote(params);
|
|
200
|
+
return {
|
|
201
|
+
content: [
|
|
202
|
+
{
|
|
203
|
+
type: "text",
|
|
204
|
+
text: JSON.stringify(quote, null, 2),
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case "build_swap_tx": {
|
|
211
|
+
const params = z
|
|
212
|
+
.object({
|
|
213
|
+
fromToken: z.string(),
|
|
214
|
+
toToken: z.string(),
|
|
215
|
+
amount: z.number().positive(),
|
|
216
|
+
recipientAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
|
|
217
|
+
slippageBps: z.number().optional().default(50),
|
|
218
|
+
})
|
|
219
|
+
.parse(args);
|
|
220
|
+
const tx = await buildSwapTx(params);
|
|
221
|
+
return {
|
|
222
|
+
content: [
|
|
223
|
+
{
|
|
224
|
+
type: "text",
|
|
225
|
+
text: JSON.stringify(tx, null, 2),
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case "list_supported_tokens": {
|
|
232
|
+
const tokens = await getSupportedTokens();
|
|
233
|
+
return {
|
|
234
|
+
content: [
|
|
235
|
+
{
|
|
236
|
+
type: "text",
|
|
237
|
+
text: JSON.stringify(tokens, null, 2),
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
default:
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
246
|
+
isError: true,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
253
|
+
isError: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ─── Start ─────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
async function main() {
|
|
261
|
+
const transport = new StdioServerTransport();
|
|
262
|
+
await server.connect(transport);
|
|
263
|
+
console.error("intent-swap MCP server running on stdio");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
main().catch((err) => {
|
|
267
|
+
console.error("Fatal:", err);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent parser — rule-based with optional OpenAI fallback.
|
|
3
|
+
* Kept lightweight so the MCP server works without an API key.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ParsedIntent {
|
|
7
|
+
intentType: "swap" | "conditional";
|
|
8
|
+
fromToken: string;
|
|
9
|
+
toToken: string;
|
|
10
|
+
amount: number | null;
|
|
11
|
+
amountType: "exact" | "percentage" | "max" | null;
|
|
12
|
+
slippagePref: "low" | "normal" | "high";
|
|
13
|
+
condition: {
|
|
14
|
+
token: string;
|
|
15
|
+
operator: "above" | "below";
|
|
16
|
+
targetPrice: number;
|
|
17
|
+
} | null;
|
|
18
|
+
summary: string;
|
|
19
|
+
parsedBy: "rules" | "llm";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Token registry ────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const TOKENS = [
|
|
25
|
+
"ETH", "WETH", "WBTC", "BTC", "USDC", "USDT", "DAI", "ARB",
|
|
26
|
+
"UNI", "LINK", "MATIC", "OP", "PEPE", "SHIB", "AAVE", "CRV",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const TOKEN_ALIASES: Record<string, string> = {
|
|
30
|
+
"以太": "ETH", "以太坊": "ETH",
|
|
31
|
+
"比特": "WBTC", "比特币": "WBTC",
|
|
32
|
+
"稳定币": "USDC", "美元": "USDC",
|
|
33
|
+
"泰达": "USDT",
|
|
34
|
+
"以太坊包装": "WETH",
|
|
35
|
+
"polygon": "MATIC", "optimism": "OP",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function normalizeText(text: string): string {
|
|
39
|
+
let result = text.toUpperCase();
|
|
40
|
+
for (const [alias, token] of Object.entries(TOKEN_ALIASES)) {
|
|
41
|
+
result = result.replace(new RegExp(alias, "gi"), token);
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractToken(text: string, exclude?: string): string {
|
|
47
|
+
const normalized = normalizeText(text);
|
|
48
|
+
const exact = TOKENS.find(
|
|
49
|
+
(t) => t !== exclude && new RegExp(`\\b${t}\\b`).test(normalized)
|
|
50
|
+
);
|
|
51
|
+
if (exact) return exact;
|
|
52
|
+
return TOKENS.find((t) => t !== exclude && normalized.includes(t)) ?? "USDC";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractAmount(text: string): {
|
|
56
|
+
amount: number | null;
|
|
57
|
+
amountType: "exact" | "percentage" | "max" | null;
|
|
58
|
+
} {
|
|
59
|
+
if (/half|一半/i.test(text)) return { amount: 50, amountType: "percentage" };
|
|
60
|
+
if (/all|max|full|全部|所有|最大|everything/i.test(text))
|
|
61
|
+
return { amount: null, amountType: "max" };
|
|
62
|
+
const pct = text.match(/(\d+)\s*[%%]/);
|
|
63
|
+
if (pct) return { amount: Number(pct[1]), amountType: "percentage" };
|
|
64
|
+
const num = text.match(/(\d+\.?\d*)/);
|
|
65
|
+
if (num) return { amount: Number(num[1]), amountType: "exact" };
|
|
66
|
+
return { amount: null, amountType: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractSlippage(text: string): "low" | "normal" | "high" {
|
|
70
|
+
if (/low\s*slippage|tight|minimal|低滑点|精确/i.test(text)) return "low";
|
|
71
|
+
if (/high\s*slippage|fast|urgent|高滑点|快速|紧急/i.test(text)) return "high";
|
|
72
|
+
return "normal";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractCondition(text: string): ParsedIntent["condition"] {
|
|
76
|
+
// "when ETH drops below 3000" / "if ETH goes above 4000"
|
|
77
|
+
const match = text.match(
|
|
78
|
+
/(?:when|if|once|当|如果)\s+(\w+)\s+(?:drops?|falls?|goes?|跌|低于|下跌|涨|高于|上涨)\s+(?:below|above|under|over|到|至)?\s*\$?(\d+(?:,\d{3})*(?:\.\d+)?)/i
|
|
79
|
+
);
|
|
80
|
+
if (!match) return null;
|
|
81
|
+
|
|
82
|
+
const token = match[1].toUpperCase();
|
|
83
|
+
const price = Number(match[2].replace(/,/g, ""));
|
|
84
|
+
const isBelow = /drops?|falls?|below|under|跌|低于|下跌/i.test(match[0]);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
token: TOKENS.includes(token) ? token : "ETH",
|
|
88
|
+
operator: isBelow ? "below" : "above",
|
|
89
|
+
targetPrice: price,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Rule-based parser ─────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function ruleParse(intent: string): ParsedIntent {
|
|
96
|
+
const fromToken = extractToken(intent);
|
|
97
|
+
const toToken = extractToken(intent, fromToken);
|
|
98
|
+
const { amount, amountType } = extractAmount(intent);
|
|
99
|
+
const slippagePref = extractSlippage(intent);
|
|
100
|
+
const condition = extractCondition(intent);
|
|
101
|
+
|
|
102
|
+
const amountStr =
|
|
103
|
+
amount === null
|
|
104
|
+
? amountType === "max"
|
|
105
|
+
? "all"
|
|
106
|
+
: "some"
|
|
107
|
+
: amountType === "percentage"
|
|
108
|
+
? `${amount}%`
|
|
109
|
+
: `${amount}`;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
intentType: condition ? "conditional" : "swap",
|
|
113
|
+
fromToken,
|
|
114
|
+
toToken,
|
|
115
|
+
amount,
|
|
116
|
+
amountType,
|
|
117
|
+
slippagePref,
|
|
118
|
+
condition,
|
|
119
|
+
summary: `Swap ${amountStr} ${fromToken} → ${toToken} with ${slippagePref} slippage`,
|
|
120
|
+
parsedBy: "rules",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── LLM parser (requires OPENAI_API_KEY env var) ─────────────────────────
|
|
125
|
+
|
|
126
|
+
async function llmParse(intent: string): Promise<ParsedIntent> {
|
|
127
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
128
|
+
if (!apiKey) throw new Error("OPENAI_API_KEY not set");
|
|
129
|
+
|
|
130
|
+
const OpenAI = (await import("openai")).default;
|
|
131
|
+
const client = new OpenAI({ apiKey });
|
|
132
|
+
|
|
133
|
+
const completion = await client.chat.completions.create({
|
|
134
|
+
model: "gpt-4o-mini",
|
|
135
|
+
temperature: 0,
|
|
136
|
+
response_format: { type: "json_object" },
|
|
137
|
+
messages: [
|
|
138
|
+
{
|
|
139
|
+
role: "system",
|
|
140
|
+
content: `You are a DeFi swap intent parser. Extract structured swap info from natural language.
|
|
141
|
+
Supported tokens: ${TOKENS.join(", ")}
|
|
142
|
+
Return ONLY valid JSON matching this schema:
|
|
143
|
+
{
|
|
144
|
+
"intentType": "swap" | "conditional",
|
|
145
|
+
"fromToken": string,
|
|
146
|
+
"toToken": string,
|
|
147
|
+
"amount": number | null,
|
|
148
|
+
"amountType": "exact" | "percentage" | "max" | null,
|
|
149
|
+
"slippagePref": "low" | "normal" | "high",
|
|
150
|
+
"condition": null | { "token": string, "operator": "above"|"below", "targetPrice": number },
|
|
151
|
+
"summary": string
|
|
152
|
+
}
|
|
153
|
+
Rules:
|
|
154
|
+
- amountType="max" means all tokens, set amount=null
|
|
155
|
+
- Write summary in the same language as the input
|
|
156
|
+
- If tokens are ambiguous, default fromToken="ETH", toToken="USDC"`,
|
|
157
|
+
},
|
|
158
|
+
{ role: "user", content: intent },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const raw = completion.choices[0]?.message?.content;
|
|
163
|
+
if (!raw) throw new Error("Empty LLM response");
|
|
164
|
+
const parsed = JSON.parse(raw);
|
|
165
|
+
if (!parsed.fromToken || !parsed.toToken || !parsed.intentType) {
|
|
166
|
+
throw new Error("Invalid LLM response structure");
|
|
167
|
+
}
|
|
168
|
+
return { ...parsed, parsedBy: "llm" as const };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse a natural language swap intent.
|
|
175
|
+
* Strategy: rule-based first (fast, no API call), LLM fallback for complex/ambiguous inputs.
|
|
176
|
+
* If OPENAI_API_KEY is not set, always uses rules.
|
|
177
|
+
*/
|
|
178
|
+
export async function parseIntent(intent: string): Promise<ParsedIntent> {
|
|
179
|
+
// Rule-based handles the common cases instantly
|
|
180
|
+
const ruleResult = ruleParse(intent);
|
|
181
|
+
|
|
182
|
+
// If rules found both tokens and an amount, trust it
|
|
183
|
+
if (ruleResult.fromToken !== ruleResult.toToken && ruleResult.amount !== null) {
|
|
184
|
+
return ruleResult;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Ambiguous — try LLM for better understanding
|
|
188
|
+
try {
|
|
189
|
+
return await llmParse(intent);
|
|
190
|
+
} catch {
|
|
191
|
+
// No API key or LLM failed — fall back to rule result
|
|
192
|
+
return ruleResult;
|
|
193
|
+
}
|
|
194
|
+
}
|