wickedapi-mcp 0.3.0 → 0.3.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/LICENSE +1 -1
- package/README.md +7 -2
- package/dist/clientIp.js +67 -0
- package/dist/http.js +82 -0
- package/dist/index.js +5 -324
- package/dist/rateLimit.js +22 -0
- package/dist/server.js +332 -0
- package/package.json +11 -4
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -21,6 +21,10 @@ Plus **6 resources** (the platform directory, `llms.txt`, and each service's liv
|
|
|
21
21
|
|
|
22
22
|
These tools never sign anything or hold a key. Identity, Registry registration and Memory take a signature you produce with your own wallet (`identity_get_nonce` / `memory_prepare` return the exact message to sign). Calls that need x402 payment return the real `402` challenge (decoded under `payment_required` for x402 v2) rather than auto-paying — `WICKEDAPI_PAYER_PRIVATE_KEY` auto-pay applies to the Trading, Protocol Health, Token Risk and Broker Sync tools.
|
|
23
23
|
|
|
24
|
+
## Remote endpoint (no install)
|
|
25
|
+
|
|
26
|
+
The same 70 tools are served over streamable HTTP at **`https://mcp.wickedapi.com/mcp`** (stateless, rate-limited per IP; the public deployment carries no personal keys, so paid calls return the x402 challenge). Point any streamable-HTTP MCP client at it. To self-host, build the `Dockerfile` (or `npm run build && npm run start:http`); see `.env.example` for `RATE_LIMIT_PER_MINUTE`, `ALLOWED_HOSTS` and `TRUSTED_CLIENT_IP_HEADER`.
|
|
27
|
+
|
|
24
28
|
## No signup required
|
|
25
29
|
|
|
26
30
|
Every tool hits a real endpoint gated by [x402](https://x402.org) (HTTP-native pay-per-call, USDC on Base) with an API-key fallback — and the Token Risk Oracle tool is free no matter what:
|
|
@@ -100,8 +104,9 @@ If neither `WICKEDAPI_API_KEY` nor `WICKEDAPI_PAYER_PRIVATE_KEY` is set, paid ca
|
|
|
100
104
|
|
|
101
105
|
```bash
|
|
102
106
|
npm install
|
|
103
|
-
npm
|
|
104
|
-
node dist/index.js
|
|
107
|
+
npm test # builds, then unit + end-to-end tests (e2e needs network)
|
|
108
|
+
node dist/index.js # stdio
|
|
109
|
+
node dist/http.js # streamable HTTP on $PORT (default 8080)
|
|
105
110
|
```
|
|
106
111
|
|
|
107
112
|
## What this is not
|
package/dist/clientIp.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Who is calling, for rate limiting. Ported from the Python http_app.py so
|
|
2
|
+
// behaviour is unchanged after the cutover.
|
|
3
|
+
//
|
|
4
|
+
// Behind Railway's edge proxy the TCP peer is always an internal address, so
|
|
5
|
+
// the limiter keys on the proxy-set X-Real-IP header instead. That is only
|
|
6
|
+
// safe because the proxy sets the header itself; set TRUSTED_CLIENT_IP_HEADER
|
|
7
|
+
// to empty to fall back to the TCP peer when running without a proxy, or to
|
|
8
|
+
// cf-connecting-ip if the domain is ever proxied through Cloudflare.
|
|
9
|
+
import { isIP } from "node:net";
|
|
10
|
+
const TRUSTED_HEADER = (process.env.TRUSTED_CLIENT_IP_HEADER ?? "x-real-ip").trim().toLowerCase();
|
|
11
|
+
function expandIPv6(ip) {
|
|
12
|
+
let s = ip.split("%")[0];
|
|
13
|
+
let tail = [];
|
|
14
|
+
const v4 = s.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
15
|
+
if (v4) {
|
|
16
|
+
const o = v4[1].split(".").map(Number);
|
|
17
|
+
tail = [(o[0] << 8) | o[1], (o[2] << 8) | o[3]];
|
|
18
|
+
s = s.slice(0, -v4[1].length) + "0:0";
|
|
19
|
+
}
|
|
20
|
+
const halves = s.split("::");
|
|
21
|
+
if (halves.length > 2)
|
|
22
|
+
return null;
|
|
23
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
24
|
+
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
25
|
+
const fill = 8 - head.length - rest.length;
|
|
26
|
+
if ((halves.length === 1 && fill !== 0) || fill < 0)
|
|
27
|
+
return null;
|
|
28
|
+
const groups = [...head, ...Array(halves.length === 2 ? fill : 0).fill("0"), ...rest].map((g) => parseInt(g, 16));
|
|
29
|
+
if (groups.length !== 8 || groups.some((g) => Number.isNaN(g)))
|
|
30
|
+
return null;
|
|
31
|
+
if (tail.length) {
|
|
32
|
+
groups[6] = tail[0];
|
|
33
|
+
groups[7] = tail[1];
|
|
34
|
+
}
|
|
35
|
+
return groups;
|
|
36
|
+
}
|
|
37
|
+
/** Canonical rate-limit key, or null if `raw` isn't an IP. IPv4-mapped IPv6
|
|
38
|
+
* collapses to the IPv4 address; IPv6 is grouped by /64 so rotating addresses
|
|
39
|
+
* inside one prefix can't dodge the limit. */
|
|
40
|
+
export function normalizeIp(raw) {
|
|
41
|
+
const s = raw.trim();
|
|
42
|
+
const kind = isIP(s);
|
|
43
|
+
if (kind === 4)
|
|
44
|
+
return s;
|
|
45
|
+
if (kind !== 6)
|
|
46
|
+
return null;
|
|
47
|
+
const g = expandIPv6(s);
|
|
48
|
+
if (!g)
|
|
49
|
+
return null;
|
|
50
|
+
if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) {
|
|
51
|
+
return `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
|
|
52
|
+
}
|
|
53
|
+
return `${g.slice(0, 4).map((x) => x.toString(16)).join(":")}::/64`;
|
|
54
|
+
}
|
|
55
|
+
export function clientKey(req) {
|
|
56
|
+
if (TRUSTED_HEADER) {
|
|
57
|
+
const raw = req.headers[TRUSTED_HEADER];
|
|
58
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
59
|
+
if (value) {
|
|
60
|
+
const key = normalizeIp(value);
|
|
61
|
+
if (key)
|
|
62
|
+
return key;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const peer = req.socket.remoteAddress ?? "unknown";
|
|
66
|
+
return normalizeIp(peer) ?? peer;
|
|
67
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Streamable-HTTP entry point for the public remote at https://mcp.wickedapi.com/mcp.
|
|
2
|
+
// Stateless: every request gets its own McpServer + transport (specs are
|
|
3
|
+
// loaded once at boot), so it scales and restarts without session loss.
|
|
4
|
+
// Public-endpoint additions over the stdio build: a per-IP rate limit and a
|
|
5
|
+
// Host allowlist (DNS-rebinding protection). Tools and upstream calls are
|
|
6
|
+
// identical.
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
|
+
import { clientKey } from "./clientIp.js";
|
|
10
|
+
import { rateLimited } from "./rateLimit.js";
|
|
11
|
+
import { buildServer, loadWorld, SERVER_VERSION } from "./server.js";
|
|
12
|
+
const PORT = Number(process.env.PORT || 8080);
|
|
13
|
+
const RATE_LIMIT_PER_MINUTE = Number(process.env.RATE_LIMIT_PER_MINUTE || 30);
|
|
14
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
15
|
+
const ALLOWED_HOSTS = (process.env.ALLOWED_HOSTS || "mcp.wickedapi.com,wicked-mcp-production.up.railway.app,localhost:8080,127.0.0.1:8080")
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((h) => h.trim().toLowerCase())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
function send(res, status, body, headers = {}) {
|
|
20
|
+
res.writeHead(status, { "content-type": "application/json", ...headers });
|
|
21
|
+
res.end(JSON.stringify(body));
|
|
22
|
+
}
|
|
23
|
+
async function readJson(req) {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let size = 0;
|
|
26
|
+
for await (const chunk of req) {
|
|
27
|
+
size += chunk.length;
|
|
28
|
+
if (size > MAX_BODY_BYTES)
|
|
29
|
+
throw Object.assign(new Error("payload too large"), { status: 413 });
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
}
|
|
32
|
+
if (!chunks.length)
|
|
33
|
+
return undefined;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw Object.assign(new Error("invalid JSON"), { status: 400 });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
const world = await loadWorld();
|
|
43
|
+
const { summary } = buildServer(world);
|
|
44
|
+
const http = createServer(async (req, res) => {
|
|
45
|
+
const url = new URL(req.url || "/", "http://x");
|
|
46
|
+
if (url.pathname === "/health")
|
|
47
|
+
return send(res, 200, { status: "ok", version: SERVER_VERSION });
|
|
48
|
+
const host = (req.headers.host || "").toLowerCase();
|
|
49
|
+
if (!ALLOWED_HOSTS.includes(host))
|
|
50
|
+
return send(res, 421, { error: "Invalid Host header" });
|
|
51
|
+
if (url.pathname !== "/mcp")
|
|
52
|
+
return send(res, 404, { error: "Not found. The MCP endpoint is /mcp." });
|
|
53
|
+
if (req.method !== "POST") {
|
|
54
|
+
return send(res, 405, { jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed (stateless server: POST only)." }, id: null }, { allow: "POST" });
|
|
55
|
+
}
|
|
56
|
+
if (rateLimited(clientKey(req), RATE_LIMIT_PER_MINUTE)) {
|
|
57
|
+
return send(res, 429, { error: `Rate limit exceeded: ${RATE_LIMIT_PER_MINUTE} requests/minute per IP. Run this server locally (npx wickedapi-mcp) for unlimited use.` }, { "retry-after": "60" });
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const body = await readJson(req);
|
|
61
|
+
const { server } = buildServer(world);
|
|
62
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
63
|
+
res.on("close", () => {
|
|
64
|
+
void transport.close();
|
|
65
|
+
void server.close();
|
|
66
|
+
});
|
|
67
|
+
await server.connect(transport);
|
|
68
|
+
await transport.handleRequest(req, res, body);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const status = err.status ?? 500;
|
|
72
|
+
if (!res.headersSent) {
|
|
73
|
+
send(res, status, { jsonrpc: "2.0", error: { code: -32603, message: err.message }, id: null });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
http.listen(PORT, "0.0.0.0", () => console.error(`[wickedapi-mcp] http on :${PORT}/mcp - ${summary}.`));
|
|
78
|
+
}
|
|
79
|
+
main().catch((err) => {
|
|
80
|
+
console.error("[wickedapi-mcp] fatal:", err);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -1,330 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
// stdio entry point: `npx wickedapi-mcp` for Claude Desktop, Cursor, .mcpb, etc.
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import {
|
|
5
|
-
import { registerSuiteTools } from "./suite.js";
|
|
6
|
-
function envUrl(name, fallback) {
|
|
7
|
-
return (process.env[name] || fallback).replace(/\/$/, "");
|
|
8
|
-
}
|
|
9
|
-
const SERVICES = [
|
|
10
|
-
{ id: "trading", name: "Trading Data API", baseUrl: envUrl("WICKEDAPI_BASE_URL", "https://api.wickedapi.com") },
|
|
11
|
-
{ id: "protocol_health", name: "Protocol Health Oracle", baseUrl: envUrl("WICKEDAPI_PROTOCOL_HEALTH_URL", "https://protocol-health-oracle.wickedapi.com") },
|
|
12
|
-
{ id: "token_risk", name: "Token Risk Oracle", baseUrl: envUrl("WICKEDAPI_TOKEN_RISK_URL", "https://token-risk-oracle.wickedapi.com") },
|
|
13
|
-
{ id: "broker_sync", name: "Broker Sync API", baseUrl: envUrl("WICKEDAPI_BROKER_SYNC_URL", "https://broker-sync-api.wickedapi.com") },
|
|
14
|
-
];
|
|
15
|
-
const SHOWCASE_URL = envUrl("WICKEDAPI_SHOWCASE_URL", "https://wickedapi.com");
|
|
16
|
-
const API_KEY = process.env.WICKEDAPI_API_KEY;
|
|
17
|
-
const PAYER_KEY = process.env.WICKEDAPI_PAYER_PRIVATE_KEY;
|
|
18
|
-
// A few auto-generated tool names collide or read badly (e.g. GET and POST
|
|
19
|
-
// both hitting /v1/protocols) — override just those, keyed by
|
|
20
|
-
// "METHOD /openapi/path/{param}".
|
|
21
|
-
const TOOL_NAME_OVERRIDES = {
|
|
22
|
-
protocol_health: {
|
|
23
|
-
"GET /v1/protocols": "protocol_health_list_protocols",
|
|
24
|
-
"POST /v1/protocols": "protocol_health_track_protocol",
|
|
25
|
-
"GET /v1/health/{slug}": "protocol_health_score",
|
|
26
|
-
"POST /v1/refresh": "protocol_health_refresh_all",
|
|
27
|
-
"POST /v1/ingest": "protocol_health_sync_universe",
|
|
28
|
-
},
|
|
29
|
-
broker_sync: {
|
|
30
|
-
"POST /v1/users": "broker_sync_register_user",
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
// If a funded Base-mainnet wallet key is provided, every call auto-pays the
|
|
34
|
-
// x402 402 challenge in USDC — no API key or signup needed at all. Loaded
|
|
35
|
-
// lazily so a plain API-key setup never has to pull in viem/x402-fetch.
|
|
36
|
-
async function buildFetcher() {
|
|
37
|
-
if (!PAYER_KEY)
|
|
38
|
-
return fetch;
|
|
39
|
-
let deps;
|
|
40
|
-
try {
|
|
41
|
-
deps = await Promise.all([import("viem"), import("viem/accounts"), import("viem/chains"), import("x402-fetch")]);
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
console.error("[wickedapi-mcp] WICKEDAPI_PAYER_PRIVATE_KEY is set, but the wallet-payment dependencies " +
|
|
45
|
-
"(viem, x402-fetch) aren't installed in this distribution. Falling back to plain fetch — " +
|
|
46
|
-
"calls without an API key will surface the 402 challenge instead of auto-paying. " +
|
|
47
|
-
"Use `npx wickedapi-mcp` for full auto-pay support.");
|
|
48
|
-
return fetch;
|
|
49
|
-
}
|
|
50
|
-
const [{ createWalletClient, http }, { privateKeyToAccount }, { base }, { wrapFetchWithPayment }] = deps;
|
|
51
|
-
const account = privateKeyToAccount(PAYER_KEY);
|
|
52
|
-
const wallet = createWalletClient({ account, chain: base, transport: http() });
|
|
53
|
-
console.error(`[wickedapi-mcp] x402 auto-pay enabled — wallet ${account.address}`);
|
|
54
|
-
// x402-fetch's Signer type expects a client extended with viem's public
|
|
55
|
-
// actions; the proven pattern in scripts/x402-bot-test.mjs passes a plain
|
|
56
|
-
// wallet client the same way — cast rather than pull in publicActions.
|
|
57
|
-
return wrapFetchWithPayment(fetch, wallet);
|
|
58
|
-
}
|
|
59
|
-
function pathSlug(openapiPath) {
|
|
60
|
-
return openapiPath
|
|
61
|
-
.replace(/^\/v1\//, "")
|
|
62
|
-
.replace(/[{}]/g, "")
|
|
63
|
-
.replace(/[\/\-]+/g, "_");
|
|
64
|
-
}
|
|
65
|
-
function toolNameFor(service, method, openapiPath) {
|
|
66
|
-
const override = TOOL_NAME_OVERRIDES[service.id]?.[`${method.toUpperCase()} ${openapiPath}`];
|
|
67
|
-
if (override)
|
|
68
|
-
return override;
|
|
69
|
-
const slug = pathSlug(openapiPath);
|
|
70
|
-
if (service.id === "trading")
|
|
71
|
-
return slug; // legacy naming, already published
|
|
72
|
-
return slug === service.id ? service.id : `${service.id}_${slug}`;
|
|
73
|
-
}
|
|
74
|
-
function zodFor(p) {
|
|
75
|
-
const base = p.type === "number" || p.type === "integer"
|
|
76
|
-
? z.coerce.number()
|
|
77
|
-
: p.type === "boolean"
|
|
78
|
-
? z.coerce.boolean()
|
|
79
|
-
: z.string();
|
|
80
|
-
const described = p.description ? base.describe(p.description) : base;
|
|
81
|
-
return p.required ? described : described.optional();
|
|
82
|
-
}
|
|
83
|
-
async function loadServiceSpec(service) {
|
|
84
|
-
try {
|
|
85
|
-
const res = await fetch(`${service.baseUrl}/openapi.json`);
|
|
86
|
-
if (!res.ok)
|
|
87
|
-
throw new Error(`HTTP ${res.status}`);
|
|
88
|
-
return (await res.json());
|
|
89
|
-
}
|
|
90
|
-
catch (err) {
|
|
91
|
-
console.error(`[wickedapi-mcp] WARNING: couldn't load ${service.name} spec (${service.baseUrl}/openapi.json): ` +
|
|
92
|
-
`${err.message} — its tools are unavailable this session, the rest load normally.`);
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
function registerServiceTools(server, service, doc, doFetch) {
|
|
97
|
-
let registered = 0;
|
|
98
|
-
for (const [openapiPath, methods] of Object.entries(doc.paths)) {
|
|
99
|
-
if (!openapiPath.startsWith("/v1/") || openapiPath === "/v1/status")
|
|
100
|
-
continue; // /v1/status is a resource instead
|
|
101
|
-
if (openapiPath.includes("webhook"))
|
|
102
|
-
continue; // called by a third party, never by an agent
|
|
103
|
-
for (const [method, op] of Object.entries(methods)) {
|
|
104
|
-
const pathParamNames = [];
|
|
105
|
-
const queryParamNames = [];
|
|
106
|
-
const bodyPropNames = [];
|
|
107
|
-
const shape = {};
|
|
108
|
-
for (const p of op.parameters ?? []) {
|
|
109
|
-
if (p.in === "path") {
|
|
110
|
-
pathParamNames.push(p.name);
|
|
111
|
-
shape[p.name] = zodFor({ type: p.schema?.type, required: true, description: p.description });
|
|
112
|
-
}
|
|
113
|
-
else if (p.in === "query") {
|
|
114
|
-
queryParamNames.push(p.name);
|
|
115
|
-
shape[p.name] = zodFor({ type: p.schema?.type, required: p.required, description: p.description });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
|
|
119
|
-
if (bodySchema) {
|
|
120
|
-
const required = new Set(bodySchema.required ?? []);
|
|
121
|
-
for (const [name, prop] of Object.entries(bodySchema.properties ?? {})) {
|
|
122
|
-
bodyPropNames.push(name);
|
|
123
|
-
shape[name] = zodFor({ type: prop.type, required: required.has(name), description: prop.description });
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
const name = toolNameFor(service, method, openapiPath);
|
|
127
|
-
server.registerTool(name, {
|
|
128
|
-
description: `[${service.name}] ${[op.summary, op.description].filter(Boolean).join(" — ")}`.slice(0, 1000),
|
|
129
|
-
inputSchema: shape,
|
|
130
|
-
}, async (args) => {
|
|
131
|
-
let urlPath = openapiPath;
|
|
132
|
-
for (const p of pathParamNames)
|
|
133
|
-
urlPath = urlPath.replace(`{${p}}`, encodeURIComponent(String(args[p])));
|
|
134
|
-
const url = new URL(service.baseUrl + urlPath);
|
|
135
|
-
for (const p of queryParamNames) {
|
|
136
|
-
const v = args[p];
|
|
137
|
-
if (v !== undefined && v !== null)
|
|
138
|
-
url.searchParams.set(p, String(v));
|
|
139
|
-
}
|
|
140
|
-
const headers = {};
|
|
141
|
-
if (API_KEY)
|
|
142
|
-
headers["x-api-key"] = API_KEY;
|
|
143
|
-
const init = { method: method.toUpperCase(), headers };
|
|
144
|
-
if (bodyPropNames.length) {
|
|
145
|
-
const bodyObj = {};
|
|
146
|
-
for (const p of bodyPropNames)
|
|
147
|
-
if (args[p] !== undefined)
|
|
148
|
-
bodyObj[p] = args[p];
|
|
149
|
-
headers["content-type"] = "application/json";
|
|
150
|
-
init.body = JSON.stringify(bodyObj);
|
|
151
|
-
}
|
|
152
|
-
const r = await doFetch(url.toString(), init);
|
|
153
|
-
const text = await r.text();
|
|
154
|
-
let body;
|
|
155
|
-
try {
|
|
156
|
-
body = JSON.parse(text);
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
body = text;
|
|
160
|
-
}
|
|
161
|
-
return {
|
|
162
|
-
isError: !r.ok,
|
|
163
|
-
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
164
|
-
};
|
|
165
|
-
});
|
|
166
|
-
registered++;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return registered;
|
|
170
|
-
}
|
|
171
|
-
// --- Resources -----------------------------------------------------------
|
|
172
|
-
// Reference material an agent can read without invoking a tool: the
|
|
173
|
-
// platform-wide directory/llms.txt, and each service's live endpoint
|
|
174
|
-
// catalog (the /v1/status we deliberately excluded from becoming a tool).
|
|
175
|
-
function registerResources(server) {
|
|
176
|
-
server.registerResource("platform-services", "wickedapi://platform/services", {
|
|
177
|
-
title: "WickedAPI service directory",
|
|
178
|
-
description: "Every live WickedAPI service — URL, category, one-line description, auth modes, and links to each one's own OpenAPI spec/docs/llms.txt.",
|
|
179
|
-
mimeType: "application/json",
|
|
180
|
-
}, async (uri) => {
|
|
181
|
-
const r = await fetch(`${SHOWCASE_URL}/services.json`);
|
|
182
|
-
if (!r.ok)
|
|
183
|
-
throw new Error(`Failed to load service directory: HTTP ${r.status}`);
|
|
184
|
-
return { contents: [{ uri: uri.toString(), mimeType: "application/json", text: await r.text() }] };
|
|
185
|
-
});
|
|
186
|
-
server.registerResource("platform-llms-txt", "wickedapi://platform/llms", {
|
|
187
|
-
title: "WickedAPI llms.txt",
|
|
188
|
-
description: "Terse, platform-wide summary for agents deciding what to call — auth modes, free vs. paid endpoints.",
|
|
189
|
-
mimeType: "text/plain",
|
|
190
|
-
}, async (uri) => {
|
|
191
|
-
const r = await fetch(`${SHOWCASE_URL}/llms.txt`);
|
|
192
|
-
if (!r.ok)
|
|
193
|
-
throw new Error(`Failed to load llms.txt: HTTP ${r.status}`);
|
|
194
|
-
return { contents: [{ uri: uri.toString(), mimeType: "text/plain", text: await r.text() }] };
|
|
195
|
-
});
|
|
196
|
-
for (const service of SERVICES) {
|
|
197
|
-
server.registerResource(`${service.id}-status`, `wickedapi://${service.id}/status`, {
|
|
198
|
-
title: `${service.name} — live catalog`,
|
|
199
|
-
description: `Live endpoint catalog for ${service.name} (${service.baseUrl}), fetched fresh on every read.`,
|
|
200
|
-
mimeType: "application/json",
|
|
201
|
-
}, async (uri) => {
|
|
202
|
-
const r = await fetch(`${service.baseUrl}/v1/status`);
|
|
203
|
-
if (!r.ok)
|
|
204
|
-
throw new Error(`Failed to load ${service.name} status: HTTP ${r.status}`);
|
|
205
|
-
return { contents: [{ uri: uri.toString(), mimeType: "application/json", text: await r.text() }] };
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
// --- Prompts ---------------------------------------------------------------
|
|
210
|
-
// The "compound recipes" from the WickedAPI cookbook, turned into one-click
|
|
211
|
-
// prompts: each just tells the assistant which real tools to call and how
|
|
212
|
-
// to read the results — no fabricated synthesis, every number cited comes
|
|
213
|
-
// from an actual tool call made during the conversation.
|
|
214
|
-
function registerPrompts(server) {
|
|
215
|
-
server.registerPrompt("rug_check", {
|
|
216
|
-
title: "Rug-check before buying",
|
|
217
|
-
description: "Real on-chain risk signals for a token contract, plus market sentiment (and momentum, if it's a covered asset) before you decide whether to buy.",
|
|
218
|
-
argsSchema: {
|
|
219
|
-
address: z.string().describe("The token contract address (EVM) or mint address (Solana)."),
|
|
220
|
-
chain: z.string().describe("ethereum, bsc, base, polygon, arbitrum, optimism, avalanche, fantom, zksync, linea, scroll, or solana."),
|
|
221
|
-
symbol: z.string().optional().describe("If this token maps to a covered momentum-score asset (BTC, ETH, SOL, SUI, NVDA, IBIT, COIN, QQQ, TQQQ, DIA), pass it to also pull a real momentum verdict."),
|
|
222
|
-
},
|
|
223
|
-
}, ({ address, chain, symbol }) => ({
|
|
224
|
-
description: "Rug-check workflow",
|
|
225
|
-
messages: [
|
|
226
|
-
{
|
|
227
|
-
role: "user",
|
|
228
|
-
content: {
|
|
229
|
-
type: "text",
|
|
230
|
-
text: `Before I consider buying this token, run a real risk check — don't guess or assume anything:\n\n` +
|
|
231
|
-
`1. Call the \`token_risk\` tool with address="${address}" and chain="${chain}". Report the exact ` +
|
|
232
|
-
`risk_score, risk_tier, and every real flag it returns (honeypot, mint/ownership authority, holder ` +
|
|
233
|
-
`and LP concentration, buy/sell tax, trust-list status) — cite the actual values, don't summarize ` +
|
|
234
|
-
`away the specifics or invent ones that weren't returned.\n` +
|
|
235
|
-
`2. Call the \`sentiment\` tool for the current overall crypto market mood.\n` +
|
|
236
|
-
(symbol
|
|
237
|
-
? `3. Call the \`momentum_score\` tool with symbol="${symbol}" and timeframe="1h" for a real technical read.\n`
|
|
238
|
-
: "") +
|
|
239
|
-
`\nThen give a plain verdict: the real risk signals found (listed, not paraphrased), current market ` +
|
|
240
|
-
`mood, and — only if applicable — the momentum read. State clearly this is aggregated real signals, ` +
|
|
241
|
-
`never a safety guarantee.`,
|
|
242
|
-
},
|
|
243
|
-
},
|
|
244
|
-
],
|
|
245
|
-
}));
|
|
246
|
-
server.registerPrompt("vet_dependency", {
|
|
247
|
-
title: "Vet a DeFi dependency",
|
|
248
|
-
description: "Protocol activity/treasury health plus an optional contract-level risk check, before you build on a DeFi protocol.",
|
|
249
|
-
argsSchema: {
|
|
250
|
-
slug: z.string().describe('The protocol\'s DefiLlama slug, e.g. "lido".'),
|
|
251
|
-
token_address: z.string().optional().describe("The protocol's token contract address, if you also want a contract-level risk check."),
|
|
252
|
-
chain: z.string().optional().describe("Chain for token_address (default ethereum)."),
|
|
253
|
-
},
|
|
254
|
-
}, ({ slug, token_address, chain }) => ({
|
|
255
|
-
description: "Dependency-vetting workflow",
|
|
256
|
-
messages: [
|
|
257
|
-
{
|
|
258
|
-
role: "user",
|
|
259
|
-
content: {
|
|
260
|
-
type: "text",
|
|
261
|
-
text: `Before I integrate this protocol as a dependency, check its real health — don't guess:\n\n` +
|
|
262
|
-
`1. Call the \`protocol_health_score\` tool with slug="${slug}". Report the components (GitHub ` +
|
|
263
|
-
`activity, TVL trend, treasury) exactly as returned, including any that came back null (never ` +
|
|
264
|
-
`filled in).\n` +
|
|
265
|
-
(token_address
|
|
266
|
-
? `2. Call the \`token_risk\` tool with address="${token_address}" and chain="${chain || "ethereum"}" ` +
|
|
267
|
-
`for a contract-level check on its token.\n`
|
|
268
|
-
: "") +
|
|
269
|
-
`\nSummarize: is this protocol actively maintained, is its TVL trend healthy, and (if checked) does ` +
|
|
270
|
-
`its token carry any real contract-level risk flags. Cite the real numbers.`,
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
|
-
],
|
|
274
|
-
}));
|
|
275
|
-
server.registerPrompt("morning_briefing", {
|
|
276
|
-
title: "Morning briefing",
|
|
277
|
-
description: "One daily digest from macro calendar, earnings calendar, sentiment, and a momentum verdict — all free or a fraction of a cent.",
|
|
278
|
-
argsSchema: {
|
|
279
|
-
symbol: z.string().optional().describe("Asset for the momentum verdict (default BTC). One of BTC, ETH, SOL, SUI, NVDA, IBIT, COIN, QQQ, TQQQ, DIA."),
|
|
280
|
-
},
|
|
281
|
-
}, ({ symbol }) => {
|
|
282
|
-
const sym = symbol || "BTC";
|
|
283
|
-
return {
|
|
284
|
-
description: "Morning briefing workflow",
|
|
285
|
-
messages: [
|
|
286
|
-
{
|
|
287
|
-
role: "user",
|
|
288
|
-
content: {
|
|
289
|
-
type: "text",
|
|
290
|
-
text: `Compile my morning briefing from real, current data — call each tool, don't estimate:\n\n` +
|
|
291
|
-
`1. \`macro_calendar\` with days=7 — upcoming FOMC/CPI/jobs events.\n` +
|
|
292
|
-
`2. \`earnings_calendar\` with days=3 — upcoming earnings reports.\n` +
|
|
293
|
-
`3. \`sentiment\` — current crypto Fear & Greed reading.\n` +
|
|
294
|
-
`4. \`momentum_score\` with symbol="${sym}" and timeframe="1D" — today's technical verdict.\n\n` +
|
|
295
|
-
`Then write one concise digest combining all four, citing the real values from each call.`,
|
|
296
|
-
},
|
|
297
|
-
},
|
|
298
|
-
],
|
|
299
|
-
};
|
|
300
|
-
});
|
|
301
|
-
}
|
|
4
|
+
import { buildServer, loadWorld } from "./server.js";
|
|
302
5
|
async function main() {
|
|
303
|
-
const server =
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const loaded = [];
|
|
307
|
-
for (const service of SERVICES) {
|
|
308
|
-
const doc = await loadServiceSpec(service);
|
|
309
|
-
if (!doc)
|
|
310
|
-
continue;
|
|
311
|
-
totalTools += registerServiceTools(server, service, doc, doFetch);
|
|
312
|
-
loaded.push(service.name);
|
|
313
|
-
}
|
|
314
|
-
if (totalTools === 0) {
|
|
315
|
-
throw new Error("No service specs loaded — check network access and WICKEDAPI_*_URL overrides.");
|
|
316
|
-
}
|
|
317
|
-
// Reputation, Registry, Identity, Sanity, Memory and Paywall use hand-written
|
|
318
|
-
// tools (non-/v1 paths, wallet-signature auth) — registered unconditionally.
|
|
319
|
-
const suiteTools = registerSuiteTools(server);
|
|
320
|
-
totalTools += suiteTools;
|
|
321
|
-
loaded.push("Reputation, Registry, Identity, Sanity, Memory, Paywall");
|
|
322
|
-
registerResources(server);
|
|
323
|
-
registerPrompts(server);
|
|
324
|
-
const transport = new StdioServerTransport();
|
|
325
|
-
await server.connect(transport);
|
|
326
|
-
console.error(`[wickedapi-mcp] ready — ${totalTools} tools across ${loaded.length} service(s) (${loaded.join(", ")}), ` +
|
|
327
|
-
`${2 + SERVICES.length} resources, 3 prompts.`);
|
|
6
|
+
const { server, summary } = buildServer(await loadWorld());
|
|
7
|
+
await server.connect(new StdioServerTransport());
|
|
8
|
+
console.error(`[wickedapi-mcp] ready - ${summary}.`);
|
|
328
9
|
}
|
|
329
10
|
main().catch((err) => {
|
|
330
11
|
console.error("[wickedapi-mcp] fatal:", err);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const WINDOW_MS = 60_000;
|
|
2
|
+
const PRUNE_ABOVE_ENTRIES = 1000;
|
|
3
|
+
// In-memory fixed-window limiter; fine for the single instance this runs as.
|
|
4
|
+
// Move the counters to shared storage before running more than one instance.
|
|
5
|
+
const hits = new Map();
|
|
6
|
+
let lastPrune = 0;
|
|
7
|
+
export function rateLimited(key, limit = Number(process.env.RATE_LIMIT_PER_MINUTE || 30), now = Date.now()) {
|
|
8
|
+
if (hits.size >= PRUNE_ABOVE_ENTRIES && now - lastPrune > 30_000) {
|
|
9
|
+
lastPrune = now;
|
|
10
|
+
for (const [k, h] of hits)
|
|
11
|
+
if (!h.length || now - h[h.length - 1] > WINDOW_MS)
|
|
12
|
+
hits.delete(k);
|
|
13
|
+
}
|
|
14
|
+
const bucket = (hits.get(key) ?? []).filter((t) => now - t <= WINDOW_MS);
|
|
15
|
+
if (bucket.length >= limit) {
|
|
16
|
+
hits.set(key, bucket);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
bucket.push(now);
|
|
20
|
+
hits.set(key, bucket);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { registerSuiteTools } from "./suite.js";
|
|
4
|
+
function envUrl(name, fallback) {
|
|
5
|
+
return (process.env[name] || fallback).replace(/\/$/, "");
|
|
6
|
+
}
|
|
7
|
+
const SERVICES = [
|
|
8
|
+
{ id: "trading", name: "Trading Data API", baseUrl: envUrl("WICKEDAPI_BASE_URL", "https://api.wickedapi.com") },
|
|
9
|
+
{ id: "protocol_health", name: "Protocol Health Oracle", baseUrl: envUrl("WICKEDAPI_PROTOCOL_HEALTH_URL", "https://protocol-health-oracle.wickedapi.com") },
|
|
10
|
+
{ id: "token_risk", name: "Token Risk Oracle", baseUrl: envUrl("WICKEDAPI_TOKEN_RISK_URL", "https://token-risk-oracle.wickedapi.com") },
|
|
11
|
+
{ id: "broker_sync", name: "Broker Sync API", baseUrl: envUrl("WICKEDAPI_BROKER_SYNC_URL", "https://broker-sync-api.wickedapi.com") },
|
|
12
|
+
];
|
|
13
|
+
const SHOWCASE_URL = envUrl("WICKEDAPI_SHOWCASE_URL", "https://wickedapi.com");
|
|
14
|
+
const API_KEY = process.env.WICKEDAPI_API_KEY;
|
|
15
|
+
const PAYER_KEY = process.env.WICKEDAPI_PAYER_PRIVATE_KEY;
|
|
16
|
+
// A few auto-generated tool names collide or read badly (e.g. GET and POST
|
|
17
|
+
// both hitting /v1/protocols) — override just those, keyed by
|
|
18
|
+
// "METHOD /openapi/path/{param}".
|
|
19
|
+
const TOOL_NAME_OVERRIDES = {
|
|
20
|
+
protocol_health: {
|
|
21
|
+
"GET /v1/protocols": "protocol_health_list_protocols",
|
|
22
|
+
"POST /v1/protocols": "protocol_health_track_protocol",
|
|
23
|
+
"GET /v1/health/{slug}": "protocol_health_score",
|
|
24
|
+
"POST /v1/refresh": "protocol_health_refresh_all",
|
|
25
|
+
"POST /v1/ingest": "protocol_health_sync_universe",
|
|
26
|
+
},
|
|
27
|
+
broker_sync: {
|
|
28
|
+
"POST /v1/users": "broker_sync_register_user",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
// If a funded Base-mainnet wallet key is provided, every call auto-pays the
|
|
32
|
+
// x402 402 challenge in USDC — no API key or signup needed at all. Loaded
|
|
33
|
+
// lazily so a plain API-key setup never has to pull in viem/x402-fetch.
|
|
34
|
+
async function buildFetcher() {
|
|
35
|
+
if (!PAYER_KEY)
|
|
36
|
+
return fetch;
|
|
37
|
+
let deps;
|
|
38
|
+
try {
|
|
39
|
+
deps = await Promise.all([import("viem"), import("viem/accounts"), import("viem/chains"), import("x402-fetch")]);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
console.error("[wickedapi-mcp] WICKEDAPI_PAYER_PRIVATE_KEY is set, but the wallet-payment dependencies " +
|
|
43
|
+
"(viem, x402-fetch) aren't installed in this distribution. Falling back to plain fetch — " +
|
|
44
|
+
"calls without an API key will surface the 402 challenge instead of auto-paying. " +
|
|
45
|
+
"Use `npx wickedapi-mcp` for full auto-pay support.");
|
|
46
|
+
return fetch;
|
|
47
|
+
}
|
|
48
|
+
const [{ createWalletClient, http }, { privateKeyToAccount }, { base }, { wrapFetchWithPayment }] = deps;
|
|
49
|
+
const account = privateKeyToAccount(PAYER_KEY);
|
|
50
|
+
const wallet = createWalletClient({ account, chain: base, transport: http() });
|
|
51
|
+
console.error(`[wickedapi-mcp] x402 auto-pay enabled — wallet ${account.address}`);
|
|
52
|
+
// x402-fetch's Signer type expects a client extended with viem's public
|
|
53
|
+
// actions; the proven pattern in scripts/x402-bot-test.mjs passes a plain
|
|
54
|
+
// wallet client the same way — cast rather than pull in publicActions.
|
|
55
|
+
return wrapFetchWithPayment(fetch, wallet);
|
|
56
|
+
}
|
|
57
|
+
function pathSlug(openapiPath) {
|
|
58
|
+
return openapiPath
|
|
59
|
+
.replace(/^\/v1\//, "")
|
|
60
|
+
.replace(/[{}]/g, "")
|
|
61
|
+
.replace(/[\/\-]+/g, "_");
|
|
62
|
+
}
|
|
63
|
+
function toolNameFor(service, method, openapiPath) {
|
|
64
|
+
const override = TOOL_NAME_OVERRIDES[service.id]?.[`${method.toUpperCase()} ${openapiPath}`];
|
|
65
|
+
if (override)
|
|
66
|
+
return override;
|
|
67
|
+
const slug = pathSlug(openapiPath);
|
|
68
|
+
if (service.id === "trading")
|
|
69
|
+
return slug; // legacy naming, already published
|
|
70
|
+
return slug === service.id ? service.id : `${service.id}_${slug}`;
|
|
71
|
+
}
|
|
72
|
+
function zodFor(p) {
|
|
73
|
+
const base = p.type === "number" || p.type === "integer"
|
|
74
|
+
? z.coerce.number()
|
|
75
|
+
: p.type === "boolean"
|
|
76
|
+
? z.coerce.boolean()
|
|
77
|
+
: z.string();
|
|
78
|
+
const described = p.description ? base.describe(p.description) : base;
|
|
79
|
+
return p.required ? described : described.optional();
|
|
80
|
+
}
|
|
81
|
+
async function loadServiceSpec(service) {
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(`${service.baseUrl}/openapi.json`);
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
throw new Error(`HTTP ${res.status}`);
|
|
86
|
+
return (await res.json());
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.error(`[wickedapi-mcp] WARNING: couldn't load ${service.name} spec (${service.baseUrl}/openapi.json): ` +
|
|
90
|
+
`${err.message} — its tools are unavailable this session, the rest load normally.`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function registerServiceTools(server, service, doc, doFetch) {
|
|
95
|
+
let registered = 0;
|
|
96
|
+
for (const [openapiPath, methods] of Object.entries(doc.paths)) {
|
|
97
|
+
if (!openapiPath.startsWith("/v1/") || openapiPath === "/v1/status")
|
|
98
|
+
continue; // /v1/status is a resource instead
|
|
99
|
+
if (openapiPath.includes("webhook"))
|
|
100
|
+
continue; // called by a third party, never by an agent
|
|
101
|
+
for (const [method, op] of Object.entries(methods)) {
|
|
102
|
+
const pathParamNames = [];
|
|
103
|
+
const queryParamNames = [];
|
|
104
|
+
const bodyPropNames = [];
|
|
105
|
+
const shape = {};
|
|
106
|
+
for (const p of op.parameters ?? []) {
|
|
107
|
+
if (p.in === "path") {
|
|
108
|
+
pathParamNames.push(p.name);
|
|
109
|
+
shape[p.name] = zodFor({ type: p.schema?.type, required: true, description: p.description });
|
|
110
|
+
}
|
|
111
|
+
else if (p.in === "query") {
|
|
112
|
+
queryParamNames.push(p.name);
|
|
113
|
+
shape[p.name] = zodFor({ type: p.schema?.type, required: p.required, description: p.description });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
|
|
117
|
+
if (bodySchema) {
|
|
118
|
+
const required = new Set(bodySchema.required ?? []);
|
|
119
|
+
for (const [name, prop] of Object.entries(bodySchema.properties ?? {})) {
|
|
120
|
+
bodyPropNames.push(name);
|
|
121
|
+
shape[name] = zodFor({ type: prop.type, required: required.has(name), description: prop.description });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const name = toolNameFor(service, method, openapiPath);
|
|
125
|
+
server.registerTool(name, {
|
|
126
|
+
description: `[${service.name}] ${[op.summary, op.description].filter(Boolean).join(" — ")}`.slice(0, 1000),
|
|
127
|
+
inputSchema: shape,
|
|
128
|
+
}, async (args) => {
|
|
129
|
+
let urlPath = openapiPath;
|
|
130
|
+
for (const p of pathParamNames)
|
|
131
|
+
urlPath = urlPath.replace(`{${p}}`, encodeURIComponent(String(args[p])));
|
|
132
|
+
const url = new URL(service.baseUrl + urlPath);
|
|
133
|
+
for (const p of queryParamNames) {
|
|
134
|
+
const v = args[p];
|
|
135
|
+
if (v !== undefined && v !== null)
|
|
136
|
+
url.searchParams.set(p, String(v));
|
|
137
|
+
}
|
|
138
|
+
const headers = {};
|
|
139
|
+
if (API_KEY)
|
|
140
|
+
headers["x-api-key"] = API_KEY;
|
|
141
|
+
const init = { method: method.toUpperCase(), headers };
|
|
142
|
+
if (bodyPropNames.length) {
|
|
143
|
+
const bodyObj = {};
|
|
144
|
+
for (const p of bodyPropNames)
|
|
145
|
+
if (args[p] !== undefined)
|
|
146
|
+
bodyObj[p] = args[p];
|
|
147
|
+
headers["content-type"] = "application/json";
|
|
148
|
+
init.body = JSON.stringify(bodyObj);
|
|
149
|
+
}
|
|
150
|
+
const r = await doFetch(url.toString(), init);
|
|
151
|
+
const text = await r.text();
|
|
152
|
+
let body;
|
|
153
|
+
try {
|
|
154
|
+
body = JSON.parse(text);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
body = text;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
isError: !r.ok,
|
|
161
|
+
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
registered++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return registered;
|
|
168
|
+
}
|
|
169
|
+
// --- Resources -----------------------------------------------------------
|
|
170
|
+
// Reference material an agent can read without invoking a tool: the
|
|
171
|
+
// platform-wide directory/llms.txt, and each service's live endpoint
|
|
172
|
+
// catalog (the /v1/status we deliberately excluded from becoming a tool).
|
|
173
|
+
function registerResources(server) {
|
|
174
|
+
server.registerResource("platform-services", "wickedapi://platform/services", {
|
|
175
|
+
title: "WickedAPI service directory",
|
|
176
|
+
description: "Every live WickedAPI service — URL, category, one-line description, auth modes, and links to each one's own OpenAPI spec/docs/llms.txt.",
|
|
177
|
+
mimeType: "application/json",
|
|
178
|
+
}, async (uri) => {
|
|
179
|
+
const r = await fetch(`${SHOWCASE_URL}/services.json`);
|
|
180
|
+
if (!r.ok)
|
|
181
|
+
throw new Error(`Failed to load service directory: HTTP ${r.status}`);
|
|
182
|
+
return { contents: [{ uri: uri.toString(), mimeType: "application/json", text: await r.text() }] };
|
|
183
|
+
});
|
|
184
|
+
server.registerResource("platform-llms-txt", "wickedapi://platform/llms", {
|
|
185
|
+
title: "WickedAPI llms.txt",
|
|
186
|
+
description: "Terse, platform-wide summary for agents deciding what to call — auth modes, free vs. paid endpoints.",
|
|
187
|
+
mimeType: "text/plain",
|
|
188
|
+
}, async (uri) => {
|
|
189
|
+
const r = await fetch(`${SHOWCASE_URL}/llms.txt`);
|
|
190
|
+
if (!r.ok)
|
|
191
|
+
throw new Error(`Failed to load llms.txt: HTTP ${r.status}`);
|
|
192
|
+
return { contents: [{ uri: uri.toString(), mimeType: "text/plain", text: await r.text() }] };
|
|
193
|
+
});
|
|
194
|
+
for (const service of SERVICES) {
|
|
195
|
+
server.registerResource(`${service.id}-status`, `wickedapi://${service.id}/status`, {
|
|
196
|
+
title: `${service.name} — live catalog`,
|
|
197
|
+
description: `Live endpoint catalog for ${service.name} (${service.baseUrl}), fetched fresh on every read.`,
|
|
198
|
+
mimeType: "application/json",
|
|
199
|
+
}, async (uri) => {
|
|
200
|
+
const r = await fetch(`${service.baseUrl}/v1/status`);
|
|
201
|
+
if (!r.ok)
|
|
202
|
+
throw new Error(`Failed to load ${service.name} status: HTTP ${r.status}`);
|
|
203
|
+
return { contents: [{ uri: uri.toString(), mimeType: "application/json", text: await r.text() }] };
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// --- Prompts ---------------------------------------------------------------
|
|
208
|
+
// The "compound recipes" from the WickedAPI cookbook, turned into one-click
|
|
209
|
+
// prompts: each just tells the assistant which real tools to call and how
|
|
210
|
+
// to read the results — no fabricated synthesis, every number cited comes
|
|
211
|
+
// from an actual tool call made during the conversation.
|
|
212
|
+
function registerPrompts(server) {
|
|
213
|
+
server.registerPrompt("rug_check", {
|
|
214
|
+
title: "Rug-check before buying",
|
|
215
|
+
description: "Real on-chain risk signals for a token contract, plus market sentiment (and momentum, if it's a covered asset) before you decide whether to buy.",
|
|
216
|
+
argsSchema: {
|
|
217
|
+
address: z.string().describe("The token contract address (EVM) or mint address (Solana)."),
|
|
218
|
+
chain: z.string().describe("ethereum, bsc, base, polygon, arbitrum, optimism, avalanche, fantom, zksync, linea, scroll, or solana."),
|
|
219
|
+
symbol: z.string().optional().describe("If this token maps to a covered momentum-score asset (BTC, ETH, SOL, SUI, NVDA, IBIT, COIN, QQQ, TQQQ, DIA), pass it to also pull a real momentum verdict."),
|
|
220
|
+
},
|
|
221
|
+
}, ({ address, chain, symbol }) => ({
|
|
222
|
+
description: "Rug-check workflow",
|
|
223
|
+
messages: [
|
|
224
|
+
{
|
|
225
|
+
role: "user",
|
|
226
|
+
content: {
|
|
227
|
+
type: "text",
|
|
228
|
+
text: `Before I consider buying this token, run a real risk check — don't guess or assume anything:\n\n` +
|
|
229
|
+
`1. Call the \`token_risk\` tool with address="${address}" and chain="${chain}". Report the exact ` +
|
|
230
|
+
`risk_score, risk_tier, and every real flag it returns (honeypot, mint/ownership authority, holder ` +
|
|
231
|
+
`and LP concentration, buy/sell tax, trust-list status) — cite the actual values, don't summarize ` +
|
|
232
|
+
`away the specifics or invent ones that weren't returned.\n` +
|
|
233
|
+
`2. Call the \`sentiment\` tool for the current overall crypto market mood.\n` +
|
|
234
|
+
(symbol
|
|
235
|
+
? `3. Call the \`momentum_score\` tool with symbol="${symbol}" and timeframe="1h" for a real technical read.\n`
|
|
236
|
+
: "") +
|
|
237
|
+
`\nThen give a plain verdict: the real risk signals found (listed, not paraphrased), current market ` +
|
|
238
|
+
`mood, and — only if applicable — the momentum read. State clearly this is aggregated real signals, ` +
|
|
239
|
+
`never a safety guarantee.`,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
}));
|
|
244
|
+
server.registerPrompt("vet_dependency", {
|
|
245
|
+
title: "Vet a DeFi dependency",
|
|
246
|
+
description: "Protocol activity/treasury health plus an optional contract-level risk check, before you build on a DeFi protocol.",
|
|
247
|
+
argsSchema: {
|
|
248
|
+
slug: z.string().describe('The protocol\'s DefiLlama slug, e.g. "lido".'),
|
|
249
|
+
token_address: z.string().optional().describe("The protocol's token contract address, if you also want a contract-level risk check."),
|
|
250
|
+
chain: z.string().optional().describe("Chain for token_address (default ethereum)."),
|
|
251
|
+
},
|
|
252
|
+
}, ({ slug, token_address, chain }) => ({
|
|
253
|
+
description: "Dependency-vetting workflow",
|
|
254
|
+
messages: [
|
|
255
|
+
{
|
|
256
|
+
role: "user",
|
|
257
|
+
content: {
|
|
258
|
+
type: "text",
|
|
259
|
+
text: `Before I integrate this protocol as a dependency, check its real health — don't guess:\n\n` +
|
|
260
|
+
`1. Call the \`protocol_health_score\` tool with slug="${slug}". Report the components (GitHub ` +
|
|
261
|
+
`activity, TVL trend, treasury) exactly as returned, including any that came back null (never ` +
|
|
262
|
+
`filled in).\n` +
|
|
263
|
+
(token_address
|
|
264
|
+
? `2. Call the \`token_risk\` tool with address="${token_address}" and chain="${chain || "ethereum"}" ` +
|
|
265
|
+
`for a contract-level check on its token.\n`
|
|
266
|
+
: "") +
|
|
267
|
+
`\nSummarize: is this protocol actively maintained, is its TVL trend healthy, and (if checked) does ` +
|
|
268
|
+
`its token carry any real contract-level risk flags. Cite the real numbers.`,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
}));
|
|
273
|
+
server.registerPrompt("morning_briefing", {
|
|
274
|
+
title: "Morning briefing",
|
|
275
|
+
description: "One daily digest from macro calendar, earnings calendar, sentiment, and a momentum verdict — all free or a fraction of a cent.",
|
|
276
|
+
argsSchema: {
|
|
277
|
+
symbol: z.string().optional().describe("Asset for the momentum verdict (default BTC). One of BTC, ETH, SOL, SUI, NVDA, IBIT, COIN, QQQ, TQQQ, DIA."),
|
|
278
|
+
},
|
|
279
|
+
}, ({ symbol }) => {
|
|
280
|
+
const sym = symbol || "BTC";
|
|
281
|
+
return {
|
|
282
|
+
description: "Morning briefing workflow",
|
|
283
|
+
messages: [
|
|
284
|
+
{
|
|
285
|
+
role: "user",
|
|
286
|
+
content: {
|
|
287
|
+
type: "text",
|
|
288
|
+
text: `Compile my morning briefing from real, current data — call each tool, don't estimate:\n\n` +
|
|
289
|
+
`1. \`macro_calendar\` with days=7 — upcoming FOMC/CPI/jobs events.\n` +
|
|
290
|
+
`2. \`earnings_calendar\` with days=3 — upcoming earnings reports.\n` +
|
|
291
|
+
`3. \`sentiment\` — current crypto Fear & Greed reading.\n` +
|
|
292
|
+
`4. \`momentum_score\` with symbol="${sym}" and timeframe="1D" — today's technical verdict.\n\n` +
|
|
293
|
+
`Then write one concise digest combining all four, citing the real values from each call.`,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
export const SERVER_VERSION = "0.3.1";
|
|
301
|
+
export async function loadWorld() {
|
|
302
|
+
const doFetch = await buildFetcher();
|
|
303
|
+
const specs = [];
|
|
304
|
+
for (const service of SERVICES) {
|
|
305
|
+
const doc = await loadServiceSpec(service);
|
|
306
|
+
if (doc)
|
|
307
|
+
specs.push({ service, doc });
|
|
308
|
+
}
|
|
309
|
+
if (specs.length === 0) {
|
|
310
|
+
throw new Error("No service specs loaded - check network access and WICKEDAPI_*_URL overrides.");
|
|
311
|
+
}
|
|
312
|
+
return { doFetch, specs };
|
|
313
|
+
}
|
|
314
|
+
export function buildServer(world) {
|
|
315
|
+
const server = new McpServer({ name: "wickedapi", version: SERVER_VERSION });
|
|
316
|
+
let totalTools = 0;
|
|
317
|
+
const loaded = [];
|
|
318
|
+
for (const { service, doc } of world.specs) {
|
|
319
|
+
totalTools += registerServiceTools(server, service, doc, world.doFetch);
|
|
320
|
+
loaded.push(service.name);
|
|
321
|
+
}
|
|
322
|
+
// Reputation, Registry, Identity, Sanity, Memory and Paywall use hand-written
|
|
323
|
+
// tools (non-/v1 paths, wallet-signature auth), registered unconditionally.
|
|
324
|
+
totalTools += registerSuiteTools(server);
|
|
325
|
+
loaded.push("Reputation, Registry, Identity, Sanity, Memory, Paywall");
|
|
326
|
+
registerResources(server);
|
|
327
|
+
registerPrompts(server);
|
|
328
|
+
return {
|
|
329
|
+
server,
|
|
330
|
+
summary: `${totalTools} tools across ${loaded.length} group(s) (${loaded.join(", ")}), ${2 + SERVICES.length} resources, 3 prompts`,
|
|
331
|
+
};
|
|
332
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wickedapi-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "MCP server for the whole Wicked suite — trading data, DeFi protocol health, token safety, brokerage sync, agent identity, reputation, tool registry, hallucination checks, persistent memory, and x402 paywalls. 70 tools, 6 resources, 3 prompts. No signup required: pay per call in USDC via x402, or use a partner API key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"node": ">=20"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
|
-
"homepage": "https://
|
|
17
|
+
"homepage": "https://mcp.wickedapi.com",
|
|
18
18
|
"bugs": {
|
|
19
19
|
"url": "https://api.wickedapi.com"
|
|
20
20
|
},
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "tsc -p tsconfig.json",
|
|
24
24
|
"start": "node dist/index.js",
|
|
25
|
-
"prepublishOnly": "npm run build"
|
|
25
|
+
"prepublishOnly": "npm run build",
|
|
26
|
+
"start:http": "node dist/http.js",
|
|
27
|
+
"test": "npm run build && node --test \"test/*.test.mjs\""
|
|
26
28
|
},
|
|
27
29
|
"keywords": [
|
|
28
30
|
"mcp",
|
|
@@ -51,5 +53,10 @@
|
|
|
51
53
|
"devDependencies": {
|
|
52
54
|
"@types/node": "^22.20.1",
|
|
53
55
|
"typescript": "^5.9.3"
|
|
54
|
-
}
|
|
56
|
+
},
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/choaticpixels/wicked-mcp.git"
|
|
60
|
+
},
|
|
61
|
+
"mcpName": "io.github.choaticpixels/wicked-mcp"
|
|
55
62
|
}
|