Beyond One Tool Call at a Time: Introducing Code Mode
Part of the series Building Code Mode for APIs with MCP and Isolated Workers
LLMs can request tool calls. It may sound obvious nowadays, but at first, it was not possible. It is funny to say “at first” as if it were a long time ago, but it was only 3 years ago.
Often called "tool calling" or "function calling," this capability lets a model interact with an external system. For example, an LLM can read or write a file through a tool call. Without it, a model can still explain and generate content, but it cannot directly act on an external system.
When paired with useful tools, a model can work towards a goal: it can gather the information it needs and take the necessary actions. A clear request can be enough for a well-scoped task, although reliable results still depend on the available tools, instructions, and safeguards.
However, we need to provide tools that allow them to perform specific actions on our behalf.
Adding a custom tool to ChatGPT
Imagine you are using ChatGPT and want to allow it to read files on your drive to provide contextual answers. You must provide it with a tool to read files, but you do not have access to the model's implementation.
From a code perspective, this looks like this:
import OpenAI from 'openai'
const client = new OpenAI()
const tools = [
{
type: 'function',
name: 'get_weather',
description: 'Get current temperature for a given location.',
parameters: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'City and country e.g. Bogotá, Colombia',
},
},
required: ['location'],
additionalProperties: false,
},
strict: true,
},
]
const response = await client.responses.create({
model: 'gpt-5.5',
input: [
{ role: 'user', content: 'What is the weather like in Paris today?' },
],
tools,
})
// From the OpenAI Developer documentation: https://developers.openai.com/api/docs/guides/tools?tool-type=function-callingWe quickly understand that we do not have access to that client in ChatGPT. Even if we did have access to it, we would face two problems:
- Non-developers would not make these changes themselves.
- We would have to implement it for all clients, like Claude, VS Code, Dust, etc.
A nightmare for everyone, including ourselves.
That's where MCP comes in.
MCP to uniformly expose tools
[!INFO] MCP fundamentals are outside the scope of this series. If you need an introduction to the protocol or to the basic relationship between an AI agent and an MCP server, begin with AI Agents and MCP Server: Teaming Up for the Agentic Web, especially its MCP server article.
In short, MCP is a protocol that allows an AI agent to communicate with a server. The server can provide additional capabilities to the agent, such as access to tools. The agent, known as the client, can then use these tools to perform actions on behalf of the user.
Concretely, the server exposes tools through a uniform API, and the client can call one of these to know what tools are available, and then register them to the model. The model can then call these tools as if they were native functions. When a tool is called, the client sends the request to the server, instead of executing it locally. The server then executes the tool and returns the result to the client, which then returns it to the model.
From a code point of view, the client looks like this:
// This is pseudo-code, not a real implementation.
const client = new LlmClient({
provider: 'openai',
})
const mcpClient = new McpClient({
server: 'https://mcp.example.com',
})
const tools = await mcpClient.getTools() // Provide a weather tool, similar to the previous example.
const response = await client.responses.create({
model: 'gpt-5.5',
input: [
{ role: 'user', content: 'What is the weather like in Paris today?' },
],
tools, // The tools are now provided by the MCP server, not hardcoded in the client.
})Dependency between tool calls
With this approach, the model receives each tool result before it can decide what to do next. If an action requires three dependent tool calls, the model needs three turns, and the intermediate results become part of its context.
Let's be honest, this works fine most of the time. However, it's not optimal for all cases.
And with the MCP server in the loop, the chain of events looks like this:
- MCP client connects to the MCP server
- MCP client requests the list of available tools from the MCP server
- MCP server returns the list of available tools to the MCP client
- MCP client registers the tools with the model
- Model calls a tool (e.g.,
get_weather) with the required parameters - MCP client receives the tool call request from the model
- MCP client sends the tool call request to the MCP server
- MCP server executes the tool (e.g., fetches the weather data for the specified location)
- MCP server returns the result of the tool execution to the MCP client
- MCP client returns the result of the tool execution to the model
The good news, and why we call it "uniform", is that all MCP-related steps are the same for every client and MCP server. You just need to focus on the tool implementation.
Imagine the following context. You are working on a Drive that contains many Markdown files, and your agent is connected to that Drive using an MCP that contains two tools: readFile and writeFile. Your goal is to create a file that contains all the titles of one Markdown file.
The readFile tool is defined as follows in the MCP server:
server.registerTool(
'readFile',
{
title: 'Drive Read File',
description: 'Read a Markdown file from the local Drive fixture.',
inputSchema: z.object({
path: z.string().min(1)
}),
annotations: {
title: 'Drive Read File',
readOnlyHint: true
}
},
async ({ path }) => {
const content = await readDriveFile(path)
return { content: [{ type: 'text', text: content }] }
}
)and the writeFile tool is defined as follows in the MCP server:
server.registerTool(
'writeFile',
{
title: 'Drive Write File',
description: 'Write a Markdown file to the local Drive fixture.',
inputSchema: z.object({
path: z.string().min(1),
content: z.string()
}),
annotations: {
title: 'Drive Write File',
destructiveHint: true
}
},
async ({ path, content }) => {
await writeDriveFile(path, content)
return { content: [{ type: 'text', text: JSON.stringify({ success: true }) }] }
}
)With the current MCP approach, the model must call readFile to read the file, load the content into its context, extract the titles, and then call writeFile to write the titles into a new file. This means that the model must load the entire content of the file into its context, which can be very large and can lead to context overflow, prompt injection, and unnecessary token costs, without the certainty that the model will extract the titles correctly.
That is exactly why Code Mode shines. In the next article, we will transform that MCP server into a Code Mode server and see how the model can orchestrate the tool calls without loading the entire content into its context.
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!
Discussions
Add a Comment
You need to be logged in to access this feature.