@morebetterclaw/forge-swap 0.1.1
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/.env.example +21 -0
- package/.env.railway +24 -0
- package/.mcpregistry_github_token +1 -0
- package/.mcpregistry_registry_token +1 -0
- package/BUILD_BRIEF_V1.md +216 -0
- package/README.md +189 -0
- package/SKILL.md +93 -0
- package/deploy/README.md +156 -0
- package/package.json +40 -0
- package/public/mcp.json +10 -0
- package/railway.json +13 -0
- package/server.json +21 -0
- package/setup-check.js +80 -0
- package/src/formatter.js +56 -0
- package/src/health.js +27 -0
- package/src/index.js +72 -0
- package/src/mcp.js +200 -0
- package/src/parser.js +90 -0
- package/src/server.js +177 -0
- package/src/swapkit.js +135 -0
- package/src/telegram.js +239 -0
- package/src/test.js +109 -0
- package/src/wallet.js +157 -0
package/deploy/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Deploying crypto-swap-agent to Railway.app
|
|
2
|
+
|
|
3
|
+
> Week 2 deployment guide — Railway.app setup, environment configuration, and monitoring.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- [Railway CLI](https://docs.railway.app/develop/cli) installed: `npm install -g @railway/cli`
|
|
10
|
+
- Railway account: [railway.app](https://railway.app)
|
|
11
|
+
- Project code pushed to GitHub or available locally
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Quick Deploy
|
|
16
|
+
|
|
17
|
+
### Option A — GitHub (recommended)
|
|
18
|
+
|
|
19
|
+
1. Go to [railway.app/new](https://railway.app/new)
|
|
20
|
+
2. Select **"Deploy from GitHub repo"**
|
|
21
|
+
3. Authorise Railway and choose the `crypto-swap-agent` repo
|
|
22
|
+
4. Railway auto-detects Node.js and sets the build command
|
|
23
|
+
5. Add environment variables (see table below)
|
|
24
|
+
6. Click **Deploy**
|
|
25
|
+
|
|
26
|
+
### Option B — Railway CLI
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Install CLI
|
|
30
|
+
npm install -g @railway/cli
|
|
31
|
+
|
|
32
|
+
# Login
|
|
33
|
+
railway login
|
|
34
|
+
|
|
35
|
+
# Initialise project (from project root)
|
|
36
|
+
railway init
|
|
37
|
+
|
|
38
|
+
# Deploy
|
|
39
|
+
railway up
|
|
40
|
+
|
|
41
|
+
# Open service URL
|
|
42
|
+
railway open
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Environment Variables
|
|
48
|
+
|
|
49
|
+
Set these in Railway Dashboard → **Variables** tab, or via `railway variables set KEY=VALUE`.
|
|
50
|
+
|
|
51
|
+
| Variable | Required | Default | Description |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `NODE_ENV` | Yes | `production` | Node environment |
|
|
54
|
+
| `PORT` | No | `3000` | Port (Railway injects this automatically) |
|
|
55
|
+
| `THORNODE_URL` | Yes | `https://thornode.ninerealms.com` | THORNode API endpoint |
|
|
56
|
+
| `LOG_LEVEL` | No | `info` | Logging verbosity (`debug`, `info`, `warn`, `error`) |
|
|
57
|
+
| `RATE_LIMIT_WINDOW_MS` | No | `60000` | Rate limit window in ms |
|
|
58
|
+
| `RATE_LIMIT_MAX` | No | `100` | Max requests per window |
|
|
59
|
+
| `ALLOWED_ORIGINS` | No | `*` | CORS allowed origins (comma-separated) |
|
|
60
|
+
|
|
61
|
+
> **Never set private keys or wallet secrets in Railway environment variables** unless using Railway's encrypted secrets feature. Prefer a secrets manager (e.g. Doppler, HashiCorp Vault) for sensitive values.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Health Check Configuration
|
|
66
|
+
|
|
67
|
+
Railway uses the `/health` endpoint to determine service availability.
|
|
68
|
+
|
|
69
|
+
In Railway Dashboard → **Settings** → **Health Check**:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Path: /health
|
|
73
|
+
Timeout: 10s
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The endpoint returns:
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"status": "ok",
|
|
80
|
+
"version": "1.0.0",
|
|
81
|
+
"timestamp": "2025-03-09T23:00:00.000Z"
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Railway will restart the service if this endpoint returns a non-200 status.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Custom Domain
|
|
90
|
+
|
|
91
|
+
1. Dashboard → **Settings** → **Domains**
|
|
92
|
+
2. Click **Generate Domain** for a `*.railway.app` subdomain, OR
|
|
93
|
+
3. Click **Custom Domain** and add your CNAME record
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Monitoring
|
|
98
|
+
|
|
99
|
+
### Railway Built-in Metrics
|
|
100
|
+
|
|
101
|
+
Railway Dashboard provides:
|
|
102
|
+
- CPU and memory usage graphs
|
|
103
|
+
- Request logs (stream via `railway logs --tail`)
|
|
104
|
+
- Deployment history and rollback
|
|
105
|
+
|
|
106
|
+
### External Uptime Monitoring (recommended)
|
|
107
|
+
|
|
108
|
+
| Tool | Free tier | Setup |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| [UptimeRobot](https://uptimerobot.com) | 50 monitors, 5-min checks | Add `GET https://<your-domain>/health` |
|
|
111
|
+
| [Betterstack](https://betterstack.com/uptime) | 10 monitors, 3-min checks | Add HTTP monitor for `/health` |
|
|
112
|
+
| [Cronitor](https://cronitor.io) | 5 monitors | Monitor URL + alert on non-200 |
|
|
113
|
+
|
|
114
|
+
### Log Streaming
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Tail live logs
|
|
118
|
+
railway logs --tail
|
|
119
|
+
|
|
120
|
+
# Filter for errors
|
|
121
|
+
railway logs --tail | grep ERROR
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Scaling
|
|
127
|
+
|
|
128
|
+
Railway supports vertical scaling (memory/CPU) per service:
|
|
129
|
+
|
|
130
|
+
- Dashboard → **Settings** → **Resources**
|
|
131
|
+
- Recommended starting config: **512 MB RAM, 0.5 vCPU**
|
|
132
|
+
- Scale up if handling high quote volume or running background scan loops
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Rollback
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# List recent deployments
|
|
140
|
+
railway deployments list
|
|
141
|
+
|
|
142
|
+
# Roll back to a specific deployment
|
|
143
|
+
railway deployments rollback <deployment-id>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Troubleshooting
|
|
149
|
+
|
|
150
|
+
| Issue | Solution |
|
|
151
|
+
|---|---|
|
|
152
|
+
| Build fails | Check `package.json` `engines.node` matches Railway's Node version |
|
|
153
|
+
| Health check failing | Verify `/health` route is mounted before auth middleware |
|
|
154
|
+
| Port not bound | Ensure `app.listen(process.env.PORT \|\| 3000)` — Railway injects `PORT` |
|
|
155
|
+
| Missing env vars | Check Railway Variables tab; redeploy after adding new vars |
|
|
156
|
+
| Memory OOM | Increase RAM in Railway Resources settings |
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@morebetterclaw/forge-swap",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "FORGE — cross-chain swap agent powered by THORChain. Protocol-native routing with embedded affiliate fees.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"mcpName": "io.github.morebetterclaw/forge-swap",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node src/index.js",
|
|
10
|
+
"dev": "node --watch src/index.js",
|
|
11
|
+
"telegram": "node src/telegram.js",
|
|
12
|
+
"test": "node src/test.js",
|
|
13
|
+
"test:api": "node src/test.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
17
|
+
"dotenv": "^16.4.1",
|
|
18
|
+
"ethers": "^6.16.0",
|
|
19
|
+
"express": "^4.18.2",
|
|
20
|
+
"express-rate-limit": "^7.2.0"
|
|
21
|
+
},
|
|
22
|
+
"optionalDependencies": {
|
|
23
|
+
"node-telegram-bot-api": "^0.64.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"openclaw",
|
|
27
|
+
"thorchain",
|
|
28
|
+
"swapkit",
|
|
29
|
+
"defi",
|
|
30
|
+
"swap",
|
|
31
|
+
"mcp",
|
|
32
|
+
"cross-chain"
|
|
33
|
+
],
|
|
34
|
+
"author": "MoreBetter Studios",
|
|
35
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/morebetterclaw/forge.git"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/public/mcp.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "FORGE",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cross-chain swap routing via THORChain. Non-custodial, protocol-native affiliate fee model.",
|
|
5
|
+
"url": "https://forge-api-production-50de.up.railway.app/mcp",
|
|
6
|
+
"transport": "streamable-http",
|
|
7
|
+
"tools": ["forge_quote", "forge_execute", "forge_assets", "forge_status"],
|
|
8
|
+
"contact": "https://morebetterstudios.com",
|
|
9
|
+
"license": "MIT"
|
|
10
|
+
}
|
package/railway.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://railway.app/railway.schema.json",
|
|
3
|
+
"build": {
|
|
4
|
+
"builder": "NIXPACKS"
|
|
5
|
+
},
|
|
6
|
+
"deploy": {
|
|
7
|
+
"startCommand": "node src/index.js",
|
|
8
|
+
"healthcheckPath": "/health",
|
|
9
|
+
"healthcheckTimeout": 30,
|
|
10
|
+
"restartPolicyType": "ON_FAILURE",
|
|
11
|
+
"restartPolicyMaxRetries": 3
|
|
12
|
+
}
|
|
13
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.morebetterclaw/forge-swap",
|
|
4
|
+
"description": "FORGE Swap — cross-chain swaps via THORChain. Quote and execute BTC, ETH, RUNE, USDC swaps.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/morebetterclaw/forge",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.1",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "@morebetterclaw/forge-swap",
|
|
14
|
+
"version": "0.1.1",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "streamable-http",
|
|
17
|
+
"url": "https://forge-api-production-50de.up.railway.app/mcp"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
package/setup-check.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* setup-check.js — Crypto Swap Agent pre-flight check
|
|
4
|
+
* Run: node setup-check.js
|
|
5
|
+
*
|
|
6
|
+
* Verifies all required env vars and connectivity before deployment.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import 'dotenv/config';
|
|
10
|
+
import https from 'https';
|
|
11
|
+
|
|
12
|
+
const RED = '\x1b[31m';
|
|
13
|
+
const GREEN = '\x1b[32m';
|
|
14
|
+
const YELLOW = '\x1b[33m';
|
|
15
|
+
const RESET = '\x1b[0m';
|
|
16
|
+
|
|
17
|
+
let pass = 0, warn = 0, fail = 0;
|
|
18
|
+
|
|
19
|
+
function check(label, condition, hint = '', severity = 'fail') {
|
|
20
|
+
if (condition) {
|
|
21
|
+
console.log(`${GREEN} ✓${RESET} ${label}`);
|
|
22
|
+
pass++;
|
|
23
|
+
} else {
|
|
24
|
+
const icon = severity === 'warn' ? `${YELLOW} ⚠${RESET}` : `${RED} ✗${RESET}`;
|
|
25
|
+
console.log(`${icon} ${label}`);
|
|
26
|
+
if (hint) console.log(` → ${hint}`);
|
|
27
|
+
severity === 'warn' ? warn++ : fail++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function ping(url, label) {
|
|
32
|
+
return new Promise(resolve => {
|
|
33
|
+
https.get(url, { timeout: 5000 }, res => {
|
|
34
|
+
check(`${label} reachable (HTTP ${res.statusCode})`, res.statusCode < 500);
|
|
35
|
+
resolve();
|
|
36
|
+
}).on('error', err => {
|
|
37
|
+
check(`${label} reachable`, false, err.message);
|
|
38
|
+
resolve();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
(async () => {
|
|
44
|
+
console.log('\n⚡ Crypto Swap Agent — Setup Check\n');
|
|
45
|
+
|
|
46
|
+
// Env vars
|
|
47
|
+
console.log('─── Environment Variables ───────────────────────');
|
|
48
|
+
check('FEE_RECIPIENT_ADDRESS set',
|
|
49
|
+
!!process.env.FEE_RECIPIENT_ADDRESS && !process.env.FEE_RECIPIENT_ADDRESS.includes('your_address'),
|
|
50
|
+
'Set a valid thor1... address to receive 0.5% affiliate fees');
|
|
51
|
+
check('SWAP_FEE_BPS set',
|
|
52
|
+
!!process.env.SWAP_FEE_BPS,
|
|
53
|
+
'Default: 50 (= 0.5%). Add to .env', 'warn');
|
|
54
|
+
check('WALLET_PRIVATE_KEY set (for autonomous execution)',
|
|
55
|
+
!!process.env.WALLET_PRIVATE_KEY,
|
|
56
|
+
'Optional for quote-only mode. Required for autonomous swap execution.', 'warn');
|
|
57
|
+
check('EVM_RPC_URL set',
|
|
58
|
+
!!process.env.EVM_RPC_URL && !process.env.EVM_RPC_URL.includes('your_key'),
|
|
59
|
+
'Required for EVM swap execution. Get free key at alchemy.com', 'warn');
|
|
60
|
+
check('PORT set', !!process.env.PORT, 'Default: 3000', 'warn');
|
|
61
|
+
|
|
62
|
+
// Connectivity
|
|
63
|
+
console.log('\n─── Connectivity ─────────────────────────────────');
|
|
64
|
+
await ping('https://thornode.ninerealms.com/thorchain/inbound_addresses', 'THORChain NineRealms');
|
|
65
|
+
await ping('https://midgard.ninerealms.com/v2/pools?limit=1', 'THORChain Midgard');
|
|
66
|
+
await ping('https://api.coingecko.com/api/v3/ping', 'CoinGecko');
|
|
67
|
+
|
|
68
|
+
// Summary
|
|
69
|
+
console.log('\n─── Summary ──────────────────────────────────────');
|
|
70
|
+
console.log(` ${GREEN}${pass} passed${RESET} ${YELLOW}${warn} warnings${RESET} ${RED}${fail} failed${RESET}`);
|
|
71
|
+
if (fail > 0) {
|
|
72
|
+
console.log(`\n${RED}⚠ Fix failed checks before deploying.${RESET}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
} else if (warn > 0) {
|
|
75
|
+
console.log(`\n${YELLOW}⚠ Warnings present — check before going live.${RESET}`);
|
|
76
|
+
console.log('\nReady for: node src/test.js → then deploy to Railway');
|
|
77
|
+
} else {
|
|
78
|
+
console.log(`\n${GREEN}✓ All checks passed. Deploy with: railway up${RESET}`);
|
|
79
|
+
}
|
|
80
|
+
})();
|
package/src/formatter.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatters — structured JSON responses for OpenClaw + agent consumers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function formatQuote(quoteData, parsed) {
|
|
6
|
+
const routes = quoteData.routes || [];
|
|
7
|
+
if (!routes.length) {
|
|
8
|
+
return { success: false, error: 'No routes found', pair: `${parsed.fromAsset} → ${parsed.toAsset}` };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const best = routes[0];
|
|
12
|
+
return {
|
|
13
|
+
success: true,
|
|
14
|
+
pair: `${parsed.fromAsset} → ${parsed.toAsset}`,
|
|
15
|
+
sellAmount: parsed.amount,
|
|
16
|
+
expectedOutput: best.expectedOutputMaxSlippage,
|
|
17
|
+
expectedOutputOptimal: best.expectedOutput,
|
|
18
|
+
priceImpact: best.priceImpactPercent,
|
|
19
|
+
route: best.providers?.join(' → ') || 'THORChain',
|
|
20
|
+
estimatedTimeSeconds: best.estimatedTime,
|
|
21
|
+
affiliateFee: '0.5%',
|
|
22
|
+
warning: best.warnings?.[0] || null,
|
|
23
|
+
rawRoutes: routes.length,
|
|
24
|
+
timestamp: new Date().toISOString()
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatSwapResult(swapData) {
|
|
29
|
+
return {
|
|
30
|
+
success: true,
|
|
31
|
+
status: swapData.status,
|
|
32
|
+
memo: swapData.memo,
|
|
33
|
+
depositAddress: swapData.depositAddress,
|
|
34
|
+
depositAsset: swapData.depositAsset,
|
|
35
|
+
depositAmount: swapData.depositAmount,
|
|
36
|
+
expectedOutput: swapData.expectedOutput,
|
|
37
|
+
route: swapData.route,
|
|
38
|
+
affiliateFee: swapData.affiliateFee,
|
|
39
|
+
nextStep: swapData.warning,
|
|
40
|
+
timestamp: new Date().toISOString()
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatAssetList(assets) {
|
|
45
|
+
return {
|
|
46
|
+
success: true,
|
|
47
|
+
count: assets.length,
|
|
48
|
+
assets: assets.slice(0, 50).map(a => ({
|
|
49
|
+
identifier: a.identifier || a.asset,
|
|
50
|
+
chain: a.chain,
|
|
51
|
+
symbol: a.ticker || a.symbol,
|
|
52
|
+
decimals: a.decimals
|
|
53
|
+
})),
|
|
54
|
+
note: 'Showing top 50. Full list available via SwapKit API.'
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/health.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* health.js — Health check Express router
|
|
3
|
+
* Provides GET /health endpoint for Railway.app and uptime monitors.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Router } from 'express';
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
const VERSION = process.env.npm_package_version || '1.0.0';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* GET /health
|
|
14
|
+
* Returns service health status, version, and current timestamp.
|
|
15
|
+
* Used by Railway.app health checks and external uptime monitors.
|
|
16
|
+
*
|
|
17
|
+
* Response: { status: 'ok', version: '1.0.0', timestamp: '<ISO8601>' }
|
|
18
|
+
*/
|
|
19
|
+
router.get('/health', (req, res) => {
|
|
20
|
+
res.status(200).json({
|
|
21
|
+
status: 'ok',
|
|
22
|
+
version: VERSION,
|
|
23
|
+
timestamp: new Date().toISOString(),
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export default router;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crypto Swap Agent — MoreBetter Studios
|
|
3
|
+
* Cross-chain swap skill via THORChain direct integration
|
|
4
|
+
*
|
|
5
|
+
* Entry point:
|
|
6
|
+
* node src/index.js → starts HTTP server
|
|
7
|
+
* node src/index.js quote ... → CLI mode (local testing)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import 'dotenv/config';
|
|
11
|
+
import { SwapKitApi } from './swapkit.js';
|
|
12
|
+
import { parseSwapCommand } from './parser.js';
|
|
13
|
+
import { formatQuote, formatSwapResult, formatAssetList } from './formatter.js';
|
|
14
|
+
|
|
15
|
+
const FEE_BPS = parseInt(process.env.SWAP_FEE_BPS || '50');
|
|
16
|
+
const FEE_ADDRESS = process.env.FEE_RECIPIENT_ADDRESS || '';
|
|
17
|
+
|
|
18
|
+
const CLI_COMMANDS = ['quote', 'execute', 'status', 'assets', 'list'];
|
|
19
|
+
const firstArg = process.argv[2]?.toLowerCase();
|
|
20
|
+
|
|
21
|
+
if (firstArg && CLI_COMMANDS.includes(firstArg)) {
|
|
22
|
+
// ── CLI mode ──────────────────────────────────────────────────────────────
|
|
23
|
+
const api = new SwapKitApi({ feeBps: FEE_BPS, feeAddress: FEE_ADDRESS });
|
|
24
|
+
const command = process.argv.slice(2).join(' ');
|
|
25
|
+
|
|
26
|
+
async function main(command) {
|
|
27
|
+
const parsed = parseSwapCommand(command);
|
|
28
|
+
|
|
29
|
+
switch (parsed.action) {
|
|
30
|
+
case 'quote': {
|
|
31
|
+
const quote = await api.getQuote(parsed.fromAsset, parsed.toAsset, parsed.amount);
|
|
32
|
+
console.log(JSON.stringify(formatQuote(quote, parsed), null, 2));
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
case 'execute': {
|
|
36
|
+
if (!parsed.destAddress) {
|
|
37
|
+
console.log(JSON.stringify({ error: 'Destination address required for execute' }));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const result = await api.executeSwap(parsed.fromAsset, parsed.toAsset, parsed.amount, parsed.destAddress);
|
|
41
|
+
console.log(JSON.stringify(formatSwapResult(result), null, 2));
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case 'status': {
|
|
45
|
+
const status = await api.getStatus(parsed.txHash);
|
|
46
|
+
console.log(JSON.stringify(status, null, 2));
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case 'assets': {
|
|
50
|
+
const assets = await api.getSupportedAssets();
|
|
51
|
+
console.log(JSON.stringify(formatAssetList(assets), null, 2));
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
default:
|
|
55
|
+
console.log(JSON.stringify({
|
|
56
|
+
error: `Unknown command: ${parsed.action}`,
|
|
57
|
+
supported: ['quote', 'execute', 'status', 'assets'],
|
|
58
|
+
}));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
main(command).catch(err => {
|
|
64
|
+
console.log(JSON.stringify({ error: err.message }));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
} else {
|
|
69
|
+
// ── Server mode ───────────────────────────────────────────────────────────
|
|
70
|
+
const { startServer } = await import('./server.js');
|
|
71
|
+
startServer();
|
|
72
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp.js — Model Context Protocol server for FORGE
|
|
3
|
+
* Exposes 4 tools via Streamable HTTP transport (2026 spec).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { Router } from 'express';
|
|
10
|
+
|
|
11
|
+
const TOOLS = [
|
|
12
|
+
{
|
|
13
|
+
name: 'forge_quote',
|
|
14
|
+
description:
|
|
15
|
+
'Get a cross-chain swap quote via THORChain. Returns expected output, fees, route, and slippage. Use before executing any swap.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
fromAsset: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Source asset in CHAIN.TICKER format e.g. ETH.ETH, BTC.BTC, AVAX.USDC',
|
|
22
|
+
},
|
|
23
|
+
toAsset: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'Destination asset in CHAIN.TICKER format',
|
|
26
|
+
},
|
|
27
|
+
amount: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Amount to swap in human-readable units e.g. 0.1',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
required: ['fromAsset', 'toAsset', 'amount'],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'forge_execute',
|
|
37
|
+
description:
|
|
38
|
+
'Build a cross-chain swap transaction. Returns the vault deposit address and memo — the user\'s wallet sends funds to this address with the memo to execute the swap. FORGE never holds funds.',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
fromAsset: { type: 'string' },
|
|
43
|
+
toAsset: { type: 'string' },
|
|
44
|
+
amount: { type: 'string' },
|
|
45
|
+
destinationAddress: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Recipient address for the destination asset',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ['fromAsset', 'toAsset', 'amount', 'destinationAddress'],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'forge_assets',
|
|
55
|
+
description:
|
|
56
|
+
'List all supported assets available for cross-chain swapping via FORGE/THORChain.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'forge_status',
|
|
64
|
+
description: 'Check the status of a cross-chain swap by transaction hash.',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
txHash: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: 'Transaction hash to check',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ['txHash'],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create an Express Router that mounts an MCP server at /mcp.
|
|
80
|
+
* @param {import('./swapkit.js').SwapKitApi} swapApi
|
|
81
|
+
* @returns {import('express').Router}
|
|
82
|
+
*/
|
|
83
|
+
export function createMcpRouter(swapApi) {
|
|
84
|
+
const router = Router();
|
|
85
|
+
|
|
86
|
+
// Build MCP server once; transports are per-request (stateless mode)
|
|
87
|
+
const server = new Server(
|
|
88
|
+
{ name: 'forge', version: '0.1.0' },
|
|
89
|
+
{ capabilities: { tools: {} } }
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// List tools
|
|
93
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
94
|
+
return { tools: TOOLS };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Call tool
|
|
98
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
99
|
+
const { name, arguments: args } = request.params;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
let result;
|
|
103
|
+
|
|
104
|
+
switch (name) {
|
|
105
|
+
case 'forge_quote': {
|
|
106
|
+
const { fromAsset, toAsset, amount } = args;
|
|
107
|
+
result = await swapApi.getQuote(fromAsset, toAsset, parseFloat(amount));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'forge_execute': {
|
|
111
|
+
const { fromAsset, toAsset, amount, destinationAddress } = args;
|
|
112
|
+
result = await swapApi.executeSwap(
|
|
113
|
+
fromAsset,
|
|
114
|
+
toAsset,
|
|
115
|
+
parseFloat(amount),
|
|
116
|
+
destinationAddress
|
|
117
|
+
);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'forge_assets': {
|
|
121
|
+
result = await swapApi.getSupportedAssets();
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'forge_status': {
|
|
125
|
+
const { txHash } = args;
|
|
126
|
+
result = await swapApi.getStatus(txHash);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
return {
|
|
131
|
+
content: [{ type: 'text', text: `Error: Unknown tool: ${name}` }],
|
|
132
|
+
isError: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
138
|
+
};
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
142
|
+
isError: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// POST /mcp — fresh server + transport per request (stateless)
|
|
148
|
+
router.post('/', async (req, res) => {
|
|
149
|
+
try {
|
|
150
|
+
// Create a fresh server instance for each request — MCP transport is stateful
|
|
151
|
+
const reqServer = new Server(
|
|
152
|
+
{ name: 'forge', version: '0.1.0' },
|
|
153
|
+
{ capabilities: { tools: {} } }
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Register tools on the fresh instance
|
|
157
|
+
reqServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
158
|
+
reqServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
159
|
+
const { name, arguments: args } = request.params;
|
|
160
|
+
try {
|
|
161
|
+
let result;
|
|
162
|
+
switch (name) {
|
|
163
|
+
case 'forge_quote': {
|
|
164
|
+
const { fromAsset, toAsset, amount } = args;
|
|
165
|
+
result = await swapApi.getQuote(fromAsset, toAsset, parseFloat(amount));
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case 'forge_execute': {
|
|
169
|
+
const { fromAsset, toAsset, amount, destinationAddress } = args;
|
|
170
|
+
result = await swapApi.executeSwap(fromAsset, toAsset, parseFloat(amount), destinationAddress);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case 'forge_assets': {
|
|
174
|
+
result = await swapApi.getSupportedAssets();
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case 'forge_status': {
|
|
178
|
+
const { txHash } = args;
|
|
179
|
+
result = await swapApi.getStatus(txHash);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
default:
|
|
183
|
+
return { content: [{ type: 'text', text: `Error: Unknown tool: ${name}` }], isError: true };
|
|
184
|
+
}
|
|
185
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
192
|
+
await reqServer.connect(transport);
|
|
193
|
+
await transport.handleRequest(req, res, req.body);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return router;
|
|
200
|
+
}
|