Skip to content

Commit 476a48d

Browse files
committed
feat(mcp): implement Code Mode with search and execute tools
- Add codemode executor (isolated-vm, 30s timeout, no network) - Add codemode bindings: search, execute, get_latest_price (token-injected) - Add createServerCodeModeOnly and pyth-mcp-codemode entrypoint - Add pythProAccessToken config and PYTH_PRO_ACCESS_TOKEN env - Add redact utils and logger serializers for token/sensitive data - Add unit, integration, and security tests for Code Mode Made-with: Cursor
1 parent ba24aa7 commit 476a48d

16 files changed

Lines changed: 1155 additions & 4 deletions

File tree

apps/mcp/README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,22 @@
22

33
MCP server that exposes Pyth Pro real-time and historical market data to AI assistants.
44

5-
## Tools
5+
## Modes
6+
7+
### Legacy (stdio) — `pyth-mcp`
8+
9+
Exposes 4 tools: `get_symbols`, `get_historical_price`, `get_candlestick_data`, `get_latest_price`. Use for local Claude Desktop / Claude Code. Token passed as tool parameter.
10+
11+
### Code Mode only (stdio) — `pyth-mcp-codemode`
12+
13+
Exposes 2 tools: `search`, `execute`. LLM writes async code against `codemode.*` functions. Token injected server-side — never exposed to the model. Use for hosted/public endpoint.
14+
15+
```sh
16+
# Run Code Mode (requires PYTH_PRO_ACCESS_TOKEN for get_latest_price)
17+
PYTH_PRO_ACCESS_TOKEN=<token> node apps/mcp/dist/index-codemode.js
18+
```
19+
20+
## Tools (Legacy mode)
621

722
| Tool | Description |
823
|------|-------------|
@@ -11,6 +26,8 @@ MCP server that exposes Pyth Pro real-time and historical market data to AI assi
1126
| `get_historical_price` | Point-in-time price snapshots |
1227
| `get_candlestick_data` | OHLC candlestick bars |
1328

29+
Code Mode exposes these via `codemode.get_symbols`, `codemode.get_latest_price`, etc. inside `execute()`.
30+
1431
## Setup
1532

1633
```sh
@@ -51,7 +68,7 @@ The access token is only needed for `get_latest_price`. All other tools work wit
5168

5269
| Variable | Default | Description |
5370
|----------|---------|-------------|
54-
| `PYTH_PRO_ACCESS_TOKEN` || Bearer token for Router API |
71+
| `PYTH_PRO_ACCESS_TOKEN` || Bearer token for Router API. Required for Code Mode `get_latest_price`; injected server-side |
5572
| `PYTH_CHANNEL` | `fixed_rate@200ms` | Default price channel |
5673
| `PYTH_LOG_LEVEL` | `info` | Log level (debug/info/warn/error) |
5774
| `PYTH_REQUEST_TIMEOUT_MS` | `10000` | HTTP request timeout |

apps/mcp/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
{
22
"bin": {
3-
"pyth-mcp": "./dist/index.js"
3+
"pyth-mcp": "./dist/index.js",
4+
"pyth-mcp-codemode": "./dist/index-codemode.js"
45
},
56
"dependencies": {
67
"@modelcontextprotocol/sdk": "^1.12.1",
78
"@pythnetwork/pyth-lazer-sdk": "^5.2.1",
9+
"isolated-vm": "^5.0.4",
810
"pino": "catalog:",
911
"zod": "^3.25.0"
1012
},
@@ -51,7 +53,7 @@
5153
"url": "https://github.com/pyth-network/pyth-crosschain"
5254
},
5355
"scripts": {
54-
"build": "tsup src/index.ts --format esm --dts --clean",
56+
"build": "tsup src/index.ts src/index-codemode.ts --format esm --dts --clean",
5557
"clean": "rm -rf dist/",
5658
"prepublishOnly": "pnpm run build",
5759
"start": "node dist/index.js",

apps/mcp/src/codemode/bindings.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* Code Mode bindings — maps codemode.* calls to Pyth API handlers.
3+
* get_latest_price receives server-injected token; model never sees it.
4+
*/
5+
6+
import type { HistoryClient } from "../clients/history.js";
7+
import type { RouterClient } from "../clients/router.js";
8+
import type { Config } from "../config.js";
9+
import { ASSET_TYPES, RESOLUTIONS } from "../constants.js";
10+
import type { Logger } from "pino";
11+
import { resolveChannel } from "../utils/channel.js";
12+
import { addDisplayPrices } from "../utils/display-price.js";
13+
import {
14+
alignTimestampToChannel,
15+
normalizeTimestampToMicroseconds,
16+
} from "../utils/timestamp.js";
17+
18+
export type BindingContext = {
19+
config: Config;
20+
historyClient: HistoryClient;
21+
routerClient: RouterClient;
22+
logger: Logger;
23+
/** Server-managed token for get_latest_price. Injected server-side only. */
24+
serverToken: string | undefined;
25+
};
26+
27+
/** Copy out isolate-held values (handles ivm.Reference) */
28+
function unwrapArg<T>(arg: unknown): T {
29+
if (arg == null) return arg as T;
30+
const ref = arg as { copy?: () => T };
31+
if (typeof ref.copy === "function") return ref.copy() as T;
32+
return arg as T;
33+
}
34+
35+
export function createBindings(ctx: BindingContext): Record<
36+
string,
37+
(arg: unknown) => Promise<unknown>
38+
> {
39+
const { config, historyClient, routerClient, logger, serverToken } = ctx;
40+
41+
return {
42+
async get_symbols(arg: unknown) {
43+
const p = unwrapArg<{
44+
query?: string;
45+
asset_type?: string;
46+
limit?: number;
47+
offset?: number;
48+
}>(arg);
49+
const asset_type = p?.asset_type;
50+
const limit = Math.min(
51+
200,
52+
Math.max(1, p?.limit ?? 50),
53+
);
54+
const offset = Math.max(0, p?.offset ?? 0);
55+
56+
const { data: feeds } = await historyClient.getSymbols(
57+
undefined,
58+
asset_type && ASSET_TYPES.includes(asset_type) ? asset_type : undefined,
59+
);
60+
61+
let filtered = feeds;
62+
const q = (p?.query ?? "").trim().toLowerCase();
63+
if (q) {
64+
filtered = feeds.filter(
65+
(f) =>
66+
f.name.toLowerCase().includes(q) ||
67+
f.symbol.toLowerCase().includes(q) ||
68+
f.description.toLowerCase().includes(q),
69+
);
70+
}
71+
72+
const totalAvailable = filtered.length;
73+
const page = filtered.slice(offset, offset + limit);
74+
const hasMore = offset + limit < totalAvailable;
75+
76+
return {
77+
count: page.length,
78+
feeds: page,
79+
has_more: hasMore,
80+
next_offset: hasMore ? offset + limit : null,
81+
offset,
82+
total_available: totalAvailable,
83+
};
84+
},
85+
86+
async get_historical_price(arg: unknown) {
87+
const p = unwrapArg<{
88+
channel?: string;
89+
price_feed_ids?: number[];
90+
symbols?: string[];
91+
timestamp: number;
92+
}>(arg);
93+
94+
const effectiveSymbols =
95+
(p?.price_feed_ids?.length ?? 0) > 0 ? undefined : p?.symbols;
96+
if (
97+
!(p?.price_feed_ids?.length ?? 0) &&
98+
!(effectiveSymbols?.length ?? 0)
99+
) {
100+
throw new Error(
101+
"At least one of 'price_feed_ids' or 'symbols' is required",
102+
);
103+
}
104+
105+
const channel = resolveChannel(p?.channel, config);
106+
let ids: number[] = p?.price_feed_ids ? [...p.price_feed_ids] : [];
107+
108+
if ((effectiveSymbols?.length ?? 0) > 0) {
109+
const { data: allFeeds } = await historyClient.getSymbols();
110+
for (const symbol of effectiveSymbols ?? []) {
111+
const feed = allFeeds.find((f) => f.symbol === symbol);
112+
if (!feed)
113+
throw new Error(
114+
`Feed not found: ${symbol}. Use get_symbols to discover available feeds.`,
115+
);
116+
ids.push(feed.pyth_lazer_id);
117+
}
118+
}
119+
ids = [...new Set(ids)];
120+
121+
const timestampUs = alignTimestampToChannel(
122+
normalizeTimestampToMicroseconds(p!.timestamp),
123+
channel,
124+
);
125+
const { data: prices } = await historyClient.getHistoricalPrice(
126+
channel,
127+
ids,
128+
timestampUs,
129+
);
130+
return prices.map((price) => addDisplayPrices(price));
131+
},
132+
133+
async get_candlestick_data(arg: unknown) {
134+
const p = unwrapArg<{
135+
channel?: string;
136+
from: number;
137+
to: number;
138+
resolution: string;
139+
symbol: string;
140+
}>(arg);
141+
142+
if (!p?.symbol) throw new Error("symbol is required");
143+
if (p.from >= p.to) throw new Error("'from' must be before 'to'");
144+
145+
const resolution = p.resolution;
146+
if (!RESOLUTIONS.includes(resolution as (typeof RESOLUTIONS)[number])) {
147+
throw new Error(
148+
`Invalid resolution. Valid: ${RESOLUTIONS.join(", ")}`,
149+
);
150+
}
151+
152+
const channel = resolveChannel(p.channel, config);
153+
const { data } = await historyClient.getCandlestickData(
154+
channel,
155+
p.symbol,
156+
resolution,
157+
p.from,
158+
p.to,
159+
);
160+
161+
if (data.s === "no_data")
162+
throw new Error(
163+
"No candlestick data available for the requested range. Try a different time range or symbol.",
164+
);
165+
if (data.s === "error")
166+
throw new Error(data.errmsg ?? "Unknown error from Pyth History API");
167+
168+
return data;
169+
},
170+
171+
async get_latest_price(arg: unknown) {
172+
const p = unwrapArg<{
173+
channel?: string;
174+
price_feed_ids?: number[];
175+
properties?: string[];
176+
symbols?: string[];
177+
}>(arg);
178+
179+
if (!serverToken) {
180+
throw new Error(
181+
"Server is not configured with a Pyth Pro access token. get_latest_price is unavailable.",
182+
);
183+
}
184+
185+
const effectiveSymbols =
186+
(p?.price_feed_ids?.length ?? 0) > 0 ? undefined : p?.symbols;
187+
const effectiveCount =
188+
(effectiveSymbols?.length ?? 0) + (p?.price_feed_ids?.length ?? 0);
189+
190+
if (effectiveCount === 0) {
191+
throw new Error(
192+
"At least one of 'symbols' or 'price_feed_ids' is required",
193+
);
194+
}
195+
if (effectiveCount > 100) {
196+
throw new Error(
197+
"Combined total of symbols and price_feed_ids must not exceed 100",
198+
);
199+
}
200+
201+
const channel = resolveChannel(p?.channel, config);
202+
const { data: feeds } = await routerClient.getLatestPrice(
203+
serverToken,
204+
effectiveSymbols,
205+
p?.price_feed_ids,
206+
p?.properties,
207+
channel,
208+
);
209+
return feeds.map((f) => addDisplayPrices(f));
210+
},
211+
};
212+
}

apps/mcp/src/codemode/executor.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Code Mode executor — runs LLM-generated code in an isolated V8 sandbox.
3+
*
4+
* Deny-by-default outbound policy:
5+
* - No fetch(), no XMLHttpRequest, no WebSocket — isolate has no network APIs
6+
* - No require(), no import — no module loading
7+
* - No process, no global — no env var leaks
8+
* - Only codemode.* calls are allowed via host callback; those route through approved bindings
9+
*
10+
* The isolate receives only: __hostCall (callback) and a bootstrap that defines codemode.
11+
* User code cannot bypass this to access external networks.
12+
*/
13+
14+
import ivm from "isolated-vm";
15+
16+
export type ExecutionResult =
17+
| { ok: true; result: unknown }
18+
| { ok: false; error: string; logs?: string[] };
19+
20+
export interface ExecutorOptions {
21+
timeoutMs?: number;
22+
memoryLimitMb?: number;
23+
}
24+
25+
const DEFAULT_TIMEOUT_MS = 30_000;
26+
const DEFAULT_MEMORY_MB = 128;
27+
28+
/** Host callback: (toolName, arg) => Promise<result> */
29+
export type HostBindingFn = (toolName: string, arg: unknown) => Promise<unknown>;
30+
31+
const BOOTSTRAP = `
32+
const codemode = {
33+
get_symbols: (arg) => __hostCall('get_symbols', arg),
34+
get_historical_price: (arg) => __hostCall('get_historical_price', arg),
35+
get_candlestick_data: (arg) => __hostCall('get_candlestick_data', arg),
36+
get_latest_price: (arg) => __hostCall('get_latest_price', arg),
37+
};
38+
`.trim();
39+
40+
export function createExecutor(options: ExecutorOptions = {}): {
41+
execute(code: string, hostCall: HostBindingFn): Promise<ExecutionResult>;
42+
} {
43+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
44+
const memoryLimitMb = options.memoryLimitMb ?? DEFAULT_MEMORY_MB;
45+
46+
async function execute(
47+
code: string,
48+
hostCall: HostBindingFn,
49+
): Promise<ExecutionResult> {
50+
const isolate = new ivm.Isolate({ memoryLimit: memoryLimitMb });
51+
let disposed = false;
52+
const dispose = () => {
53+
if (!disposed) {
54+
disposed = true;
55+
isolate.dispose();
56+
}
57+
};
58+
59+
try {
60+
const ctx = await isolate.createContext();
61+
62+
const hostCallCallback = new ivm.Callback(
63+
async (name: string, arg: unknown): Promise<unknown> => {
64+
return hostCall(name, arg);
65+
},
66+
{ async: true },
67+
);
68+
ctx.global.set("__hostCall", hostCallCallback);
69+
70+
await ctx.eval(BOOTSTRAP, { timeout: 5_000 });
71+
72+
const wrapped = `(async () => { ${code} })()`;
73+
const resultRef = await ctx.eval(wrapped, {
74+
copy: true,
75+
timeout: timeoutMs,
76+
});
77+
78+
dispose();
79+
return { ok: true, result: resultRef };
80+
} catch (err) {
81+
dispose();
82+
const message =
83+
err instanceof Error ? err.message : String(err ?? "Unknown error");
84+
return { ok: false, error: message };
85+
}
86+
}
87+
88+
return { execute };
89+
}

0 commit comments

Comments
 (0)