Letting the Model Write Code with CodeCall

Intermediate

Every tool you add is one more name, description and schema that the model reads before it answers anything. At a dozen tools that's nothing. At a few hundred, the list crowds the model's context, similar tools blur together, and a task that takes five calls takes five round trips, with every result passing through the model. @frontmcp/plugin-codecall changes what the model is shown: instead of your tools, a few CodeCall tools that it uses to search yours, read the ones it needs, and call them, one at a time or from a short script that runs on your server and sends back only the answer.

You will learn

  • What a long tool list costs a model
  • How to put your tools behind CodeCall, and when it's worth it
  • How the model finds tools with codecall:search and reads them with codecall:describe
  • How it calls them, one with codecall:invoke, or several from a codecall:execute script
  • What a script may do, and how to keep tools out of its reach

A server with too many tools

The help desk has grown. It has seven areas now, tickets, customers, invoices, agents, macros, articles and SLA policies, each with five actions. That's 35 tools, written here with a loop to keep the example short. Open the Tests tab:

Open
import { Tool, ToolContext, z } from "@frontmcp/sdk";

const areas = ["ticket", "customer", "invoice", "agent", "macro", "article", "sla policy"];

const actions: [string, (area: string) => string][] = [
  ["list", (area) => `List ${area}s, newest first. Filter by status and page through the results.`],
  ["get", (area) => `Get one ${area} by its id, with every field.`],
  ["create", (area) => `Create a new ${area}. Returns the new record with its id.`],
  ["update", (area) => `Update the fields of one ${area}. Fields you leave out keep their value.`],
  ["delete", (area) => `Delete one ${area}. This can't be undone.`],
];

export const helpDeskTools = areas.flatMap((area) =>
  actions.map(([action, describe]) => {
    @Tool({
      name: `${action}_${area.replace(" ", "_")}`,
      description: describe(area),
      inputSchema: {
        id: z.string().optional().describe(`The ${area}'s id`),
        status: z.enum(["open", "pending", "closed"]).optional().describe("Only records with this status"),
        page: z.number().int().min(1).optional().describe("Page number, from 1"),
        pageSize: z.number().int().min(1).max(100).optional().describe("Records per page, up to 100"),
      },
    })
    class AreaTool extends ToolContext {
      async execute(input: { id?: string; status?: "open" | "pending" | "closed"; page?: number; pageSize?: number }) {
        return { action, area, ...input };
      }
    }
    return AreaTool;
  }),
);

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Nothing here is wrong, and a client lists all 35 tools. Open the Capabilities tab and scroll: that's what the model reads before every answer, about 20 KB of names, descriptions and schemas, and each new tool adds half a kilobyte more. Three problems grow with the list:

  1. Context. The whole list is sent with every request to the model, whether the conversation needs one tool or none. At 200 tools it's over 100 KB.
  2. Choice. The more tools look alike, the more often the model picks the wrong one, or calls two to be sure.
  3. Round trips. "Which urgent tickets are open, and whose are they?" takes a search and then a customer lookup per ticket. Each call is a round trip through the model, and each result, every field of every ticket, lands in its context, where it stays.

Putting the tools behind CodeCall

Install the plugin:

npm install @frontmcp/plugin-codecall

It needs Node 24 or later. Then register it on the app:

Open
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { helpDeskTools } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: helpDeskTools,
  plugins: [CodeCallPlugin.init({ mode: "codecall_only" })],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Open Capabilities again: the app's 35 tools are gone from the list, and CodeCall's six are there instead. The Call tab shows the model searching for two of the tools it can no longer see. The model now works in steps:

ToolWhat the model does with it
codecall:searchFinds tools for short phrases, like "close ticket".
codecall:describeReads the input and output schemas of the tools it picked.
codecall:invokeCalls one tool, and gets its result.
codecall:executeRuns a short script that calls several tools, and gets what the script returns.
codecall:searchSkills, codecall:searchKnowledgeSearch the server's skills.

Each has a long description written for the model, which explains that flow. Every tool call still goes through FrontMCP's normal flow, with the caller's credentials: validation, hooks and authorization all apply, as for a direct call.

mode: "codecall_only" is the default, and the usual choice: CodeCall's tools are listed, yours aren't. "codecall_opt_in" lists every tool as before and lets CodeCall use only the ones marked codecall: { enabledInCodeCall: true }, which is a way to try CodeCall on a server whose clients already use the tools directly. In codecall_only, a client can't call your tools directly any more: a tools/call for one that isn't listed gets Tool "…" not found. Modes lists all three.

Finding tools: search, then describe

The rest of this lesson uses a smaller help desk, with real tickets and customers, so there's something to find. codecall:search takes one or more short phrases, searches for each on its own, and merges the results, best first:

Open
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open", priority: "high", customerId: "C-1" },
  { id: "T-2", title: "Invoice shows the wrong total", status: "open", priority: "low", customerId: "C-2" },
  { id: "T-3", title: "Export times out", status: "open", priority: "high", customerId: "C-2" },
  { id: "T-4", title: "Password reset email never arrives", status: "closed", priority: "high", customerId: "C-1" },
];

const customers = [
  { id: "C-1", name: "Acme Corp", plan: "enterprise" },
  { id: "C-2", name: "Globex", plan: "starter" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by status and priority",
  inputSchema: { status: z.enum(["open", "closed"]).optional(), priority: z.enum(["low", "high"]).optional() },
})
export class SearchTickets extends ToolContext {
  async execute({ status, priority }: { status?: "open" | "closed"; priority?: "low" | "high" }) {
    return { tickets: tickets.filter((t) => (!status || t.status === status) && (!priority || t.priority === priority)) };
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string().describe("A ticket id, like T-1") },
  examples: [{ description: "Look up the ticket about logging in", input: { id: "T-1" } }],
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

@Tool({ name: "get_customer", description: "Get a customer account by its id", inputSchema: { id: z.string() } })
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return customers.find((c) => c.id === id) ?? this.fail(new PublicMcpError(`There's no customer ${id}.`));
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice in full", inputSchema: { invoiceId: z.string() } })
export class RefundInvoice extends ToolContext {
  async execute({ invoiceId }: { invoiceId: string }) {
    return { invoiceId, status: "refunded" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

  • Search matches words, from each tool's name (split on _, -, : and .), its description, its tags, the names of its input fields and its examples. Built-in synonyms help (add also finds create), but words aren't reduced to their stem, so ticket doesn't find tickets, and a phrase in other words, like give money back, finds nothing. Write descriptions with the words a model would search for.
  • Each result has the tool's name, description, appId, a relevanceScore from 0 to 1, and matchedQueries, the phrases that found it. totalAvailableTools counts the tools CodeCall may use.
  • codecall:describe takes the names the model picked and returns each tool's input and output schema as JSON Schema, with the text of each field's .describe(), and some usage examples. A name CodeCall doesn't have comes back in notFound.

CodeCall caches the results of codecall:search and codecall:describe for 60 seconds per signed-in caller, with the Cache plugin it brings along. After you change a tool, a caller who asked about it in the last minute gets the old answer until then. Anonymous callers, like the Playground's, aren't cached.

Calling one tool: codecall:invoke

Once the model knows a tool's schema, codecall:invoke calls it and returns the tool's own result, errors included:

Open
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { CloseTicket, GetCustomer, GetTicket, SearchTickets } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, GetTicket, CloseTicket, GetCustomer],
  plugins: [CodeCallPlugin.init({ mode: "codecall_only" })],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

That's the same result the model would get from calling get_ticket directly, so for a single call nothing changes but the path. Try T-9 in the Call tab: the tool's error message comes through too.

Several calls in one script: codecall:execute

"Which urgent tickets are open, and whose are they?" needs a search and then one customer lookup per ticket. With codecall:execute, the model writes those calls as a short JavaScript program and sends it in one request. The script runs on your server, and only what it returns goes back to the model:

const { tickets } = await callTool("search_tickets", { status: "open", priority: "high" });
const out = [];
for (const t of tickets) {
  const customer = await callTool("get_customer", { id: t.customerId });
  out.push({ ticket: t.id, title: t.title, customer: customer.name });
}
return out;

Here are the tools above again, with codecall:execute already called with that script. It's in the Call tab's script field: change it and send it again.

Open

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

codecall:execute answers:

{
  "status": "ok",
  "result": [
    { "ticket": "T-1", "title": "Cannot log in", "customer": "Acme Corp" },
    { "ticket": "T-3", "title": "Export times out", "customer": "Globex" }
  ]
}

Three tool calls, one round trip, and the model reads two short lines instead of every field of every ticket and customer. The script is the body of an async function: it uses await at the top level and returns its answer.

  • callTool(name, input) calls a tool through the same flow as a direct call, with the caller's credentials, and returns the object the tool returned.
  • parallel(items, fn) runs calls at once, like Promise.all, for up to 100 items.
  • mcpLog(level, message) adds a line to the result's logs, since there's no console.
  • status says how the script ended: ok with a result, or syntax_error, illegal_access, tool_error, runtime_error or timeout, with an error. codecall:execute answers with such an object whatever happens to the script, so the model can read what went wrong and try again. Only input that doesn't fit its schema, like a script under 23 characters, is an error result.

The same question with parallel(), which fetches the customers at once:

const { tickets } = await callTool("search_tickets", { status: "open" });
const owners = await parallel(tickets, (t) => callTool("get_customer", { id: t.customerId }));
return tickets.map((t, i) => `${t.id}: ${owners[i].name}`);
// { "status": "ok", "result": ["T-1: Acme Corp", "T-2: Globex", "T-3: Globex"] }

What a script may do

A script is code a model wrote, so CodeCall treats it as untrusted. It runs a subset of JavaScript called AgentScript, and checks each script before running any of it. Anything outside the subset is refused with illegal_access, and nothing in the script runs, not even the tool calls before the refused line. Checking works in the Playground too, so the tests here try some refused scripts. Add your own:

Open
import { test, expect } from "@frontmcp/testing";
import { closed } from "./tools";

const run = async (mcp: any, script: string) => (await mcp.tools.call("codecall:execute", { script })).json();

test("while loops are refused; use for…of", async ({ mcp }) => {
  const result = await run(mcp, "let page = 1;\nwhile (page < 3) { page++; }\nreturn callTool('search_tickets', {});");
  expect(result.status).toBe("illegal_access");
  expect(result.error.message).toContain("FORBIDDEN_LOOP (line 2): Only for-of loops are allowed (vm.allowLoops is false) (while loop)");
});

test("so are counting for loops, in the default preset", async ({ mcp }) => {
  const result = await run(mcp, "let n = 0;\nfor (let i = 0; i < 3; i++) { n += i; }\nreturn callTool('search_tickets', {});");
  expect(result.error.message).toContain("FORBIDDEN_LOOP (line 2): Only for-of loops are allowed (vm.allowLoops is false) (for loop)");
});

test("function declarations are refused; use arrow functions", async ({ mcp }) => {
  const result = await run(mcp, "function isOpen(t) { return t.status === 'open'; }\nreturn callTool('search_tickets', {});");
  expect(result.error.message).toContain("NO_USER_FUNCTION_DECLARATION");
});

test("console doesn't exist; use mcpLog()", async ({ mcp }) => {
  const result = await run(mcp, "console.log('hi');\nreturn callTool('search_tickets', {});");
  expect(result.error.message).toContain("DISALLOWED_IDENTIFIER (line 1): console is not available in CodeCall scripts; use mcpLog(level, message)");
});

test("eval, process and constructor tricks are refused", async ({ mcp }) => {
  for (const script of [
    "return eval(\"callTool('search_tickets', {})\");",
    "const env = process.env;\nreturn callTool('search_tickets', {});",
    "return [].constructor.constructor('return process')();",
  ]) {
    expect((await run(mcp, script)).status).toBe("illegal_access");
  }
});

test("a refused script has no effect at all", async ({ mcp }) => {
  const result = await run(mcp, "await callTool('close_ticket', { id: 'T-2' });\nwhile (true) {}\nreturn 1;");
  expect(result.status).toBe("illegal_access");
  expect(closed).toEqual([]); // close_ticket never ran
});

test("a script shorter than 23 characters is an error result", async ({ mcp }) => {
  expect(await mcp.tools.call("codecall:execute", { script: "return 1 + 1" })).toBeError("INVALID_INPUT");
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A script may useIt may not use
const, let, if, for…of (with break and continue), try/catch, throwfor (…; …; …), unless the server sets vm.allowLoops; while, do…while, for…in
Arrow functions, also asyncfunction declarations and expressions, class
callTool(), parallel(), getTool(), mcpLog(), mcpNotify()console, fetch, setTimeout, process, require, import(), globalThis
Math, JSON, Object, Array, String, Number, Date, and their methodseval, Function, .constructor, __proto__, regular expressions, Promise, Map, Set, Error

A tool's name in callTool() must be a string literal: callTool(name, …) with a variable is refused. To throw an error of your own, throw "message". AgentScript lists every rule.

A script that passes the checks runs in a fresh sandbox, within limits set by the vm option's preset (secure by default):

LimitsecureWhen it's reached
Computing time3.5 secondsThe script ends with timeout. Time spent waiting for tools doesn't count.
Tool calls5,000 per scriptruntime_error: Maximum tool call limit exceeded (5000). …
Loop iterations10,000 per loop, in every presetruntime_error: Maximum iteration limit exceeded (10000). This limit prevents infinite loops.
Items in parallel()100runtime_error: parallel() is limited to 100 items

vm lists the other presets.

Deep diveWhen a tool fails inside a scriptShow detailsHide details

A failed call throws, and a script can catch it. The error has a message, but not the tool's: to the script, every failure reads Tool "get_ticket" execution failed (or was not found, timed out, Access denied):

try {
  return await callTool("get_ticket", { id: "T-9" });
} catch (error) {
  return { failed: error.message };
}
// { "status": "ok", "result": { "failed": "Tool \"get_ticket\" execution failed" } }

The tool said There's no ticket T-9., and that doesn't reach the script. Without the try, the script ends with { "status": "tool_error", "error": { "source": "tool", "toolName": "get_ticket", "message": "Tool \"get_ticket\" execution failed", "code": "EXECUTION" } }. When the model needs to know why, have the tool return an answer instead of failing, like { found: false }, or call it with codecall:invoke, which returns the tool's own error. callTool(name, input, { throwOnError: false }) returns { success, data } or { success, error } instead of throwing, with the same message.

Keeping tools away from CodeCall

CodeCall may use every tool of the server by default, so a script can call a destructive tool as easily as a harmless one, as often as the limits allow. Keep such tools away from it, with codecall: { enabledInCodeCall: false } on the tool, or an includeTools rule, which can read the tool's annotations:

Open
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { DeleteCustomer, GetTicket, PurgeClosedTickets } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, DeleteCustomer, PurgeClosedTickets],
  plugins: [
    CodeCallPlugin.init({
      mode: "codecall_only",
      // ✅ No tool marked destructive, whoever wrote it
      includeTools: (tool) => !tool.annotations?.destructiveHint,
    }),
  ],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A tool CodeCall may not use isn't found by search, is in notFound for codecall:describe, is refused by codecall:invoke, and gets Access denied for tool "delete_customer" in a script. The model can't tell whether such a tool exists. The reverse option, codecall: { visibleInListTools: true }, keeps a tool in tools/list next to CodeCall's, for one the model should always see, like a health check.

In codecall_only, as here, a client that sends tools/call for delete_customer by name gets Tool "delete_customer" not found too, as the last test shows: CodeCall takes it out of tools/list, and FrontMCP answers a direct call of a tool CodeCall hides as if it didn't exist. Your own tools can still call it with this.callTool().

The CRM with CodeCall example puts 47 tools behind CodeCall, keeps the destructive ones out of reach, and runs one script that combines eight calls.

Recap

  • Every listed tool is read by the model before every answer. A long list costs context and accuracy, and multi-step tasks cost a round trip per call.
  • CodeCallPlugin.init({ mode: "codecall_only" }) lists CodeCall's six tools instead of yours. They're about 21 KB, whatever is behind them, so CodeCall pays off for large servers, not small ones.
  • The model finds tools with codecall:search (words, not meaning), reads their schemas with codecall:describe, and calls one with codecall:invoke. Give tools good descriptions and examples.
  • codecall:execute runs an AgentScript program on the server that calls several tools and returns only the answer. Scripts are checked before they run, and run within limits. The Playground runs them in a browser sandbox.
  • Keep destructive tools away with enabledInCodeCall: false or includeTools. In codecall_only, a client can't call them by name either; in the other modes, use authorities for that.
  • Every option, AgentScript's rules and the sandbox's limits are in the CodeCall reference.

Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press Check.

Challenge 1 of 3

Put the help desk behind CodeCall

The help desk lists its tools directly. Put them behind CodeCall, so the model sees CodeCall's tools and reaches the help desk's through them.

Open
import { App } from "@frontmcp/sdk";
import { CloseTicket, GetCustomer, GetTicket, SearchTickets } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, GetTicket, CloseTicket, GetCustomer],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.