wickedapi-mcp 0.3.0 → 0.4.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 WickedAPI
3
+ Copyright (c) 2026 choaticpixels
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -17,10 +17,14 @@ MCP server for the whole [Wicked](https://wickedapi.com) suite — trading data,
17
17
  | [Wicked Memory](https://memory.wickedapi.com) | 8 (`memory_prepare`, `memory_store`, `memory_search`, …) | wallet signature on every call; only search is metered |
18
18
  | [x402 Paywall](https://paywall.wickedapi.com) | 8 (`paywall_*`) | `paywall_signup` is open; the rest need the tenant key |
19
19
 
20
- Plus **6 resources** (the platform directory, `llms.txt`, and each service's live endpoint catalog) and **3 prompts** (`rug_check`, `vet_dependency`, `morning_briefing`) that chain tools together the way the [cookbook](../cookbooks/wickedapi-cookbook.md) does.
20
+ Plus **6 resources** (the platform directory, `llms.txt`, and each service's live endpoint catalog) and **7 prompts** (`rug_check`, `vet_dependency`, `morning_briefing`, `fact_check`, `verify_agent`, `find_reliable_tool`, `agent_memory_guide`) that chain tools together the way the [cookbook](../cookbooks/wickedapi-cookbook.md) does.
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:
@@ -71,6 +75,15 @@ Read without calling a tool — useful for a client that surfaces resources in i
71
75
  - `wickedapi://platform/llms` — the platform's `llms.txt`
72
76
  - `wickedapi://trading/status`, `wickedapi://protocol_health/status`, `wickedapi://token_risk/status`, `wickedapi://broker_sync/status` — each service's live endpoint catalog
73
77
 
78
+ ## Quick start
79
+
80
+ ```bash
81
+ claude mcp add --transport http wicked https://mcp.wickedapi.com/mcp # Claude Code, no install
82
+ npx -y wickedapi-mcp # local stdio
83
+ ```
84
+
85
+ Claude Desktop / Cursor / VS Code config and per-framework starters: see the [cookbooks](https://github.com/choaticpixels/api_services/tree/main/cookbooks). The remote also serves a landing page at `/`, `/llms.txt`, an MCP server card at `/.well-known/mcp/server-card.json`, and aggregate per-tool call counts at `/stats` (counts only; no arguments or IPs).
86
+
74
87
  ## Prompts
75
88
 
76
89
  One-click workflows that chain real tool calls (Claude Desktop and other prompt-aware clients surface these directly):
@@ -78,6 +91,10 @@ One-click workflows that chain real tool calls (Claude Desktop and other prompt-
78
91
  - **`rug_check`** (`address`, `chain`, optional `symbol`) — token risk check + market sentiment + optional momentum read, before buying.
79
92
  - **`vet_dependency`** (`slug`, optional `token_address`, `chain`) — protocol health + optional contract-level risk check, before building on a DeFi protocol.
80
93
  - **`morning_briefing`** (optional `symbol`) — macro calendar + earnings calendar + sentiment + momentum verdict, as one digest.
94
+ - **`fact_check`** (`output`, optional `source`) — split an answer into claims and verify each with Wicked Sanity.
95
+ - **`verify_agent`** (`wallet`) — Identity assertion + Reputation stake/tier before you transact with an agent.
96
+ - **`find_reliable_tool`** (`need`, optional `category`, `min_score`) — Registry search by real uptime/latency/schema scores.
97
+ - **`agent_memory_guide`** (`wallet`, `task`) — the two-step signed Memory flow, explained to the model.
81
98
 
82
99
  ## Environment variables
83
100
 
@@ -100,8 +117,9 @@ If neither `WICKEDAPI_API_KEY` nor `WICKEDAPI_PAYER_PRIVATE_KEY` is set, paid ca
100
117
 
101
118
  ```bash
102
119
  npm install
103
- npm run build
104
- node dist/index.js
120
+ npm test # builds, then unit + end-to-end tests (e2e needs network)
121
+ node dist/index.js # stdio
122
+ node dist/http.js # streamable HTTP on $PORT (default 8080)
105
123
  ```
106
124
 
107
125
  ## What this is not
@@ -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,100 @@
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 { landingHtml, llmsTxt, serverCard } from "./landing.js";
12
+ import { knownTools, usageHooks, usageSnapshot } from "./usage.js";
13
+ import { buildServer, loadWorld, SERVER_VERSION } from "./server.js";
14
+ const PORT = Number(process.env.PORT || 8080);
15
+ const RATE_LIMIT_PER_MINUTE = Number(process.env.RATE_LIMIT_PER_MINUTE || 30);
16
+ const MAX_BODY_BYTES = 1_000_000;
17
+ const ALLOWED_HOSTS = (process.env.ALLOWED_HOSTS || "mcp.wickedapi.com,wicked-mcp-production.up.railway.app,localhost:8080,127.0.0.1:8080")
18
+ .split(",")
19
+ .map((h) => h.trim().toLowerCase())
20
+ .filter(Boolean);
21
+ function send(res, status, body, headers = {}) {
22
+ res.writeHead(status, { "content-type": "application/json", ...headers });
23
+ res.end(JSON.stringify(body));
24
+ }
25
+ function sendText(res, contentType, body) {
26
+ res.writeHead(200, { "content-type": `${contentType}; charset=utf-8`, "cache-control": "public, max-age=300" });
27
+ res.end(body);
28
+ }
29
+ async function readJson(req) {
30
+ const chunks = [];
31
+ let size = 0;
32
+ for await (const chunk of req) {
33
+ size += chunk.length;
34
+ if (size > MAX_BODY_BYTES)
35
+ throw Object.assign(new Error("payload too large"), { status: 413 });
36
+ chunks.push(chunk);
37
+ }
38
+ if (!chunks.length)
39
+ return undefined;
40
+ try {
41
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
42
+ }
43
+ catch {
44
+ throw Object.assign(new Error("invalid JSON"), { status: 400 });
45
+ }
46
+ }
47
+ async function main() {
48
+ const world = await loadWorld();
49
+ const { summary } = buildServer(world, { onRegister: usageHooks.onRegister });
50
+ const tools = [...knownTools];
51
+ const http = createServer(async (req, res) => {
52
+ const url = new URL(req.url || "/", "http://x");
53
+ if (url.pathname === "/health")
54
+ return send(res, 200, { status: "ok", version: SERVER_VERSION });
55
+ const host = (req.headers.host || "").toLowerCase();
56
+ if (!ALLOWED_HOSTS.includes(host))
57
+ return send(res, 421, { error: "Invalid Host header" });
58
+ if (req.method === "GET" || req.method === "HEAD") {
59
+ if (url.pathname === "/")
60
+ return sendText(res, "text/html", landingHtml(tools, SERVER_VERSION));
61
+ if (url.pathname === "/llms.txt")
62
+ return sendText(res, "text/plain", llmsTxt(tools, SERVER_VERSION));
63
+ if (url.pathname === "/.well-known/mcp/server-card.json" || url.pathname === "/.well-known/mcp.json") {
64
+ return send(res, 200, serverCard(tools, SERVER_VERSION), { "cache-control": "public, max-age=300" });
65
+ }
66
+ if (url.pathname === "/stats")
67
+ return send(res, 200, usageSnapshot());
68
+ }
69
+ if (url.pathname !== "/mcp")
70
+ return send(res, 404, { error: "Not found. The MCP endpoint is /mcp." });
71
+ if (req.method !== "POST") {
72
+ return send(res, 405, { jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed (stateless server: POST only)." }, id: null }, { allow: "POST" });
73
+ }
74
+ if (rateLimited(clientKey(req), RATE_LIMIT_PER_MINUTE)) {
75
+ 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" });
76
+ }
77
+ try {
78
+ const body = await readJson(req);
79
+ const { server } = buildServer(world, usageHooks);
80
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
81
+ res.on("close", () => {
82
+ void transport.close();
83
+ void server.close();
84
+ });
85
+ await server.connect(transport);
86
+ await transport.handleRequest(req, res, body);
87
+ }
88
+ catch (err) {
89
+ const status = err.status ?? 500;
90
+ if (!res.headersSent) {
91
+ send(res, status, { jsonrpc: "2.0", error: { code: -32603, message: err.message }, id: null });
92
+ }
93
+ }
94
+ });
95
+ http.listen(PORT, "0.0.0.0", () => console.error(`[wickedapi-mcp] http on :${PORT}/mcp - ${summary}.`));
96
+ }
97
+ main().catch((err) => {
98
+ console.error("[wickedapi-mcp] fatal:", err);
99
+ process.exit(1);
100
+ });
package/dist/index.js CHANGED
@@ -1,330 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
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 { z } from "zod";
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 = new McpServer({ name: "wickedapi", version: "0.3.0" });
304
- const doFetch = await buildFetcher();
305
- let totalTools = 0;
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,96 @@
1
+ // Discovery surfaces served by the HTTP deployment: a human landing page,
2
+ // /llms.txt for crawlers and agents, and an MCP server card. All three are
3
+ // generated from the tools actually registered, so they cannot drift from
4
+ // what the server exposes.
5
+ const SITE = "https://mcp.wickedapi.com";
6
+ const GROUPS = [
7
+ { id: "protocol_health", label: "Protocol Health Oracle", prefix: "protocol_health_", blurb: "Score any DeFi protocol before you integrate it." },
8
+ { id: "token_risk", label: "Token Risk Oracle", prefix: "token_risk", blurb: "Free rug / honeypot check for EVM and Solana tokens." },
9
+ { id: "broker_sync", label: "Broker Sync", prefix: "broker_sync_", blurb: "Positions and balances across 30+ brokerages." },
10
+ { id: "reputation", label: "Wicked Reputation", prefix: "reputation_", blurb: "On-chain agent stake, tiers and slashing history. Free." },
11
+ { id: "registry", label: "Wicked Registry", prefix: "registry_", blurb: "Real uptime, latency and schema scores for x402/MCP tools." },
12
+ { id: "identity", label: "Wicked Identity", prefix: "identity_", blurb: "Know-Your-Agent verification with signed assertions." },
13
+ { id: "sanity", label: "Wicked Sanity", prefix: "sanity_", blurb: "Hallucination check against your source or live web evidence." },
14
+ { id: "memory", label: "Wicked Memory", prefix: "memory_", blurb: "Persistent, wallet-scoped agent memory with semantic search." },
15
+ { id: "paywall", label: "x402 Paywall", prefix: "paywall_", blurb: "Put any endpoint behind pay-per-call USDC." },
16
+ ];
17
+ export const PROMPT_NAMES = ["rug_check", "vet_dependency", "morning_briefing", "fact_check", "verify_agent", "find_reliable_tool", "agent_memory_guide"];
18
+ function grouped(tools) {
19
+ const claimed = new Set();
20
+ const out = GROUPS.map((g) => {
21
+ const names = tools.filter((t) => t.startsWith(g.prefix));
22
+ names.forEach((n) => claimed.add(n));
23
+ return { ...g, tools: names };
24
+ });
25
+ const trading = tools.filter((t) => !claimed.has(t));
26
+ return [{ id: "trading", label: "Trading Data API", blurb: "Prices, OHLCV, indicators, funding/OI, macro, prediction markets, momentum score.", tools: trading }, ...out];
27
+ }
28
+ export function llmsTxt(tools, version) {
29
+ const g = grouped(tools);
30
+ return [
31
+ "# Wicked MCP",
32
+ "",
33
+ `> One MCP server for the whole Wicked suite: ${tools.length} tools across ${g.length} services - market data, DeFi and token risk, brokerage sync, agent identity, reputation, tool registry, hallucination checks, persistent memory and x402 paywalls. Version ${version}.`,
34
+ "",
35
+ "## Connect",
36
+ `- Remote (streamable HTTP, no install): ${SITE}/mcp`,
37
+ "- Local (stdio): `npx -y wickedapi-mcp`",
38
+ "- Claude Code: `claude mcp add --transport http wicked " + SITE + "/mcp`",
39
+ "- No signup needed. Priced calls return a real x402 payment challenge (USDC on Base); set WICKEDAPI_API_KEY or a payer wallet locally for free-tier / auto-pay.",
40
+ "- The public remote is rate limited per IP; run it locally with `npx` for unlimited use.",
41
+ "",
42
+ "## Services and tools",
43
+ ...g.map((s) => `- ${s.label} (${s.tools.length}): ${s.blurb} Tools: ${s.tools.join(", ")}`),
44
+ "",
45
+ "## Workflow prompts",
46
+ ...PROMPT_NAMES.map((p) => `- ${p}`),
47
+ "",
48
+ "## Links",
49
+ "- Platform: https://wickedapi.com (service directory at https://wickedapi.com/services.json)",
50
+ "- Source: https://github.com/choaticpixels/wicked-mcp",
51
+ "- npm: https://www.npmjs.com/package/wickedapi-mcp",
52
+ "- Registry: io.github.choaticpixels/wicked-mcp on https://registry.modelcontextprotocol.io",
53
+ "- Server card: " + SITE + "/.well-known/mcp/server-card.json",
54
+ "",
55
+ "Wallet-signed tools (identity, memory, registry registration) never sign for you: you supply the signature from your own wallet.",
56
+ "",
57
+ ].join("\n");
58
+ }
59
+ export function serverCard(tools, version) {
60
+ return {
61
+ $schema: "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
62
+ version: "1.0",
63
+ protocolVersion: "2025-06-18",
64
+ serverInfo: { name: "io.github.choaticpixels/wicked-mcp", title: "Wicked MCP", version },
65
+ description: `The whole Wicked suite for agents: ${tools.length} tools across market data, DeFi/token risk, identity, reputation, registry, sanity checks, memory and paywalls. x402-native.`,
66
+ documentationUrl: "https://github.com/choaticpixels/wicked-mcp",
67
+ transport: { type: "streamable-http", endpoint: "/mcp" },
68
+ capabilities: { tools: { listChanged: false }, resources: {}, prompts: {} },
69
+ authentication: { required: false, note: "Optional keys unlock free tiers; otherwise paid calls return an x402 challenge." },
70
+ packages: [{ registry: "npm", name: "wickedapi-mcp", transport: "stdio" }],
71
+ tools: tools.map((name) => ({ name })),
72
+ };
73
+ }
74
+ const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
75
+ export function landingHtml(tools, version) {
76
+ const g = grouped(tools);
77
+ const rows = g
78
+ .map((s) => `<details><summary><b>${esc(s.label)}</b> <span>${s.tools.length} tools</span><em>${esc(s.blurb)}</em></summary><code>${s.tools.map(esc).join(" &middot; ")}</code></details>`)
79
+ .join("\n");
80
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
81
+ <title>Wicked MCP</title><meta name="description" content="One MCP server for the whole Wicked suite: ${tools.length} tools for agents.">
82
+ <style>:root{color-scheme:light dark;--bg:#fff;--fg:#14171f;--mut:#5b6472;--card:#f4f6fa;--acc:#6d28d9}@media(prefers-color-scheme:dark){:root{--bg:#0e1117;--fg:#e8ebf2;--mut:#98a2b3;--card:#181d27;--acc:#a78bfa}}
83
+ body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,sans-serif}main{max-width:760px;margin:0 auto;padding:32px 16px}h1{margin:0 0 4px}p.l{color:var(--mut);margin-top:0}
84
+ pre{background:var(--card);padding:12px 14px;border-radius:8px;overflow:auto;font-size:14px}details{background:var(--card);border-radius:8px;padding:10px 14px;margin:8px 0}summary{cursor:pointer}summary span{color:var(--acc);margin:0 8px;font-size:14px}summary em{display:block;color:var(--mut);font-style:normal;font-size:14px}
85
+ code{display:block;margin-top:8px;font-size:13px;color:var(--mut);word-break:break-word}a{color:var(--acc)}footer{color:var(--mut);font-size:14px;margin-top:24px}</style></head><body><main>
86
+ <h1>Wicked MCP</h1><p class="l">${tools.length} tools. ${g.length} services. One server for agents. v${esc(version)}</p>
87
+ <h3>Connect in one line</h3>
88
+ <pre>claude mcp add --transport http wicked ${SITE}/mcp</pre>
89
+ <pre>{ "mcpServers": { "wicked": { "url": "${SITE}/mcp" } } }</pre>
90
+ <pre>npx -y wickedapi-mcp # local, stdio - unlimited, supports your API key or payer wallet</pre>
91
+ <p>No signup. Priced calls return a real x402 payment challenge (USDC on Base). The public endpoint is rate limited per IP.</p>
92
+ <h3>What's inside</h3>
93
+ ${rows}
94
+ <footer><a href="https://wickedapi.com">wickedapi.com</a> &middot; <a href="https://github.com/choaticpixels/wicked-mcp">GitHub</a> &middot; <a href="https://www.npmjs.com/package/wickedapi-mcp">npm</a> &middot; <a href="/llms.txt">llms.txt</a> &middot; <a href="/.well-known/mcp/server-card.json">server card</a> &middot; <a href="/stats">usage</a></footer>
95
+ </main></body></html>`;
96
+ }
@@ -0,0 +1,85 @@
1
+ import { z } from "zod";
2
+ const text = (t) => ({ role: "user", content: { type: "text", text: t } });
3
+ export function registerSuitePrompts(server) {
4
+ server.registerPrompt("fact_check", {
5
+ title: "Fact-check before acting",
6
+ description: "Split an answer into claims and verify each against your source text (or live web evidence) with Wicked Sanity before you rely on it.",
7
+ argsSchema: {
8
+ output: z.string().describe("The text to check - an AI answer, summary or report."),
9
+ source: z.string().optional().describe("The source text the output must be faithful to. Omit to check against live web evidence instead."),
10
+ },
11
+ }, ({ output, source }) => ({
12
+ description: "Fact-check workflow",
13
+ messages: [
14
+ text(`Verify the following output before I act on it. Don't judge it yourself - use the tools.\n\n` +
15
+ `1. Split it into individual factual claims (one sentence each).\n` +
16
+ `2. Call \`sanity_check_batch\` once with all claims, ` +
17
+ (source
18
+ ? `mode="grounded" and context set to the source text below.\n`
19
+ : `mode="open" (no source supplied, so check against live web evidence).\n`) +
20
+ `3. Report every claim with its exact verdict (supported / contradicted / unsupported / insufficient_evidence) ` +
21
+ `and the evidence returned. Treat unsupported and insufficient_evidence as UNVERIFIED, and contradicted as wrong.\n` +
22
+ `4. If the response has http_status 402, 429 or another error, say so plainly - do not guess verdicts.\n\n` +
23
+ `OUTPUT TO CHECK:\n${output}\n` +
24
+ (source ? `\nSOURCE:\n${source}\n` : "")),
25
+ ],
26
+ }));
27
+ server.registerPrompt("verify_agent", {
28
+ title: "Check an agent's trust",
29
+ description: "Combine Wicked Identity (is this wallet a verified autonomous agent?) and Wicked Reputation (what does it have at stake?) before you transact with it.",
30
+ argsSchema: { wallet: z.string().describe("The agent's 0x wallet address.") },
31
+ }, ({ wallet }) => ({
32
+ description: "Agent trust check",
33
+ messages: [
34
+ text(`Before I deal with the agent at ${wallet}, check its real trust signals - don't assume anything:\n\n` +
35
+ `1. Call \`identity_status\` with wallet="${wallet}": does it hold a valid, unexpired Know-Your-Agent assertion? Report the exact fields.\n` +
36
+ `2. Call \`reputation_status\` with wallet="${wallet}": tier, active stake, reputation score, slash count. ` +
37
+ `An http_status 404 means the wallet never registered - report that as "unranked", not an error.\n` +
38
+ `3. Call \`reputation_statement\` only if it has stake or slashes and I need the event history.\n\n` +
39
+ `Summarize plainly: verified or not, stake and tier, any slashes, and what is unknown. State that this is ` +
40
+ `a set of signals, not a guarantee. If a call returns a 402, show the payment instructions rather than guessing.`),
41
+ ],
42
+ }));
43
+ server.registerPrompt("find_reliable_tool", {
44
+ title: "Find a reliable tool",
45
+ description: "Search the Wicked Registry for x402/MCP tools by real uptime, latency and schema-conformance scores before you pay for one.",
46
+ argsSchema: {
47
+ need: z.string().describe('What you need the tool for, e.g. "token price data".'),
48
+ category: z.string().optional().describe('Registry category filter, e.g. "data" or "crypto".'),
49
+ min_score: z.string().optional().describe("Minimum composite score 0-100."),
50
+ },
51
+ }, ({ need, category, min_score }) => ({
52
+ description: "Reliable tool discovery",
53
+ messages: [
54
+ text(`I need a tool for: ${need}. Find options with real reliability data - never recommend from memory.\n\n` +
55
+ `1. Call \`registry_featured_tools\` (free) for the top scored tools.\n` +
56
+ `2. Call \`registry_search_tools\`` +
57
+ (category ? ` with category="${category}"` : "") +
58
+ (min_score ? ` and min_score=${min_score}` : "") +
59
+ `. If it returns a 402, show me the payment instructions and continue with the free results.\n` +
60
+ `3. For the best 1-3 candidates call \`registry_tool_detail\` (or \`registry_tool_badge\` for a free score) and report ` +
61
+ `uptime, p95 latency and schema conformance exactly as returned. Tools with no check history have no score - say so.\n\n` +
62
+ `Recommend one, with the numbers that justify it.`),
63
+ ],
64
+ }));
65
+ server.registerPrompt("agent_memory_guide", {
66
+ title: "Use Wicked Memory",
67
+ description: "Walk through storing and searching persistent, wallet-scoped memory - including the two-step signing flow.",
68
+ argsSchema: {
69
+ wallet: z.string().describe("Your agent's 0x wallet address (memories are scoped to it)."),
70
+ task: z.string().describe('What to do, e.g. "remember that I prefer concise answers" or "find what I stored about the Q3 launch".'),
71
+ },
72
+ }, ({ wallet, task }) => ({
73
+ description: "Wicked Memory walkthrough",
74
+ messages: [
75
+ text(`Use Wicked Memory for wallet ${wallet} to: ${task}\n\n` +
76
+ `Every memory call needs a fresh wallet signature, and this server never signs or holds a key:\n` +
77
+ `1. Call \`memory_prepare\` with the operation ("store" or "search") and the exact params you will use. It returns \`message_to_sign\`, \`timestamp\` and \`nonce\`.\n` +
78
+ `2. Ask ME to sign \`message_to_sign\` with the wallet (EIP-191 personal_sign) and give you the signature. Never invent one.\n` +
79
+ `3. Call the matching tool (\`memory_store\` / \`memory_search\`) with the SAME params plus timestamp, nonce and signature.\n` +
80
+ `4. A nonce is single-use and the timestamp must be within 5 minutes - prepare again for every call. ` +
81
+ `\`memory_search\` is metered: if it returns http_status 402, show me the payment instructions; the same signed request can be retried with a payment_signature.\n\n` +
82
+ `Report exactly what the tools returned.`),
83
+ ],
84
+ }));
85
+ }
@@ -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,337 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { registerSuiteTools } from "./suite.js";
4
+ import { registerSuitePrompts } from "./prompts.js";
5
+ import { instrument } from "./usage.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
+ }
302
+ export const SERVER_VERSION = "0.4.0";
303
+ export async function loadWorld() {
304
+ const doFetch = await buildFetcher();
305
+ const specs = [];
306
+ for (const service of SERVICES) {
307
+ const doc = await loadServiceSpec(service);
308
+ if (doc)
309
+ specs.push({ service, doc });
310
+ }
311
+ if (specs.length === 0) {
312
+ throw new Error("No service specs loaded - check network access and WICKEDAPI_*_URL overrides.");
313
+ }
314
+ return { doFetch, specs };
315
+ }
316
+ export function buildServer(world, hooks) {
317
+ const server = new McpServer({ name: "wickedapi", version: SERVER_VERSION });
318
+ if (hooks)
319
+ instrument(server, hooks);
320
+ let totalTools = 0;
321
+ const loaded = [];
322
+ for (const { service, doc } of world.specs) {
323
+ totalTools += registerServiceTools(server, service, doc, world.doFetch);
324
+ loaded.push(service.name);
325
+ }
326
+ // Reputation, Registry, Identity, Sanity, Memory and Paywall use hand-written
327
+ // tools (non-/v1 paths, wallet-signature auth), registered unconditionally.
328
+ totalTools += registerSuiteTools(server);
329
+ loaded.push("Reputation, Registry, Identity, Sanity, Memory, Paywall");
330
+ registerResources(server);
331
+ registerPrompts(server);
332
+ registerSuitePrompts(server);
333
+ return {
334
+ server,
335
+ summary: `${totalTools} tools across ${loaded.length} group(s) (${loaded.join(", ")}), ${2 + SERVICES.length} resources, 7 prompts`,
336
+ };
337
+ }
package/dist/usage.js ADDED
@@ -0,0 +1,46 @@
1
+ const startedAt = Date.now();
2
+ const counts = new Map();
3
+ export const knownTools = new Set();
4
+ export const usageHooks = {
5
+ onRegister: (name) => knownTools.add(name),
6
+ onToolCall: (name, ms, isError) => {
7
+ const c = counts.get(name) ?? { calls: 0, errors: 0, totalMs: 0 };
8
+ c.calls++;
9
+ c.totalMs += ms;
10
+ if (isError)
11
+ c.errors++;
12
+ counts.set(name, c);
13
+ console.log(JSON.stringify({ evt: "tool_call", tool: name, error: isError, ms }));
14
+ },
15
+ };
16
+ export function usageSnapshot() {
17
+ const tools = [...counts.entries()]
18
+ .map(([tool, c]) => ({ tool, calls: c.calls, errors: c.errors, avg_ms: Math.round(c.totalMs / c.calls) }))
19
+ .sort((a, b) => b.calls - a.calls);
20
+ return {
21
+ since: new Date(startedAt).toISOString(),
22
+ note: "In-memory since last deploy; durable history is in the service logs.",
23
+ total_calls: tools.reduce((n, t) => n + t.calls, 0),
24
+ tools,
25
+ };
26
+ }
27
+ // Wrap server.registerTool so every handler reports to the hooks. Must run
28
+ // before any tools are registered.
29
+ export function instrument(server, hooks) {
30
+ const original = server.registerTool.bind(server);
31
+ server.registerTool = (name, config, handler) => {
32
+ hooks.onRegister?.(name);
33
+ return original(name, config, async (...args) => {
34
+ const t0 = Date.now();
35
+ let isError = true;
36
+ try {
37
+ const result = await handler(...args);
38
+ isError = Boolean(result?.isError);
39
+ return result;
40
+ }
41
+ finally {
42
+ hooks.onToolCall?.(name, Date.now() - t0, isError);
43
+ }
44
+ });
45
+ };
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wickedapi-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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://api.wickedapi.com",
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
  }