CodeCall

@frontmcp/plugin-codecall is for servers with more tools than a model can usefully read. Instead of every tool and its schema, tools/list shows CodeCall's meta-tools. The model searches your tools with codecall:search, reads the schemas of the ones it picked with codecall:describe, and calls them: one at a time with codecall:invoke, or several in one request with codecall:execute, which runs a short JavaScript program, written in a restricted subset called AgentScript, in a sandbox on the server. A script can loop over results, filter them and join them, so only the answer goes back to the model. This page covers every option, the tools CodeCall adds, AgentScript, the sandbox's limits, and what it does and doesn't protect. Letting the Model Write Code with CodeCall teaches it step by step. The CRM with CodeCall example is a whole server built on it.

import { CodeCallPlugin } from "@frontmcp/plugin-codecall";

@App({ id, name, tools, plugins: [CodeCallPlugin.init({ mode: "codecall_only", ...options })] })

Reference

CodeCallPlugin.init(options)

Install the package, and register the plugin on an app with init():

npm install @frontmcp/plugin-codecall
help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";

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

See more examples below.

@frontmcp/plugins re-exports the same CodeCallPlugin, with the Cache, Remember and Dashboard plugins; see Plugins and adapters. The package needs Node 24 or later (its engines field). It loads in CommonJS and ES module projects alike; before 1.9, ES module projects couldn't import it (Troubleshooting).

  • Where to register it. In @App({ plugins }) or @FrontMcp({ plugins }). It makes no difference to which tools it manages: on one app, it still hides every app's tools from tools/list (unless you set appIds), and its tools search and call every app's tools. See Where you register a plugin for why.
  • All options are optional. CodeCallPlugin.init(), CodeCallPlugin.init({}) and plugins: [CodeCallPlugin], the class without init(), give the defaults. Changed in 1.9.4: in 1.9.3 the class registered none of CodeCall's tools and still hid the app's, so the server had no tools at all (Troubleshooting).
  • Options are checked by init(), at import. A wrong value throws a ZodError naming the option, like Invalid option: expected one of "codecall_only"|"codecall_opt_in"|"metadata_driven" for mode.

Options

All optional.

OptionTypeDefaultDescription
mode"codecall_only" | "codecall_opt_in" | "metadata_driven""codecall_only"Which tools tools/list shows, and which ones CodeCall may use. See Modes.
appIdsstring[]every appIn codecall_only, hide only these apps' tools from tools/list. It doesn't limit what CodeCall searches or calls. See Several apps on one server.
includeTools(tool) => booleanKeeps tools away from CodeCall: a tool it returns false for isn't searched, described, invoked or callable from scripts. tool is { name, fullName, appId, description, tags, source, annotations, metadata }, read-only: fullName is <app id>:<name>, annotations the tool's annotations, and metadata its @Tool options. See Keeping tools away from CodeCall.
directCalls{ enabled, allowedTools?, filter? }unsetLimits codecall:invoke. Unset, it may call every tool CodeCall can use. See directCalls.
vmobject{ preset: "secure" }The sandbox's limits. See vm.
embeddingobjectTF-IDFHow codecall:search ranks tools. See embedding.
sidecarobject{ enabled: false }Allows scripts longer than 64 KB. See sidecar.
topKnumber8Results per query for a codecall:search call that doesn't give its own topK.
maxDefinitionsnumber8Most tools one codecall:describe call may name. A call that names more is refused with INVALID_INPUT: codecall:describe describes at most 8 tools per call; describe the rest in another call.

Modes

modeIn tools/listWhat CodeCall may use
"codecall_only" (default)CodeCall's tools, and tools with codecall: { visibleInListTools: true }. With appIds, also every tool of the other apps.Every tool, except those with enabledInCodeCall: false
"codecall_opt_in"Every tool, except those with visibleInListTools: falseOnly tools with codecall: { enabledInCodeCall: true }
"metadata_driven"Every tool, except those with visibleInListTools: falseEvery tool, except those with enabledInCodeCall: false

"What CodeCall may use" is one decision, shared by all its tools: a tool CodeCall may not use isn't in search results, codecall:describe lists it in notFound, codecall:invoke refuses it, and a script's callTool() gets Access denied. In every mode, CodeCall also refuses:

  • tools with visibility: "hidden" or "internal" (or the older hideFromDiscovery: true);
  • tools whose name starts with system:, internal: or __;
  • tools includeTools returns false for;
  • tools whose availableWhen surface leaves out the caller's, like surface: ["agent"] when an MCP client calls CodeCall (a client counts as "mcp");
  • its own tools, the ones named codecall:….

CodeCall's own tools are listed in every mode, whatever appIds and the tools' metadata say.

Direct calls

A client that sends tools/call for a tool CodeCall takes out of tools/list gets Tool "…" not found, code TOOL_NOT_FOUND, as if the tool didn't exist, before its input is checked or any hook, approval or cache runs. In codecall_only that's every tool but CodeCall's own, those with visibleInListTools: true, and, with appIds, the other apps' tools. In codecall_opt_in and metadata_driven it's the tools with visibleInListTools: false. In every mode, a tool with visibility: "hidden", which FrontMCP leaves out of tools/list, can't be called directly either, unless it sets codecall: { visibleInListTools: true }: then a client that knows its name can call it, though it stays out of the list.

Calls on the server still reach those tools: CodeCall's own codecall:invoke and scripts, and this.callTool() from a tool, an agent or a job. A widget's calls, a WebMCP agent's and createDirect()'s callTool() count as a client's: a tool they call needs visibleInListTools: true.

Changed in 1.9: a client that sent tools/call for a tool CodeCall hid got it called, in every mode, past every CodeCall rule. Changed in 1.9.2: visibleInListTools: false had no effect in codecall_opt_in, and a client could call a hidden tool directly in codecall_opt_in and metadata_driven.

The codecall field on @Tool

CodeCall adds a codecall field to @Tool's options:

@Tool({
  name: "delete_customer",
  description: "Delete a customer and all their tickets",
  inputSchema: { id: z.string() },
  codecall: { enabledInCodeCall: false, visibleInListTools: true },
})
FieldTypeDescription
visibleInListToolsbooleantrue keeps the tool in tools/list in codecall_only, and lets a client call a hidden tool directly in every mode. false removes it from tools/list in codecall_opt_in and metadata_driven.
enabledInCodeCallbooleanfalse keeps the tool away from CodeCall in codecall_only and metadata_driven. In codecall_opt_in, only tools with true are used.
appIdstringShown as the tool's appId by codecall:describe. Search results always show the app the tool belongs to.
sourcestringPassed to includeTools and directCalls.filter as source.
tagsstring[]Passed to includeTools and directCalls.filter as tags, instead of the tool's own tags. Search indexes the tool's own tags, not these.

Two of @Tool's own options matter to CodeCall too: tags are indexed for search, and examples are indexed and returned by codecall:describe as usage examples.

vm

Limits for scripts run by codecall:execute. A preset sets the defaults, and the other fields override them.

FieldTypesecure defaultDescription
preset"locked_down" | "secure" | "balanced" | "experimental""secure"Sets the defaults below.
timeoutMsnumber3500How long a script may compute. The script ends with status timeout. Time spent waiting for a tool isn't counted: see Limits.
maxStepsnumber5000How many tools a script may call, with callTool() or through a tool namespace. More ends it with Maximum tool call limit exceeded (5000). This limit prevents runaway script execution. It also sets how many times a script may call getTool(), mcpLog() and mcpNotify(): ten times as many.
maxSanitizeDepthnumber50How deeply nested a script's return value may be.
maxSanitizePropertiesnumber2000How many items any array or object in a script's return value may have.
allowLoopsbooleanfalsefalse allows for…of loops only. true allows for (…; …; …) loops too. while, do…while and for…in are refused either way. See What a script can write.
allowConsolebooleanDeprecated, and has no effect: a script that uses console is always refused. Use mcpLog().
disabledBuiltins, disabledGlobalsstring[]per presetNames a script may not use: a script that refers to one is refused before it runs, with illegal_access and JSON is disabled by vm.disabledGlobals. A field or key with that name, like formats.JSON, is fine. A list you set replaces the preset's, and can only add to what AgentScript refuses: a name left out, like eval, is still refused (What a script can use). Under secure, disabledBuiltins is eval, Function and AsyncFunction, and disabledGlobals is require, process, fetch, setTimeout, setInterval, setImmediate, global and globalThis.
PresettimeoutMsmaxStepsmaxSanitizeDepthmaxSanitizePropertiesallowLoops
locked_down20002000101000false
secure35005000502000false
balanced500010000505000true
experimental100002000010020000true

Changed in 1.9.2: allowLoops was unused, and for (…; …; …) loops ran in every preset. Under the default secure preset they're now refused. Changed in 1.9.3: disabledBuiltins and disabledGlobals were accepted and unused.

embedding

How codecall:search ranks tools.

FieldTypeDefaultDescription
strategy"tfidf" | "ml""tfidf"tfidf ranks by shared words, in memory, with no model. ml ranks by meaning, with a local embedding model run through vectoriadb: install @huggingface/transformers (an optional peer of vectoriadb). The server loads the model when it starts, and downloads it into cacheDir first if it isn't there: see Running CodeCall in production. Without the package, every search fails with VectoriaDB must be initialized before searching.
modelNamestring"Xenova/all-MiniLM-L6-v2"ml only. The model.
cacheDirstring"./.cache/transformers"ml only. Where the model is stored.
useHNSWbooleanfalseml only. An approximate index, faster for very large tool sets.
synonymExpansionfalse | { enabled, additionalSynonyms, replaceDefaults, maxExpansionsPerTerm }enabledtfidf only. Adds synonyms to each query word, from CodeCall's built-in groups. false turns it off. additionalSynonyms adds groups of your own, like [["reimburse", "refund"]]; with replaceDefaults: true only yours are used.

The Playground and this page's examples use tfidf, since the Playground can't run ml. What ml does on a server was checked on Node, and is in Running CodeCall in production.

A tool's searchable text is its name split on :, -, _ and ., its description (which counts most), its tags, the names of its input properties, and its examples' descriptions and string inputs. Words are matched as they are, without stemming, so ticket doesn't match tickets. Each query word also matches up to five synonyms, from 57 built-in groups: add matches create, new, insert, make and append; client matches user, account, member, profile and identity; bug matches task, ticket, issue, story and epic. Only the tools CodeCall may use are indexed, and the index is rebuilt whenever the server's tools change. A search leaves out tools whose surface doesn't include the caller's, and totalAvailableTools doesn't count them.

directCalls

Limits codecall:invoke. The other CodeCall tools aren't affected.

FieldTypeDescription
enabledbooleanRequired. false makes codecall:invoke refuse every tool. It stays listed.
allowedToolsstring[]Only these tools may be invoked, by exact name.
filter(tool) => booleanOnly tools it returns true for. tool is the same object includeTools gets, with annotations and metadata.

A tool must also be one CodeCall may use: directCalls can narrow what codecall:invoke reaches, never widen it.

sidecar

Without the sidecar, a script longer than 65,536 characters ends with runtime_error: Script length (… characters) exceeds maximum allowed length (65536 characters). Enable sidecar to handle large data, or reduce script size.

FieldTypeDefaultDescription
enabledbooleanfalseRemoves that check. Scripts are still limited to 102,400 characters by codecall:execute's input, and to 10,000 characters a line.
maxScriptLengthWhenDisablednumber | null65536The longest script without the sidecar. null for no limit.
maxTotalSize, maxReferenceSize, extractionThreshold, maxResolvedSize, allowCompositesthe sandbox'sPassed to the sandbox's store for large values when enabled is true.

In tests on Node, tool results of several hundred KB passed from one tool to another in a script the same way with and without the sidecar.

The tools CodeCall adds

A model finds tools with codecall:search, reads their schemas with codecall:describe, and then calls them, here from one script:

ToolWhat it doesAnnotations
codecall:searchFinds tools for short queriesreadOnlyHint, openWorldHint
codecall:describeReturns tools' input and output schemas, with usage examplesreadOnlyHint, openWorldHint
codecall:invokeCalls one tool and returns its result
codecall:executeRuns an AgentScript program that calls tools
codecall:searchSkillsFinds skills that call toolsreadOnlyHint, openWorldHint
codecall:searchKnowledgeFinds skills that are only instructionsreadOnlyHint, openWorldHint

They're listed in every mode, and neither codecall:invoke nor a script can call them. Each has a long description written for the model, which explains the search, describe, then execute or invoke flow. Two of them are written from the plugin's options, so the model plans with the server's real limits: codecall:search's gives the topK and the minRelevanceScore default, and codecall:execute's the loops vm.allowLoops permits, the names the vm lists refuse, vm.timeoutMs and vm.maxSteps, like LIMITS: 10000 iterations per loop, 3.5s timeout, 5000 tool calls under secure. Changed in 1.9.3: they named a 0.3 threshold, a 30-second timeout and 100 tool calls, whatever the options said.

codecall:search

InputTypeDefaultDescription
queriesstring[]Required. 1 to 10 short phrases, 2 to 256 characters each, like ["close ticket", "refund invoice"]. Each is searched on its own.
topKnumberthe plugin's topK, 8Results per query, up to 50.
minRelevanceScorenumber0.1Drops results scoring below this, from 0 to 1.
appIdsstring[]Only tools of these apps.
excludeToolNamesstring[]Leave these out, like tools the model already found.

For { "queries": ["close ticket", "refund invoice"] }, with the tools from Finding tools with codecall:search, it returns:

{
  "tools": [
    { "name": "close_ticket", "appId": "help-desk", "description": "Close a support ticket", "relevanceScore": 0.97, "matchedQueries": ["close ticket"] },
    { "name": "refund_invoice", "appId": "help-desk", "description": "Refund an invoice in full", "relevanceScore": 0.82, "matchedQueries": ["refund invoice"] }
  ],
  "warnings": [{ "type": "low_relevance", "message": "8 result(s) filtered due to relevance below 0.1" }],
  "totalAvailableTools": 5
}

(The scores are rounded here.)

  • tools are the matches of all the queries, each tool once, best first. matchedQueries says which queries found it, and relevanceScore is its best score.
  • warnings has no_results when nothing matched, low_relevance when minRelevanceScore dropped some, and excluded_tool_not_found (with affectedTools) for names in excludeToolNames CodeCall doesn't have.
  • totalAvailableTools is how many tools CodeCall may use.

codecall:describe

InputTypeDescription
toolNamesstring[]Required. At least one name, without repeats: a repeated name fails with Duplicate tool names are not allowed: …. At most the plugin's maxDefinitions, 8 by default.

It returns tools, one for each name CodeCall may use, and notFound, the other names (left out when there are none). A name in notFound may not exist, or may be a tool CodeCall refuses: the model can't tell which. Each tool has:

FieldDescription
name, descriptionAs on the tool.
appIdcodecall.appId, or else the app the tool belongs to.
inputSchemaJSON Schema, with the text of each field's .describe().
outputSchemaJSON Schema, or null for a tool without one.
annotationsThe tool's annotations, when it has any.
usageExamplesUp to 5 { description, code }, where code is a callTool() snippet for a script. A tool's own examples give one each, and are the only ones shown. A tool without any gets one that CodeCall writes from its name and input schema, using only properties the schema declares (the first value of an enum, a placeholder for a string).

The example CodeCall writes for a tool without examples names a variable after the tool, so for a tool named with a hyphen, like fetch-order, it isn't valid JavaScript (const fetch-order = await callTool(...)). Names with underscores or colons are fine. A tool with examples never gets one of these.

codecall:invoke

InputTypeDescription
toolstringRequired. The tool's name. get_ticket also finds get-ticket, and the reverse.
inputobjectRequired. The tool's arguments.

It calls the tool through the normal tools/call flow, with the caller's credentials, and returns the tool's own result: its content, structuredContent, and isError when the tool failed, with the tool's error message. A tool CodeCall may not use, or directCalls doesn't allow, gets an isError result with Tool "…" is not available. Use codecall:search to discover available tools., and a CodeCall tool gets Tool "…" cannot be invoked directly. CodeCall tools are internal and not accessible via codecall:invoke.

codecall:invoke declares no outputSchema, since what it returns is another tool's result. Changed in 1.9.1: it declared one that those results didn't match, so a client that checks results against the listed schema refused them.

codecall:execute

InputTypeDescription
scriptstringRequired. The AgentScript program. At least 23 characters (the length of return callTool("a",{})) and at most 102,400.
allowedToolsstring[]Tools this script may call, by exact name. Others get Access denied.

codecall:execute answers with a result object whatever happens to the script, with one of these status values. Only an input that fails the schema above, like a script under 23 characters, is an isError result.

statusFieldsWhen
"ok"result, and logs when the script called mcpLog() or mcpNotify()The script finished. result is what it returned, left out when that's undefined.
"syntax_error"error: { message, location: { line, column } }The script doesn't parse. It didn't run. The position is the script's own: Failed to parse AgentScript code: Unexpected token (2:10), with location: { line: 2, column: 10 }.
"illegal_access"error: { kind: "IllegalBuiltinAccess", message }The script uses something AgentScript refuses. It didn't run. message starts with AgentScript validation failed: and lists each rule broken, like FORBIDDEN_LOOP (line 4): Only for-of loops are allowed (vm.allowLoops is false) (while loop).
"tool_error"error: { source: "tool", toolName, message, code }A tool call failed or was refused, and the script didn't catch it. See Calling tools.
"runtime_error"error: { source: "script", message, name }The script threw, or hit a limit.
"timeout"error: { message }The script computed longer than vm.timeoutMs: Script execution timed out after 3500ms.

No result carries a stack trace, and absolute file paths in an error's message are replaced with [path]: a script that throws "Could not read /srv/app/config/secrets.json" ends with the message Could not read [path]. The output schema still has an optional stack field, which is never set. See What a failed script sends back.

tool_error doesn't repeat the arguments the script passed; its schema's optional toolInput is never set. illegal_access and syntax_error messages name the script's own lines, in every script, a tool namespace or not: a while on the script's second line is reported on line 2.

codecall:searchSkills and codecall:searchKnowledge

They search the server's skills, split in two: codecall:searchSkills finds skills that call something (they have tools, or OpenAPI operations from Skilled OpenAPI), and codecall:searchKnowledge finds the rest, skills that are only instructions.

InputTypeDefaultDescription
queriesstring[]Required. 1 to 10 phrases, 2 to 256 characters each.
tagsstring[]Only skills with one of these tags.
excludeSkillNamesstring[]Leave these out.
topKnumber5Results per query, up to 50.
minRelevanceScorenumber0.1Drops results scoring below this.

codecall:searchSkills returns skills, each { name, description, tags, tools, operations, relevanceScore, matchedQueries, source }, with warnings and totalExecutableSkills. codecall:searchKnowledge returns knowledge, each without tools and operations, with warnings and totalKnowledgeSkills. To read a skill's instructions, read its skill://<name>/SKILL.md resource: codecall:describe only describes tools, although codecall:searchKnowledge's description tells the model to use it.

AgentScript

AgentScript is the JavaScript subset codecall:execute runs. A script is the body of an async function: use await at the top level, and return the answer.

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

With the tools from Putting tools behind CodeCall, this returns { "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, and one answer for the model. The Playground runs scripts too (In the Playground says how), and a test there checks every script result on this page.

What a script can use

NameDescription
callTool(name, input)Calls a tool and returns its result. See Calling tools.
getTool(name){ name, description, inputSchema, outputSchema } for a tool the script may call, with the schemas as JSON Schema (or null). undefined for any other name.
parallel(items, fn)Calls fn(item, index) for each item at once, and returns the results in order, like Promise.all(items.map(fn)). More than 100 items ends the script with parallel() is limited to 100 items. More than 31 calls of one tool at once are stopped sooner, as RAPID_ENUMERATION: see Script recipes.
mcpLog(level, message, metadata?)Adds "[mcp:<level>] <message>" to the result's logs, and writes it to the server log. level is "debug", "info", "warn" or "error".
mcpNotify(event, payload)Adds "[notify] <event>" to logs, and writes a debug line to the server log. Nothing is sent to the client.
Tool namespacesA tool named mail.send, one dot between two identifiers, can also be called as mail.send(input), unless the part before the dot is a reserved word or a name AgentScript already has, like delete.item or parallel.run. See Calling tools.
Math, JSON, Object, Array, String, Number, DateThe standard objects.
parseInt, parseFloat, isNaN, isFinite, encodeURI, encodeURIComponent, decodeURI, decodeURIComponent, NaN, Infinity, undefinedThe standard functions and values.

Nothing else exists: no Promise, Error, Map, Set, RegExp, Symbol, Boolean, fetch, setTimeout, process, require or globalThis, and no codecallContext either. Using one fails validation with UNKNOWN_GLOBAL: Unknown identifier "__safe_Error", followed by the list of allowed names. A name in vm.disabledBuiltins or vm.disabledGlobals gets DISALLOWED_IDENTIFIER instead, and under the default secure preset that includes eval, Function, fetch, setTimeout, process, require and globalThis: process is disabled by vm.disabledGlobals. console doesn't exist either, and gets a message of its own: DISALLOWED_IDENTIFIER (line 1): console is not available in CodeCall scripts; use mcpLog(level, message). A property named console, like settings.console, is fine.

What a script can write

Allowed: const and let, arrow functions (also async, and recursive), for…of loops with break and continue (and for (…; …; …) loops when vm.allowLoops is true), if, ?:, try/catch/finally, throw, destructuring, spread, template literals, ?. and ??, object and array literals, and the methods of strings, arrays and objects.

Refused, before the script runs, with status illegal_access:

CodeRuleMessage
for (…; …; …), while (…), do … while, for (… in …)FORBIDDEN_LOOPOnly for-of loops are allowed (vm.allowLoops is false) (while loop). With vm.allowLoops, for (…; …; …) is allowed and the others get Loop constructs are not allowed (while loop)
function f() {}NO_USER_FUNCTION_DECLARATIONFunction declarations are not allowed in AgentScript v1. …
function () {}, class, methods and getters in object literalsNO_USER_FUNCTION_EXPRESSION, NO_USER_METHOD_DEFINITIONFunction expressions are not allowed in AgentScript v1. …
/ab+c/NO_REGEX_LITERALRegex literals are not allowed in this security mode: /ab+c/
x.constructorNO_CONSTRUCTOR_ACCESSAccess to .constructor property is not allowed
x.__proto__DISALLOWED_IDENTIFIERAccess to "__proto__" is not allowed
import("fs")NO_EVALDynamic import() is not allowed (enables dynamic code loading)
names starting __ag_ or __safe_RESERVED_PREFIXIdentifier "__ag_x" uses reserved prefix "__ag_". …
consoleDISALLOWED_IDENTIFIERconsole is not available in CodeCall scripts; use mcpLog(level, message)
A name in vm.disabledBuiltins or vm.disabledGlobals: under secure, eval, Function (with or without new), process, require, fetch, setTimeout and globalThisDISALLOWED_IDENTIFIEReval is disabled by vm.disabledBuiltins, process is disabled by vm.disabledGlobals
Any other name not in the table above, and eval or process when a list of your own leaves them outUNKNOWN_GLOBALUnknown identifier "__safe_Map". …
callTool(name, …), with a variable, or a template literal with ${…}, as the tool's nameDYNAMIC_CALL_TARGETFunction "__safe_callTool" requires argument 1 to be a static string literal. …

To throw your own error, throw "message" or throw { message: "…" }: Error doesn't exist. Either ends the script with runtime_error and that message.

Calling tools

callTool(name, input) sends a tools/call through the server's normal flow, with the credentials of the client that called codecall:execute. The tool's hooks run, and its input is validated, as for a direct call.

  • The result is the tool's first text block, parsed as JSON: the object the tool returned. A tool that returns a plain value, like a string, gives { value: … }, as it would to a client (Returning results). input must be an object: anything else ends the script with Tool arguments must be an object.

  • A failed call throws. Catch it with try/catch; the error has a message and a name, "ToolError", and nothing else, and the message doesn't say why the tool failed:

    The tool…message
    failed, or its input was invalidTool "get_ticket" execution failed
    failed with a message containing "not found"Tool "get_ticket" was not found
    failed with a message containing "timeout" or "timed out"Tool "get_ticket" execution timed out
    isn't one CodeCall may use, isn't in allowedTools, or doesn't existAccess denied for tool "get_ticket"
    is a CodeCall toolSelf-reference attack: Attempted to call CodeCall tool "codecall:search" from within AgentScript

    An uncaught failure ends the script with tool_error, like { "source": "tool", "toolName": "get_ticket", "message": "Tool \"get_ticket\" execution failed", "code": "EXECUTION" }, with the message from the table and a code (below). A tool's time-out is one too, with code: "TIMEOUT". Because a tool's own message is replaced, a tool that throws User u9 not found reaches the script as Tool "…" was not found, code: "NOT_FOUND", as if the tool didn't exist.

  • { throwOnError: false }, as the third argument, makes a failure a value instead: a success is { success: true, data }, and a failure { success: false, error: { name, message, toolName, code } }, with the same message as in the table and a code like EXECUTION, NOT_FOUND, TIMEOUT, ACCESS_DENIED or SELF_REFERENCE. See Handling a tool's failure in a script.

  • Namespaced tools. A tool named mail.send is also mail.send(input, options), which calls callTool("mail.send", input, options): mail.list() with no argument sends {}. A failure is the same as callTool()'s, with { throwOnError: false } too. These calls count toward the limits and the suspicious-pattern checks like any other tool call. A script can refer to a namespace by another name too: const m = mail; await m.list(). A namespace whose name starts with _, like _admin.list, isn't offered: _admin.list() fails validation with UNKNOWN_GLOBAL.

Limits

LimitValueWhat happens
Script length23 to 102,400 charactersAn isError result: Too small: expected string to have >=23 characters, or Too big: expected string to have <=102400 characters.
Script length without the sidecar65,536 charactersruntime_error, name: "ScriptTooLargeError".
Line length10,000 charactersillegal_access, PRESCANNER_LINE_TOO_LONG.
Iterations10,000 per loop, not configurableruntime_error: Maximum iteration limit exceeded (10000). This limit prevents infinite loops., whatever the loop. Nested loops each get 10,000. Before 1.9.3 a for (…; …; …) loop's message had no count, and its name was DoubleVMExecutionError.
Computing timevm.timeoutMstimeout: Script execution timed out after 3500ms.
Tool callsvm.maxSteps per script, through callTool() and namespaces togetherruntime_error: Maximum tool call limit exceeded (5000). …
Calls to getTool(), mcpLog() and mcpNotify()10 × vm.maxSteps per scriptruntime_error: Maximum global function call limit exceeded (50000). This limit prevents runaway calls into host functions.
Call rate100 a second, tool calls and the three functions above togetherruntime_error: Operation rate limit exceeded (100 operations/second)
Return valuevm.maxSanitizeDepth, vm.maxSanitizePropertiesruntime_error: Tool handler return value exceeds maximum properties (2000)., or … maximum depth (50). …

Time spent waiting for a tool isn't computing time: a script that waits five seconds for a slow tool finishes with ok under the 3.5-second default. Give slow tools their own timeouts.

The return value is converted to plain JSON: a circular reference becomes "[Circular]", and a Date becomes its ISO string, like "1970-01-01T00:00:00.000Z" (with Enclave 2.16; 2.15 turned it into {}). A function anywhere in it ends the script with runtime_error. A NaN in it fails the result's output check, and the call becomes an isError result: Tool output validation failed (non-finite-number at structuredContent.result.…: NaN).

The sandbox also watches the order of a script's tool calls, by tool name and arguments, and stops a script that looks like one of these patterns, with runtime_error and Suspicious pattern detected: <description> [<id>]. They're on in every preset, and CodeCall has no option for them:

IdStops a script that…
EXFIL_LIST_SENDcalls a tool whose name contains send, export, post, write, upload, publish, emit, transmit or forward within 5 seconds of one containing list, query, get, fetch, read, search, find or select
DELETE_AFTER_ACCESScalls a tool whose name contains delete, remove, destroy, purge, clear, wipe or erase within 30 seconds of one of those reading tools
CREDENTIAL_EXFILcalls a tool whose name contains http, api, external, webhook, slack, email, sms or notification within 10 seconds of one containing secret, credential, password, token, key or auth
BULK_OPERATIONcalls a tool whose name has bulk, batch, mass or dump as a word of its own (users:bulk, not bulk_update), or passes arguments whose JSON has limit followed anywhere by a number of 4 digits or more, "*", or no_limit
RAPID_ENUMERATIONcalls the same tool more than 30 times in 5 seconds

They match parts of names, so they stop ordinary scripts: get_customer then delete_customer, list_users then send_invoice, or { limit: 5, year: 2024 }. Such a task has to be split over two codecall:execute calls, or done with codecall:invoke.

Security

A script is code a model wrote, so treat it as untrusted. CodeCall's protection is in three places:

  • Before a script runs, it's parsed and checked against AgentScript's rules, and anything that could reach the host is refused: eval, Function, import(), require, process, globalThis, .constructor, __proto__, timers and fetch. vm.disabledBuiltins and vm.disabledGlobals add names to refuse, and can't allow any of these. Validation happens before anything runs, so a refused script has no effect at all.
  • While it runs, it's in a fresh sandbox built on Node's vm module, with only the globals listed above, and the limits above. Node documents vm as not being a security mechanism by itself, which is why the checks before a script runs matter: they refuse the known ways out of a vm context, like .constructor chains. (The Playground runs it in a different sandbox: see In the Playground.)
  • Every tool call goes through the same policy as search and describe: the mode, enabledInCodeCall, includeTools, hidden tools, reserved names and the caller's surface, plus the script's allowedTools. That holds for calls through a tool namespace too, which also count toward the limits. A script can't call CodeCall's own tools.

What goes back to the model is limited too: a script's result is converted to plain JSON, and a failed script's error carries only its message and name, with absolute file paths replaced by [path], never a stack trace.

What it doesn't protect:

  • Your tools. A script may call any tool CodeCall may use, with any input, as many times as the limits allow. Keep destructive tools away from CodeCall (enabledInCodeCall: false, includeTools), and check permissions in the tools themselves: CodeCall calls them with the caller's credentials, so authorities and your own checks apply.
  • Direct calls to listed tools. A tool CodeCall refuses but leaves in tools/list can still be called by name; see Direct calls.
  • Time spent in tools. timeoutMs doesn't count it.

Audit events

CodeCall reports what it does to AuditLoggerService, exported by the package. It writes every event to the server log at info, under [codecall:audit], and calls each listener you subscribe:

const unsubscribe = this.get(AuditLoggerService).subscribe((event) => {
  // event: { type, timestamp, executionId, durationMs?, data }
});

Get it with this.get() in a tool or a plugin's hook, as in Recording what CodeCall does. Injecting it into your own provider's useFactory gives you a separate instance, which never receives events.

typedata
codecall:search:performedqueryLength, resultCount
codecall:describe:performedtoolCount, toolNames (the first 10)
codecall:invoke:performedtoolName, success
codecall:execution:startscriptHash, scriptLength
codecall:execution:successscriptHash, scriptLength, toolCallCount
codecall:execution:failurescriptHash, scriptLength, error
codecall:execution:timeoutscriptHash, scriptLength
codecall:tool:call:start, codecall:tool:call:successtoolName, callDepth
codecall:tool:call:failuretoolName, callDepth, errorCode
codecall:security:access-deniedblocked (the tool name), reason
codecall:security:self-referenceblocked, reason
codecall:security:ast-blockedblocked (the rules broken, like FORBIDDEN_LOOP), reason

reason for access-denied is the real one, like Tool "delete_customer" sets enabledInCodeCall: false, which the model and the client never see. Scripts are identified by scriptHash, not their text. AUDIT_EVENT_TYPES exports the type names.

In the Playground

A server runs each script in a sandbox built on Node's vm module, which a browser doesn't have. The Playground runs FrontMCP in a Web Worker, so codecall:execute runs the script in @enclave-vm/browser, Enclave's browser sandbox, instead:

  • The script is parsed, checked against AgentScript's rules and rewritten by the same code as on a server, so syntax_error and illegal_access are the same, with the same messages.
  • It then runs in two nested iframes on the page, sandbox="allow-scripts" without allow-same-origin, with a content security policy that blocks the network, eval and Function. The outer one checks each tool call: the call rate, and the same suspicious-sequence patterns as a server.
  • Its tool calls go back to the worker, through a message channel, and run the same flow as a server's: includeTools, allowedTools, the tool's own hooks and input checks, and the limits on a call's size and result. So do the results: a script's return value is converted to plain JSON, with the same limits and the same errors.

That keeps the script away from the page and the network. It isn't a claim about the Playground's security: the tools a script calls run in the same tab, and they're yours. What differs from a server:

On a serverIn the Playground
IsolationTwo nested vm contextsTwo nested iframes, with frozen built-in prototypes
Computing timevm.timeoutMs, not counting the wait for toolsThe same rule, checked in the sandbox's loops. A script stuck in one long call to a built-in, not in a loop, isn't stopped. In some browsers a script that computes freezes the tab until it ends or reaches the limit
getTool(name)Any nameNames that appear as string literals in the script: getTool("get_ticket"), or names in an array it loops over. A name it builds, like getTool(`get_${kind}`), or reads from a tool's result, ends the script with getTool("…") is not available here
mcpLog(), mcpNotify()Run when calledRun, in order, when the script finishes and returns: a script that fails never runs them, so the server log doesn't hear of them
Strict modeScripts are sloppy: assigning to a method of a built-in does nothing, and this is an objectScripts are strict: that assignment throws a TypeError, and this is undefined
Memory limitMemoryLimitError from joined strings and templates, and RangeError: String.repeat would exceed memory limit: 6MB > 1MBMemoryLimitError from the same, a RangeError without the sizes from repeat, join and fill; padStart and padEnd aren't counted
sidecarLarge tool results become referencesHas no effect: Enclave's browser sandbox has no sidecar
MessagesRAPID_ENUMERATION: Rapid enumeration of resources (same operation called too many times in 5s)Rapid enumeration of resources

The rest of what a script does gives the same answer in both, which the tests in Running a script and Hitting the limits check: parallel(), getTool() with a name in the script, { throwOnError: false }, allowedTools, a script's errors, the sandbox's pattern check and each limit.

Caveats

  • An unused option: vm.allowConsole (deprecated) is accepted and changes nothing in FrontMCP 1.9.4: a script never has console, whatever it says. Log with mcpLog().
  • CodeCall brings the Cache plugin along. Its search and describe tools are cached for 60 seconds per caller: a signed-in caller who repeats an identical codecall:search or codecall:describe within a minute gets the first result again, marked _meta.cache: "hit", even if your tools changed meanwhile. codecall:execute and codecall:invoke are never cached: a script runs, and a tool is called, every time. Anonymous callers, as in the Playground, count as a new caller on every request, so their calls are never served from the cache.
  • Each script gets a new sandbox. Nothing carries over from one codecall:execute call to the next, and CodeCall keeps no state between requests other than its search index, so servers behind a load balancer need nothing shared.

Usage

Putting tools behind CodeCall

With mode: "codecall_only", tools/list shows CodeCall's six tools instead of the app's five. The model finds the others through them. Open Capabilities to see the list, and the Call tab for codecall:describe:

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

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Then the model calls the tools it found. codecall:invoke runs one:

{ "name": "codecall:invoke", "arguments": { "tool": "close_ticket", "input": { "id": "T-2" } } }

and codecall:execute runs a script that can use several, like the one in AgentScript, which lists the urgent tickets with their customers' names in one request.

Finding tools with codecall:search

Each query is searched on its own, and the results are merged, with matchedQueries saying which query found each tool. The tool's description tells the model to send one short query per action, like ["close ticket", "refund invoice"]:

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

@Tool({ name: "search_tickets", description: "Search support tickets by status", inputSchema: { status: z.string().optional() } })
export class SearchTickets extends ToolContext {
  async execute(_input: { status?: string }) {
    return { tickets: [] };
  }
}

@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: "create_customer", description: "Create a customer account", inputSchema: { name: z.string() } })
export class CreateCustomer extends ToolContext {
  async execute({ name }: { name: string }) {
    return { id: "C-3", name };
  }
}

@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" };
  }
}

@Tool({
  name: "export_report",
  description: "Export the monthly report as CSV",
  inputSchema: { month: z.string() },
  tags: ["analytics"],
  examples: [{ description: "Numbers for the last quarter", input: { month: "2026-06" } }],
})
export class ExportReport extends ToolContext {
  async execute({ month }: { month: string }) {
    return { month, csv: "…" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

TF-IDF matches words, not meaning, and doesn't reduce words to their stem: ticket doesn't match tickets. The built-in synonyms cover common verbs and nouns, so add client finds create_customer, but a query in other words, like give the money back, finds nothing useful. Write descriptions with the words a model would search for, and add tags and examples.

Choosing what tools/list shows

Modes

Example 1 of 3

codecall_only, with one tool listed

visibleInListTools: true keeps a tool in the list, next to CodeCall's, for a tool the model should always have, like a health check. It stays available through CodeCall too.

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

@Tool({
  name: "health",
  description: "Check that the help desk is up",
  inputSchema: {},
  codecall: { visibleInListTools: true },
})
export class Health extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Keeping tools away from CodeCall

Four ways, in every mode: enabledInCodeCall: false on the tool, an includeTools filter, visibility: "hidden", or a name starting with system:, internal: or __. Each keeps the tool out of search, codecall:describe and codecall:invoke, and out of scripts. In codecall_only, as here, a client can't call them by name either, since CodeCall takes them out of tools/list; in the other modes it can, except a hidden tool (Direct calls). The filter here reads the tools' annotations, and keeps every tool marked destructiveHint away:

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

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, DeleteCustomer, PurgeTickets, RotateKeys, Reindex, SummarizeThread],
  plugins: [
    CodeCallPlugin.init({
      mode: "codecall_only",
      // tool is { name, fullName, appId, description, tags, source, annotations, metadata }
      includeTools: (tool) => !tool.annotations?.destructiveHint,
    }),
  ],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A script that calls one of them gets Access denied for tool "delete_customer", and the audit log records why: Tool "delete_customer" sets enabledInCodeCall: false.

availableWhen is different. FrontMCP itself refuses a tool whose surface leaves out "mcp", like summarize_thread, to MCP clients: a direct call gets Tool "summarize_thread" not found. CodeCall passes the client's surface on, so its search, describe, invoke and scripts refuse the tool to that client as well.

Limiting codecall:invoke

codecall:invoke calls a tool without a script: a single call, with nothing to filter. directCalls narrows which tools it may call, without changing what scripts can use, with a list of names or a rule:

directCalls

Example 1 of 2

allowedTools

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

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [SearchTickets, GetTicket, CloseTicket],
  plugins: [
    CodeCallPlugin.init({
      directCalls: { enabled: true, allowedTools: ["search_tickets", "get_ticket"] },
    }),
  ],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

directCalls: { enabled: false } refuses every tool; codecall:invoke is still listed.

Several apps on one server

CodeCall on one app hides every app's tools from tools/list, because a plugin's list hooks see the whole server. appIds limits the hiding to the apps you name. Search and calls aren't limited either way: the model can reach the billing app's tools through CodeCall, and filter by app with codecall:search's own appIds input.

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

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

@App({ id: "billing", name: "Billing", tools: [GetInvoice] })
export class BillingApp {}

@FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [HelpDeskApp, BillingApp] })
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Remove appIds and get_invoice disappears from the list too. Registering CodeCall on @FrontMcp instead of the app behaves the same.

Running a script

codecall:execute runs a script and returns its result. The model writes the script after reading schemas with codecall:describe. With the tools from Putting tools behind CodeCall:

{
  "name": "codecall:execute",
  "arguments": {
    "script": "const { tickets } = await callTool(\"search_tickets\", { status: \"open\" });\nconst owners = await parallel(tickets, (t) => callTool(\"get_customer\", { id: t.customerId }));\nreturn tickets.map((t, i) => `${t.id}: ${owners[i].name}`);"
  }
}

returns

{ "status": "ok", "result": ["T-1: Acme Corp", "T-2: Globex", "T-3: Globex"] }

The same script, readable:

const { tickets } = await callTool("search_tickets", { status: "open" });
// One get_customer call per ticket, all at once
const owners = await parallel(tickets, (t) => callTool("get_customer", { id: t.customerId }));
return tickets.map((t, i) => `${t.id}: ${owners[i].name}`);

More scripts, with the same tools. The comment is the codecall:execute result, and script.test.ts in Putting tools behind CodeCall runs each one.

const tool = getTool("get_ticket");
return tool.inputSchema.required;
// { "status": "ok", "result": ["id"] }
mcpLog("info", "looking up open tickets");
const { tickets } = await callTool("search_tickets", { status: "open" });
return tickets.length;
// { "status": "ok", "result": 3, "logs": ["[mcp:info] looking up open tickets"] }
const ticket = await callTool("get_ticket", { id: "T-2" });
return await callTool("close_ticket", { id: ticket.id });
// { "status": "ok", "result": { "id": "T-2", "status": "closed" } }
return { asDate: new Date(0), asText: new Date(0).toISOString() };
// { "status": "ok", "result": { "asDate": "1970-01-01T00:00:00.000Z", "asText": "1970-01-01T00:00:00.000Z" } }

allowedTools narrows one script to the tools the model said it would use. With "allowedTools": ["search_tickets"], a script that goes on to call get_customer ends with { "status": "tool_error", "error": { "source": "tool", "toolName": "get_customer", "message": "Access denied for tool \"get_customer\"", "code": "ACCESS_DENIED" } }.

Handling a tool's failure in a script

A failed call throws. Catch it to carry on; the error only has a message, and it doesn't say why the tool failed:

try {
  return await callTool("get_ticket", { id: "T-9" });
} catch (error) {
  return { failed: error.message };
}

returns { "status": "ok", "result": { "failed": "Tool \"get_ticket\" execution failed" } }: the tool's own message, There's no ticket T-9., 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" } }.

{ throwOnError: false } does the same without try: the call returns { success, data } or { success, error } instead of throwing:

const result = await callTool("get_ticket", { id: "T-9" }, { throwOnError: false });
if (!result.success) return { failed: result.error.message, code: result.error.code };
return result.data;

returns { "status": "ok", "result": { "failed": "Tool \"get_ticket\" execution failed", "code": "EXECUTION" } }. A refused call, like one to a tool outside allowedTools, comes back the same way, with code: "ACCESS_DENIED".

Either way, the script doesn't learn why the tool failed. For a result the model can read, have the tool return it instead of failing, like { found: false }, or call it with codecall:invoke, which returns the tool's own error.

What AgentScript refuses

A script is checked before it runs. One that doesn't parse ends with syntax_error, and one that uses something AgentScript doesn't allow with illegal_access; either way nothing in it has run. This happens in the Playground too, so try your own scripts in the test file:

Open
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { SearchTickets } from "./help-desk.app";

const run = async (mcp: any, script: string) => (await mcp.tools.call("codecall:execute", { script })).json();
const countTo3 = "let n = 0;\nfor (let i = 0; i < 3; i++) { n += i; }\nreturn callTool('search_tickets', {});";

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 for (;;) loops, unless vm.allowLoops is true", async ({ mcp }) => {
  expect((await run(mcp, countTo3)).error.message).toContain("FORBIDDEN_LOOP (line 2): Only for-of loops are allowed (vm.allowLoops is false) (for loop)");

  @App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], plugins: [CodeCallPlugin.init({ mode: "codecall_only", vm: { allowLoops: true } })] })
  class WithLoops {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [WithLoops] });
  const execute = async (script: string) => (await server.callTool("codecall:execute", { script })).structuredContent as any;
  expect((await execute(countTo3)).status).toBe("ok");
  expect((await execute("let page = 1;\nwhile (page < 3) { page++; }\nreturn callTool('search_tickets', {});")).error.message).toContain(
    "FORBIDDEN_LOOP (line 2): Loop constructs are not allowed (while loop)",
  );
  await server.dispose();
});

test("a script that doesn't parse is a syntax_error, at its own line and column", async ({ mcp }) => {
  expect(await run(mcp, "const x = 1;\nconst y = ;\nreturn callTool('search_tickets', {});")).toEqual({
    status: "syntax_error",
    error: { message: "Failed to parse AgentScript code: Unexpected token (2:10)", location: { line: 2, column: 10 } },
  });
});

test("function declarations are refused; use arrow functions", async ({ mcp }) => {
  const result = await run(mcp, "function open(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("regular expressions are refused", async ({ mcp }) => {
  const result = await run(mcp, "const r = await callTool('search_tickets', {});\nreturn /T-\\d+/.test(r.tickets[0].id);");
  expect(result.error.message).toContain("NO_REGEX_LITERAL");
});

test("so are eval, constructor tricks and process", async ({ mcp }) => {
  for (const script of [
    "return eval(\"callTool('search_tickets', {})\");",
    "return [].constructor.constructor('return process')();",
    "const env = process.env;\nreturn callTool('search_tickets', {});",
  ]) {
    expect((await run(mcp, script)).status).toBe("illegal_access");
  }
  expect((await run(mcp, "const env = process.env;\nreturn callTool('search_tickets', {});")).error.message).toContain(
    "DISALLOWED_IDENTIFIER (line 1): process is disabled by vm.disabledGlobals",
  );
});

test("vm.disabledGlobals refuses more names, and leaving one out allows nothing", async () => {
  @App({
    id: "help-desk",
    name: "Help Desk",
    tools: [SearchTickets],
    plugins: [CodeCallPlugin.init({ mode: "codecall_only", vm: { disabledGlobals: ["JSON"] } })],
  })
  class WithoutJson {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [WithoutJson] });
  const execute = async (script: string) => (await server.callTool("codecall:execute", { script })).structuredContent as any;
  expect(await execute("const r = await callTool('search_tickets', {});\nreturn JSON.stringify(r);")).toEqual({
    status: "illegal_access",
    error: { kind: "IllegalBuiltinAccess", message: "AgentScript validation failed:\nDISALLOWED_IDENTIFIER (line 2): JSON is disabled by vm.disabledGlobals" },
  });
  expect(await execute("const formats = { JSON: 'application/json' };\nreturn formats.JSON;")).toEqual({ status: "ok", result: "application/json" });
  expect((await execute("const env = process.env;\nreturn callTool('search_tickets', {});")).error.message).toContain('UNKNOWN_GLOBAL (line 1): Unknown identifier "__safe_process"');
  await server.dispose();
});

test("callTool needs the tool's name as a string literal", async ({ mcp }) => {
  const result = await run(mcp, "const name = 'search_tickets';\nreturn callTool(name, {});");
  expect(result.error.message).toContain("DYNAMIC_CALL_TARGET");
});

test("a script needs at least 23 characters", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:execute", { script: "return 1 + 1" });
  expect(result).toBeError("INVALID_INPUT");
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Change a refused script into an allowed one and it runs.

What a failed script sends back

A failed script's result goes to the model and the client, so it carries only what they need: source, message and name. There's no stack trace, and absolute file paths in the message are replaced with [path]:

Open
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";

@Tool({ name: "search_tickets", description: "Search support tickets by status", inputSchema: { status: z.string().optional() } })
export class SearchTickets extends ToolContext {
  async execute(_input: { status?: string }) {
    return { tickets: [{ id: "T-1", title: "Cannot log in" }] };
  }
}

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The error is { "source": "script", "message": "Cannot read properties of undefined (reading 'title')", "name": "TypeError" }. A path in a message is replaced, and a URL is kept:

const { tickets } = await callTool("search_tickets", {});
throw "Could not read /srv/app/config/secrets.json, see https://example.com/help";
// { "status": "runtime_error", "error": { "source": "script", "message": "Could not read [path], see https://example.com/help", "name": "DoubleVMExecutionError" } }

Hitting the limits

The vm option sets how long a script may compute, how many tools it may call and how big a result may be. This server sets small limits so each is easy to reach: 300 ms, 4 tool calls and 50 items. It also sets allowLoops: true, so the scripts can count with for (…; …; …). codecall:execute's description tells the model these limits. The first call sends a script that makes five tool calls, and the tests cross the others. Change a limit in help-desk.app.ts and see which test notices.

Open
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@Tool({ name: "slow_report", description: "Build a report, which takes a while", inputSchema: {} })
export class SlowReport extends ToolContext {
  async execute() {
    await new Promise((resolve) => setTimeout(resolve, 600));
    return { rows: 3 };
  }
}

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, SlowReport],
  plugins: [CodeCallPlugin.init({ mode: "codecall_only", vm: { timeoutMs: 300, maxSteps: 4, maxSanitizeProperties: 50, allowLoops: true } })],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Script recipes

What a model can do in one script depends on your tools as much as on AgentScript. A tool that takes a list of ids lets a script look up more records than the sandbox allows one call at a time, a cursor lets it walk pages, and a failure it can catch lets it carry on past one bad record. These recipes run against one help desk: 120 tickets, three customers, and tools that read tickets one at a time, up to 50 at a time by id, or a page of 50 at a time. The first Playground's tests check every recipe. Each later one starts the same server with its recipe already sent, so the answer is in the Call tab, where you can change the script and send it again.

Fan-out and fan-in

parallel() starts every call at once and returns the results in order. Collect the distinct ids first, so each customer is fetched once however many of their tickets the list has, then join the answers in the script. Four tickets take two get_customer calls:

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

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

// T-1 to T-120: every third ticket is closed, every 25th is high priority, and T-60's customer was deleted
const tickets = Array.from({ length: 120 }, (_, i) => ({
  id: `T-${i + 1}`,
  status: i % 3 === 2 ? "closed" : "open",
  priority: i % 25 === 0 ? "high" : "normal",
  customerId: i === 59 ? "C-9" : customers[i % 3].id,
}));

@Tool({
  name: "list_tickets",
  description: "List support tickets, 50 a page. Send nextCursor back as cursor for the next page.",
  inputSchema: {
    status: z.enum(["open", "closed"]).optional(),
    priority: z.enum(["high", "normal"]).optional(),
    cursor: z.string().optional(),
  },
})
export class ListTickets extends ToolContext {
  async execute({ status, priority, cursor }: { status?: "open" | "closed"; priority?: "high" | "normal"; cursor?: string }) {
    const matching = tickets.filter((t) => (!status || t.status === status) && (!priority || t.priority === priority));
    const start = Number(cursor ?? 0);
    const end = start + 50;
    return { tickets: matching.slice(start, end), nextCursor: end < matching.length ? String(end) : undefined };
  }
}

@Tool({
  name: "get_tickets",
  description: "Get up to 50 support tickets by their ids",
  inputSchema: { ids: z.array(z.string()).max(50) },
})
export class GetTickets extends ToolContext {
  async execute({ ids }: { ids: string[] }) {
    return { tickets: tickets.filter((t) => ids.includes(t.id)) };
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return tickets.find((t) => t.id === id) ?? this.fail(new PublicMcpError(`There's no ticket ${id}.`));
  }
}

@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}.`));
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

returns

{
  "status": "ok",
  "result": [
    { "ticket": "T-1", "customer": "Acme Corp" },
    { "ticket": "T-26", "customer": "Globex" },
    { "ticket": "T-76", "customer": "Acme Corp" },
    { "ticket": "T-101", "customer": "Globex" }
  ]
}

parallel() takes up to 100 items, but the sandbox stops a script that calls one tool many times in a row: 31 calls of get_customer at once go through, and the 32nd ends the script with Suspicious pattern detected: … [RAPID_ENUMERATION] (Limits). Fetching each customer once keeps this fan-out well under it.

More items than parallel() takes

For more records than that, the model needs a tool that takes a list. get_tickets takes up to 50 ids, so this script sends 120 in three chunks, all at once:

Open

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

It returns { "status": "ok", "result": { "asked": 120, "found": 120, "open": 80 } }. parallel() over the 120 ids themselves would end the script with parallel() is limited to 100 items. Size the chunks from the tool's own limit, and put that limit in its description and schema, where the model reads it: a chunk of 60 fails get_tickets' input check, which the script sees as Tool "get_tickets" execution failed.

Walking pages

Following a cursor takes a while loop in most JavaScript, and AgentScript refuses it, as it refuses for (let i = 0; …) under the default preset. Loop with for…of over a fixed number of pages instead, and break when there's no nextCursor: the number is also the most pages the script reads. Keep a running total rather than every ticket, so the answer stays small:

Open

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

It returns { "status": "ok", "result": { "open": 80, "closed": 40 } }, after three list_tickets calls.

Partial success

With { throwOnError: false }, a failed call is a value rather than the end of the script (Handling a tool's failure in a script). In a fan-out, that keeps one bad record from losing the others. T-60's customer was deleted, so the script returns the other two, with null in its place and the error's code:

Open

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

returns

{
  "status": "ok",
  "result": [
    { "ticket": "T-59", "customer": "Globex" },
    { "ticket": "T-60", "customer": null, "error": "EXECUTION" },
    { "ticket": "T-61", "customer": "Acme Corp" }
  ]
}

Returning early

Check before you act, and return as soon as the answer is known: nothing after the return runs. T-3 is closed already, so this script reads it and stops, without calling close_ticket:

Open

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

It returns { "status": "ok", "result": { "closed": false, "reason": "T-3 is already closed" } }. With "T-1", an open ticket, it goes on to close it, and returns { "closed": true, "ticket": { "id": "T-1", "status": "closed" } }.

Recording what CodeCall does

CodeCall writes each audit event to the server log. To send them somewhere else, subscribe to AuditLoggerService. This plugin subscribes the first time any tool is called, and keeps the events for a tool to show. Call audit_trail after the other calls:

Open
import { DynamicPlugin, FlowCtxOf, Plugin, Provider, ToolHook } from "@frontmcp/sdk";
import { AuditLoggerService } from "@frontmcp/plugin-codecall";

@Provider({ name: "AuditTrail" })
export class AuditTrail {
  events: { type: string; data: Record<string, unknown> }[] = [];
}

@Plugin({ name: "codecall-audit", providers: [AuditTrail], exports: [AuditTrail] })
export class CodeCallAudit extends DynamicPlugin<object> {
  private subscribed = false;

  @ToolHook.Will("execute")
  async subscribe(_ctx: FlowCtxOf<"tools:call-tool">) {
    if (this.subscribed) return;
    this.subscribed = true;
    const trail = this.get(AuditTrail);
    this.get(AuditLoggerService).subscribe((event) => trail.events.push({ type: event.type, data: { ...event.data } }));
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A script adds codecall:execution:start, a codecall:tool:call:start and …:success pair for each call, and codecall:execution:success, as the last test above shows.

Searching skills

codecall:searchSkills and codecall:searchKnowledge search the server's skills: a skill with tools is found by the first, one with only instructions by the second. The model reads a skill's instructions from its skill:// resource.

Open
import { skill } from "@frontmcp/sdk";

export const refundAnInvoice = skill({
  name: "refund-an-invoice",
  description: "Refund a customer's invoice after checking it was paid",
  instructions: "Get the invoice with get_invoice. If its status is paid, call refund_invoice.",
  tools: ["get_invoice", "refund_invoice"],
  tags: ["billing"],
});

export const refundPolicy = skill({
  name: "refund-policy",
  description: "When customers may get a refund",
  instructions: "Refunds are allowed within 30 days of payment. Never refund an invoice twice.",
  tags: ["billing", "policy"],
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Running CodeCall in production

Go through this list before CodeCall faces real clients. Most of it is options covered above; the Playground after the list puts the rest into code.

  • Keep the sandbox's limits tight. The default secure preset refuses for (…; …; …) loops and ends a script after 3.5 seconds of computing or 5,000 tool calls, and locked_down is tighter still. balanced and experimental allow those loops and give scripts more of everything: to raise one limit, set its own field, like vm: { timeoutMs: 5000 }, rather than changing the preset. The model reads the limits in codecall:execute's description. See vm.

  • Keep tools that delete, pay or send away from CodeCall, with enabledInCodeCall: false or an includeTools filter, as in Keeping tools away from CodeCall; help-desk.app.ts below filters on destructiveHint. directCalls narrows what codecall:invoke may call. CodeCall calls tools with the caller's credentials, so authorities and the tools' own checks still apply, and in codecall_opt_in and metadata_driven a client can still call a listed tool directly (Direct calls).

  • Limit how often codecall:execute runs. CodeCall's tools set no rateLimit of their own. throttle.defaultRateLimit gives each of them a count, but it also counts every call a script makes against the tool it calls, and a script whose call is refused only sees Tool "…" execution failed. To limit scripts alone, check codecall:execute in a hook on the acquireQuota stage, where FrontMCP checks a tool's own limit, as execute-limit.plugin.ts does. A caller over the limit gets RATE_LIMIT_EXCEEDED and Rate limit exceeded. Retry after N seconds, as from any rate limit.

  • Watch the audit events. CodeCall writes each one to the server log at info, and AuditLoggerService hands them to your listeners, so you can count them, as metrics.plugin.ts does. Every script ends with codecall:execution:success, codecall:execution:failure (it was refused, threw, or a tool call failed) or codecall:execution:timeout, each with durationMs. Listeners run inside the call, one after another: keep them quick, and hand anything slow to a queue. An error a listener throws is dropped, and the call and the other listeners go on. Events carry a script's hash and length, never its text, or a tool's arguments or result.

  • Roll out one mode at a time. Start with codecall_opt_in: clients see and call every tool as before, and CodeCall reaches only the tools marked enabledInCodeCall: true. Then metadata_driven, where it reaches every tool but those marked false. Last, codecall_only takes the tools out of tools/list, and a client that calls one by name gets Tool "…" not found: that's the step existing clients notice. Going back is the same change the other way, with nothing to migrate, since CodeCall keeps no state between requests. Read the mode from the environment, as help-desk.app.ts does, and restart: codeCallModeSchema.parse() throws for a misspelled mode, so the server doesn't start with one.

  • With embedding.strategy: "ml", ship the model with the server. Checked outside the Playground, on Node 24 with @huggingface/transformers 3.8.1:

    • The server loads the model when it starts, in the background, and first downloads it into embedding.cacheDir if it isn't there: about 91 MB for the default model, in <cacheDir>/Xenova/all-MiniLM-L6-v2/.
    • Until the model is loaded, every codecall:search fails with VectoriaDB must be initialized before searching. Call initialize() first.: half a second with the model on disk, and three seconds with the download, in that test.
    • With no model on disk and no network, the process exits on an unhandled EmbeddingError: Failed to initialize embedding model: fetch failed.
    • The default cacheDir, ./.cache/transformers, is relative to the working directory.

    Set cacheDir to an absolute path, and put the model there when you build the image, by starting the server once or copying the folder, or mount it from a volume. With the files in place, the server started and searched with no network.

  • Several instances. CodeCall itself keeps nothing between requests (Caveats). The counts in execute-limit.plugin.ts are per process, like FrontMCP's own rate limits by default: give createGuardManager() the same storage as throttle to share them (Limiting by an argument). The metrics below are per process too.

The server below puts the preset, the filter, the limit on scripts, the metrics and the mode into code. Its first call is one script; press Call twice more within a minute, and the third is refused. The tests check the rest:

Open
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin, codeCallModeSchema } from "@frontmcp/plugin-codecall";
import { ExecuteLimit } from "./execute-limit.plugin";
import { CodeCallMetricsPlugin } from "./metrics.plugin";
import { CloseTicket, DeleteCustomer, GetTicket } from "./tools";

// Change modes, and back, without changing code. A misspelled mode throws here, at startup.
const mode = codeCallModeSchema.parse(process.env.CODECALL_MODE ?? "codecall_only");

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, CloseTicket, DeleteCustomer],
  plugins: [
    CodeCallPlugin.init({
      mode,
      vm: { preset: "secure" }, // the default, written down
      includeTools: (tool) => !tool.annotations?.destructiveHint,
    }),
    ExecuteLimit,
    CodeCallMetricsPlugin,
  ],
})
export class HelpDeskApp {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

execute-limit.plugin.ts keys its count with buildPartitionContext(), which the guard itself uses for partitionBy: "userId": each signed-in caller counts on their own, and every anonymous caller shares one count, as the partitionBy table describes.


Troubleshooting

Dynamic require of "events" is not supported

The project is an ES module ("type": "module" in package.json), and its @frontmcp/* packages are older than 1.9.0: CodeCall's ES module build loaded the Cache plugin's, which called require() without defining it, so @frontmcp/plugin-codecall and @frontmcp/plugins failed when the module loaded. Update every @frontmcp/* package to 1.9.0 or later, which loads in an ES module project (ES module projects).

tools/list is empty, and every call is Tool "…" not found

The plugin is registered as plugins: [CodeCallPlugin], without init(), on FrontMCP 1.9.3. That version registers none of CodeCall's tools for the class, and CodeCall still hides the app's, so the server has no tools and logs nothing about it. Update to 1.9.4, where the class gives the defaults like CodeCallPlugin.init(), or use CodeCallPlugin.init().

Provider "CodeCallConfig" is not available

The full message is Provider "CodeCallConfig" is not available: not found in local or parent registries (for codecall:search, Provider "_ToolSearchService" …), from every CodeCall tool, on FrontMCP 1.9.2 and earlier. The plugin is registered as plugins: [CodeCallPlugin], without init(), so its providers were never created. Update to 1.9.4, or use CodeCallPlugin.init({ … }).

VectoriaDB must be initialized before searching

Every codecall:search fails with VectoriaDB must be initialized before searching. Call initialize() first., and embedding.strategy is "ml". Either @huggingface/transformers isn't installed, and nothing else is logged: install it, or use "tfidf". Or the server started moments ago and is still loading the model, or downloading it: searches work once it's loaded, and a model already in embedding.cacheDir loads sooner (Running CodeCall in production).

EmbeddingError: Failed to initialize embedding model: fetch failed

The server exits right after it starts, with this unhandled error, and embedding.strategy is "ml". The model isn't in embedding.cacheDir, and the server couldn't download it, as in a container without network access. Put the model in the image, or on a volume, and set cacheDir to its absolute path (Running CodeCall in production).

codecall:search finds nothing, or the wrong tools

Search matches words, not meaning, and not stems (ticket doesn't find tickets). Check warnings: low_relevance says how many results minRelevanceScore dropped. Split requests into short queries, one action each; put the words a model would use in your tools' descriptions, tags and examples; and check the tool is one CodeCall may use: totalAvailableTools counts them, and codecall:describe lists the others in notFound. With very few tools, TF-IDF scores are low: when CodeCall may use only one tool, every search finds nothing.

Too small: expected string to have >=23 characters

codecall:execute's script is shorter than 23 characters, like return 1 + 1. A script that calls no tool has no reason to run on the server; one that calls a tool is longer than that.

Unknown identifier "__safe_…"

The script uses a name AgentScript doesn't have, like Error, Promise, Map or codecallContext. The message lists the names it does have. For errors throw "message"; for concurrency parallel(). See What a script can use.

… is disabled by vm.disabledGlobals

Or … is disabled by vm.disabledBuiltins, with DISALLOWED_IDENTIFIER: the script uses a name one of the vm lists refuses, and it didn't run. Under the default secure preset that's eval, Function, process, require, fetch, setTimeout and the other names AgentScript never has. A name you added yourself, like JSON, can be taken out of your list again; one AgentScript doesn't have is refused anyway, with UNKNOWN_GLOBAL. Before 1.9.3 both lists were ignored.

console is not available in CodeCall scripts; use mcpLog(level, message)

DISALLOWED_IDENTIFIER: the script uses console, which doesn't exist in any preset, whatever vm.allowConsole says. Log with mcpLog("info", "…"), which adds the line to the result's logs.

Only for-of loops are allowed (vm.allowLoops is false)

The script has a for (…; …; …), while, do…while or for…in loop, and vm.allowLoops is false, as in the default secure preset. Loop over an array with for…of: for (const ticket of tickets). To allow for (…; …; …) loops, set vm: { allowLoops: true }, or use the balanced preset. Before 1.9.2, for (…; …; …) loops ran in every preset.

Loop constructs are not allowed (while loop)

The script has a while, do…while or for…in loop, and vm.allowLoops is true. AgentScript never runs those. Rewrite while (page < n) as for (let page = 1; page < n; page++).

Suspicious pattern detected: … [DELETE_AFTER_ACCESS]

Also with [EXFIL_LIST_SEND], [BULK_OPERATION], [CREDENTIAL_EXFIL] or [RAPID_ENUMERATION]. The sandbox stopped the script because its tool calls matched a pattern, by tool name or arguments, like reading a customer and then deleting one. CodeCall has no option to turn this off. Split the work over two codecall:execute calls, or use codecall:invoke for the second step. [RAPID_ENUMERATION] also stops a fan-out of more than 31 calls of one tool: fetch each id once, or give the model a tool that takes a list (Script recipes).

Maximum global function call limit exceeded (…)

The script called getTool(), mcpLog() or mcpNotify() more than ten times vm.maxSteps. With the default maxSteps, the call rate usually stops such a script first, with Operation rate limit exceeded (100 operations/second): those calls count toward it together with tool calls. Call getTool() once per tool and keep the result, and log once per step rather than once per item.

Maximum iteration limit exceeded (10000). This limit prevents infinite loops.

A loop ran more than 10,000 times, with any kind of loop. Before 1.9.3 a for (…; …; …) loop ended with Maximum iteration limit exceeded. This limit prevents infinite loops., without the count. The limit is per loop and can't be changed. Page through data with a tool's own limit and offset, and filter in the tool rather than in the script.

parallel() is limited to 100 items

The script passed parallel() more than 100 items. One tool can't be called that many times at once anyway: its 32nd call is stopped as RAPID_ENUMERATION. Give the model a tool that takes a list of ids, and send them in chunks, as in More items than parallel() takes.

Script execution timed out after 3500ms

The script computed for longer than vm.timeoutMs, which the preset sets (3500 ms for secure). Raise vm.timeoutMs, or do the heavy work in a tool. Waiting for tools doesn't count toward it.

Tool handler return value exceeds maximum properties (2000).

The script's return value has an array or object with more items than vm.maxSanitizeProperties (or is nested deeper than vm.maxSanitizeDepth, … maximum depth (50)). The message says "tool handler", but it's about what the script returned. Return less: the point of a script is to return only the answer.

Access denied for tool "…" in a script

The script called a tool that CodeCall may not use, that isn't in the script's allowedTools, or that doesn't exist: the message is the same for all three, and for a misspelled name. codecall:describe with the name tells you whether CodeCall may use it; the audit event codecall:security:access-denied has the reason.

Tool "…" execution failed in a script

The tool failed, or its input didn't match its schema. Scripts don't get the tool's own message. Call the tool with codecall:invoke to see it, or have the tool return a result that describes the problem. A tool whose message contains "not found" reaches the script as Tool "…" was not found, even though the tool exists.