Building on Mirobody
Adding Custom Tools
Drop a .py file into a tool directory, restart, and the engine turns your function into an MCP tool
A tool is a plain Python function. You do not register it, you do not write its JSON schema, and there is nothing to compile: the engine reads your type hints and your docstring at startup and builds the schema the model sees.
Tools & Agent Overview sketches that discovery pass. This page is the working procedure — where the file goes, what each part of it turns into, and how to tell whether it loaded.
File location
Section titled “File location”Discovery walks the directories listed in the MCP_TOOL_DIRS config key. It ships with
exactly one entry — the engine’s own packaged directory — so your own tools go into a
directory you add yourself. List yours first: directories are scanned in order, which
is what lets a deployment override a packaged tool without editing the package.
MCP_TOOL_DIRS: - tools # yours, scanned first - mirobody/agent/tools # the packaged defaultInside a listed directory the rules are narrow, and worth knowing before you name the file:
- Only files ending in
.pyare read, and subdirectories are not walked — a nested package is invisible to discovery. - A leading underscore excludes the file.
__init__.pyand private helper modules are skipped for free; that is also the way to park a shared module next to your tools. - Module-level functions are registered as tools, unless their name starts with
_or they were imported from somewhere else. - Classes are only inspected when the class name ends in
Service. In such a class, public methods become tools;_-prefixed methods, methods inherited from a base class and methods imported from another module are all ignored. - An import error is logged and that one module is skipped. The server still starts, and every other tool still loads.
A tool from scratch
Section titled “A tool from scratch”The example below is a complete, working tool: it takes the caller’s identity, one required argument and one optional argument, reads the engine’s own time-series store, and returns a structured result.
Create the file
touch tools/goal_service.pyThe name of the file does not matter. The class name does — it has to end in
Service.
Write the service
from datetime import datetime, timedeltafrom typing import Any
from mirobody.utils import execute_query
class GoalService: """Compare a user's recorded readings against a target value."""
def __init__(self): self.name = "Goal Service" self.version = "1.0.0"
async def count_readings_above_goal( self, user_info: dict[str, Any], indicator: str, goal: float, days: int = 30, ) -> dict[str, Any]: """ Count how many of a user's recent readings cleared a target value. Call query_health_indicators first to get the exact indicator name.
Args: indicator: Exact indicator name, as returned by query_health_indicators. goal: The value a reading has to reach, in the indicator's own unit. days: How many days back to look. """ user_id = user_info.get("user_id") if not user_id: return {"success": False, "error": "Authorization required."}
rows = await execute_query( """ SELECT tsd.start_time, tsd.value FROM th_series_data tsd WHERE tsd.user_id = :user_id AND tsd.indicator = :indicator AND tsd.deleted = 0 AND tsd.start_time >= :since ORDER BY tsd.start_time DESC """, { "user_id": user_id, "indicator": indicator, "since": datetime.now() - timedelta(days=days), }, )
hits = [] for row in rows or []: try: if float(row["value"]) >= goal: hits.append(str(row["start_time"])) except (TypeError, ValueError): continue
return { "success": True, "data": { "indicator": indicator, "goal": goal, "readings_examined": len(rows or []), "readings_above_goal": len(hits), "times": hits[:20], }, }Put the directory on the list
Add tools to MCP_TOOL_DIRS in your config.{env}.yaml, as shown above. Skip this
step if you dropped the file into a directory that is already listed.
Restart the process
docker compose restart mirobodyDiscovery only runs at startup, so a new or edited tool needs a restart. Running from
a checkout instead, restart mirobody serve.
Confirm it is there
curl -sX POST http://localhost:18080/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep count_readings_above_goalSchema generation
Section titled “Schema generation”Each parameter’s type hint becomes one JSON Schema type. The mapping is small and there is no validation layer behind it:
- str
- string
- int
- integer
- float
- number
- bool
- boolean
- list[str]
- array, items: string
- dict[str, Any]
- object
- anything else
- string the fallback — no error is raised
A parameter with no default value is required; one with a default is optional and
its default is advertised in the schema. A None default is deliberately left out of
the schema, because some model APIs reject a null there.
Description generation
Section titled “Description generation”The docstring is the only thing the model has to decide whether and how to call your tool. It is read in three parts:
- Everything before the first section header becomes the tool description, with blank lines dropped and the remaining lines joined
- The
Args:block onename: textline per parameter becomes that parameter's description; an indented continuation line is appended to the previous one - The
Returns:block parsed and then discarded — it is documentation for humans, and never reaches the model
The section header is matched case-insensitively and several spellings are accepted:
Args:, Arguments:, Params: and Parameters: (plus their singular forms) all open
the argument block; Returns: and Results: (and their singular forms) close it.
A key in the Args: block that does not match a parameter name is ignored, which is why
user_info is conventionally left out of the docstring — it is not in the schema, so
there is nothing to describe.
Tools that act on behalf of a user
Section titled “Tools that act on behalf of a user”Declare a user_info parameter and the engine fills it in from the authenticated caller
before your function runs. It is stripped from the schema, so the model can neither
supply nor forge it:
{"success": True, "user_id": "...", "session_id": "..."}Read the caller out of it and refuse the call when there is none:
user_id = user_info.get("user_id")if not user_id: return {"success": False, "error": "Authorization required."}Declare it as the first parameter, as every shipped tool does, and scope every query to
that user_id.
Registering only when configured
Section titled “Registering only when configured”A Service class can decline to exist. Define a static _enabled(); return False and
the whole class is skipped, so none of its tools appear anywhere — not in the
agent’s tool set, not in tools/list. This is how an optional integration disappears
cleanly instead of failing at call time — the pattern an optional integration of your own
should copy:
@staticmethoddef _enabled() -> bool: """Only register when MY_INTEGRATION_API_KEY is configured.""" from mirobody.utils import global_config return bool(global_config().get_str("MY_INTEGRATION_API_KEY"))An exception raised inside _enabled() is treated as False — the class is skipped and
a warning is logged, so a broken check cannot take the server down with it.
Return values and failures
Section titled “Return values and failures”Return a dict. The engine reads three keys from it when it hands the result to an MCP
client:
| Key | Effect |
|---|---|
success | A bool. False marks the MCP result as an error. |
data | On success, this is unwrapped and also sent as structuredContent. |
error | On failure, this string is what the client is shown. |
Both a plain def and an async def work — the caller awaits the result only when the
function is a coroutine. Any exception escaping your function is caught, logged, and
returned as {"success": False, "error": "..."}, so a bad tool cannot crash a chat
turn. Catching it yourself is still better: you get to say something the model can act
on.
Keep results small. Return a URL or a key rather than a large blob, and cap list lengths — everything you return is spent from the model’s context window.
Overriding the generated schema
Section titled “Overriding the generated schema”The type hints only describe flat scalars, arrays and untyped objects. For a tool whose
arguments are nested, attach a hand-written JSON Schema to the function as an
inputSchema attribute and it is used verbatim:
class ReportService: async def build_report(self, sections: list[dict], user_info: dict) -> dict: """Build a report from a nested section tree.""" ...
ReportService.build_report.inputSchema = { "type": "object", "properties": { "sections": { "type": "array", "items": { "type": "object", "properties": { "title": {"type": "string"}, "indicators": {"type": "array", "items": {"type": "string"}}, }, "required": ["title"], }, } }, "required": ["sections"],}One of the shipped tools uses this mechanism: after the class definition it attaches a
schema from chart_schema/*.json to each chart method after the class is defined. Once
the attribute is there the type hints are no longer consulted for the schema, so keep
them for your own sake and treat the attached schema as the contract.
Tool visibility per agent
Section titled “Tool visibility per agent”A newly discovered tool is offered to every agent, because the shipped config.yaml
sets neither list. Narrow it per agent with ALLOWED_TOOLS_{NAME} (whitelist) and
DISALLOWED_TOOLS_{NAME} (blacklist, applied last so it always wins), where {NAME} is
the agent name in upper case — see Tools & Agent Overview.
Troubleshooting
Section titled “Troubleshooting”tools/list is the ground truth. If your tool is missing from it, the startup log
answers why: every registered tool is logged as Loaded tool: <name>, a module that
failed to import as Error importing tool module <module>, and a class that opted out as
Skipping disabled tool class: <Class>.
docker compose logs mirobody | grep -E "Loaded tool|Error importing tool module|Skipping disabled"Work down the list when none of those lines mentions your file: is the directory in
MCP_TOOL_DIRS, does the filename avoid a leading underscore, does the class name end
in Service, is the method public — and did you restart?
Next steps
Section titled “Next steps”The shipped tools, read as worked examples
Call your tool from Claude or Cursor
When instructions beat writing code
MCP_TOOL_DIRS and the rest of the config layers