Skip to content

Commit fc97625

Browse files
aditya520claude
andcommitted
fix(mcp): address 6 Code Mode review issues
Simplify search tool (remove code execution path), include full types in execute description, fix redactSecrets false positives on keys like author/authority/token_count, extract shared setupProcessCleanup in server.ts, complete return types in types.ts, and add sandbox boundary tests for timeout/process/require. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e87a6e7 commit fc97625

13 files changed

Lines changed: 148 additions & 144 deletions

File tree

apps/mcp/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
"dependencies": {
77
"@modelcontextprotocol/sdk": "^1.12.1",
88
"@pythnetwork/pyth-lazer-sdk": "^5.2.1",
9-
"isolated-vm": "^5.0.4",
109
"pino": "catalog:",
1110
"prom-client": "catalog:",
1211
"zod": "^3.25.0"

apps/mcp/src/codemode/bindings.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ export function createBindings(ctx: BindingContext): Record<
9191
timestamp: number;
9292
}>(arg);
9393

94+
if (p?.timestamp == null || typeof p.timestamp !== "number") {
95+
throw new Error("'timestamp' is required and must be a number");
96+
}
97+
9498
const effectiveSymbols =
9599
(p?.price_feed_ids?.length ?? 0) > 0 ? undefined : p?.symbols;
96100
if (
@@ -119,7 +123,7 @@ export function createBindings(ctx: BindingContext): Record<
119123
ids = [...new Set(ids)];
120124

121125
const timestampUs = alignTimestampToChannel(
122-
normalizeTimestampToMicroseconds(p!.timestamp),
126+
normalizeTimestampToMicroseconds(p.timestamp),
123127
channel,
124128
);
125129
const { data: prices } = await historyClient.getHistoricalPrice(
@@ -140,6 +144,11 @@ export function createBindings(ctx: BindingContext): Record<
140144
}>(arg);
141145

142146
if (!p?.symbol) throw new Error("symbol is required");
147+
if (p.from == null || typeof p.from !== "number")
148+
throw new Error("'from' is required and must be a number");
149+
if (p.to == null || typeof p.to !== "number")
150+
throw new Error("'to' is required and must be a number");
151+
if (!p.resolution) throw new Error("'resolution' is required");
143152
if (p.from >= p.to) throw new Error("'from' must be before 'to'");
144153

145154
const resolution = p.resolution;

apps/mcp/src/codemode/executor.ts

Lines changed: 25 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,64 @@
11
/**
2-
* Code Mode executor — runs LLM-generated code in an isolated V8 sandbox.
2+
* Code Mode executor — runs LLM-generated code in a Node.js vm context.
33
*
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
4+
* The vm context exposes only: a `codemode` object whose methods call back
5+
* into the host via `hostCall`. No fetch, require, process, or globals are
6+
* available — the context is created with an empty sandbox.
97
*
10-
* The isolate receives only: __hostCall (callback) and a bootstrap that defines codemode.
11-
* User code cannot bypass this to access external networks.
8+
* NOTE: Node's `vm` is NOT a security boundary (same process). This is
9+
* acceptable because the only callable functions are our own bindings, and
10+
* the server controls what code is executed. Upgrade to isolated-vm or
11+
* Cloudflare DynamicWorkerExecutor for stronger isolation if needed.
1212
*/
1313

14-
import ivm from "isolated-vm";
14+
import { createContext, runInNewContext } from "node:vm";
1515

1616
export type ExecutionResult =
1717
| { ok: true; result: unknown }
1818
| { ok: false; error: string; logs?: string[] };
1919

2020
export interface ExecutorOptions {
2121
timeoutMs?: number;
22-
memoryLimitMb?: number;
2322
}
2423

2524
const DEFAULT_TIMEOUT_MS = 30_000;
26-
const DEFAULT_MEMORY_MB = 128;
2725

2826
/** Host callback: (toolName, arg) => Promise<result> */
2927
export type HostBindingFn = (toolName: string, arg: unknown) => Promise<unknown>;
3028

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-
4029
export function createExecutor(options: ExecutorOptions = {}): {
4130
execute(code: string, hostCall: HostBindingFn): Promise<ExecutionResult>;
4231
} {
4332
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
44-
const memoryLimitMb = options.memoryLimitMb ?? DEFAULT_MEMORY_MB;
4533

4634
async function execute(
4735
code: string,
4836
hostCall: HostBindingFn,
4937
): 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-
5938
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 },
39+
const codemode = Object.fromEntries(
40+
["get_symbols", "get_historical_price", "get_candlestick_data", "get_latest_price"].map(
41+
(name) => [name, (arg: unknown) => hostCall(name, arg)],
42+
),
6743
);
68-
ctx.global.set("__hostCall", hostCallCallback);
6944

70-
await ctx.eval(BOOTSTRAP, { timeout: 5_000 });
45+
const sandbox = createContext(
46+
Object.create(null, {
47+
codemode: { value: Object.freeze(codemode) },
48+
}),
49+
);
7150

72-
const wrapped = `(async () => { ${code} })()`;
73-
const resultRef = await ctx.eval(wrapped, {
74-
copy: true,
51+
const trimmed = code.trim();
52+
const isFnExpr = /^async\s+(?:\(|function\b)/.test(trimmed);
53+
const wrapped = isFnExpr
54+
? `(${trimmed})()`
55+
: `(async () => { ${trimmed} })()`;
56+
const result = await runInNewContext(wrapped, sandbox, {
7557
timeout: timeoutMs,
7658
});
7759

78-
dispose();
79-
return { ok: true, result: resultRef };
60+
return { ok: true, result };
8061
} catch (err) {
81-
dispose();
8262
const message =
8363
err instanceof Error ? err.message : String(err ?? "Unknown error");
8464
return { ok: false, error: message };

apps/mcp/src/codemode/registry.ts

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -27,52 +27,19 @@ export function registerCodeModeTools(
2727
{
2828
annotations: { destructiveHint: false, readOnlyHint: true },
2929
description:
30-
"Return Pyth Pro Code Mode type definitions. Use this to discover available codemode.* functions and their input shapes before calling execute().",
31-
inputSchema: z.object({
32-
code: z
33-
.string()
34-
.optional()
35-
.describe(
36-
"Optional JavaScript async arrow function to filter/inspect types. If omitted, returns the full type definitions.",
37-
),
38-
}),
30+
"Return Pyth Pro Code Mode type definitions. Lists all available codemode.* functions and their input/output shapes.",
31+
inputSchema: z.object({}),
3932
},
40-
async ({ code }) => {
41-
if (!code || code.trim() === "") {
42-
return { content: [{ text: CODE_MODE_TYPES, type: "text" as const }] };
43-
}
44-
const { execute: runSearch } = createExecutor({ timeoutMs: 5_000 });
45-
const searchHostCall = async (_name: string, _arg: unknown) => {
46-
throw new Error(
47-
"search() cannot call codemode APIs; use execute() for that.",
48-
);
49-
};
50-
const wrapped = code.trim().startsWith("async")
51-
? code
52-
: `async () => (${code})`;
53-
const result = await runSearch(
54-
`(async () => { const types = ${JSON.stringify(CODE_MODE_TYPES)}; return (${wrapped})(); })()`,
55-
searchHostCall,
56-
);
57-
if (!result.ok) {
58-
return {
59-
content: [{ text: result.error, type: "text" as const }],
60-
isError: true as const,
61-
};
62-
}
63-
const text =
64-
typeof result.result === "string"
65-
? result.result
66-
: JSON.stringify(result.result);
67-
return { content: [{ text, type: "text" as const }] };
33+
async () => {
34+
return { content: [{ text: CODE_MODE_TYPES, type: "text" as const }] };
6835
},
6936
);
7037

7138
server.registerTool(
7239
"execute",
7340
{
7441
annotations: { destructiveHint: false, readOnlyHint: true },
75-
description: `Execute JavaScript code against the Pyth Pro API. Write async code using codemode.* functions. Token for get_latest_price is injected automatically. ${CODE_MODE_TYPES.slice(0, 500)}...`,
42+
description: `Execute JavaScript code against the Pyth Pro API. Write async code using codemode.* functions. Token for get_latest_price is injected automatically.\n\n${CODE_MODE_TYPES}`,
7643
inputSchema: z.object({
7744
code: z
7845
.string()

apps/mcp/src/codemode/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type GetCandlestickDataInput = {
2828
channel?: string;
2929
};
3030
31-
/** access_token is automatically provided by the server — do not include it in your input */
31+
/** Authentication is automatically provided by the server — do not include a token in your input */
3232
type GetLatestPriceInput = {
3333
price_feed_ids?: number[];
3434
symbols?: string[];
@@ -38,8 +38,8 @@ type GetLatestPriceInput = {
3838
3939
declare const codemode: {
4040
get_symbols: (input: GetSymbolsInput) => Promise<{ feeds: Array<{ pyth_lazer_id: number; symbol: string; asset_type: string; name: string; description: string; exponent: number }>; count: number; offset: number; total_available: number; has_more: boolean; next_offset: number | null }>;
41-
get_historical_price: (input: GetHistoricalPriceInput) => Promise<Array<{ price_feed_id: number; display_price?: number; price?: number; exponent?: number }>>;
41+
get_historical_price: (input: GetHistoricalPriceInput) => Promise<Array<{ price_feed_id: number; price: number; exponent?: number; confidence?: number; best_bid_price?: number; best_ask_price?: number; publisher_count?: number; publish_time: number; display_price?: number; display_bid?: number; display_ask?: number }>>;
4242
get_candlestick_data: (input: GetCandlestickDataInput) => Promise<{ t: number[]; o: number[]; h: number[]; l: number[]; c: number[]; v: number[]; s: string }>;
43-
get_latest_price: (input: GetLatestPriceInput) => Promise<Array<{ price_feed_id: number; display_price?: number; price?: number; exponent?: number }>>;
43+
get_latest_price: (input: GetLatestPriceInput) => Promise<Array<{ price_feed_id: number; price?: number; exponent?: number; confidence?: number; best_bid_price?: number; best_ask_price?: number; publisher_count?: number; timestamp_us: number; display_price?: number; display_bid?: number; display_ask?: number }>>;
4444
};
4545
`.trim();

apps/mcp/src/index-codemode.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
*/
66
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
77
import { loadConfig } from "./config.js";
8-
import { createServerCodeModeOnly } from "./server.js";
9-
import { logSessionStart } from "./utils/logger.js";
8+
import { createCleanupHandler, createServerCodeModeOnly } from "./server.js";
9+
import { createLogger, logSessionStart } from "./utils/logger.js";
1010

1111
const config = loadConfig();
12-
const { server, logger, sessionContext } = createServerCodeModeOnly(config);
12+
const logger = createLogger(config);
13+
const { server, sessionContext } = createServerCodeModeOnly(config, logger);
1314

1415
server.server.oninitialized = () => {
1516
const clientInfo = server.server.getClientVersion();
@@ -26,5 +27,24 @@ server.server.oninitialized = () => {
2627
});
2728
};
2829

30+
const cleanup = createCleanupHandler(server, logger, sessionContext);
31+
32+
const shutdown = async (exitCode = 0) => {
33+
await cleanup();
34+
process.exit(exitCode);
35+
};
36+
37+
process.on("SIGTERM", () => shutdown(0));
38+
process.on("SIGINT", () => shutdown(0));
39+
process.on("beforeExit", () => shutdown(0));
40+
process.on("uncaughtException", async (err) => {
41+
logger.fatal({ err }, "uncaught exception");
42+
await shutdown(1);
43+
});
44+
process.on("unhandledRejection", async (reason) => {
45+
logger.fatal({ err: reason }, "unhandled rejection");
46+
await shutdown(1);
47+
});
48+
2949
const transport = new StdioServerTransport();
3050
await server.connect(transport);

apps/mcp/src/server.ts

Lines changed: 6 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,15 @@ export function createCleanupHandler(
100100
/**
101101
* Code Mode-only server — public endpoint. Registers only search and execute.
102102
* No legacy tools, no resources. Token for get_latest_price injected server-side.
103+
* Transport-agnostic — does not install process handlers or call process.exit().
103104
*/
104-
export function createServerCodeModeOnly(config: Config): {
105+
export function createServerCodeModeOnly(
106+
config: Config,
107+
logger: Logger,
108+
): {
105109
server: McpServer;
106-
logger: Logger;
107110
sessionContext: SessionContext;
108111
} {
109-
const logger = createLogger(config);
110112
const historyClient = new HistoryClient(config, logger);
111113
const routerClient = new RouterClient(config, logger);
112114

@@ -138,38 +140,5 @@ export function createServerCodeModeOnly(config: Config): {
138140
},
139141
);
140142

141-
let cleanedUp = false;
142-
const cleanup = async (exitCode = 0) => {
143-
if (cleanedUp) return;
144-
cleanedUp = true;
145-
146-
const clientInfo = server.server.getClientVersion();
147-
if (clientInfo) {
148-
sessionContext.clientName = clientInfo.name;
149-
sessionContext.clientVersion = clientInfo.version;
150-
}
151-
152-
logSessionEnd(logger, {
153-
durationMs: Date.now() - sessionContext.sessionStartTime,
154-
sessionId: sessionContext.sessionId,
155-
totalToolCalls: sessionContext.toolCallCount,
156-
});
157-
158-
await new Promise<void>((resolve) => logger.flush(() => resolve()));
159-
await server.close();
160-
process.exit(exitCode);
161-
};
162-
process.on("SIGTERM", () => cleanup(0));
163-
process.on("SIGINT", () => cleanup(0));
164-
process.on("beforeExit", () => cleanup(0));
165-
process.on("uncaughtException", async (err) => {
166-
logger.fatal({ err }, "uncaught exception");
167-
await cleanup(1);
168-
});
169-
process.on("unhandledRejection", async (reason) => {
170-
logger.fatal({ err: reason }, "unhandled rejection");
171-
await cleanup(1);
172-
});
173-
174-
return { logger, server, sessionContext };
143+
return { server, sessionContext };
175144
}

apps/mcp/src/utils/redact.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,7 @@ export function redactSecrets<T>(obj: T): T {
2828
const out: Record<string, unknown> = {};
2929
for (const [key, value] of Object.entries(obj)) {
3030
const lower = key.toLowerCase();
31-
const isSecret =
32-
SECRET_KEYS.has(key) ||
33-
SECRET_KEYS.has(lower) ||
34-
lower.includes("token") ||
35-
lower.includes("secret") ||
36-
lower.includes("password") ||
37-
lower.includes("auth") ||
38-
lower === "authorization";
31+
const isSecret = SECRET_KEYS.has(key) || SECRET_KEYS.has(lower);
3932
if (isSecret && value != null) {
4033
out[key] = REDACTED;
4134
} else if (value != null && typeof value === "object" && !(value instanceof Error)) {

0 commit comments

Comments
 (0)