Replacing the Evaluator with Isolated Cloudflare Workers

Part of the series Building Code Mode for APIs with MCP and Isolated Workers

In the previous parts, we implemented search and execute tools that used a trusted local evaluator based on the Function constructor to run AI-generated code. That approach worked for development but raised serious security concerns.

In this part, we will replace that local evaluator with an isolated environment using Cloudflare Dynamic Workers. Isolation and explicit capabilities let us constrain what generated code can do instead of granting it the host's permissions.

User input can't be trusted

One of the first things we learn when building websites with forms is that we cannot trust user input. It is not only about malicious users, but also mistakes and unexpected input.

Generated code is user input too. A model can make mistakes, and a malicious user can try to steer the model towards an unsafe action.

Imagine that, instead of writing a function to search the OpenAPI specification, the model wrote the following code:

typescript
async () => {
  return process.env
}

When generated code runs in the host process, it can access any global or capability the host exposes. Depending on the environment, that may include environment variables, filesystem access, process APIs, or unrestricted network access. The Function constructor is an evaluator. It was never designed to run untrusted code.

So we need a sandbox. Never trust the user, or the generated code.

Sandboxes are slow and expensive

A sandbox is an isolated environment where untrusted code cannot affect the rest of the system. In our case, it is the right way to run generated code.

Traditional sandboxes often use virtual machines or containers. Their startup time, memory footprint, and price vary by provider and configuration, but a full operating-system environment is much more than this task needs.

There is another problem. Many agents need environments in which to run code. If every short-lived tool execution starts a full operating-system environment, capacity and cost can add up quickly.

We need something lighter, faster, and more scalable. We are running small JavaScript functions. We need a constrained JavaScript runtime.

Isolate the memory

Let us step back. Is there an existing solution that runs untrusted code in an isolated environment?

Browsers!

Browsers routinely run code from many different websites on the same machine. To keep that code separate, V8 uses isolates: independent JavaScript heaps that can share a process without sharing memory.

An isolate is much lighter than a virtual machine or a container. It gives each execution its own memory space, so code running in one isolate cannot directly inspect or modify another execution's objects.

Memory isolation alone is not enough, though. Untrusted code must also be given only the capabilities it needs: no filesystem access, no arbitrary secrets, and no unrestricted network access.

This is where Cloudflare Workers are particularly useful. They build on V8 isolates and a capability-based runtime: a Worker only receives the bindings and outbound access that we explicitly grant it.

Node.js also uses V8, but Node itself is not a secure sandbox. The node:vm module documentation explicitly says it should not be used to run untrusted code. For this use case, we need a runtime designed to enforce those boundaries.

Dynamic Workers to the rescue

Cloudflare Workers provide this kind of isolated runtime. With Dynamic Workers, we do not deploy a new Worker through an HTTP endpoint for each execution.

Instead, the parent Worker uses the Dynamic Worker Loader binding to create a fresh Worker at runtime, wait for its RPC response, and return that response to the MCP client. Cloudflare describes fresh isolates as starting in a few milliseconds and using a few megabytes of memory, which makes them suitable for short-lived code execution.

From local eval to Dynamic Workers

So now we have the concept, let's implement it in our code.

Currently, we have:

typescript
/* eslint-disable no-new-func */
let searchFunction: unknown
try {
  searchFunction = new Function(
    'spec',
    `"use strict"; return (${trimmedCode});`
  )(searchSpec)
}
catch (error) {
  throw new Error(`Could not compile search code: ${errorMessage(error)}`)
}

We will replace it with a call to a new worker that we create on the fly.

Note

This series assumes you are already familiar with Cloudflare Bindings.

typescript
async function runSearch(code: string): Promise<unknown> {
  const worker = env.LOADER.get(`markethub-search-${crypto.randomUUID()}`, () => ({
    compatibilityDate: '2026-07-11',
    globalOutbound: null,
    mainModule: 'worker.js',
    modules: {
      'worker.js': `
import { WorkerEntrypoint } from "cloudflare:workers";

const spec = ${JSON.stringify(searchSpec)};

export default class SearchExecutor extends WorkerEntrypoint {
  async evaluate() {
    try {
      const result = await (${code})();
      return { result, err: undefined };
    } catch (error) {
      return {
        result: undefined,
        err: error instanceof Error ? error.message : String(error)
      };
    }
  }
}
      `
    }
  }))

  const entrypoint = worker.getEntrypoint() as unknown as SearchExecutorEntrypoint
  const response = await entrypoint.evaluate()

  if (response.err)
    throw new Error(response.err)
  return response.result
}

That may sound complicated, but it is not.

  • env.LOADER.get is a Cloudflare binding that allows us to create a new worker on the fly. We give it a unique name to create a new worker for each tool call to make sure each tool execution is isolated
  • globalOutbound: null disables all outbound fetch() and connect() calls from the dynamic Worker. This is why the search executor can inspect its embedded specification but cannot access the network.
  • mainModule is the entrypoint of our worker. It's like telling Vite to read index.ts or main.ts from the HTML file. In our case, we tell it to read worker.js that we define in the modules property.
  • modules is a map of module names to their content, similarly to a virtual file system. By module, we mean ECMAScript modules. In our case, we define a single module called worker.js that contains the code to execute the AI's code.

Between our worker and the newly created one, Cloudflare uses RPC (Cap'n Proto). That is why we can call entrypoint.evaluate() as if it were a local function. Under the hood, Cloudflare will serialize the request, send it to the new worker, execute it, and return the result.

Give execute only an allowlisted gateway

Search needs no outbound access, but execute needs to call the local MarketHub API. The execute implementation changes only that capability boundary:

typescript
const worker = env.LOADER.get(`markethub-execute-${crypto.randomUUID()}`, () => ({
  compatibilityDate: '2026-07-11',
  globalOutbound: exports.GlobalOutbound({}),
  mainModule: 'worker.js',
  modules: {
    'worker.js': executeWorkerSource(code),
  },
}))

Passing null means deny all outbound access. Passing exports.GlobalOutbound({}) instead routes every dynamic Worker fetch() and connect() call through the parent Worker's gateway. The sandbox still has no direct network access; it receives only the behavior that the gateway permits.

typescript
export class GlobalOutbound extends WorkerEntrypoint<Env> {
  async fetch(request: Request): Promise<Response> {
    const allowedOrigin = new URL(this.env.MARKETHUB_API_BASE).origin
    const requestedOrigin = new URL(request.url).origin

    if (requestedOrigin !== allowedOrigin) {
      return new Response('Forbidden: outbound destination is not allowed', {
        status: 403
      })
    }

    return fetch(request)
  }
}

The fixture is unauthenticated, so its gateway only allowlists the MarketHub origin. In production, the same gateway can inject a credential held by the parent Worker before forwarding the request. The generated code never sees that credential. This is the essential property.

Bound every execution

Isolation controls what generated code can access. Limits control how much work it can do.

The companion implementation already rejects generated programs longer than 20,000 characters and truncates formatted results after 12,000 characters. Those bounds prevent a single model response or result from consuming the surrounding agent context indefinitely.

Dynamic Workers can also enforce per-invocation CPU and subrequest limits. For example, a production configuration could start with the following conservative budget and tune it from observed workloads:

typescript
const worker = env.LOADER.get(workerId, () => ({
  compatibilityDate: '2026-07-11',
  limits: {
    cpuMs: 50,
    subRequests: 10,
  },
  globalOutbound: exports.GlobalOutbound({}),
  mainModule: 'worker.js',
  modules: { 'worker.js': executeWorkerSource(code) },
}))

If the dynamic Worker exceeds either limit, it throws an exception. The right values depend on the expected work: search may need very little CPU and no subrequests, while execute needs enough subrequests for its planned API calls.

You're now ready

You now have an MCP server that uses Code Mode while keeping generated code inside a fresh isolated runtime. Search has no outbound access and execute has only an allowlisted gateway, and input, output, CPU, subrequest budgets prevent one execution from consuming unbounded resources.

I hope you've enjoyed this series and you learned something new. The companion repository, code-mode, is available to access all the code we've discussed. Each article has a dedicated branch.

If you have any questions, feel free to reach out to me on Twitter or LinkedIn.

PP

Thanks for reading! My name is Estéban, and I love to write about web development and the human journey around it.

I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.

If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.

I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below, it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!

Reactions

Discussions

Add a Comment

You need to be logged in to access this feature.