Loading...
Loading...
Add custom local tools to ToolUniverse and use them alongside the 1000+ built-in tools. Use this skill when a user wants to: create their own tool for a private or custom API, add a local tool to their workspace, integrate an internal service with ToolUniverse, or use a custom tool via the MCP server or Python API. Covers both the JSON config approach (easiest, no Python needed) and the Python class approach (full control). Also covers how to verify tools loaded correctly and how to call them. Also covers the plugin package approach for reusable, shareable, pip-installable tool sets.
npx skill4agent add mims-harvard/tooluniverse tooluniverse-custom-tool| 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 |
.tooluniverse/tools/mkdir -p .tooluniverse/tools.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"]
}
}
].tooluniverse/tools/my_tool.pyfrom 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()}run(self, **named_params)input_schema# 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.jsontu testreturn_schemaresult["data"]return_schemaexpect_statusexpect_keystu test[]"type": "array"test_examplestest_examplesintitleurllibcurlcurlurllibimport urllib.request, json
with urllib.request.urlopen("https://api.example.com/search?q=test") as r:
print(json.dumps(json.loads(r.read()), indent=2))certification.oshwa.org/api/projectsmy_tool_tests.json{
"tool_name": "MyAPI_search",
"tests": [
{
"name": "basic search",
"args": {"q": "climate change"},
"expect_status": "success",
"expect_keys": ["data"]
}
]
}test_examplesreturn_schema{
"name": "MyAPI_search",
...
"test_examples": [
{"q": "climate change"},
{"q": "CRISPR", "limit": 3}
],
"return_schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"score": { "type": "number" }
}
}
}
}tu testresult["data"]return_schemarun()"data"data"type": "array"data"type": "object".tooluniverse/tools/tu serve # MCP stdio server (Claude Desktop, etc.)
tooluniverse # same--workspaceTOOLUNIVERSE_HOME./.tooluniverse/~/.tooluniverse/sources.tooluniverse/profile.yamlname: my-profile
sources:
- ./my-custom-tools/ # relative to profile.yaml location
- /absolute/path/tools/tu serve --load .tooluniverse/profile.yamlpip installpyproject.tomlmy_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_envdata/data/pyproject.toml[project.entry-points."tooluniverse.plugins"]
my-tools = "my_tools_package"my_tools_packageBaseToolDictimport 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)}BaseTooltooluniverse.base_tool@register_tool("ClassName")run(self, arguments: Dict).get()__init__tool_configsuper().__init__(tool_config)data/my_api_tools.json"type"@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.py_discover_entry_point_plugins()@register_tool"""My tools plugin for ToolUniverse."""@register_tool"""My tools plugin for ToolUniverse."""
from . import my_api_tool # optional — for IDE support
from . import my_other_tool # optionalregister_tool_configs()# Install in editable mode — path must point to the directory containing pyproject.toml
pip install -e /path/to/my_project_root
# Verify the entry point is registered
python -c "
from importlib.metadata import entry_points
eps = entry_points(group='tooluniverse.plugins')
print([ep.name for ep in eps])
"
# Test the tool — MUST run from the plugin repo directory
cd /path/to/my_project_root
tu test MyAPI_search '{"query": "test"}'tu testpip install -etu test.tooluniverse/profile.yamltest_examples{ "name": "MyAPI_search", ..., "test_examples": [{"query": "test"}] }tu test MyAPI_searchtu listtu info MyAPI_searchtu test MyAPI_searchDict[str, float]metadata_PACKAGE_THETA_JA = {"sot-23": 200.0, "to-220": 50.0, "bga-256": 20.0}
def run(self, arguments):
theta = arguments.get("theta_ja")
if theta is None and arguments.get("package"):
key = arguments["package"].lower()
if key not in _PACKAGE_THETA_JA:
return {"status": "error",
"message": f"Unknown package. Known: {list(_PACKAGE_THETA_JA)}"}
theta = _PACKAGE_THETA_JA[key]
return {
"status": "success",
"data": {"theta_ja": theta, ...},
"metadata": {"package_presets": _PACKAGE_THETA_JA},
}operation# C_min = I × Δt / ΔV → also: ΔV = I × Δt / C
op = arguments.get("operation") or self.operation
if op == "solve_capacitance":
dV = _req_float(arguments, "voltage_droop_V")
C_min = I * dt / dV
...
elif op == "solve_droop":
C = _req_float(arguments, "capacitance_F")
dV = I * dt / C
..."fields": {"operation": "default_op"}import math
_MU0 = 4.0 * math.pi * 1e-7 # H/m — permeability of free space
_KB_EV = 8.617333e-5 # eV/K — Boltzmann constant
# Material-specific values as a named dict
_MATERIAL_EA = {"al": 0.7, "cu": 0.9, "w": 1.0} # activation energy in eVdatadata = {
"junction_temp_C": tj,
"headroom_C": tj_max - tj,
"passes_thermal": (tj_max - tj) >= 0,
...
}_req_float