cloudflare/cloudflare-typescript

Public

mirrored fromhttps://github.com/cloudflare/cloudflare-typescriptAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v7

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

packages/mcp-server/src/code-tool.ts

402lines · modecode

1// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
2
3import {
4 ContentBlock,
5 McpRequestContext,
6 McpTool,
7 Metadata,
8 ToolCallResult,
9 asErrorResult,
10 asTextContentResult,
11} from './types';
12import { Tool } from '@modelcontextprotocol/sdk/types.js';
13import { readEnv } from './util';
14import { WorkerInput, WorkerOutput } from './code-tool-types';
15import { getLogger } from './logger';
16import { SdkMethod } from './methods';
17import { McpCodeExecutionMode } from './options';
18import { ClientOptions } from 'cloudflare';
19
20const prompt = `Runs JavaScript code to interact with the Cloudflare API.
21
22You are a skilled TypeScript programmer writing code to interface with the service.
23Define an async function named "run" that takes a single parameter of an initialized SDK client and it will be run.
24For example:
25
26\`\`\`
27async function run(client) {
28 const zone = await client.zones.create({
29 account: { id: '023e105f4ecef8ad9ca31a8372d0c353' },
30 name: 'example.com',
31 type: 'full',
32 });
33
34 console.log(zone.id);
35}
36\`\`\`
37
38You will be returned anything that your function returns, plus the results of any console.log statements.
39Do not add try-catch blocks for single API calls. The tool will handle errors for you.
40Do not add comments unless necessary for generating better code.
41Code will run in a container, and cannot interact with the network outside of the given SDK client.
42Variables will not persist between calls, so make sure to return or log any data you might need later.
43Remember that you are writing TypeScript code, so you need to be careful with your types.
44Always type dynamic key-value stores explicitly as Record<string, YourValueType> instead of {}.`;
45
46/**
47 * A tool that runs code against a copy of the SDK.
48 *
49 * Instead of exposing every endpoint as its own tool, which uses up too many tokens for LLMs to use at once,
50 * we expose a single tool that can be used to search for endpoints by name, resource, operation, or tag, and then
51 * a generic endpoint that can be used to invoke any endpoint with the provided arguments.
52 *
53 * @param blockedMethods - The methods to block for code execution. Blocking is done by simple string
54 * matching, so it is not secure against obfuscation. For stronger security, block in the downstream API
55 * with limited API keys.
56 * @param codeExecutionMode - Whether to execute code in a local Deno environment or in a remote
57 * sandbox environment hosted by Stainless.
58 */
59export function codeTool({
60 blockedMethods,
61 codeExecutionMode,
62}: {
63 blockedMethods: SdkMethod[] | undefined;
64 codeExecutionMode: McpCodeExecutionMode;
65}): McpTool {
66 const metadata: Metadata = { resource: 'all', operation: 'write', tags: [] };
67 const tool: Tool = {
68 name: 'execute',
69 description: prompt,
70 inputSchema: {
71 type: 'object',
72 properties: {
73 code: {
74 type: 'string',
75 description: 'Code to execute.',
76 },
77 intent: {
78 type: 'string',
79 description: 'Task you are trying to perform. Used for improving the service.',
80 },
81 },
82 required: ['code'],
83 },
84 };
85
86 const logger = getLogger();
87
88 const handler = async ({
89 reqContext,
90 args,
91 }: {
92 reqContext: McpRequestContext;
93 args: any;
94 }): Promise<ToolCallResult> => {
95 const code = args.code as string;
96 // Do very basic blocking of code that includes forbidden method names.
97 //
98 // WARNING: This is not secure against obfuscation and other evasion methods. If
99 // stronger security blocks are required, then these should be enforced in the downstream
100 // API (e.g., by having users call the MCP server with API keys with limited permissions).
101 if (blockedMethods) {
102 const blockedMatches = blockedMethods.filter((method) => code.includes(method.fullyQualifiedName));
103 if (blockedMatches.length > 0) {
104 return asErrorResult(
105 `The following methods have been blocked by the MCP server and cannot be used in code execution: ${blockedMatches
106 .map((m) => m.fullyQualifiedName)
107 .join(', ')}`,
108 );
109 }
110 }
111
112 let result: ToolCallResult;
113 const startTime = Date.now();
114
115 if (codeExecutionMode === 'local') {
116 logger.debug('Executing code in local Deno environment');
117 result = await localDenoHandler({ reqContext, args });
118 } else {
119 logger.debug('Executing code in remote Stainless environment');
120 result = await remoteStainlessHandler({ reqContext, args });
121 }
122
123 logger.info(
124 {
125 codeExecutionMode,
126 durationMs: Date.now() - startTime,
127 isError: result.isError,
128 contentRows: result.content?.length ?? 0,
129 },
130 'Got code tool execution result',
131 );
132 return result;
133 };
134
135 return { metadata, tool, handler };
136}
137
138const remoteStainlessHandler = async ({
139 reqContext,
140 args,
141}: {
142 reqContext: McpRequestContext;
143 args: any;
144}): Promise<ToolCallResult> => {
145 const code = args.code as string;
146 const intent = args.intent as string | undefined;
147 const client = reqContext.client;
148
149 const codeModeEndpoint = readEnv('CODE_MODE_ENDPOINT_URL') ?? 'https://api.stainless.com/api/ai/code-tool';
150
151 const localClientEnvs = {
152 CLOUDFLARE_API_TOKEN: readEnv('CLOUDFLARE_API_TOKEN') ?? client.apiToken ?? undefined,
153 CLOUDFLARE_API_KEY: readEnv('CLOUDFLARE_API_KEY') ?? client.apiKey ?? undefined,
154 CLOUDFLARE_EMAIL: readEnv('CLOUDFLARE_EMAIL') ?? client.apiEmail ?? undefined,
155 CLOUDFLARE_API_USER_SERVICE_KEY:
156 readEnv('CLOUDFLARE_API_USER_SERVICE_KEY') ?? client.userServiceKey ?? undefined,
157 CLOUDFLARE_BASE_URL: readEnv('CLOUDFLARE_BASE_URL') ?? client.baseURL ?? undefined,
158 };
159 // Merge any upstream client envs from the request header, with upstream values taking precedence.
160 const mergedClientEnvs = { ...localClientEnvs, ...reqContext.upstreamClientEnvs };
161
162 // Setting a Stainless API key authenticates requests to the code tool endpoint.
163 const res = await fetch(codeModeEndpoint, {
164 method: 'POST',
165 headers: {
166 ...(reqContext.stainlessApiKey && { Authorization: reqContext.stainlessApiKey }),
167 'Content-Type': 'application/json',
168 'x-stainless-mcp-client-envs': JSON.stringify(mergedClientEnvs),
169 },
170 body: JSON.stringify({
171 project_name: 'cloudflare',
172 code,
173 intent,
174 client_opts: {},
175 } satisfies WorkerInput),
176 });
177
178 if (!res.ok) {
179 if (res.status === 404 && !reqContext.stainlessApiKey) {
180 throw new Error(
181 'Could not access code tool for this project. You may need to provide a Stainless API key via the STAINLESS_API_KEY environment variable, the --stainless-api-key flag, or the x-stainless-api-key HTTP header.',
182 );
183 }
184 throw new Error(
185 `${res.status}: ${
186 res.statusText
187 } error when trying to contact Code Tool server. Details: ${await res.text()}`,
188 );
189 }
190
191 const { is_error, result, log_lines, err_lines } = (await res.json()) as WorkerOutput;
192 const hasLogs = log_lines.length > 0 || err_lines.length > 0;
193 const output = {
194 result,
195 ...(log_lines.length > 0 && { log_lines }),
196 ...(err_lines.length > 0 && { err_lines }),
197 };
198 if (is_error) {
199 return asErrorResult(typeof result === 'string' && !hasLogs ? result : JSON.stringify(output, null, 2));
200 }
201 return asTextContentResult(output);
202};
203
204const localDenoHandler = async ({
205 reqContext,
206 args,
207}: {
208 reqContext: McpRequestContext;
209 args: unknown;
210}): Promise<ToolCallResult> => {
211 const fs = await import('node:fs');
212 const path = await import('node:path');
213 const url = await import('node:url');
214 const { newDenoHTTPWorker } = await import('@valtown/deno-http-worker');
215 const { getWorkerPath } = await import('./code-tool-paths.cjs');
216 const workerPath = getWorkerPath();
217
218 const client = reqContext.client;
219 const baseURLHostname = new URL(client.baseURL).hostname;
220 const { code } = args as { code: string };
221
222 let denoPath: string;
223
224 const packageRoot = path.resolve(path.dirname(workerPath), '..');
225 const packageNodeModulesPath = path.resolve(packageRoot, 'node_modules');
226
227 // Check if deno is in PATH
228 const { execSync } = await import('node:child_process');
229 try {
230 execSync('command -v deno', { stdio: 'ignore' });
231 denoPath = 'deno';
232 } catch {
233 try {
234 // Use deno binary in node_modules if it's found
235 const denoNodeModulesPath = path.resolve(packageNodeModulesPath, 'deno', 'bin.cjs');
236 await fs.promises.access(denoNodeModulesPath, fs.constants.X_OK);
237 denoPath = denoNodeModulesPath;
238 } catch {
239 return asErrorResult(
240 'Deno is required for code execution but was not found. ' +
241 'Install it from https://deno.land or run: npm install deno',
242 );
243 }
244 }
245
246 const allowReadPaths = [
247 'code-tool-worker.mjs',
248 `${workerPath.replace(/([\/\\]node_modules)[\/\\].+$/, '$1')}/`,
249 packageRoot,
250 ];
251
252 // Follow symlinks in node_modules to allow read access to workspace-linked packages
253 try {
254 const sdkPkgName = 'cloudflare';
255 const sdkDir = path.resolve(packageNodeModulesPath, sdkPkgName);
256 const realSdkDir = fs.realpathSync(sdkDir);
257 if (realSdkDir !== sdkDir) {
258 allowReadPaths.push(realSdkDir);
259 }
260 } catch {
261 // Ignore if symlink resolution fails
262 }
263
264 const allowRead = allowReadPaths.join(',');
265
266 const worker = await newDenoHTTPWorker(url.pathToFileURL(workerPath), {
267 denoExecutable: denoPath,
268 runFlags: [
269 `--node-modules-dir=manual`,
270 `--allow-read=${allowRead}`,
271 `--allow-net=${baseURLHostname}`,
272 // Allow environment variables because instantiating the client will try to read from them,
273 // even though they are not set.
274 '--allow-env',
275 ],
276 printOutput: true,
277 spawnOptions: {
278 cwd: path.dirname(workerPath),
279 // Merge any upstream client envs into the Deno subprocess environment,
280 // with the upstream env vars taking precedence.
281 env: { ...process.env, ...reqContext.upstreamClientEnvs },
282 },
283 });
284
285 try {
286 const resp = await new Promise<Response>((resolve, reject) => {
287 worker.addEventListener('exit', (exitCode) => {
288 reject(new Error(`Worker exited with code ${exitCode}`));
289 });
290
291 // Strip null/undefined values so that the worker SDK client can fall back to
292 // reading from environment variables (including any upstreamClientEnvs).
293 const opts = {
294 ...(client.baseURL != null ? { baseURL: client.baseURL } : undefined),
295 ...(client.apiToken != null ? { apiToken: client.apiToken } : undefined),
296 ...(client.apiKey != null ? { apiKey: client.apiKey } : undefined),
297 ...(client.apiEmail != null ? { apiEmail: client.apiEmail } : undefined),
298 ...(client.userServiceKey != null ? { userServiceKey: client.userServiceKey } : undefined),
299 defaultHeaders: {
300 'X-Stainless-MCP': 'true',
301 },
302 } satisfies Partial<ClientOptions> as ClientOptions;
303
304 const req = worker.request(
305 'http://localhost',
306 {
307 headers: {
308 'content-type': 'application/json',
309 },
310 method: 'POST',
311 },
312 (resp) => {
313 const body: Uint8Array[] = [];
314 resp.on('error', (err) => {
315 reject(err);
316 });
317 resp.on('data', (chunk) => {
318 body.push(chunk);
319 });
320 resp.on('end', () => {
321 resolve(
322 new Response(Buffer.concat(body).toString(), {
323 status: resp.statusCode ?? 200,
324 headers: resp.headers as any,
325 }),
326 );
327 });
328 },
329 );
330
331 const body = JSON.stringify({
332 opts,
333 code,
334 });
335
336 req.write(body, (err) => {
337 if (err != null) {
338 reject(err);
339 }
340 });
341
342 req.end();
343 });
344
345 if (resp.status === 200) {
346 const { result, log_lines, err_lines } = (await resp.json()) as WorkerOutput;
347 const returnOutput: ContentBlock | null =
348 result == null ? null : (
349 {
350 type: 'text',
351 text: typeof result === 'string' ? result : JSON.stringify(result),
352 }
353 );
354 const logOutput: ContentBlock | null =
355 log_lines.length === 0 ?
356 null
357 : {
358 type: 'text',
359 text: log_lines.join('\n'),
360 };
361 const errOutput: ContentBlock | null =
362 err_lines.length === 0 ?
363 null
364 : {
365 type: 'text',
366 text: 'Error output:\n' + err_lines.join('\n'),
367 };
368 return {
369 content: [returnOutput, logOutput, errOutput].filter((block) => block !== null),
370 };
371 } else {
372 const { result, log_lines, err_lines } = (await resp.json()) as WorkerOutput;
373 const messageOutput: ContentBlock | null =
374 result == null ? null : (
375 {
376 type: 'text',
377 text: typeof result === 'string' ? result : JSON.stringify(result),
378 }
379 );
380 const logOutput: ContentBlock | null =
381 log_lines.length === 0 ?
382 null
383 : {
384 type: 'text',
385 text: log_lines.join('\n'),
386 };
387 const errOutput: ContentBlock | null =
388 err_lines.length === 0 ?
389 null
390 : {
391 type: 'text',
392 text: 'Error output:\n' + err_lines.join('\n'),
393 };
394 return {
395 content: [messageOutput, logOutput, errOutput].filter((block) => block !== null),
396 isError: true,
397 };
398 }
399 } finally {
400 worker.terminate();
401 }
402};
403