Calling APIs with Code Mode and Returning Only What Matters

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

We have seen how to let the model write JavaScript code to search an OpenAPI specification that is larger than the context window. The specification can grow over time, although the runtime still has practical limits on execution time, memory, and returned data.

Now, we'll see how to let the model write JavaScript to interact with our API, based on the knowledge it has gathered from the OpenAPI specification.

Our MCP needs a second tool

Our goal was to create a system that makes interactions between the LLM and our API easier. For now, our LLM can retrieve and understand the API surface:

json
{
  "code": "async () => {\n  const results = [];\n  for (const [path, methods] of Object.entries(spec.paths)) {\n    for (const [method, operation] of Object.entries(methods)) {\n      if (operation?.tags?.some(tag => tag.toLowerCase() === 'returns')) {\n        results.push({\n          method: method.toUpperCase(),\n          path,\n          operationId: operation.operationId,\n          summary: operation.summary,\n          description: operation.description,\n          parameters: operation.parameters,\n          requestBody: operation.requestBody,\n        });\n      }\n    }\n  }\n  return results;\n}"
}
json
[
  {
    "method": "GET",
    "path": "/returns",
    "operationId": "listReturns",
    "summary": "List return requests",
    "description": "List return requests, optionally narrowed to an order, order item, seller, or return status.",
    "parameters": [],
    "requestBody": null
  }
]

However, it cannot interact with it.

To do so, we need to provide it with a second tool: the executor.

The executor tool

The executor tool completes the duo of the search tool. Under the hood, it is a wrapper around a pre-configured API client, a fetch function in our case, that will allow the model to call the API using the information it has gathered from the OpenAPI specification.

At a high level, it is very similar to the search tool, with the same idea of providing the model with a pre-configured playground to write code against a predefined typed API. The model is responsible for orchestrating one or multiple requests, depending on the complexity of the task, and returning only what matters.

typescript
export function registerExecuteTool(server: McpServer): void {
  server.registerTool(
    'execute',
    {
      title: 'MarketHub API Code Executor',
      description: '...',
      inputSchema: z.object({
        code: z
          .string()
          .max(MAX_CODE_LENGTH)
          .describe('An async arrow function that executes a MarketHub API request')
      }),
    },
    async ({ code }) => {
      try {
        const result = await evaluateExecuteCode(code)
        return { content: [{ type: 'text', text: formatExecuteResult(result) }] }
      }
      catch (error) {
        return {
          content: [{ type: 'text', text: `Execute failed: ${errorMessage(error)}` }],
          isError: true
        }
      }
    }
  )
}

As you can see, the similarity with the search tool is obvious. We take a JavaScript arrow function as input, evaluate it, and return the result.

Two important differences to note:

  1. The description is shaped to make the model understand that it can write code against the API, and that it should return only what matters.
  2. We use evaluateExecuteCode instead of evaluateSearchCode. At their core, both evaluate code, but the data provided in the context is different.

The description starts by explaining succinctly what the tool does.

text
Execute JavaScript code against the local MarketHub API.

First use the 'search' tool to find the right endpoint, method, parameters, and request body, then write code using api.request().

Then, the description provides a TypeScript interface to give the model complete knowledge of how to write code.

typescript
interface ApiRequestOptions {
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
  path: string
  query?: Record<string, string | number | boolean | undefined>
  body?: unknown
  contentType?: string
  rawBody?: boolean
}

interface ApiResponse<T = unknown> {
  success: boolean
  status: number
  result: T
  errors: Array<{ code: string | number, message: string }>
  messages: Array<{ code: string | number, message: string }>
}

declare const api: {
  request: <T = unknown>(options: ApiRequestOptions) => Promise<ApiResponse<T>>
}

With this, you should be able to write code against the API. By reading the ApiRequestOptions interface, we clearly understand that we are doing no more than wrapping fetch. The parameters are nearly the same, only simplified to reveal the necessary information to the model. For the response, we do the heavy lifting to reduce the model's work.

And finally, the description provides a small example of how to use the tool.

typescript
async () => {
  const response = await api.request({
    method: 'GET',
    path: '/products',
    query: { 'status:eq': 'active', '_per_page': 5 }
  })

  return response.result.map(product => ({
    id: product.id,
    name: product.name,
    status: product.status
  }))
}
Executing an API request and returning only the selected product fields.

Code Mode or simple tool call

You may ask yourself, why not just provide a simple tool call to the model, like we did for most MCPs? For example, we could imagine a tool named "list_returns" that would take a seller ID and return the list of returns. The model would just need to call this tool with the right parameters, and it would get the result.

You are right. We could have done that. But if you have 150 endpoints, it means having 150 tools. You could reduce that number by grouping some endpoints and using a parameter, similar to GitHub's MCP. For example, their tool "pull_request_read" has a "method" parameter that can take the values "get", "get_diff", "get_status", "get_files", "get_commits", "get_review_comments", "get_reviews", "get_comments", and "get_check_runs". I will let you imagine the complexity under the hood to make this work and the headache of grouping them. If they add a new method to GitHub tomorrow, they have to update their MCP.

Also, Code Mode allows the model to shape the input and output of the request as needed. For example, if the model knows that the endpoint is very verbose, it can choose to return only the fields it needs. It can also chain requests, create loops, use if/else statements, retry if the output is not what it expects, and so on. The model is free to orchestrate the request as it wants.

typescript
async () => {
  const sellers = (await api.request({
    method: 'GET',
    path: '/sellers',
    query: { 'status:eq': 'active', '_per_page': 5 }
  })).result

  const results = []

  for (const seller of sellers) {
    const products = (await api.request({
      method: 'GET',
      path: '/products',
      query: {
        sellerId: seller.id,
        _embed: 'variants',
        _per_page: 10
      }
    })).result

    for (const product of products) {
      for (const variant of product.variants ?? []) {
        results.push({
          seller: seller.name,
          product: product.name,
          sku: variant.sku
        })
      }
    }
  }

  return results
}

With direct tool calls, this workflow requires repeated agent round trips: the model receives each result, decides what to do next, and sends another call. Code Mode keeps that control flow, filtering, and result shaping inside the generated program.

Return only what's useful

If we take a step back, this may look over-engineered. Let us compare the two approaches before looking at the token measurements.

The Cloudflare OpenAPI snapshot used in this series is 23,792,523 characters long. Dividing by four gives a rough estimate of 6 million tokens, but token counts vary by model and content.

ConcernDirect tool callsCode Mode
Dependent workThe model receives each intermediate result before choosing the next call.The generated program can pass intermediate values between calls and return only its final projection.
Large API surfaceThe server must expose and document a tool surface the model can select from.The model can search a server-side specification and then compose the narrow calls it needs.
Data shapingThe model often receives each tool's response before it can reduce it.The generated program can filter, aggregate, and project data before returning it.

A carefully designed direct-tool server can also batch data and keep responses compact. Code Mode is most useful when a task needs dependent calls, local processing, or an evolving API surface.

To illustrate, let me ask my agent to list the top 10 most CPU intensive workers:

Listing the 10 most CPU-intensive workers with Code Mode.

In this recorded run, the agent used 34k tokens.

In our example, the OpenAPI specification is 89,105 characters long, roughly 22k tokens using the same four-characters-per-token shortcut.

Now, let us try some examples to see how many tokens the model needs with our Code Mode MCP.

  1. "List requested return requests for seller seller-aurora, sorted by most recently updated, returning the first 10 results."

    • 18.1k tokens
    Listing return requests for a seller with Code Mode.
  2. "Create a pending seller named North Star Living with slug north-star-living and email hello@north-star-living.example."

    • 16.9k tokens
    Creating a pending seller with Code Mode.
  3. "Publish product product-desk-lamp by changing its status to active."

    • 17.8k tokens
    Publishing a product with Code Mode.
  4. "Remove product product-desk-lamp"

    • 17.5k tokens
    Removing a product with Code Mode.

These measurements are examples from a local fixture. This is not a true benchmark. However, the important thing to keep in mind is that the model does not need the entire API description to interact with the API. It builds its own context from what it needs.

To a safer execution

Great!

We gave the model a tool to search the OpenAPI specification, and now, it can even execute the API calls itself, and return only what matters. But, is it safe?

The model code cannot be trusted. That is the first rule in web security: never trust user input. Here, model-generated code is user input executed by a trusted local evaluator based on the Function constructor. In the next article, we will see how to make that execution safer with a dedicated environment.

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!

Continue readingReplacing the Evaluator with Isolated Cloudflare Workers

Reactions

Discussions

Add a Comment

You need to be logged in to access this feature.