| name | tooluniverse-custom-tool |
| description | 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. |
| disable-model-invocation | true |
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.
For the full JSON field reference, see references/json-tool.md.
Python class
Create .tooluniverse/tools/my_tool.py:
from tooluniverse.tool_registry import register_tool
@register_tool
class MyAPI_search:
name = "MyAPI_search"
description = "Search my internal database. Returns matching records with id, title, and score."
input_schema = {
"type": "object",
"properties": {
"q": {"type": "string", "description": "Search query"},
"limit": {"type": "integer", "description": "Max results (default 10)"}
},
"required": ["q"]
}
def run(self, q: str, limit: int = 10) -> dict:
import requests
resp = requests.get(
"https://my-api.example.com/search",
params={"q": q, "limit": limit},
timeout=30,
)
resp.raise_for_status()
return {"status": "success", "data": resp.json()}
Note: workspace Python tools use run(self, **named_params) — arguments are unpacked as keyword
arguments matching the input_schema properties.
For the full Python class reference, see references/python-tool.md.
Test workspace tools
tu test MyAPI_search
tu test MyAPI_search '{"q": "test"}'
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/.
pyproject.toml entry point
[project.entry-points."tooluniverse.plugins"]
my-tools = "my_tools_package"
The value (my_tools_package) must be the importable Python package name.
Python class in a plugin package
Plugin package tools use BaseTool and receive all arguments as a single Dict:
import requests
from typing import Dict, Any
from tooluniverse.base_tool import BaseTool
from tooluniverse.tool_registry import register_tool
@register_tool("MyAPITool")
class MyAPITool(BaseTool):
"""Tool description here."""
def __init__(self, tool_config: Dict[str, Any]):
super().__init__(tool_config)
self.timeout = tool_config.get("timeout", 30)
fields = tool_config.get("fields", {})
self.operation = fields.get("operation", "search")
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
query = arguments.get("query", "")
if not query:
return {"error": "query parameter is required"}
try:
resp = requests.get(
"https://my-api.example.com/search",
params={"q": query},
timeout=self.timeout,
)
resp.raise_for_status()
return {"status": "success", "data": resp.json()}
except requests.exceptions.RequestException as e:
return {"error": str(e)}
Key differences from the workspace pattern:
- 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
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.
For complete patterns, see references/python-tool.md.