Skip to content
Get Started

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.

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.

config.localdb.yaml
MCP_TOOL_DIRS:
- tools # yours, scanned first
- mirobody/agent/tools # the packaged default

Inside a listed directory the rules are narrow, and worth knowing before you name the file:

  • Only files ending in .py are read, and subdirectories are not walked — a nested package is invisible to discovery.
  • A leading underscore excludes the file. __init__.py and 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.

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

Terminal window
touch tools/goal_service.py

The name of the file does not matter. The class name does — it has to end in Service.

Write the service

tools/goal_service.py
from datetime import datetime, timedelta
from 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

Terminal window
docker compose restart mirobody

Discovery 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

Terminal window
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_goal

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.

The docstring is the only thing the model has to decide whether and how to call your tool. It is read in three parts:

  1. Everything before the first section header becomes the tool description, with blank lines dropped and the remaining lines joined
  2. The Args: block one name: text line per parameter becomes that parameter's description; an indented continuation line is appended to the previous one
  3. 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.

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.

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:

tools/my_integration_service.py
@staticmethod
def _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 a dict. The engine reads three keys from it when it hands the result to an MCP client:

KeyEffect
successA bool. False marks the MCP result as an error.
dataOn success, this is unwrapped and also sent as structuredContent.
errorOn 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.

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:

tools/report_service.py
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.

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.

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

Terminal window
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?