Give an AI agent typed tools behind one execute tool (and MCP)
Register Zod operations with Mycelium, discover them by name or description with Forage, and expose an authorized catalog over MCP.
An agent with many tools needs a way to find the right operation without putting
every signature in its prompt. Mycelium registers typed operations behind one
execute tool. Forage lets the model search and describe that catalog without
running its operations.
The model can call the operations the host registers and admits. The host still owns authorization, credentials and the real execution sandbox. A typed catalog alone does not isolate model-generated code.
Before you start
Section titled “Before you start”npm install @fungi.computer/mycelium zodUse an ESM TypeScript project. Mycelium installs its runtime dependencies,
including Forage, Mule and Effect. Mule consumes the resulting execute tool;
you do not need a model provider to verify the operation boundary first.
1. Register a Zod operation and run it without a model
Section titled “1. Register a Zod operation and run it without a model”This example registers demo.ping, validates its input and output, and calls
that known binding. Save it as tools.ts in an ESM project and run it with a
TypeScript-capable host such as Node 24.
The direct sandbox is a test adapter, not isolation. Its callback below deliberately ignores the submitted code. Never replace it with privileged evaluation of an agent’s JavaScript.
import { Mycelium } from "@fungi.computer/mycelium";import { directSandbox } from "@fungi.computer/mycelium/testing";import * as Effect from "effect/Effect";import * as Stream from "effect/Stream";import { z } from "zod";
const demo = Mycelium.module({ id: "example-demo", name: "demo", operations: { ping: Mycelium.operation({ description: "Return a small health-check reply", access: "read", input: z.object({}), output: z.object({ value: z.string() }), execute: async () => ({ value: "pong" }), }), },});
const runtime = await Mycelium.make({ modules: [demo], sandbox: directSandbox((bindings, signal) => { signal.throwIfAborted(); const ping = bindings.demo?.ping; if (ping === undefined) throw new Error("Missing demo.ping binding"); return ping({}); }),});
const lease = await runtime.acquire();try { const prepared = await Effect.runPromise( lease.executeTool.prepare({ type: "toolCall", id: "example-execute", name: "execute", arguments: { code: "return demo.ping({})" }, }), ); const parts = await Effect.runPromise(Stream.runCollect(prepared.execute())); const text = parts[0]?.result.content[0]; if (text?.type !== "text" || text.text !== '{"value":"pong"}') { throw new Error("Unexpected execute result"); } console.log(text.text);} finally { await lease.release(); await runtime.close();}Expect {"value":"pong"}. This checks registration, schema validation, the
known binding and cleanup. It does not prove an untrusted-code sandbox.
The stable module id identifies authority; name is the model-facing alias.
Zod input is decoded before the operation runs, and output is validated before
it is returned. Unlabelled operations default to write; label genuine reads
explicitly.
2. Find operations without running them
Section titled “2. Find operations without running them”The acquired lease exposes its Forage catalog. Before releasing the lease, a host can inspect it without dispatching the operation:
const matches = lease.catalog.search({ query: "health" });const description = lease.catalog.describe({ path: "demo.ping" });const firstPage = lease.catalog.list({ limit: 25 });Inside a real execute sandbox, the model uses the corresponding discovery
helpers:
return await forage.search({ query: "health" });After finding the path:
return await forage.describe({ path: "demo.ping" });Descriptions contain the input schema and callable types. Read them before
calling the operation. Search ranks names and descriptions; paged listing is the
exhaustive route. Follow nextCursor until it is null.
Forage discovers capabilities; it never executes them or grants permission. The operation’s access check still runs when it is called.
3. Supply the actual sandbox and model loop
Section titled “3. Supply the actual sandbox and model loop”Replace directSandbox with a host implementation of Mycelium’s sandbox
contract before accepting model-authored code. That boundary receives code,
admitted bindings, execution identity and limits. The host must enforce
isolation, external access and cancellation.
Mule receives lease.executeTool as its top-level tool. Operations and Forage’s
discovery helpers stay behind that door rather than becoming a flat model tool
list. Every execute starts fresh: it is not a REPL, and variables do not
survive between calls.
Start with the sandbox contract and connect a model provider. Do not treat a successful test-adapter call as proof that a model can safely evaluate arbitrary JavaScript in your host process.
4. Serve the authorized catalog over MCP
Section titled “4. Serve the authorized catalog over MCP”@fungi.computer/mycelium/mcp serves the same capability sources through
Streamable HTTP MCP. createMcpCatalog adapts the host’s current authorized
sources and grants; createMcp owns the MCP transport.
Illustrative: this wiring function requires the host’s real sandbox, authentication and fresh authority loader. It does not invent a login or start an HTTP server.
import type { Sandbox } from "@fungi.computer/mycelium";import { createMcp, createMcpCatalog, type McpCatalogSnapshot, type McpPrincipal,} from "@fungi.computer/mycelium/mcp";
export function openToolsMcp(options: { sandbox: Sandbox; load: (principal: McpPrincipal) => Promise<McpCatalogSnapshot>; authenticate: Parameters<typeof createMcp>[0]["authenticate"];}) { const catalog = createMcpCatalog({ sandbox: options.sandbox, load: options.load, }); return createMcp({ catalog, authenticate: options.authenticate });}The host’s load(principal) returns { instructions, sources, grants }.
Sources project the registered modules for that caller. Grants explicitly name
permitted module IDs; an omitted module is refused. Begin a fresh authority read
on each load, not a cached snapshot that could restore revoked access.
Mount the returned handleRequest(request) at your MCP route, and await
close() during transport shutdown. Authentication runs on every HTTP request
and before each operation, including notifications and disconnects. It returns
an admitted canonical principal or a refusal, not a bearer credential as an
actor ID.
The host’s approval owner decides whether a write applies, waits or is denied. MCP does not automatically ask for confirmation on every write, and approval does not widen the catalog’s grants.