@mantlejs/mcp-code
MCP code mode for Mantle JS. Instead of one MCP tool per service method, the agent gets a typed TypeScript API of your exposed services and writes a short script against it. The script runs in a QuickJS sandbox. Many service calls happen in one round trip, filtering and joining happen in code, and only the final answer goes back to the model.
Every mantle.<path>.<method>() call inside a script runs the service’s full hook pipeline as the MCP session — authenticate, authorizeAgent(), validation, @mantlejs/audit — exactly like @mantlejs/mcp’s per-method tools. Code mode changes how the agent calls your API, not what it’s allowed to do.
Experimental. Ships in the
experimentalrelease group (0.3.0-experimental, install withnpm install @mantlejs/mcp-code@experimental); the API may change before it’s promoted.
Installation
Section titled “Installation”npm install @mantlejs/mcp @mantlejs/mcp-code@mantlejs/mantle and @mantlejs/mcp are peer dependencies. Requires Node ≥ 22 (TypeScript input needs ≥ 22.13 — see TypeScript input).
Quick start
Section titled “Quick start”import { mantle, RepositoryService } from "@mantlejs/mantle";import { express } from "@mantlejs/express";import { mcp } from "@mantlejs/mcp";import { codeMode } from "@mantlejs/mcp-code";
const app = mantle() .configure(express()) .configure( mcp({ transport: "http", services: { articles: ["find", "get"], comments: ["find"] }, // deny-by-default, as always codeMode: codeMode(), }), );
app.use("articles", new RepositoryService(new ArticleRepository(app)), { schema: articleSchema });app.use("comments", new RepositoryService(new CommentRepository(app)), { schema: commentSchema });app.listen(3030);With a codeMode provider, mcp() defaults to mode: "code":
mode |
What the agent sees |
|---|---|
"code" |
search_api + execute + the mantle://code/api.d.ts resource (default) |
"both" |
The above plus the per-method tools (articles_find, …) |
"tools" |
Per-method tools only — the provider isn’t used |
App-authored tools, resources, prompts, and event resources are registered in every mode.
What the agent sees
Section titled “What the agent sees”search_api({ query?, paths? })
Section titled “search_api({ query?, paths? })”Loads the typed API a piece at a time. With no arguments it returns a one-line index (articles — find, get); query keyword-matches service paths, method names, and descriptions; paths selects exact services. The result is a self-contained TypeScript declaration module:
type Id = string | number;interface Paginated<T> { total: number; limit: number; skip: number; data: T[];}
/** A `articles` record. */type ArticlesRecord = { id?: string; title: string; tags: string[]; status: "draft" | "published" };/** Query for `mantle.articles.find()`. */type ArticlesQuery = { where?: { $or?: { [key: string]: unknown }[] } & { [key: string]: unknown | { $lt?: unknown; $like?: unknown } }; /** Maximum records to return. Default 25; requests above 100 are clamped. Page with skip. */ limit?: number; skip?: number; sort?: { [key: string]: "asc" | "desc" }; select?: string[];};
declare const mantle: { articles: { /** Find articles records matching a query. Results are paginated — … */ find(query?: ArticlesQuery): Promise<ArticlesRecord[] | Paginated<ArticlesRecord>>; /** Get a single articles record by id. */ get(id: Id, query?: ArticlesIdQuery): Promise<ArticlesRecord>; };};The same module (for the session) is served as the mantle://code/api.d.ts resource, for clients that load resources up front.
- One source of truth. Every parameter and return type is converted from the exact JSON Schema
@mantlejs/mcplists the per-method tool with (describeServiceMethod()), so code mode and tool mode can’t drift. Record types come from the schema attached inapp.use(); services without one get{ [key: string]: unknown }. - Operators match the adapter. The
wheretype offers exactly the operators the repository’sdescribe().capabilities.operatorsreports. - Paths are keys, verbatim.
mantle.articles,mantle["blog-posts"],mantle["admin/reports"]— no aliasing, so there’s never a collision or a renamed path to guess. - Signatures:
find(query?),get(id, query?),remove(id, query?),create(data),update(id, data),patch(id, data), custom methods(data?)— the same argument objects tool mode takes, positionally.
execute({ code })
Section titled “execute({ code })”Runs a script — the body of an async function: top-level await works and return sends back the result.
const { data } = await mantle.articles.find({ where: { status: "published" }, limit: 100, select: ["id", "tags"] });const counts = {};for (const article of data) for (const tag of article.tags) counts[tag] = (counts[tag] ?? 0) + 1;console.log("articles scanned:", data.length);return counts;returns
{ "result": { "guide": 2, "start": 1 }, "executionId": "…", "calls": 1, "logs": ["[log] articles scanned: 2"] }plus notes when something was cut short (a partial find page, dropped log lines).
-
Values cross as JSON in both directions —
Datebecomes an ISO string, functions andundefinedare dropped. -
Run independent calls with
Promise.all— bridged calls run concurrently. -
Service errors are catchable. A rejected call throws an
Errorcarrying the service error’sname,message,code,className,data, andhint:try {return await mantle.articles.get(id);} catch (e) {if (e.name === "NotFound") return null;throw e;} -
Uncaught errors come back as an MCP tool error with the same
MantleError.toJSON()shape tool mode uses. A service error keeps its class (NotFound,Forbidden, …); the script’s own bugs (aTypeError, a syntax error) areCodeScriptError.data.executioncarries{ executionId, calls, logs }, and the originaldata, if any, moves todata.cause.
Limits
Section titled “Limits”Every execution gets a fresh sandbox — nothing carries over between calls — and every limit fails with a typed error. None can take down the host process.
| Limit | Default | Error | Enforcement |
|---|---|---|---|
timeoutMs |
10 000 | CodeTimeout (408) |
Interrupt handler for CPU loops and a host timer for a bridged call that never settles |
memoryBytes |
32 MiB | CodeLimitExceeded (422) |
QuickJS’s own limit — soft, it undercounts some allocations |
wasmMemoryBytes |
64 MiB | CodeLimitExceeded (422) |
Bounded WebAssembly.Memory per execution — the hard cap. Minimum 16 MiB |
stackBytes |
192 KiB | CodeLimitExceeded (422) |
QuickJS shares the host thread’s stack — keep it under ~¼ of it (~256 KiB on Node’s main thread) |
maxCalls |
50 | CodeLimitExceeded (422) |
Bridged calls per execution. Fails the execution even if the script catches the error |
maxOutputBytes |
64 KiB | CodeOutputTooLarge (413) |
JSON result size; log output past it is dropped with a note |
codeMode({ limits: { timeoutMs: 5_000, maxCalls: 20 } });A stack limit above the safe ceiling can overflow the host’s native stack; that’s contained as CodeExecutorFault (500) and the sandbox instance is discarded. A bridged service call already in flight when a timeout fires runs to completion on the host — it can’t be cancelled — but its result is discarded.
TypeScript input
Section titled “TypeScript input”On Node ≥ 22.13 the script may use TypeScript annotations — types are stripped host-side with module.stripTypeScriptTypes (positions preserved, so error line numbers match the script). Only erasable syntax is supported: enum and namespace fail with a BadRequest and a hint. Agent code is not type-checked. On older Node versions scripts must be plain JavaScript, and the execute description says so. Node prints a one-time ExperimentalWarning the first time the stripper runs.
Security model
Section titled “Security model”- The hook pipeline is the authority. Each bridged call is
createServiceMethodRunner()from@mantlejs/mcp— the identical path tool mode uses — with the session’s params,provider: "mcp", the session’sauthorizationheader, and fresh params per call. Hooks see the same request either way;HookContext.provideris"mcp"for both. - The expose map is the hard boundary. Only the exposed methods exist inside the sandbox. A registered-but-unexposed service or method isn’t there to call.
- Agent narrowing is display only. When the session authenticated with an agent token,
search_apiand the resource show only the methods its capability scope grants. Enforcement stays inauthorizeAgent(): an exposed-but-out-of-scope call still reaches the hook and throws a catchableForbidden— the same denial tool mode returns. - No ambient capabilities. No
fetch,XMLHttpRequest,WebSocket,require/import,process, filesystem, or timers; the guest holds no host object references.DateandMath.randombehave normally. - Every call is attributable. Each bridged call carries
params.mcp = { mode: "code", executionId, scriptHash }. With@mantlejs/audit, a script that made N calls produces N audit records sharing theexecutionId. The script source itself is recorded only withcodeMode({ auditScriptSource: true })— off by default, since scripts can embed data; the SHA-256scriptHashis always there to match a record to a script.
Custom executors
Section titled “Custom executors”The sandbox is pluggable. Implement CodeExecutor:
interface CodeExecutor { execute(code: string, bridge: CodeBridge, limits: CodeLimits): Promise<CodeExecutionResult>;}
codeMode({ executor: myWorkerExecutor() });The contract — fresh state per execution, JSON-only values, the mantle API built from bridge.services, no host capabilities, every limit typed, never rejecting — is published as data, so any executor can run the reference suite:
import { CODE_EXECUTOR_CONFORMANCE_CASES, conformanceBridge, DEFAULT_CODE_LIMITS } from "@mantlejs/mcp-code";
for (const testCase of CODE_EXECUTOR_CONFORMANCE_CASES) { it(testCase.name, async () => { const bridge = conformanceBridge(); const result = await myExecutor.execute(testCase.code, bridge, { ...DEFAULT_CODE_LIMITS, ...testCase.limits }); // compare with testCase.expect and testCase.check — see quickjs-executor.spec.ts for the full loop });}When to prefer tool mode
Section titled “When to prefer tool mode”- Small APIs — a handful of methods costs little context as tools.
- Clients that don’t write code well — some models are much better at choosing a tool than at writing a correct script.
- Interactive, one-call-at-a-time work where intermediate results should be shown to the user anyway.
mode: "both" serves both surfaces from one endpoint, or mount two mcp() endpoints with different paths (see the knowledge-base example).
| Export | Description |
|---|---|
codeMode(options?) |
The McpCodeModeProvider for mcp({ codeMode }). Options: executor, limits, auditScriptSource |
quickJsExecutor() |
The default CodeExecutor (QuickJS sync WASM build, fresh bounded instance per execution) |
DEFAULT_CODE_LIMITS, MIN_WASM_MEMORY_BYTES |
Limit defaults and the wasmMemoryBytes floor |
CodeTimeout, CodeLimitExceeded, CodeOutputTooLarge, CodeScriptError, CodeExecutorFault |
Typed errors (MantleError subclasses) |
CODE_EXECUTOR_CONFORMANCE_CASES, conformanceBridge() |
The executor contract as data |
API_RESOURCE_URI |
"mantle://code/api.d.ts" |
| Types | CodeModeOptions, CodeModeCallMetadata, CodeExecutionReport, CodeExecutor, CodeBridge, CodeLimits, CodeExecutionResult, ConformanceCase, ConformanceBridge, BridgeCall |