Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
14a618b
feat(okx): add REST envelope types
biwasxyz May 2, 2026
af5149e
feat(okx): add REST client with envelope unwrap and US base URL switch
biwasxyz May 2, 2026
a33b81b
feat(okx): add market data helpers for ticker, tickers, books, candles
biwasxyz May 2, 2026
1edf257
feat(okx): add service barrel exports
biwasxyz May 2, 2026
de3a469
feat(okx): add okx_market_* MCP tools for ticker, tickers, books, can…
biwasxyz May 2, 2026
f8ab514
feat(okx): aggregate OKX tool groups behind registerOkxTools
biwasxyz May 2, 2026
10b4097
feat(okx): wire OKX tools into the MCP server registry
biwasxyz May 2, 2026
958c3b6
test(okx): cover client envelope, base URL switch, and market helpers
biwasxyz May 2, 2026
887883d
feat(okx): add HMAC signing and lazy credential loader
biwasxyz May 2, 2026
327fc86
feat(okx): add okxAuthGet for signed private requests + WaaS base URL
biwasxyz May 2, 2026
a9b8403
feat(okx): add DEX aggregator service for single-chain swap routing
biwasxyz May 2, 2026
d890e1d
feat(okx): add Wallet API service for chains, balances, UTXOs
biwasxyz May 2, 2026
8f61032
feat(okx): expose Phase 2 auth/dex/wallet helpers from service barrel
biwasxyz May 2, 2026
d06409b
feat(okx): add okx_dex_* MCP tools for aggregator routing
biwasxyz May 2, 2026
db9eb3a
feat(okx): add okx_wallet_* MCP tools for chains, balances, UTXOs
biwasxyz May 2, 2026
f2f761b
feat(okx): wire Phase 2 DEX + Wallet tools into the OKX aggregator
biwasxyz May 2, 2026
3dc9bf4
test(okx): cover HMAC signing, credential loader, and signed client
biwasxyz May 2, 2026
b236261
fix(okx): align tickers description with the actual instType enum
biwasxyz May 4, 2026
b586838
refactor(okx): make OkxCandle a 9-element tuple for positional safety
biwasxyz May 4, 2026
eb3b846
refactor(okx): single source of truth for candle bar enum
biwasxyz May 4, 2026
338a10f
fix(okx): read OKX_WEB3_BASE_URL at call time, not at module load
biwasxyz May 4, 2026
f9646b6
feat(okx): type DEX response shapes + drop double-casts on params
biwasxyz May 4, 2026
3be4b9c
feat(okx): make project_id optional via requireProjectId flag
biwasxyz May 4, 2026
a5df897
docs(okx): clarify candles limit=300 against the live server cap
biwasxyz May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/services/okx/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Low-level OKX REST client.
*
* Wraps fetch with the OKX v5 response envelope: throws OkxApiError on
* non-zero `code` or non-2xx HTTP status, returns the unwrapped `data` array
* on success.
*
* Base URL is configurable via OKX_BASE_URL env var so US users can route
* through https://us.okx.com (US-restricted), defaulting to https://www.okx.com.
*
* Phase 1 only uses public market endpoints (no auth). HMAC signing for
* DEX/Wallet/CEX private endpoints will be added in Phase 2.
*/

import type { OkxEnvelope } from "./types.js";
import { OkxApiError } from "./types.js";

const DEFAULT_BASE_URL = "https://www.okx.com";

export function getOkxBaseUrl(): string {
const env = process.env.OKX_BASE_URL?.trim();
return env && env.length > 0 ? env.replace(/\/$/, "") : DEFAULT_BASE_URL;
}

function buildQuery(params?: Record<string, string | number | undefined>): string {
if (!params) return "";
const entries = Object.entries(params).filter(
([, v]) => v !== undefined && v !== null && v !== ""
);
if (entries.length === 0) return "";
const search = new URLSearchParams();
for (const [k, v] of entries) search.append(k, String(v));
return `?${search.toString()}`;
}

export async function okxGet<T>(
path: string,
params?: Record<string, string | number | undefined>
): Promise<T[]> {
const url = `${getOkxBaseUrl()}${path}${buildQuery(params)}`;
const response = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
});

let body: OkxEnvelope<T>;
try {
body = (await response.json()) as OkxEnvelope<T>;
} catch {
throw new OkxApiError(
String(response.status),
`OKX returned non-JSON response (HTTP ${response.status})`,
response.status
);
}

if (!response.ok) {
throw new OkxApiError(
body?.code ?? String(response.status),
body?.msg || `OKX HTTP ${response.status}`,
response.status
);
}

if (body.code !== "0") {
throw new OkxApiError(body.code, body.msg || `OKX error ${body.code}`, response.status);
}

return body.data;
}
9 changes: 9 additions & 0 deletions src/services/okx/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export * from "./types.js";
export { okxGet, getOkxBaseUrl } from "./client.js";
export {
getTicker,
getTickers,
getOrderBook,
getCandles,
} from "./market.js";
export type { OkxTicker, OkxOrderBook, OkxCandle } from "./market.js";
76 changes: 76 additions & 0 deletions src/services/okx/market.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* OKX public market data endpoints.
*
* All endpoints under /api/v5/market/* are public — no API key required.
* Verified 2026-05-02 by direct curl on www.okx.com and us.okx.com.
*
* Rate limit: 20 requests / 2 seconds per IP (per OKX v5 docs).
*/

import { okxGet } from "./client.js";
import type { OkxCandleBar, OkxTickersInstType } from "./types.js";

export interface OkxTicker {
instType: string;
instId: string;
last: string;
lastSz: string;
askPx: string;
askSz: string;
bidPx: string;
bidSz: string;
open24h: string;
high24h: string;
low24h: string;
volCcy24h: string;
vol24h: string;
sodUtc0: string;
sodUtc8: string;
ts: string;
}

export interface OkxOrderBook {
asks: string[][];
bids: string[][];
ts: string;
}

export type OkxCandle = string[];

export async function getTicker(instId: string): Promise<OkxTicker | undefined> {
const data = await okxGet<OkxTicker>("/api/v5/market/ticker", { instId });
return data[0];
}

export async function getTickers(
instType: OkxTickersInstType,
uly?: string,
instFamily?: string
): Promise<OkxTicker[]> {
return okxGet<OkxTicker>("/api/v5/market/tickers", {
instType,
uly,
instFamily,
});
}

export async function getOrderBook(instId: string, sz?: number): Promise<OkxOrderBook | undefined> {
const data = await okxGet<OkxOrderBook>("/api/v5/market/books", { instId, sz });
return data[0];
}

export async function getCandles(
instId: string,
bar: OkxCandleBar = "1H",
limit = 100,
after?: string,
before?: string
): Promise<OkxCandle[]> {
return okxGet<OkxCandle>("/api/v5/market/candles", {
instId,
bar,
limit,
after,
before,
});
}
46 changes: 46 additions & 0 deletions src/services/okx/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Shared OKX REST API types.
*
* Every OKX v5 REST response follows the same envelope:
* { code: "0", msg: "", data: T[] }
* Non-zero `code` indicates an error; `data` is always an array even for
* single-instrument lookups.
*/

export interface OkxEnvelope<T> {
code: string;
msg: string;
data: T[];
}

export class OkxApiError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly status?: number
) {
super(message);
this.name = "OkxApiError";
}
}

export type OkxInstrumentType = "SPOT" | "MARGIN" | "SWAP" | "FUTURES" | "OPTION";

/**
* Subset of OkxInstrumentType accepted by `/api/v5/market/tickers`. MARGIN is
* rejected with `51000 Parameter instType error` on this endpoint specifically
* (verified live 2026-05-02), even though MARGIN is a valid instType elsewhere.
*/
export type OkxTickersInstType = "SPOT" | "SWAP" | "FUTURES" | "OPTION";

/**
* Candle intervals accepted by `/api/v5/market/candles` (verified live 2026-05-02).
* Lowercase `m` = minutes, uppercase `M` = months — case is significant.
* Note: 8H, 6M, and 1Y are NOT accepted on this endpoint (returns 51000).
* UTC-aligned variants are supported only for ≥6H bars.
*/
export type OkxCandleBar =
| "1m" | "3m" | "5m" | "15m" | "30m"
| "1H" | "2H" | "4H" | "6H" | "12H"
| "1D" | "2D" | "3D" | "1W" | "1M" | "3M"
| "6Hutc" | "12Hutc" | "1Dutc" | "1Wutc" | "1Mutc" | "3Mutc";
4 changes: 4 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { registerBountyScannerTools } from "./bounty-scanner.tools.js";
import { registerRunesTools } from "./runes.tools.js";
import { registerInboxTools } from "./inbox.tools.js";
import { registerArxivResearchTools } from "./arxiv-research.tools.js";
import { registerOkxTools } from "./okx/index.js";
import { getSkillForTool } from "./skill-mappings.js";

/**
Expand Down Expand Up @@ -212,5 +213,8 @@ export function registerAllTools(server: McpServer): void {
// arXiv Research (public arXiv Atom API — paper search and digest compilation)
registerArxivResearchTools(server);

// OKX exchange (public market data — ticker, tickers, orderbook, candles)
registerOkxTools(server);

restoreRegisterTool();
}
14 changes: 14 additions & 0 deletions src/tools/okx/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* OKX MCP tool registration.
*
* Phase 1: public market data (no API key).
* Phase 2 will add DEX Aggregator + Wallet API tools that prompt for
* credentials lazily when invoked.
*/

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerOkxMarketTools } from "./market.tools.js";

export function registerOkxTools(server: McpServer): void {
registerOkxMarketTools(server);
}
158 changes: 158 additions & 0 deletions src/tools/okx/market.tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* OKX public market data MCP tools.
*
* No API key required — these endpoints are publicly accessible on
* www.okx.com (and us.okx.com for US-restricted users). Switch host with
* the OKX_BASE_URL env var.
*/

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { createJsonResponse, createErrorResponse } from "../../utils/index.js";
import {
getCandles,
getOrderBook,
getTicker,
getTickers,
} from "../../services/okx/index.js";

// /api/v5/market/tickers does NOT accept MARGIN (verified live: returns 51000).
const TICKERS_INSTRUMENT_TYPES = ["SPOT", "SWAP", "FUTURES", "OPTION"] as const;

// Verified live (2026-05-02): 8H, 6M, 1Y are NOT accepted on /market/candles.
// Lowercase m = minutes, uppercase M = months. UTC variants only for ≥6H bars.
const CANDLE_BARS = [
"1m", "3m", "5m", "15m", "30m",
"1H", "2H", "4H", "6H", "12H",
"1D", "2D", "3D", "1W", "1M", "3M",
"6Hutc", "12Hutc", "1Dutc", "1Wutc", "1Mutc", "3Mutc",
] as const;

export function registerOkxMarketTools(server: McpServer): void {
server.registerTool(
"okx_market_ticker",
{
description:
"Get the latest ticker for a single OKX instrument (last price, 24h volume, " +
"best bid/ask). Public endpoint — no API key required. Set OKX_BASE_URL=https://us.okx.com " +
"for US-restricted accounts.",
inputSchema: {
instId: z
.string()
.describe("Instrument id, e.g. 'BTC-USDT' (spot), 'BTC-USDT-SWAP' (perp)"),
},
},
async ({ instId }) => {
try {
const ticker = await getTicker(instId);
if (!ticker) {
return createErrorResponse(new Error(`No ticker found for ${instId}`));
}
return createJsonResponse(ticker);
} catch (error) {
return createErrorResponse(error);
}
}
);

server.registerTool(
"okx_market_tickers",
{
description:
"List tickers for all instruments of a given type (SPOT/MARGIN/SWAP/FUTURES/OPTION). " +
"Public endpoint — no API key required.",
inputSchema: {
instType: z
.enum(TICKERS_INSTRUMENT_TYPES)
.describe("Instrument type to list (MARGIN not supported on this endpoint)"),
uly: z
.string()
.optional()
.describe("Underlying, only for FUTURES/SWAP/OPTION (e.g. 'BTC-USD')"),
instFamily: z
.string()
.optional()
.describe("Instrument family, only for FUTURES/SWAP/OPTION"),
},
},
async ({ instType, uly, instFamily }) => {
try {
const data = await getTickers(instType, uly, instFamily);
return createJsonResponse(data);
} catch (error) {
return createErrorResponse(error);
}
}
);

server.registerTool(
"okx_market_orderbook",
{
description:
"Get the order book snapshot for an OKX instrument. Public endpoint — no API key required.",
inputSchema: {
instId: z.string().describe("Instrument id, e.g. 'BTC-USDT'"),
sz: z
.number()
.int()
.min(1)
.max(400)
.optional()
.describe("Depth per side (1-400, default 1)"),
},
},
async ({ instId, sz }) => {
try {
const book = await getOrderBook(instId, sz);
if (!book) {
return createErrorResponse(new Error(`No orderbook found for ${instId}`));
}
return createJsonResponse(book);
} catch (error) {
return createErrorResponse(error);
}
}
);

server.registerTool(
"okx_market_candles",
{
description:
"Get candlestick (OHLCV) data for an OKX instrument. Each candle is " +
"[ts, open, high, low, close, vol, volCcy, volCcyQuote, confirm]. Public endpoint — " +
"no API key required.",
inputSchema: {
instId: z.string().describe("Instrument id, e.g. 'BTC-USDT'"),
bar: z
.enum(CANDLE_BARS)
.optional()
.default("1H")
.describe("Candle interval (default '1H')"),
limit: z
.number()
.int()
.min(1)
.max(300)
.optional()
.default(100)
.describe("Number of candles (1-300, default 100)"),
after: z
.string()
.optional()
.describe("Pagination: return records earlier than this timestamp (ms)"),
before: z
.string()
.optional()
.describe("Pagination: return records newer than this timestamp (ms)"),
},
},
async ({ instId, bar, limit, after, before }) => {
try {
const data = await getCandles(instId, bar, limit, after, before);
return createJsonResponse(data);
} catch (error) {
return createErrorResponse(error);
}
}
);
}
Loading
Loading