Monty As The Safe Default Backend¶
Spike record for #37. All findings below were verified empirically against
pydantic-monty 0.0.18 on macOS arm64 before any Toolplane code was written.
Problem¶
A fresh default config could not serve safely: default_backend was
local_unsafe and cli.mode was ambient, so toolplane serve mcp always
required --unsafe. No safe backend was guaranteed to exist because
pyodide-deno needs Deno on PATH and re-fetches npm:pyodide from the CDN on
every run (high latency, and the #21 flake surface).
Decision¶
Make Monty (pydantic-monty) the default backend, and make the default CLI
policy disabled.
Monty is the only candidate that satisfies "always available + safe":
| requirement | monty | pyodide-deno | local_unsafe |
|---|---|---|---|
| pure pip install | yes (native wheel) | no (needs Deno) | yes |
| safe by construction | yes (no fs/network) | yes (Deno permissions) | no |
| startup latency | ~ms | seconds + CDN fetch | ~ms |
| third-party packages | no | yes | import-only |
scoped ns.member sugar |
no (see below) | yes | yes |
pyodide-deno remains the opt-in answer for package-capable snippets;
local_unsafe remains dev-only and keeps requiring --unsafe everywhere.
Empirical capability envelope (pydantic-monty 0.0.18)¶
Verified working:
async def/await, including Toolplane's exactwrap_async_mainshape and top-levelawait.- Async external functions via
Monty.run_async(external_functions=...)— the exact hookbridge.call_tooldispatch needs. External function exceptions are catchable inside the sandbox withtry/except. - Flat callable namespace: every registry capability already carries a flat
alias (
{server}_{tool}, e.g.math_multiply), so external functions cover the full capability surface. - Stdout capture via
CollectStreams; f-strings, comprehensions; a stdlib shim subset (json,math,re,datetime— NOTtypes/collections). ResourceLimits:max_duration_secs=0.5killed a hot loop at 0.51s; alsomax_memory,max_recursion_depth,max_allocations.- True concurrency: 5 parallel
run_asynccalls with async externals completed in one 0.1s sleep window (VM resume offloaded to threads). - Structured errors:
MontyRuntimeError.exception()returns the real Python exception;.traceback()returns frames.
Verified NOT working (each probed directly):
- Class definitions:
NotImplementedErrorfrom the parser. This kills the__getattr__shim used by the other backends for scoped namespaces. - Scoped namespace emulation attempts, all dead ends:
dotted external names don't bind a root name;
SimpleNamespaceinputs are not convertible; dataclass inputs preserve attribute access but callable fields are not callable in-VM; notypes/collectionsmodules to build a namespace object in-sandbox. - Third-party packages (no install, no import).
print(file=sys.stderr)('file' argument is not supported).
Consequence: namespace contract on monty¶
On the monty backend, agent code uses the flat aliases and call_tool:
r = await math_multiply(x=6, y=7) # flat alias
r2 = await call_tool("mcp.math.multiply", {"x": 6, "y": 7})
Scoped sugar (await math.multiply(...)) remains available on local_unsafe
and pyodide-deno. Capability schemas already expose aliases as data, so
flat names are discoverable through get_capability_schemas. Revisit if Monty
gains class support (it is under active development).
Scope decisions¶
- Config defaults flip (
default_backend = "monty",cli.mode = "disabled"); theToolplane()constructor defaults are unchanged — the constructor is a local dev convenience, config files describe deployments. EffectivePolicyderivation is untouched: the override allowlist stays(default_backend,), soexecute_code(backend="local_unsafe")stays blocked (fail-closed, #24/#25). Allowingpyodide-denoas a safe override under default policy is a possible follow-up, deliberately not this slice.- Version pin
pydantic-monty>=0.0.18,<0.1: pre-0.1 API churn must not be able to brick the default backend via an unconstrained resolve. - Timeout enforcement is belt-and-braces:
ResourceLimits.max_duration_secsbounds VM time, an outerasyncio.wait_forbounds wall clock including time spent inside external tool calls.