Add custom local tools to ToolUniverse alongside the 1000+ built-in tools. Covers JSON-config tools (simplest, no code), Python class tools (REST/SOAP/GraphQL APIs, computational logic), and best-practices for return schemas. Use for wrapping new APIs, adding domain-specific computations, or contributing tools to the registry.
Add custom local tools to ToolUniverse alongside the 1000+ built-in tools. Covers JSON-config tools (simplest, no code), Python class tools (REST/SOAP/GraphQL APIs, computational logic), and best-practices for return schemas. Use for wrapping new APIs, adding domain-specific computations, or contributing tools to the registry.
Adding Custom Tools to ToolUniverse
When to create a custom tool: Create one if you need to access an API that ToolUniverse
doesn't cover, or if you need a specialized data transformation that no existing tool provides.
Start with the JSON config approach (simplest — no Python needed); escalate to a Python class
only if you need custom response parsing or stateful logic.
Three ways to add tools — pick the one that fits your needs:
Approach
When to use
JSON config
REST API with standard request/response — no coding needed
Python class (workspace)
Custom logic for local/private use only
Plugin package
Reusable tools you want to share or install via pip
Option A — Workspace tools (local use)
Tools in .tooluniverse/tools/ are auto-discovered at startup. No installation needed.
mkdir -p .tooluniverse/tools
JSON config
Create .tooluniverse/tools/my_tools.json:
[{"name":"MyAPI_search"
,
"description"
:
"Search my internal database. Returns matching records with id, title, and score."
,
"type"
:
"BaseRESTTool"
,
"fields"
:
{
"endpoint"
:
"https://my-api.example.com/search"
}
,
"parameter"
:
{
"type"
:
"object"
,
"properties"
:
{
"q"
:
{
"type"
:
"string"
,
"description"
:
"Search query"
}
,
"limit"
:
{
"type"
:
[
"integer"
,
"null"
]
,
"description"
:
"Max results to return (default 10)"
}
}
,
"required"
:
[
"q"
]
}
}
]
One JSON file can define multiple tools — just add more objects to the array.
# Uses test_examples from the tool's JSON config — zero config needed
tu test MyAPI_search
# Single ad-hoc call
tu test MyAPI_search '{"q": "test"}'# Full config with assertions
tu test --config my_tool_tests.json
tu test automatically runs these checks on every call:
Result is not None or empty
return_schema validation — validates result["data"] against the JSON Schema defined in return_schema (if present)
expect_status and expect_keys — only if set in the config file
Gotchas: (1) tu test does NOT verify non-empty results — [] passes schema validation. Use test_examples args that return real data. (2) Verify test_examples manually first with urllib (not curl) to confirm the API returns JSON, not HTML. Use 2-4 broad keywords.
Add test_examples and return_schema to JSON config for best coverage. tu test validates result["data"] against return_schema (match "type": "array" or "type": "object" to your data shape).
Optional my_tool_tests.json for extra assertions (expect_status, expect_keys).
Use with MCP server
Tools in .tooluniverse/tools/ are auto-available via tu serve. Workspace priority: --workspace flag → TOOLUNIVERSE_HOME env → ./.tooluniverse/ → ~/.tooluniverse/.
To use a different tools directory, add sources: [./my-custom-tools/] in .tooluniverse/profile.yaml and start with tooluniverse --load .tooluniverse/profile.yaml.
Option B — Plugin package (shareable, pip-installable)
Use this when you want to distribute tools as a reusable Python package that other users can
install with pip install. The plugin package has the same directory layout as a workspace, plus a
pyproject.toml that declares the entry point.
Package layout
my_project_root/ # directory containing pyproject.toml
pyproject.toml
my_tools_package/ # importable Python package (matches entry-point value)
__init__.py # minimal — one-line docstring, no registration code
my_api_tool.py # tool class(es) with @register_tool
data/
my_api_tools.json # JSON tool configs (type must match registered class name)
profile.yaml # optional: name, description, required_env
JSON config files are discovered from both data/ and the package root directory. The convention is data/.
Inherit from BaseTool (from tooluniverse.base_tool)
@register_tool("ClassName") takes the class name as a string argument
run(self, arguments: Dict) receives all arguments in a single dict — extract them with .get()
__init__ receives tool_config dict; call super().__init__(tool_config) first
JSON config in a plugin package
Place configs in data/my_api_tools.json. The "type" field must match the string passed to
@register_tool(...):
[{"name":"MyAPI_search","description":"Search my API. Returns matching records.","type":"MyAPITool","fields":{"operation":"search"},"parameter":{"type":"object","properties":{"query":{"type":"string","description":"Search query"},"limit":{"type":["integer","null"],"description":"Max results"}},"required":["query"]}}]
__init__.py
Keep minimal — just a docstring. The plugin system auto-imports all .py files via _discover_entry_point_plugins(), so @register_tool decorators fire automatically. Optional: add from . import my_api_tool for IDE support (idempotent). Do NOT add registration logic or JSON loading here.
Install and verify
pip install -e /path/to/my_project_root
cd /path/to/my_project_root # MUST run from plugin repo directory
tu test MyAPI_search '{"query": "test"}'
Must pip install -e first. Run tu test from plugin repo dir (workspace auto-detection needs .tooluniverse/). Add test_examples to JSON config for zero-config testing. Use tu info MyAPI_search to confirm the tool loaded.
Offline / pure-computation tools
Calculator tools (no HTTP) follow the plugin-package pattern but skip the HTTP layer. Key design patterns:
Preset lookup tables: Define Dict[str, float] at module level. Resolution priority: explicit value → preset name → default. Include presets in metadata for discoverability.
Bidirectional equations: Expose as separate operation values in a single tool. Use "fields": {"operation": "default_op"} in JSON config.
Physical constants: Define at module level (_MU0 = 4*pi*1e-7, etc.). Material-specific values as named dicts.
Multi-output: Return all related results in data (e.g., temperature + headroom + pass/fail) rather than forcing multiple calls.