Skip to content
Build with Fungi

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.

TERMINAL
npm install @fungi.computer/mycelium zod

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

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

The acquired lease exposes its Forage catalog. Before releasing the lease, a host can inspect it without dispatching the operation:

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

JS
return await forage.search({ query: "health" });

After finding the path:

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

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

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

  • Mycelium for access labels, grants, capability snapshots and the MCP contract.
  • Forage for bounded descriptions, exhaustive listing and host-only OpenAPI intake.
  • Mule for the model/tool loop.