# CodeCall

> @frontmcp/plugin-codecall puts a large tool set behind a handful of meta-tools: the model searches for the tools it needs, reads their schemas, and calls them one at a time or from a short script that runs in a sandbox on the server.

Source: https://frontmcp.dev/reference/plugins/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](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall) teaches it step by step. The [CRM with CodeCall](https://frontmcp.dev/examples/crm-with-codecall) example is a whole server built on it.

```ts
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()`:

```bash
npm install @frontmcp/plugin-codecall
```

```ts 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.](#usage)

`@frontmcp/plugins` re-exports the same `CodeCallPlugin`, with the Cache, Remember and Dashboard plugins; see [Plugins and adapters](https://frontmcp.dev/reference/plugins). 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](#dynamic-require-of-events-is-not-supported)).

- **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`](#several-apps-on-one-server)), and its tools search and call every app's tools. See [Where you register a plugin](https://frontmcp.dev/reference/sdk/plugin#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](#toolslist-is-empty-and-every-call-is-tool--not-found)).
- **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.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | `"codecall_only" \| "codecall_opt_in" \| "metadata_driven"` | `"codecall_only"` | Which tools `tools/list` shows, and which ones CodeCall may use. See [Modes](#modes). |
| `appIds` | `string[]` | every app | In `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](#several-apps-on-one-server). |
| `includeTools` | `(tool) => boolean` | | Keeps 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](https://frontmcp.dev/reference/sdk/tool#describing-side-effects-with-annotations), and `metadata` its `@Tool` options. See [Keeping tools away from CodeCall](#keeping-tools-away-from-codecall). |
| `directCalls` | `{ enabled, allowedTools?, filter? }` | unset | Limits `codecall:invoke`. Unset, it may call every tool CodeCall can use. See [`directCalls`](#directcalls). |
| `vm` | object | `{ preset: "secure" }` | The sandbox's limits. See [`vm`](#vm). |
| `embedding` | object | TF-IDF | How `codecall:search` ranks tools. See [`embedding`](#embedding). |
| `sidecar` | object | `{ enabled: false }` | Allows scripts longer than 64 KB. See [`sidecar`](#sidecar). |
| `topK` | `number` | `8` | Results per query for a `codecall:search` call that doesn't give its own `topK`. |
| `maxDefinitions` | `number` | `8` | Most 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

| `mode` | In `tools/list` | What 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: false` | Only tools with `codecall: { enabledInCodeCall: true }` |
| `"metadata_driven"` | Every tool, except those with `visibleInListTools: false` | Every 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"`](https://frontmcp.dev/reference/sdk/tool#options) (or the older `hideFromDiscovery: true`);
- tools whose name starts with `system:`, `internal:` or `__`;
- tools `includeTools` returns `false` for;
- tools whose [`availableWhen`](https://frontmcp.dev/reference/server/environment#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"`](https://frontmcp.dev/reference/sdk/tool#options), 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()`](https://frontmcp.dev/reference/sdk/call-tool) from a tool, an agent or a job. A widget's calls, a WebMCP agent's and [`createDirect()`](https://frontmcp.dev/reference/sdk/create#frontmcpinstancecreatedirectconfig)'s `callTool()` count as a client's: a tool they call needs `visibleInListTools: true`.

> **Pitfall: A listed tool can be called directly, even one CodeCall refuses**
CodeCall's refusals, `enabledInCodeCall: false`, `includeTools` and a `system:` name, keep a tool away from CodeCall's own tools and from scripts. A client can still call a tool CodeCall refuses by name when CodeCall leaves it in `tools/list`, as `codecall_opt_in` and `metadata_driven` do. To stop a call, check the caller in the tool, or use [authorities](https://frontmcp.dev/reference/auth/authorities) or a [hook](https://frontmcp.dev/reference/sdk/hooks). [Choosing what `tools/list` shows](#choosing-what-toolslist-shows) runs a direct call in each mode.

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:

```ts
@Tool({
  name: "delete_customer",
  description: "Delete a customer and all their tickets",
  inputSchema: { id: z.string() },
  codecall: { enabledInCodeCall: false, visibleInListTools: true },
})
```

| Field | Type | Description |
| --- | --- | --- |
| `visibleInListTools` | `boolean` | `true` 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`. |
| `enabledInCodeCall` | `boolean` | `false` keeps the tool away from CodeCall in `codecall_only` and `metadata_driven`. In `codecall_opt_in`, only tools with `true` are used. |
| `appId` | `string` | Shown as the tool's `appId` by `codecall:describe`. Search results always show the app the tool belongs to. |
| `source` | `string` | Passed to `includeTools` and `directCalls.filter` as `source`. |
| `tags` | `string[]` | 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`](https://frontmcp.dev/reference/sdk/tool#options) 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.

| Field | Type | `secure` default | Description |
| --- | --- | --- | --- |
| `preset` | `"locked_down" \| "secure" \| "balanced" \| "experimental"` | `"secure"` | Sets the defaults below. |
| `timeoutMs` | `number` | `3500` | How long a script may compute. The script ends with status `timeout`. Time spent waiting for a tool isn't counted: see [Limits](#limits). |
| `maxSteps` | `number` | `5000` | How many tools a script may call, with `callTool()` or through a [tool namespace](#calling-tools). 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. |
| `maxSanitizeDepth` | `number` | `50` | How deeply nested a script's return value may be. |
| `maxSanitizeProperties` | `number` | `2000` | How many items any array or object in a script's return value may have. |
| `allowLoops` | `boolean` | `false` | `false` 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](#what-a-script-can-write). |
| `allowConsole` | `boolean` | | Deprecated, and has no effect: a script that uses `console` is always refused. Use `mcpLog()`. |
| `disabledBuiltins`, `disabledGlobals` | `string[]` | per preset | Names 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](#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`. |

| Preset | `timeoutMs` | `maxSteps` | `maxSanitizeDepth` | `maxSanitizeProperties` | `allowLoops` |
| --- | --- | --- | --- | --- | --- |
| `locked_down` | 2000 | 2000 | 10 | 1000 | `false` |
| `secure` | 3500 | 5000 | 50 | 2000 | `false` |
| `balanced` | 5000 | 10000 | 50 | 5000 | `true` |
| `experimental` | 10000 | 20000 | 100 | 20000 | `true` |

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.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `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](#running-codecall-in-production). Without the package, every search fails with [`VectoriaDB must be initialized before searching`](#vectoriadb-must-be-initialized-before-searching). |
| `modelName` | `string` | `"Xenova/all-MiniLM-L6-v2"` | `ml` only. The model. |
| `cacheDir` | `string` | `"./.cache/transformers"` | `ml` only. Where the model is stored. |
| `useHNSW` | `boolean` | `false` | `ml` only. An approximate index, faster for very large tool sets. |
| `synonymExpansion` | `false \| { enabled, additionalSynonyms, replaceDefaults, maxExpansionsPerTerm }` | enabled | `tfidf` 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](#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.

| Field | Type | Description |
| --- | --- | --- |
| `enabled` | `boolean` | **Required.** `false` makes `codecall:invoke` refuse every tool. It stays listed. |
| `allowedTools` | `string[]` | Only these tools may be invoked, by exact name. |
| `filter` | `(tool) => boolean` | Only 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.`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Removes that check. Scripts are still limited to 102,400 characters by `codecall:execute`'s input, and to 10,000 characters a line. |
| `maxScriptLengthWhenDisabled` | `number \| null` | `65536` | The longest script without the sidecar. `null` for no limit. |
| `maxTotalSize`, `maxReferenceSize`, `extractionThreshold`, `maxResolvedSize`, `allowComposites` | | the sandbox's | Passed 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:

*[Illustration: Sequence: the model calls codecall:search and gets back matching tool names, calls codecall:describe and gets their schemas and examples, then calls codecall:execute with a script. CodeCall validates the script's syntax tree and runs it in a sandbox, where it calls users:list and billing:getInvoices on your tools, filters and joins the results in JavaScript, and returns exactly the data needed.]*
| Tool | What it does | Annotations |
| --- | --- | --- |
| [`codecall:search`](#codecallsearch) | Finds tools for short queries | `readOnlyHint`, `openWorldHint` |
| [`codecall:describe`](#codecalldescribe) | Returns tools' input and output schemas, with usage examples | `readOnlyHint`, `openWorldHint` |
| [`codecall:invoke`](#codecallinvoke) | Calls one tool and returns its result | |
| [`codecall:execute`](#codecallexecute) | Runs an AgentScript program that calls tools | |
| [`codecall:searchSkills`](#codecallsearchskills-and-codecallsearchknowledge) | Finds skills that call tools | `readOnlyHint`, `openWorldHint` |
| [`codecall:searchKnowledge`](#codecallsearchskills-and-codecallsearchknowledge) | Finds skills that are only instructions | `readOnlyHint`, `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`

| Input | Type | Default | Description |
| --- | --- | --- | --- |
| `queries` | `string[]` | | **Required.** 1 to 10 short phrases, 2 to 256 characters each, like `["close ticket", "refund invoice"]`. Each is searched on its own. |
| `topK` | `number` | the plugin's `topK`, `8` | Results per query, up to 50. |
| `minRelevanceScore` | `number` | `0.1` | Drops results scoring below this, from 0 to 1. |
| `appIds` | `string[]` | | Only tools of these apps. |
| `excludeToolNames` | `string[]` | | Leave these out, like tools the model already found. |

For `{ "queries": ["close ticket", "refund invoice"] }`, with the tools from [Finding tools with `codecall:search`](#finding-tools-with-codecallsearch), it returns:

```json
{
  "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`

| Input | Type | Description |
| --- | --- | --- |
| `toolNames` | `string[]` | **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:

| Field | Description |
| --- | --- |
| `name`, `description` | As on the tool. |
| `appId` | `codecall.appId`, or else the app the tool belongs to. |
| `inputSchema` | JSON Schema, with the text of each field's `.describe()`. |
| `outputSchema` | JSON Schema, or `null` for a tool without one. |
| `annotations` | The tool's annotations, when it has any. |
| `usageExamples` | Up 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`

| Input | Type | Description |
| --- | --- | --- |
| `tool` | `string` | **Required.** The tool's name. `get_ticket` also finds `get-ticket`, and the reverse. |
| `input` | `object` | **Required.** 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`

| Input | Type | Description |
| --- | --- | --- |
| `script` | `string` | **Required.** The [AgentScript](#agentscript) program. At least 23 characters (the length of `return callTool("a",{})`) and at most 102,400. |
| `allowedTools` | `string[]` | 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.

| `status` | Fields | When |
| --- | --- | --- |
| `"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](#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](#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](#calling-tools) or not: a `while` on the script's second line is reported on line 2.

> **Note**
Changed in 1.8.5: before, `illegal_access` line numbers counted the code the sandbox rewrites the script into, so they were a few lines past the script's own.

#### `codecall:searchSkills` and `codecall:searchKnowledge`

They search the server's [skills](https://frontmcp.dev/reference/sdk/skill), split in two: `codecall:searchSkills` finds skills that call something (they have `tools`, or OpenAPI operations from [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi)), and `codecall:searchKnowledge` finds the rest, skills that are only instructions.

| Input | Type | Default | Description |
| --- | --- | --- | --- |
| `queries` | `string[]` | | **Required.** 1 to 10 phrases, 2 to 256 characters each. |
| `tags` | `string[]` | | Only skills with one of these tags. |
| `excludeSkillNames` | `string[]` | | Leave these out. |
| `topK` | `number` | `5` | Results per query, up to 50. |
| `minRelevanceScore` | `number` | `0.1` | Drops 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.

```js
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](#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](#in-the-playground) says how), and a test there checks every script result on this page.

#### What a script can use

| Name | Description |
| --- | --- |
| `callTool(name, input)` | Calls a tool and returns its result. See [Calling tools](#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](#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 namespaces | A 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](#calling-tools). |
| `Math`, `JSON`, `Object`, `Array`, `String`, `Number`, `Date` | The standard objects. |
| `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `encodeURI`, `encodeURIComponent`, `decodeURI`, `decodeURIComponent`, `NaN`, `Infinity`, `undefined` | The 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`](#vm) 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`](#vm) 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`:

| Code | Rule | Message |
| --- | --- | --- |
| `for (…; …; …)`, `while (…)`, `do … while`, `for (… in …)` | `FORBIDDEN_LOOP` | `Only 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_DECLARATION` | `Function declarations are not allowed in AgentScript v1. …` |
| `function () {}`, `class`, methods and getters in object literals | `NO_USER_FUNCTION_EXPRESSION`, `NO_USER_METHOD_DEFINITION` | `Function expressions are not allowed in AgentScript v1. …` |
| `/ab+c/` | `NO_REGEX_LITERAL` | `Regex literals are not allowed in this security mode: /ab+c/` |
| `x.constructor` | `NO_CONSTRUCTOR_ACCESS` | `Access to .constructor property is not allowed` |
| `x.__proto__` | `DISALLOWED_IDENTIFIER` | `Access to "__proto__" is not allowed` |
| `import("fs")` | `NO_EVAL` | `Dynamic import() is not allowed (enables dynamic code loading)` |
| names starting `__ag_` or `__safe_` | `RESERVED_PREFIX` | `Identifier "__ag_x" uses reserved prefix "__ag_". …` |
| `console` | `DISALLOWED_IDENTIFIER` | `console 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 `globalThis` | `DISALLOWED_IDENTIFIER` | `eval 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 out | `UNKNOWN_GLOBAL` | `Unknown identifier "__safe_Map". …` |
| `callTool(name, …)`, with a variable, or a template literal with `${…}`, as the tool's name | `DYNAMIC_CALL_TARGET` | `Function "__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](https://frontmcp.dev/reference/sdk/tool#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 invalid | `Tool "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 exist | `Access denied for tool "get_ticket"` |
  | is a CodeCall tool | `Self-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](#handling-a-tools-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](#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

| Limit | Value | What happens |
| --- | --- | --- |
| Script length | 23 to 102,400 characters | An `isError` result: `Too small: expected string to have >=23 characters`, or `Too big: expected string to have <=102400 characters`. |
| Script length without the sidecar | 65,536 characters | `runtime_error`, `name: "ScriptTooLargeError"`. |
| Line length | 10,000 characters | `illegal_access`, `PRESCANNER_LINE_TOO_LONG`. |
| Iterations | 10,000 per loop, not configurable | `runtime_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 time | `vm.timeoutMs` | `timeout`: `Script execution timed out after 3500ms`. |
| Tool calls | `vm.maxSteps` per script, through `callTool()` and namespaces together | `runtime_error`: `Maximum tool call limit exceeded (5000). …` |
| Calls to `getTool()`, `mcpLog()` and `mcpNotify()` | 10 × `vm.maxSteps` per script | `runtime_error`: `Maximum global function call limit exceeded (50000). This limit prevents runaway calls into host functions.` |
| Call rate | 100 a second, tool calls and the three functions above together | `runtime_error`: `Operation rate limit exceeded (100 operations/second)` |
| Return value | `vm.maxSanitizeDepth`, `vm.maxSanitizeProperties` | `runtime_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:

| Id | Stops a script that… |
| --- | --- |
| `EXFIL_LIST_SEND` | calls 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_ACCESS` | calls a tool whose name contains `delete`, `remove`, `destroy`, `purge`, `clear`, `wipe` or `erase` within 30 seconds of one of those reading tools |
| `CREDENTIAL_EXFIL` | calls 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_OPERATION` | calls 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_ENUMERATION` | calls 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](#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](#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](https://frontmcp.dev/reference/auth/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](#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:

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

Get it with [`this.get()`](https://frontmcp.dev/reference/sdk/get) in a tool or a plugin's hook, as in [Recording what CodeCall does](#recording-what-codecall-does). Injecting it into your own provider's `useFactory` gives you a separate instance, which never receives events.

| `type` | `data` |
| --- | --- |
| `codecall:search:performed` | `queryLength`, `resultCount` |
| `codecall:describe:performed` | `toolCount`, `toolNames` (the first 10) |
| `codecall:invoke:performed` | `toolName`, `success` |
| `codecall:execution:start` | `scriptHash`, `scriptLength` |
| `codecall:execution:success` | `scriptHash`, `scriptLength`, `toolCallCount` |
| `codecall:execution:failure` | `scriptHash`, `scriptLength`, `error` |
| `codecall:execution:timeout` | `scriptHash`, `scriptLength` |
| `codecall:tool:call:start`, `codecall:tool:call:success` | `toolName`, `callDepth` |
| `codecall:tool:call:failure` | `toolName`, `callDepth`, `errorCode` |
| `codecall:security:access-denied` | `blocked` (the tool name), `reason` |
| `codecall:security:self-reference` | `blocked`, `reason` |
| `codecall:security:ast-blocked` | `blocked` (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 server | In the Playground |
| --- | --- | --- |
| Isolation | Two nested `vm` contexts | Two nested iframes, with frozen built-in prototypes |
| Computing time | `vm.timeoutMs`, not counting the wait for tools | The 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 name | Names 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 called | Run, 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 mode | Scripts are sloppy: assigning to a method of a built-in does nothing, and `this` is an object | Scripts are strict: that assignment throws a `TypeError`, and `this` is `undefined` |
| Memory limit | `MemoryLimitError` from joined strings and templates, and `RangeError: String.repeat would exceed memory limit: 6MB > 1MB` | `MemoryLimitError` from the same, a `RangeError` without the sizes from `repeat`, `join` and `fill`; `padStart` and `padEnd` aren't counted |
| `sidecar` | Large tool results become references | Has no effect: Enclave's browser sandbox has no sidecar |
| Messages | `RAPID_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](#running-a-script) and [Hitting the limits](#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](https://frontmcp.dev/reference/plugins/cache) 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`:

```ts help-desk.app.ts active
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 {}
```

```ts tools.ts
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",
  inputSchema: { status: z.enum(["open", "closed"]).optional() },
})
export class SearchTickets extends ToolContext {
  async execute({ status }: { status?: "open" | "closed" }) {
    return { tickets: tickets.filter((t) => !status || t.status === status) };
  }
}

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

```ts codecall.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { HelpDeskApp } from "./help-desk.app";
import { GetTicket } from "./tools";

test("tools/list shows CodeCall's tools instead of the app's", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names).toEqual([
    "codecall:describe",
    "codecall:execute",
    "codecall:invoke",
    "codecall:search",
    "codecall:searchKnowledge",
    "codecall:searchSkills",
  ]);
});

test("codecall:describe returns a tool's JSON Schema and usage examples", async ({ mcp }) => {
  const { tools } = (await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket"] })).json();
  expect(tools[0].appId).toBe("help-desk");
  expect(tools[0].inputSchema.properties.id).toEqual({ type: "string", description: "A ticket id, like T-1" });
  expect(tools[0].outputSchema).toBeNull();
  expect(tools[0].usageExamples).toEqual([
    {
      description: "Look up the ticket about logging in",
      code: 'const result = await callTool(\'get_ticket\', {\n  "id": "T-1"\n});\nreturn result;',
    },
  ]);
});

test("a tool without examples gets one that only uses properties its schema declares", async ({ mcp }) => {
  const { tools } = (await mcp.tools.call("codecall:describe", { toolNames: ["get_customer"] })).json();
  expect(tools[0].usageExamples).toEqual([
    { description: "Get get_customer by id", code: "const get_customer = await callTool('get_customer', { id: 'abc123' });\nreturn get_customer;" },
  ]);
});

test("codecall:invoke returns the tool's own result, errors included", async ({ mcp }) => {
  const ok = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } });
  expect(ok.json()).toMatchObject({ id: "T-1", title: "Cannot log in" });
  const missing = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-9" } });
  expect(missing).toBeError();
  expect(missing).toHaveTextContent("There's no ticket T-9.");
});

test("codecall:invoke declares no output schema: it returns another tool's result", async ({ mcp }) => {
  const invoke = (await mcp.tools.list()).find((t: { name: string }) => t.name === "codecall:invoke");
  expect(invoke.outputSchema).toBeUndefined();
});

test("names CodeCall doesn't have are in notFound", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket", "delete_everything"] });
  expect(result.json().notFound).toEqual(["delete_everything"]);
});

test("`init()` with no argument gives the defaults", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [CodeCallPlugin.init()] })
  class WithDefaults {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [WithDefaults] });
  const names = (await server.listTools()).tools.map((tool: { name: string }) => tool.name);
  await server.dispose();
  expect(names).toContain("codecall:search");
  expect(names).not.toContain("get_ticket");
});

test("the class without init() is the same as `init()`", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [CodeCallPlugin] })
  class WithoutInit {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [WithoutInit] });
  const names = (await server.listTools()).tools.map((tool: { name: string }) => tool.name);
  const invoked = await server.callTool("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } });
  await server.dispose();
  expect(names).toContain("codecall:search");
  expect(names).not.toContain("get_ticket");
  expect(invoked.isError).toBeFalsy();
  expect(JSON.stringify(invoked.content)).toContain("Cannot log in");
});

test("codecall:search and codecall:execute describe the server's own limits", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  const description = (name: string) => tools.find((t: { name: string }) => t.name === name).description;
  expect(description("codecall:search")).toContain("topK?: number (default 8)");
  expect(description("codecall:search")).toContain("minRelevanceScore?: number (default 0.1)");
  expect(description("codecall:execute")).toContain("LIMITS: 10000 iterations per loop, 3.5s timeout, 5000 tool calls");
});

test("describe and search are cached for a signed-in caller, invoke and execute are not", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  const asAgent = { authContext: { user: { sub: "agent" } } };
  const script = "return await callTool('get_ticket', { id: 'T-1' });";
  const hitAfterTwoCalls = async (tool: string, args: object) => {
    await server.callTool(tool, args, asAgent);
    return (await server.callTool(tool, args, asAgent))._meta?.cache;
  };
  expect(await hitAfterTwoCalls("codecall:describe", { toolNames: ["get_ticket"] })).toBe("hit");
  expect(await hitAfterTwoCalls("codecall:search", { queries: ["close ticket"] })).toBe("hit");
  expect(await hitAfterTwoCalls("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } })).toBeUndefined();
  expect(await hitAfterTwoCalls("codecall:execute", { script })).toBeUndefined();
  await server.dispose();
});
```

```ts script.test.ts
import { test, expect } from "@frontmcp/testing";

const lines = (...parts: string[]) => parts.join("\n");
const run = async (mcp: any, script: string, allowedTools?: string[]) =>
  (await mcp.tools.call("codecall:execute", { script, allowedTools })).json();

const urgentTickets = lines(
  '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;",
);

test("a script joins tools in one call and returns only the answer", async ({ mcp }) => {
  expect(await run(mcp, urgentTickets)).toEqual({
    status: "ok",
    result: [
      { ticket: "T-1", title: "Cannot log in", customer: "Acme Corp" },
      { ticket: "T-3", title: "Export times out", customer: "Globex" },
    ],
  });
});

test("parallel() calls the function for each item at once, and returns the results in order", async ({ mcp }) => {
  const script = lines(
    '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}`);",
  );
  expect(await run(mcp, script)).toEqual({ status: "ok", result: ["T-1: Acme Corp", "T-2: Globex", "T-3: Globex"] });
});

test("getTool() describes a tool the script may call", async ({ mcp }) => {
  const script = lines('const tool = getTool("get_ticket");', "return tool.inputSchema.required;");
  expect(await run(mcp, script)).toEqual({ status: "ok", result: ["id"] });
});

test("mcpLog() adds a line to the result's logs", async ({ mcp }) => {
  const script = lines('mcpLog("info", "looking up open tickets");', 'const { tickets } = await callTool("search_tickets", { status: "open" });', "return tickets.length;");
  expect(await run(mcp, script)).toEqual({ status: "ok", result: 3, logs: ["[mcp:info] looking up open tickets"] });
});

test("a script can change things: it closes a ticket", async ({ mcp }) => {
  const script = lines('const ticket = await callTool("get_ticket", { id: "T-2" });', 'return await callTool("close_ticket", { id: ticket.id });');
  expect(await run(mcp, script)).toEqual({ status: "ok", result: { id: "T-2", status: "closed" } });
});

test("a Date in the result becomes its ISO string", async ({ mcp }) => {
  const script = "return { asDate: new Date(0), asText: new Date(0).toISOString() };";
  expect(await run(mcp, script)).toEqual({ status: "ok", result: { asDate: "1970-01-01T00:00:00.000Z", asText: "1970-01-01T00:00:00.000Z" } });
});

test("allowedTools narrows one script to the tools it said it would use", async ({ mcp }) => {
  expect(await run(mcp, urgentTickets, ["search_tickets"])).toEqual({
    status: "tool_error",
    error: { source: "tool", toolName: "get_customer", message: 'Access denied for tool "get_customer"', code: "ACCESS_DENIED" },
  });
});

test("a failed call throws, and the script gets a message that isn't the tool's", async ({ mcp }) => {
  const caught = lines("try {", '  return await callTool("get_ticket", { id: "T-9" });', "} catch (error) {", "  return { failed: error.message };", "}");
  expect(await run(mcp, caught)).toEqual({ status: "ok", result: { failed: 'Tool "get_ticket" execution failed' } });
  expect(await run(mcp, 'return await callTool("get_ticket", { id: "T-9" });')).toEqual({
    status: "tool_error",
    error: { source: "tool", toolName: "get_ticket", message: 'Tool "get_ticket" execution failed', code: "EXECUTION" },
  });
});

test("{ throwOnError: false } returns a failure as a value, a refused call too", async ({ mcp }) => {
  const script = lines(
    '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;",
  );
  expect(await run(mcp, script)).toEqual({ status: "ok", result: { failed: 'Tool "get_ticket" execution failed', code: "EXECUTION" } });
  const refused = lines('const result = await callTool("get_customer", { id: "C-1" }, { throwOnError: false });', "return result.error.code;");
  expect(await run(mcp, refused, ["search_tickets"])).toEqual({ status: "ok", result: "ACCESS_DENIED" });
});

test("a path in an error's message is replaced, and a URL is kept", async ({ mcp }) => {
  const script = lines('const { tickets } = await callTool("search_tickets", {});', 'throw "Could not read /srv/app/config/secrets.json, see https://example.com/help";');
  expect(await run(mcp, script)).toEqual({
    status: "runtime_error",
    error: { source: "script", message: "Could not read [path], see https://example.com/help", name: "DoubleVMExecutionError" },
  });
});
```

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

```json
{ "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](#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"]`:

```ts tools.ts active
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: "…" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { CloseTicket, CreateCustomer, ExportReport, RefundInvoice, SearchTickets } from "./tools";

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

```ts search.test.ts
import { test, expect } from "@frontmcp/testing";

const search = async (mcp: any, input: object) => (await mcp.tools.call("codecall:search", input)).json();

test("each query finds its tool", async ({ mcp }) => {
  const { tools, totalAvailableTools } = await search(mcp, { queries: ["close ticket", "refund invoice"] });
  expect(tools.map((t: any) => [t.name, t.matchedQueries])).toEqual([
    ["close_ticket", ["close ticket"]],
    ["refund_invoice", ["refund invoice"]],
  ]);
  expect(totalAvailableTools).toBe(5);
});

test("synonyms: `add client` finds create_customer", async ({ mcp }) => {
  const { tools } = await search(mcp, { queries: ["add client"] });
  expect(tools.map((t: any) => t.name)).toEqual(["create_customer"]);
});

test("tags and examples are searched too", async ({ mcp }) => {
  const { tools } = await search(mcp, { queries: ["analytics", "quarter"] });
  expect(tools.map((t: any) => [t.name, t.matchedQueries])).toEqual([["export_report", ["analytics", "quarter"]]]);
});

test("no stemming: `ticket` and `tickets` find different tools", async ({ mcp }) => {
  const { tools } = await search(mcp, { queries: ["ticket", "tickets"], topK: 1 });
  expect(tools.map((t: any) => [t.name, t.matchedQueries])).toEqual([
    ["close_ticket", ["ticket"]],
    ["search_tickets", ["tickets"]],
  ]);
});

test("excludeToolNames leaves tools out, and warns about names it doesn't know", async ({ mcp }) => {
  const { tools, warnings } = await search(mcp, { queries: ["tickets"], excludeToolNames: ["close_ticket", "reopen_ticket"] });
  expect(tools.map((t: any) => t.name)).toEqual(["search_tickets"]);
  expect(warnings).toContainEqual({
    type: "excluded_tool_not_found",
    message: "Excluded tools not found: reopen_ticket",
    affectedTools: ["reopen_ticket"],
  });
});

test("nothing found is a warning, not an error", async ({ mcp }) => {
  const result = await search(mcp, { queries: ["weather"] });
  expect(result.tools).toEqual([]);
  expect(result.warnings).toEqual([{ type: "no_results", message: "No tools found for queries: weather" }]);
});
```

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

<Examples title="Modes">

#### Example: 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.

```ts tools.ts active
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" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { GetTicket, Health } from "./tools";

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

```ts modes.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { GetTicket } from "./tools";

test("health is listed; get_ticket isn't", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("health");
  expect(tools).not.toContainTool("get_ticket");
});

test("CodeCall can use both", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:describe", { toolNames: ["health", "get_ticket"] });
  expect(result.json().notFound).toBeUndefined();
});

test("a client can call health directly, and not get_ticket", async ({ mcp }) => {
  expect(await mcp.tools.call("health", {})).toBeSuccessful();
  expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeError("TOOL_NOT_FOUND");
});

test("a tool on the server can still call get_ticket", async () => {
  @Tool({ name: "ticket_title", description: "The title of a ticket", inputSchema: { id: z.string() }, codecall: { visibleInListTools: true } })
  class TicketTitle extends ToolContext {
    async execute({ id }: { id: string }) {
      return { ticket: await this.callTool("get_ticket", { id }) };
    }
  }
  @App({ id: "help-desk", name: "Help Desk", tools: [TicketTitle, GetTicket], plugins: [CodeCallPlugin.init({ mode: "codecall_only" })] })
  class WithTitle {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [WithTitle] });
  expect((await server.callTool("ticket_title", { id: "T-1" })).structuredContent).toMatchObject({
    ticket: { structuredContent: { id: "T-1", title: "Cannot log in" } },
  });
  await server.dispose();
});
```

#### Example: codecall_opt_in
Every tool is listed as usual, unless it sets `visibleInListTools: false`, and CodeCall only uses the tools that opt in with `enabledInCodeCall: true`. Use it to try CodeCall on a few tools of a server whose clients already use the rest directly.

```ts tools.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "search_tickets",
  description: "Search support tickets by status",
  inputSchema: { status: z.string().optional() },
  codecall: { enabledInCodeCall: true },
})
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" };
  }
}

// Too big a result for the model's context: only scripts use it, and filter it first
@Tool({
  name: "export_tickets",
  description: "Export every ticket, with its full history",
  inputSchema: {},
  codecall: { enabledInCodeCall: true, visibleInListTools: false },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { tickets: [] };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { CloseTicket, ExportTickets, SearchTickets } from "./tools";

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

```ts modes.test.ts
import { test, expect } from "@frontmcp/testing";

test("every tool is listed, but export_tickets", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("search_tickets");
  expect(tools).toContainTool("close_ticket");
  expect(tools).toContainTool("codecall:execute");
  expect(tools).not.toContainTool("export_tickets");
});

test("CodeCall only uses the tools that opted in", async ({ mcp }) => {
  const described = await mcp.tools.call("codecall:describe", { toolNames: ["search_tickets", "close_ticket", "export_tickets"] });
  expect(described.json().tools.map((t: { name: string }) => t.name)).toEqual(["search_tickets", "export_tickets"]);
  expect(described.json().notFound).toEqual(["close_ticket"]);
  const invoked = await mcp.tools.call("codecall:invoke", { tool: "close_ticket", input: { id: "T-1" } });
  expect(invoked).toBeError();
  expect(invoked).toHaveTextContent('Tool "close_ticket" is not available. Use codecall:search to discover available tools.');
});

test("a client can call the listed tools directly, and not export_tickets", async ({ mcp }) => {
  expect(await mcp.tools.call("search_tickets", {})).toBeSuccessful();
  expect(await mcp.tools.call("close_ticket", { id: "T-1" })).toBeSuccessful();
  expect(await mcp.tools.call("export_tickets", {})).toBeError("TOOL_NOT_FOUND");
});
```

#### Example: metadata_driven
Every tool is listed unless it sets `visibleInListTools: false`, and CodeCall uses every tool unless it sets `enabledInCodeCall: false`. Each tool decides both for itself. A hidden tool is neither listed nor used by CodeCall, and a client can call it only if it sets `visibleInListTools: true`.

```ts tools.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

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

// Too big a result for the model's context: only scripts use it, and filter it first
@Tool({
  name: "export_tickets",
  description: "Export every ticket, with its full history",
  inputSchema: {},
  codecall: { visibleInListTools: false },
})
export class ExportTickets extends ToolContext {
  async execute() {
    return { tickets: [] };
  }
}

// For people, from the list, never for scripts
@Tool({
  name: "delete_customer",
  description: "Delete a customer and all their tickets",
  inputSchema: { id: z.string() },
  codecall: { enabledInCodeCall: false },
})
export class DeleteCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return { deleted: id };
  }
}

@Tool({ name: "rotate_keys", description: "Rotate the API keys", inputSchema: {}, visibility: "hidden" })
export class RotateKeys extends ToolContext {
  async execute() {
    return { rotated: true };
  }
}

// Hidden, but an admin client that knows the name may call it
@Tool({ name: "reindex", description: "Rebuild the search index", inputSchema: {}, visibility: "hidden", codecall: { visibleInListTools: true } })
export class Reindex extends ToolContext {
  async execute() {
    return { reindexed: true };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { DeleteCustomer, ExportTickets, GetTicket, Reindex, RotateKeys } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, ExportTickets, DeleteCustomer, RotateKeys, Reindex],
  plugins: [CodeCallPlugin.init({ mode: "metadata_driven" })],
})
export class HelpDeskApp {}
```

```ts modes.test.ts
import { test, expect } from "@frontmcp/testing";

test("export_tickets isn't listed; the others are", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("get_ticket");
  expect(tools).toContainTool("delete_customer");
  expect(tools).not.toContainTool("export_tickets");
});

test("CodeCall uses every tool but delete_customer", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket", "export_tickets", "delete_customer"] });
  expect(result.json().tools.map((t: { name: string }) => t.name)).toEqual(["get_ticket", "export_tickets"]);
  expect(result.json().notFound).toEqual(["delete_customer"]);
});

test("a client can call delete_customer directly, and not export_tickets", async ({ mcp }) => {
  expect(await mcp.tools.call("delete_customer", { id: "C-1" })).toBeSuccessful();
  expect(await mcp.tools.call("export_tickets", {})).toBeError("TOOL_NOT_FOUND");
});

test("hidden tools aren't listed; a client can call only the one with visibleInListTools", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).not.toContainTool("rotate_keys");
  expect(tools).not.toContainTool("reindex");
  expect(await mcp.tools.call("rotate_keys", {})).toBeError("TOOL_NOT_FOUND");
  expect(await mcp.tools.call("reindex", {})).toBeSuccessful();
});
```

### 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](#direct-calls)). The filter here reads the tools' annotations, and keeps every tool marked `destructiveHint` away:

```ts help-desk.app.ts active
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 {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@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: "delete_customer",
  description: "Delete a customer and all their tickets",
  inputSchema: { id: z.string() },
  codecall: { enabledInCodeCall: false },
})
export class DeleteCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return { deleted: id };
  }
}

@Tool({
  name: "purge_closed_tickets",
  description: "Delete every closed ticket",
  inputSchema: {},
  annotations: { destructiveHint: true },
})
export class PurgeTickets extends ToolContext {
  async execute() {
    return { purged: 12 };
  }
}

@Tool({ name: "rotate_keys", description: "Rotate the API keys", inputSchema: {}, visibility: "hidden" })
export class RotateKeys extends ToolContext {
  async execute() {
    return { rotated: true };
  }
}

@Tool({ name: "system:reindex", description: "Rebuild the search index", inputSchema: {} })
export class Reindex extends ToolContext {
  async execute() {
    return { reindexed: true };
  }
}

// Only for agents: MCP clients can't reach it, directly or through CodeCall
@Tool({
  name: "summarize_thread",
  description: "Summarize a ticket's thread",
  inputSchema: { id: z.string() },
  availableWhen: { surface: ["agent"] },
})
export class SummarizeThread extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, summary: "…" };
  }
}
```

```ts away.test.ts
import { test, expect } from "@frontmcp/testing";

const refused = ["delete_customer", "purge_closed_tickets", "rotate_keys", "system:reindex"];

test("codecall:describe only knows get_ticket", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket", ...refused] });
  expect(result.json().tools.map((t: { name: string }) => t.name)).toEqual(["get_ticket"]);
  expect(result.json().notFound).toEqual(refused);
});

test("codecall:invoke refuses the other four", async ({ mcp }) => {
  for (const tool of refused) {
    const result = await mcp.tools.call("codecall:invoke", { tool, input: tool === "delete_customer" ? { id: "C-1" } : {} });
    expect(result).toBeError();
    expect(result).toHaveTextContent(`Tool "${tool}" is not available.`);
  }
});

test("a client that knows the names can't call them directly either", async ({ mcp }) => {
  for (const tool of refused) {
    const result = await mcp.tools.call(tool, tool === "delete_customer" ? { id: "C-1" } : {});
    expect(result).toBeError("TOOL_NOT_FOUND");
    expect(result).toHaveTextContent(`Tool "${tool}" not found`);
  }
});

test("a tool offered only to agents is refused to the client, through CodeCall and directly", async ({ mcp }) => {
  const described = await mcp.tools.call("codecall:describe", { toolNames: ["summarize_thread"] });
  expect(described.json().notFound).toEqual(["summarize_thread"]);
  const invoked = await mcp.tools.call("codecall:invoke", { tool: "summarize_thread", input: { id: "T-1" } });
  expect(invoked).toHaveTextContent('Tool "summarize_thread" is not available.');
  expect(await mcp.tools.call("summarize_thread", { id: "T-1" })).toBeError();
});
```

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`](https://frontmcp.dev/reference/server/environment#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:

<Examples title="directCalls">

#### Example: allowedTools
```ts help-desk.app.ts active
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 {}
```

```ts tools.ts
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: [{ id: "T-1" }] };
  }
}

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

```ts invoke.test.ts
import { test, expect } from "@frontmcp/testing";

test("allowed tools are invoked", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } });
  expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in" });
});

test("others are refused, but CodeCall still describes them", async ({ mcp }) => {
  const invoked = await mcp.tools.call("codecall:invoke", { tool: "close_ticket", input: { id: "T-1" } });
  expect(invoked).toBeError();
  expect(invoked).toHaveTextContent('Tool "close_ticket" is not available. Use codecall:search to discover available tools.');
  const described = await mcp.tools.call("codecall:describe", { toolNames: ["close_ticket"] });
  expect(described.json().notFound).toBeUndefined();
});

test("the tool's own validation errors come back as they are", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: 7 } });
  expect(result).toBeError("INVALID_INPUT");
});
```

#### Example: filter
`filter` gets the same object as `includeTools`, so a rule can read the tools' annotations. This one lets `codecall:invoke` call only tools marked `readOnlyHint`:

```ts help-desk.app.ts active
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { CloseTicket, GetTicket } from "./tools";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, CloseTicket],
  plugins: [
    CodeCallPlugin.init({
      directCalls: { enabled: true, filter: (tool) => tool.annotations?.readOnlyHint === true },
    }),
  ],
})
export class HelpDeskApp {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

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

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

```ts invoke.test.ts
import { test, expect } from "@frontmcp/testing";

test("read-only tools are invoked", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } });
  expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in" });
});

test("the others are refused", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:invoke", { tool: "close_ticket", input: { id: "T-1" } });
  expect(result).toBeError();
  expect(result).toHaveTextContent('Tool "close_ticket" is not available. Use codecall:search to discover available tools.');
});
```

`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.

```ts main.ts active
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 {}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@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: "get_invoice", description: "Get an invoice by its id", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, total: 120 };
  }
}
```

```ts apps.test.ts
import { test, expect } from "@frontmcp/testing";

test("only the help desk's tools are hidden", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("get_invoice");
  expect(tools).not.toContainTool("get_ticket");
});

test("CodeCall reaches both apps' tools", async ({ mcp }) => {
  const { tools } = (await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket", "get_invoice"] })).json();
  expect(tools.map((t: { name: string; appId: string }) => [t.name, t.appId])).toEqual([
    ["get_ticket", "help-desk"],
    ["get_invoice", "billing"],
  ]);
});
```

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](#putting-tools-behind-codecall):

```json
{
  "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

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

The same script, readable:

```js
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](#putting-tools-behind-codecall) runs each one.

```js
const tool = getTool("get_ticket");
return tool.inputSchema.required;
// { "status": "ok", "result": ["id"] }
```

```js
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"] }
```

```js
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" } }
```

```js
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:

```js
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:

```js
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:

```ts refused.test.ts active
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");
});
```

```ts help-desk.app.ts
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", status: "open" }] };
  }
}

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

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]`:

```ts help-desk.app.ts active
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 {}
```

```ts errors.test.ts
import { test, expect } from "@frontmcp/testing";

test("a failed script's error has a message and a name, and no stack", async ({ mcp }) => {
  const result = (await mcp.tools.call("codecall:execute", { script: "const r = await callTool('search_tickets', {});\nreturn r.tickets[5].title;" })).json();
  expect(result.status).toBe("runtime_error");
  expect(result.error).toEqual({ source: "script", message: "Cannot read properties of undefined (reading 'title')", name: "TypeError" });
  expect(Object.keys(result.error).sort()).toEqual(["message", "name", "source"]);
});
```

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:

```js
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`](#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.

```ts help-desk.app.ts active
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 {}
```

```ts limits.test.ts
import { test, expect } from "@frontmcp/testing";

const lines = (...parts: string[]) => parts.join("\n");
const run = async (mcp: any, script: string) => (await mcp.tools.call("codecall:execute", { script })).json();

test("codecall:execute's description gives the model these limits", async ({ mcp }) => {
  const execute = (await mcp.tools.list()).find((t: { name: string }) => t.name === "codecall:execute");
  expect(execute.description).toContain("ALLOWED: for, for-of,");
  expect(execute.description).toContain("LIMITS: 10000 iterations per loop, 0.3s timeout, 4 tool calls");
});

test("computing for longer than vm.timeoutMs ends the script with timeout", async ({ mcp }) => {
  const script = lines(
    "let n = 0;",
    "for (let i = 0; i < 10000; i++) {",
    "  for (let j = 0; j < 10000; j++) {",
    "    for (let k = 0; k < 10000; k++) { n += 1; }",
    "  }",
    "}",
    "return n;",
  );
  expect(await run(mcp, script)).toEqual({ status: "timeout", error: { message: "Script execution timed out after 300ms" } });
});

test("waiting for a tool isn't computing time: a 600 ms tool finishes under a 300 ms limit", async ({ mcp }) => {
  expect(await run(mcp, 'return await callTool("slow_report", {});')).toEqual({ status: "ok", result: { rows: 3 } });
});

test("more tool calls than vm.maxSteps end the script", async ({ mcp }) => {
  const script = lines("const out = [];", "for (let i = 1; i <= 5; i++) {", '  out.push(await callTool("get_ticket", { id: "T-" + i }));', "}", "return out.length;");
  expect(await run(mcp, script)).toEqual({
    status: "runtime_error",
    error: { source: "script", message: "Maximum tool call limit exceeded (4). This limit prevents runaway script execution.", name: "Error" },
  });
});

test("so do ten times as many calls to getTool()", async ({ mcp }) => {
  const script = lines("for (let i = 0; i < 41; i++) {", '  getTool("get_ticket");', "}", "return 1;");
  expect(await run(mcp, script)).toEqual({
    status: "runtime_error",
    error: { source: "script", message: "Maximum global function call limit exceeded (40). This limit prevents runaway calls into host functions.", name: "Error" },
  });
});

test("a loop that runs more than 10,000 times ends the script, whatever the loop", async ({ mcp }) => {
  const iterationLimit = {
    status: "runtime_error",
    error: { source: "script", message: "Maximum iteration limit exceeded (10000). This limit prevents infinite loops.", name: "Error" },
  };
  const counting = lines("let n = 0;", "for (let i = 0; i < 10001; i++) { n += 1; }", "return n;");
  const overAnArray = lines("const items = Array.from({ length: 10001 }, (_, index) => index);", "let n = 0;", "for (const item of items) { n += 1; }", "return n;");
  expect(await run(mcp, counting)).toEqual(iterationLimit);
  expect(await run(mcp, overAnArray)).toEqual(iterationLimit);
});

test("parallel() takes up to 100 items", async ({ mcp }) => {
  const items = (count: number) => lines("const items = [];", `for (let i = 0; i < ${count}; i++) { items.push(i); }`, "return (await parallel(items, async (item) => item * 2)).length;");
  expect(await run(mcp, items(100))).toEqual({ status: "ok", result: 100 });
  expect(await run(mcp, items(101))).toEqual({
    status: "runtime_error",
    error: { source: "script", message: "parallel() is limited to 100 items", name: "Error" },
  });
});

test("a result with more than vm.maxSanitizeProperties items is refused", async ({ mcp }) => {
  const script = lines("const out = [];", "for (let i = 0; i < 60; i++) { out.push(i); }", "return out;");
  expect(await run(mcp, script)).toEqual({
    status: "runtime_error",
    error: { source: "script", message: "Tool handler return value exceeds maximum properties (50).", name: "Error" },
  });
});

test("a script that reads and then deletes is stopped by the sandbox's pattern check", async ({ mcp }) => {
  const script = lines('const ticket = await callTool("get_ticket", { id: "T-1" });', 'return await callTool("delete_ticket", { id: ticket.id });');
  const result = await run(mcp, script);
  expect(result.status).toBe("runtime_error");
  expect(result.error.message).toBe("Suspicious pattern detected: Delete operation after data access (potential cover-up) [DELETE_AFTER_ACCESS]");
});
```

### 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:

<Playground id="recipes" size="tall" call={{ tool: "codecall:execute", arguments: { script: [
  'const { tickets } = await callTool("list_tickets", { status: "open", priority: "high" });',
  '// Each customer once, however many of their tickets the list has',
  'const ids = tickets.map((t) => t.customerId).filter((id, i, all) => all.indexOf(id) === i);',
  'const customers = await parallel(ids, (id) => callTool("get_customer", { id }));',
  'const names = Object.fromEntries(customers.map((c) => [c.id, c.name]));',
  'return tickets.map((t) => ({ ticket: t.id, customer: names[t.customerId] }));',
].join("\n") } }}>

```ts tools.ts active
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}.`));
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { CloseTicket, GetCustomer, GetTicket, GetTickets, ListTickets } from "./tools";

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

```ts recipes.test.ts
import { test, expect } from "@frontmcp/testing";

const lines = (...parts: string[]) => parts.join("\n");
const run = async (mcp: any, script: string) => (await mcp.tools.call("codecall:execute", { script })).json();

test("fan-out and fan-in: each customer is fetched once", async ({ mcp }) => {
  const script = lines(
    'const { tickets } = await callTool("list_tickets", { status: "open", priority: "high" });',
    "const ids = tickets.map((t) => t.customerId).filter((id, i, all) => all.indexOf(id) === i);",
    'const customers = await parallel(ids, (id) => callTool("get_customer", { id }));',
    "const names = Object.fromEntries(customers.map((c) => [c.id, c.name]));",
    "return { calls: ids.length, rows: tickets.map((t) => ({ ticket: t.id, customer: names[t.customerId] })) };",
  );
  expect(await run(mcp, script)).toEqual({
    status: "ok",
    result: {
      calls: 2,
      rows: [
        { ticket: "T-1", customer: "Acme Corp" },
        { ticket: "T-26", customer: "Globex" },
        { ticket: "T-76", customer: "Acme Corp" },
        { ticket: "T-101", customer: "Globex" },
      ],
    },
  });
});

test("31 calls of one tool at once pass; the 32nd is stopped as rapid enumeration", async ({ mcp }) => {
  const fanOut = (count: number) =>
    lines(`const ids = Array.from({ length: ${count} }, () => "C-1");`, 'const all = await parallel(ids, (id) => callTool("get_customer", { id }));', "return all.length;");
  expect(await run(mcp, fanOut(31))).toEqual({ status: "ok", result: 31 });
  const stopped = await run(mcp, fanOut(32));
  expect(stopped.status).toBe("runtime_error");
  expect(stopped.error.message).toContain("[RAPID_ENUMERATION]");
});

test("parallel() refuses more than 100 items", async ({ mcp }) => {
  const script = lines('const ids = Array.from({ length: 120 }, (_, i) => `T-${i + 1}`);', 'return (await parallel(ids, (id) => callTool("get_ticket", { id }))).length;');
  expect(await run(mcp, script)).toEqual({
    status: "runtime_error",
    error: { source: "script", message: "parallel() is limited to 100 items", name: "Error" },
  });
});

const chunked = (size: number) =>
  lines(
    "const ids = Array.from({ length: 120 }, (_, i) => `T-${i + 1}`);",
    `const size = ${size};`,
    "const chunks = Array.from({ length: Math.ceil(ids.length / size) }, (_, i) => ids.slice(i * size, (i + 1) * size));",
    'const pages = await parallel(chunks, (chunk) => callTool("get_tickets", { ids: chunk }));',
    "const found = pages.flatMap((page) => page.tickets);",
    'return { asked: ids.length, found: found.length, open: found.filter((t) => t.status === "open").length };',
  );

test("chunks of 50 for a tool that takes a list: three calls for 120 tickets", async ({ mcp }) => {
  expect(await run(mcp, chunked(50))).toEqual({ status: "ok", result: { asked: 120, found: 120, open: 80 } });
});

test("a chunk bigger than the tool takes fails that call", async ({ mcp }) => {
  expect(await run(mcp, chunked(60))).toEqual({
    status: "tool_error",
    error: { source: "tool", toolName: "get_tickets", message: 'Tool "get_tickets" execution failed', code: "EXECUTION" },
  });
});

test("walking pages: a for…of over a fixed number of pages, with a running total", async ({ mcp }) => {
  const script = lines(
    "const counts = {};",
    "let cursor;",
    "let pages = 0;",
    "for (const _ of Array.from({ length: 10 })) {",
    '  const page = await callTool("list_tickets", cursor ? { cursor } : {});',
    "  pages += 1;",
    "  for (const t of page.tickets) counts[t.status] = (counts[t.status] ?? 0) + 1;",
    "  cursor = page.nextCursor;",
    "  if (!cursor) break;",
    "}",
    "return { pages, counts };",
  );
  expect(await run(mcp, script)).toEqual({ status: "ok", result: { pages: 3, counts: { open: 80, closed: 40 } } });
});

test("partial success: a failed call becomes a row with a fallback", async ({ mcp }) => {
  const script = lines(
    'const { tickets } = await callTool("get_tickets", { ids: ["T-59", "T-60", "T-61"] });',
    'const customers = await parallel(tickets, (t) => callTool("get_customer", { id: t.customerId }, { throwOnError: false }));',
    "return tickets.map((t, i) =>",
    "  customers[i].success",
    "    ? { ticket: t.id, customer: customers[i].data.name }",
    "    : { ticket: t.id, customer: null, error: customers[i].error.code },",
    ");",
  );
  expect(await run(mcp, script)).toEqual({
    status: "ok",
    result: [
      { ticket: "T-59", customer: "Globex" },
      { ticket: "T-60", customer: null, error: "EXECUTION" },
      { ticket: "T-61", customer: "Acme Corp" },
    ],
  });
});

const closeIfOpen = (id: string) =>
  lines(
    `const ticket = await callTool("get_ticket", { id: "${id}" });`,
    "if (ticket.status !== \"open\") return { closed: false, reason: `${ticket.id} is already ${ticket.status}` };",
    'return { closed: true, ticket: await callTool("close_ticket", { id: ticket.id }) };',
  );

test("returning early: a closed ticket is left alone", async ({ mcp }) => {
  expect(await run(mcp, closeIfOpen("T-3"))).toEqual({ status: "ok", result: { closed: false, reason: "T-3 is already closed" } });
  expect(await run(mcp, closeIfOpen("T-1"))).toEqual({ status: "ok", result: { closed: true, ticket: { id: "T-1", status: "closed" } } });
});
```

returns

```json
{
  "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](#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:

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:

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](#handling-a-tools-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:

returns

```json
{
  "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`:

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](#audit-events) 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:

```ts audit.plugin.ts active
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 } }));
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { AuditTrail, CodeCallAudit } from "./audit.plugin";

@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: "delete_customer",
  description: "Delete a customer and all their tickets",
  inputSchema: { id: z.string() },
  codecall: { enabledInCodeCall: false },
})
export class DeleteCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return { deleted: id };
  }
}

@Tool({
  name: "audit_trail",
  description: "What CodeCall has done so far",
  inputSchema: {},
  codecall: { visibleInListTools: true, enabledInCodeCall: false },
})
export class ShowAuditTrail extends ToolContext {
  async execute() {
    return { events: this.get(AuditTrail).events };
  }
}

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

```ts audit.test.ts
import { test, expect } from "@frontmcp/testing";

test("describe and invoke are recorded, with the real reason for a refusal", async ({ mcp }) => {
  await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket", "delete_customer"] });
  await mcp.tools.call("codecall:invoke", { tool: "get_ticket", input: { id: "T-1" } });
  const { events } = (await mcp.tools.call("audit_trail", {})).json();
  expect(events).toEqual([
    {
      type: "codecall:security:access-denied",
      data: { blocked: "delete_customer", reason: 'Tool "delete_customer" sets enabledInCodeCall: false' },
    },
    { type: "codecall:describe:performed", data: { toolCount: 2, toolNames: ["get_ticket", "delete_customer"] } },
    { type: "codecall:invoke:performed", data: { toolName: "get_ticket", success: true } },
  ]);
});

test("a script is recorded with a start and a success for each tool call", async ({ mcp }) => {
  const earlier = (await mcp.tools.call("audit_trail", {})).json().events.length;
  await mcp.tools.call("codecall:execute", { script: 'return await callTool("get_ticket", { id: "T-1" });' });
  const { events } = (await mcp.tools.call("audit_trail", {})).json();
  expect(events.slice(earlier).map((event: { type: string }) => event.type)).toEqual([
    "codecall:execution:start",
    "codecall:tool:call:start",
    "codecall:tool:call:success",
    "codecall:execution:success",
  ]);
});
```

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](https://frontmcp.dev/reference/sdk/skill): 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.

```ts skills.ts active
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"],
});
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { refundAnInvoice, refundPolicy } from "./skills";

@Tool({ name: "get_invoice", description: "Get an invoice by its id", inputSchema: { id: z.string() } })
export class GetInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "paid" };
  }
}

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

@App({
  id: "billing",
  name: "Billing",
  tools: [GetInvoice, RefundInvoice],
  skills: [refundAnInvoice, refundPolicy],
  plugins: [CodeCallPlugin.init({ mode: "codecall_only" })],
})
export class BillingApp {}
```

```ts skills.test.ts
import { test, expect } from "@frontmcp/testing";

test("a skill with tools is found by searchSkills", async ({ mcp }) => {
  const { skills } = (await mcp.tools.call("codecall:searchSkills", { queries: ["refund"] })).json();
  expect(skills.map((s: any) => [s.name, s.tools])).toEqual([["refund-an-invoice", ["get_invoice", "refund_invoice"]]]);
});

test("a skill of instructions only is found by searchKnowledge", async ({ mcp }) => {
  const { knowledge, totalKnowledgeSkills } = (await mcp.tools.call("codecall:searchKnowledge", { queries: ["refund"] })).json();
  expect(knowledge.map((k: any) => k.name)).toEqual(["refund-policy"]);
  expect(totalKnowledgeSkills).toBe(1);
});

test("the instructions are in the skill's resource, not in codecall:describe", async ({ mcp }) => {
  expect((await mcp.tools.call("codecall:describe", { toolNames: ["refund-policy"] })).json().notFound).toEqual(["refund-policy"]);
  expect((await mcp.resources.read("skill://refund-policy/SKILL.md")).text()).toContain("Never refund an invoice twice.");
});
```

### 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`](#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](#keeping-tools-away-from-codecall); `help-desk.app.ts` below filters on `destructiveHint`. [`directCalls`](#limiting-codecallinvoke) narrows what `codecall:invoke` may call. CodeCall calls tools with the caller's credentials, so [authorities](https://frontmcp.dev/reference/auth/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](#direct-calls)).
- **Limit how often `codecall:execute` runs.** CodeCall's tools set no `rateLimit` of their own. [`throttle.defaultRateLimit`](https://frontmcp.dev/reference/sdk/guard#setting-defaults-for-every-tool) 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](#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](#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](https://frontmcp.dev/reference/sdk/guard#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:

```ts help-desk.app.ts active
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 {}
```

```ts execute-limit.plugin.ts
import { buildPartitionContext, createGuardManager, DynamicPlugin, FlowCtxOf, Plugin, RateLimitError, ToolHook } from "@frontmcp/sdk";

// Counts in memory, for this process. Give it the same `storage` as `throttle` to share them between instances.
const limits = createGuardManager({ config: { enabled: true } });

@Plugin({ name: "execute-limit" })
export class ExecuteLimit extends DynamicPlugin<object> {
  // Where FrontMCP checks a tool's own rateLimit: after authorization, before the input is validated
  @ToolHook.Will("acquireQuota", { filter: (ctx) => ctx.state.tool?.metadata.name === "codecall:execute" })
  async limitScripts(ctx: FlowCtxOf<"tools:call-tool">) {
    // The caller, as partitionBy: "userId" sees it: each signed-in caller counts alone, anonymous callers together
    const caller = buildPartitionContext(ctx.state.toolContext?.context);
    const check = await (await limits).checkRateLimit("codecall:execute", { maxRequests: 2, windowMs: 60_000, partitionBy: "userId" }, caller);
    if (!check.allowed) throw new RateLimitError(Math.ceil((check.retryAfterMs ?? 60_000) / 1000));
  }
}
```

```ts metrics.plugin.ts
import { DynamicPlugin, Plugin, Provider, Tool, ToolContext, ToolHook } from "@frontmcp/sdk";
import { AuditLoggerService } from "@frontmcp/plugin-codecall";

@Provider({ name: "CodeCallMetrics" })
export class CodeCallMetrics {
  scripts: Record<string, number> = {}; // by outcome: success, failure, timeout
  scriptMs = 0;
}

@Tool({
  name: "codecall_metrics",
  description: "How many scripts CodeCall ran, by outcome, and how long they took",
  inputSchema: {},
  codecall: { visibleInListTools: true, enabledInCodeCall: false },
})
export class ShowCodeCallMetrics extends ToolContext {
  async execute() {
    const { scripts, scriptMs } = this.get(CodeCallMetrics);
    return { scripts: { ...scripts }, scriptMs };
  }
}

@Plugin({ name: "codecall-metrics", providers: [CodeCallMetrics], tools: [ShowCodeCallMetrics] })
export class CodeCallMetricsPlugin extends DynamicPlugin<object> {
  private subscribed = false;

  @ToolHook.Will("execute")
  async subscribe() {
    if (this.subscribed) return;
    this.subscribed = true;
    const metrics = this.get(CodeCallMetrics);
    // Runs inside each call: count here, and hand anything slow, like a network call, to a queue
    this.get(AuditLoggerService).subscribe((event) => {
      const outcome = event.type.match(/^codecall:execution:(success|failure|timeout)$/)?.[1];
      if (!outcome) return;
      metrics.scripts[outcome] = (metrics.scripts[outcome] ?? 0) + 1;
      metrics.scriptMs += event.durationMs ?? 0;
    });
  }
}
```

```ts tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id",
  inputSchema: { id: z.string() },
  codecall: { enabledInCodeCall: true }, // the first tool to try in codecall_opt_in
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (id !== "T-1") this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return { id, title: "Cannot log in", status: "open" };
  }
}

@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: "delete_customer",
  description: "Delete a customer and all their tickets",
  inputSchema: { id: z.string() },
  annotations: { destructiveHint: true },
})
export class DeleteCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    return { deleted: id };
  }
}
```

```ts production.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { CodeCallPlugin, codeCallModeSchema } from "@frontmcp/plugin-codecall";
import { HelpDeskApp } from "./help-desk.app";
import { CloseTicket, DeleteCustomer, GetTicket } from "./tools";

const info = { name: "help-desk", version: "1.0.0" };
const getTicket = (id: string) => `return await callTool("get_ticket", { id: "${id}" });`;

test("each script's outcome and time reach the metrics", async ({ mcp }) => {
  expect((await mcp.tools.call("codecall:execute", { script: getTicket("T-1") })).json().status).toBe("ok");
  expect((await mcp.tools.call("codecall:execute", { script: getTicket("T-9") })).json().status).toBe("tool_error");
  const metrics = (await mcp.tools.call("codecall_metrics", {})).json();
  expect(metrics.scripts).toEqual({ success: 1, failure: 1 });
  expect(metrics.scriptMs).toBeGreaterThanOrEqual(0);
});

test("a caller gets two scripts a minute; other callers count separately", async () => {
  const server = await FrontMcpInstance.createDirect({ info, apps: [HelpDeskApp] });
  const as = (sub: string) => ({ authContext: { user: { sub } } });
  await server.callTool("codecall:execute", { script: getTicket("T-1") }, as("agent-7"));
  await server.callTool("codecall:execute", { script: getTicket("T-1") }, as("agent-7"));
  await expect(server.callTool("codecall:execute", { script: getTicket("T-1") }, as("agent-7"))).rejects.toThrow(
    /^Rate limit exceeded\. Retry after \d+ seconds$/,
  );
  expect((await server.callTool("codecall:execute", { script: getTicket("T-1") }, as("agent-8"))).structuredContent).toMatchObject({ status: "ok" });
  // Only scripts are limited
  expect((await server.callTool("codecall:search", { queries: ["ticket"] }, as("agent-7"))).isError).toBeFalsy();
  await server.dispose();
});

test("with throttle.defaultRateLimit instead, a script's own calls count too", async () => {
  @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [CodeCallPlugin.init()] })
  class WithDefaults {}
  const server = await FrontMcpInstance.createDirect({
    info,
    apps: [WithDefaults],
    throttle: { enabled: true, defaultRateLimit: { maxRequests: 3, windowMs: 60_000 } },
  });
  const fourCalls = [
    "const out = [];",
    'for (const id of ["T-1", "T-1", "T-1", "T-1"]) {',
    '  out.push(await callTool("get_ticket", { id }, { throwOnError: false }));',
    "}",
    "return out.map((r) => r.success || r.error.message);",
  ].join("\n");
  expect((await server.callTool("codecall:execute", { script: fourCalls })).structuredContent).toEqual({
    status: "ok",
    result: [true, true, true, 'Tool "get_ticket" execution failed'],
  });
  await server.dispose();
});

test("delete_customer is out of CodeCall's reach", async ({ mcp }) => {
  const described = (await mcp.tools.call("codecall:describe", { toolNames: ["get_ticket", "delete_customer"] })).json();
  expect(described.notFound).toEqual(["delete_customer"]);
});

test("what clients and CodeCall see at each step of the rollout", async () => {
  const step = async (mode: "codecall_opt_in" | "metadata_driven" | "codecall_only") => {
    @App({
      id: "help-desk",
      name: "Help Desk",
      tools: [GetTicket, CloseTicket, DeleteCustomer],
      plugins: [CodeCallPlugin.init({ mode, includeTools: (tool) => !tool.annotations?.destructiveHint })],
    })
    class Step {}
    const server = await FrontMcpInstance.createDirect({ info, apps: [Step] });
    const listed = (await server.listTools()).tools.map((tool: { name: string }) => tool.name);
    const direct = await server.callTool("get_ticket", { id: "T-1" }).then(() => "ok", (error: Error) => error.message);
    const reached = (await server.callTool("codecall:describe", { toolNames: ["get_ticket", "close_ticket"] })).structuredContent as any;
    await server.dispose();
    return { listsGetTicket: listed.includes("get_ticket"), direct, codecallReaches: reached.tools.map((t: { name: string }) => t.name) };
  };
  expect(await step("codecall_opt_in")).toEqual({ listsGetTicket: true, direct: "ok", codecallReaches: ["get_ticket"] });
  expect(await step("metadata_driven")).toEqual({ listsGetTicket: true, direct: "ok", codecallReaches: ["get_ticket", "close_ticket"] });
  expect(await step("codecall_only")).toEqual({ listsGetTicket: false, direct: 'Tool "get_ticket" not found', codecallReaches: ["get_ticket", "close_ticket"] });
});

test("a misspelled mode throws at startup", () => {
  expect(() => codeCallModeSchema.parse("codecall-only")).toThrow(/Invalid option: expected one of/);
});
```

`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`](https://frontmcp.dev/reference/sdk/guard#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](https://frontmcp.dev/reference/plugins#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](#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](#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](#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`](#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](#limits), 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](#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](#limits) 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](#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](#audit-events) `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.
