cloudflare/cloudflare-typescript
Publicmirrored fromhttps://github.com/cloudflare/cloudflare-typescriptAvailable
packages/mcp-server/src/code-tool.ts
402lines · modecode
| 1 | // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. |
| 2 | |
| 3 | import { |
| 4 | ContentBlock, |
| 5 | McpRequestContext, |
| 6 | McpTool, |
| 7 | Metadata, |
| 8 | ToolCallResult, |
| 9 | asErrorResult, |
| 10 | asTextContentResult, |
| 11 | } from './types'; |
| 12 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; |
| 13 | import { readEnv } from './util'; |
| 14 | import { WorkerInput, WorkerOutput } from './code-tool-types'; |
| 15 | import { getLogger } from './logger'; |
| 16 | import { SdkMethod } from './methods'; |
| 17 | import { McpCodeExecutionMode } from './options'; |
| 18 | import { ClientOptions } from 'cloudflare'; |
| 19 | |
| 20 | const prompt = `Runs JavaScript code to interact with the Cloudflare API. |
| 21 | |
| 22 | You are a skilled TypeScript programmer writing code to interface with the service. |
| 23 | Define an async function named "run" that takes a single parameter of an initialized SDK client and it will be run. |
| 24 | For example: |
| 25 | |
| 26 | \`\`\` |
| 27 | async 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 | |
| 38 | You will be returned anything that your function returns, plus the results of any console.log statements. |
| 39 | Do not add try-catch blocks for single API calls. The tool will handle errors for you. |
| 40 | Do not add comments unless necessary for generating better code. |
| 41 | Code will run in a container, and cannot interact with the network outside of the given SDK client. |
| 42 | Variables will not persist between calls, so make sure to return or log any data you might need later. |
| 43 | Remember that you are writing TypeScript code, so you need to be careful with your types. |
| 44 | Always 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 | */ |
| 59 | export 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 | |
| 138 | const 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 | |
| 204 | const 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 | |