Building Custom Tools¶
This page is for anyone extending the package with a new tool — for
example, a git_tool, a zip_tool, or anything else your agent needs
beyond the five built-in ones.
You could, of course, just write a plain LangChain @tool-decorated
function that touches the filesystem directly. Don't do that if you
want your new tool to respect the same sandboxing everything else in this
package respects — a raw @tool function has no idea what a
SecurityContext is, so it would silently bypass path scoping, permission
checks, command allow/deny lists, and approval workflows entirely. Instead,
build your tool the same way every tool in this package is built: on top
of guard().
The pattern, in one sentence¶
A tool in this package is a factory function (make_xxx_tool(ctx=None))
that returns a StructuredTool, whose implementation calls guard()
before doing anything, and catches MeroToolsError at its boundary to
return a string instead of raising.
Everything else — argument schemas, operation dispatch, output formatting
— is just normal Python and normal LangChain StructuredTool usage.
What guard() gives you for free¶
Recall from Core Concepts:
one call to guard() gets you path scoping, permission checking, and
approval-gating, all in one place, all consistent with every other tool.
from langchain_mero_tools.core.guard import guard
from langchain_mero_tools.core.security import Permission, SecurityContext
resolved_path = guard(
ctx, # SecurityContext | None
permission=Permission.WRITE, # what this action requires
path="some/path.txt", # None if the action isn't tied to one path
detail="human-readable label for approval prompts / error messages",
)
- If
ctx is None, all checks are skipped — the tool is unrestricted. This is what makes "no context = no restrictions" work automatically for every new tool, with zero extra code. - If
pathis given, it's resolved through thePathEntrymount table (or the flat model, ifpathsis empty) and the resolvedPathis returned — always use the returned path, not the raw string, for any actual filesystem operation, since it's the symlink/..-resolved version that was actually checked. - If the permission is flagged as needing approval (the governing
PathEntry'srequired_permission, orrequire_approval_forwhen there's no path),guard()blocks on the configured approval backend before returning. - On any failure,
guard()raises aMeroToolsErrorsubclass — it never returnsNoneto signal failure, so you don't need to check a return value for that.
Step-by-step: building a new tool¶
We'll build a small word_count_tool that counts words/lines/characters
in a file — enough surface area to show every piece of the pattern without
being a toy that skips the parts that matter.
1. Define the input schema with pydantic¶
# tools/word_count_tool.py
from __future__ import annotations
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field
from ..core.exceptions import MeroToolsError
from ..core.guard import guard
from ..core.security import Permission, SecurityContext
class WordCountToolInput(BaseModel):
path: str = Field(..., description="File to count words/lines/characters in.")
Use pydantic.BaseModel + Field(...) the same way every other tool does
— this is what LangChain uses to generate the tool's schema for the model,
including field descriptions the LLM sees.
2. Map the operation to a Permission¶
Even a single-operation tool should be explicit about which permission it requires — don't default to the broadest one "just in case."
def _run(path: str, *, ctx: SecurityContext | None) -> str:
try:
resolved = guard(ctx, permission=Permission.READ, path=path, detail=f"word count {path}")
resolved = resolved or __import__("pathlib").Path(path).resolve()
if not resolved.is_file():
return f"Error: '{path}' is not a file or does not exist."
text = resolved.read_text(errors="replace")
return (
f"lines={len(text.splitlines())} "
f"words={len(text.split())} "
f"chars={len(text)}"
)
except MeroToolsError as e:
return f"Denied: {e}"
A few things worth calling out here, all matching the convention every built-in tool follows:
- Catch
MeroToolsError, not a bareexcept. Only the security layer's own exceptions should be swallowed into a"Denied: ..."string — a genuine bug in your tool should still raise and be visible, not silently reported to the agent as a permission problem. - Never raise for "expected" failures like a missing file — return a
plain
"Error: ..."string instead. The agent reads tool output as text; a Python traceback would either crash the run or (worse) get mangled into a confusing message. resolved = resolved or Path(path).resolve()—guard()returnsNoneifctx is Noneand no path was checked in an unusual way, but in the normal case where apathwas passed, it always returns the resolved path (even withctx=None, perguard()'s own fallback — see its source incore/guard.py). This defensive pattern mirrors what every built-in tool does; it costs nothing and avoids depending on an implementation detail changing later.
3. Write the factory function¶
def make_word_count_tool(ctx: SecurityContext | None = None) -> StructuredTool:
"""Build the Word Count Tool, optionally bound to a SecurityContext."""
def _tool_fn(path: str) -> str:
return _run(path, ctx=ctx)
return StructuredTool.from_function(
func=_tool_fn,
name="word_count_tool",
description="Count lines, words, and characters in a file.",
args_schema=WordCountToolInput,
)
Follow the naming convention: make_<name>_tool(ctx=None) -> StructuredTool.
This is what lets your tool slot into get_tools()'s builders dict later
if you want it included there, and it's what every developer reading this
codebase will expect to find.
4. If your tool runs a command instead of touching a path¶
If your new tool shells out (like process_tool/rsync_tool do), also
call ctx.check_command(...) after the permission check, so
allowed_commands/denied_commands apply to it too:
def _run(command: str, *, ctx: SecurityContext | None) -> str:
try:
guard(ctx, permission=Permission.EXECUTE, detail=command)
if ctx is not None:
ctx.check_command(command)
# ... subprocess.run(...) ...
except MeroToolsError as e:
return f"Denied: {e}"
guard() itself doesn't call check_command — that's on purpose, since
not every tool involves a command string. Call it explicitly, same as
process_tool and rsync_tool do.
5. Register it (optional, but recommended)¶
Add it to tools/__init__.py:
And to the top-level package __init__.py, both in the import and in
__all__, and to get_tools()'s builders dict if you want it available
via include=[...]:
# __init__.py
from .tools import make_word_count_tool # add to the import
builders = {
"file": make_file_tool,
"directory": make_directory_tool,
"search": make_search_tool,
"process": lambda c: make_process_tool(c, shell=process_shell),
"rsync": make_rsync_tool,
"word_count": make_word_count_tool, # add here
}
6. Write a test¶
Follow the existing test files' pattern (tests/test_file_tool.py,
tests/test_security.py) — at minimum, cover:
- Works with
ctx=None(unrestricted). - Denies a path not covered by any
PathEntrywhen aSecurityContextis given. - Denies when the required permission isn't granted.
- Returns a clean error string (not a raised exception) for expected failure cases like a missing file.
# tests/test_word_count_tool.py
from langchain_mero_tools import PathEntry, Permission, SecurityContext, make_word_count_tool
def test_unrestricted(tmp_path):
f = tmp_path / "a.txt"
f.write_text("hello world\nsecond line")
tool = make_word_count_tool()
result = tool.invoke({"path": str(f)})
assert "words=4" in result
def test_denied_outside_scope(tmp_path):
ctx = SecurityContext(
paths=[
PathEntry(
path=str(tmp_path / "allowed"),
slug="root",
allowed_permission=Permission.READ,
)
]
)
outside = tmp_path / "outside.txt"
outside.write_text("x")
tool = make_word_count_tool(ctx)
result = tool.invoke({"path": str(outside)})
assert "Denied" in result
def test_missing_permission(tmp_path):
ctx = SecurityContext(
paths=[
PathEntry(
path=str(tmp_path),
slug="root",
allowed_permission=Permission.NONE,
)
]
)
tool = make_word_count_tool(ctx)
result = tool.invoke({"path": str(tmp_path / "a.txt")})
assert "Denied" in result
Checklist¶
Use this as a quick reference when adding any new tool:
- [ ] Input schema is a
pydantic.BaseModelwithField(..., description=...)for every argument the model needs to understand. - [ ] Every operation is mapped to exactly one
Permission— no operation defaults to a broader permission "to be safe." - [ ] Every filesystem/process action goes through
guard(ctx, permission=..., path=..., detail=...)before it does anything. - [ ] If the tool runs a shell command,
ctx.check_command(command)is called explicitly in addition toguard(). - [ ]
MeroToolsErroris caught at the tool's boundary and turned into a"Denied: {e}"string — never left to propagate as a raised exception. - [ ] Other expected failures (missing file, bad arguments) return a plain
"Error: ..."string, not a raised exception. - [ ] The factory function is named
make_<name>_tool(ctx=None) -> StructuredTool. - [ ]
ctx=Nonemeans fully unrestricted — never hard-code a restriction that only applies when no context is passed. - [ ] (Optional) Registered in
tools/__init__.py, the top-level__init__.py, andget_tools()'sbuildersdict. - [ ] A test file exists covering the unrestricted case, a denied-path case, and a denied-permission case, at minimum.
Why not just use @tool directly?¶
LangChain's own @tool decorator is the right choice for a tool that
genuinely has no security surface — pure computation, calling an external
API with no filesystem/process access, etc. It's the wrong choice the
moment your tool touches a file, a directory, or spawns a process, because
at that point you want:
- Path resolution that's actually safe against symlink/
..tricks (this package already solved that inSecurityContext.check_path— don't re-solve it). - Consistent behavior with every other tool in the agent's toolkit, so an agent (or a developer debugging it) doesn't have to remember "this one tool works differently."
- The option to require human approval later, without rewriting the tool —
since it already goes through
guard(), addingrequire_approval_forto theSecurityContextis enough; no code changes needed in the tool itself.
guard() is the extension point specifically so you never have to
reimplement any of this.
See also¶
- Core Concepts — the full
guard()flow this pattern relies on. - Security & Sandboxing — everything
SecurityContextchecks on your behalf. - The five built-in tools' source files (
tools/file_tool.py,tools/directory_tool.py,tools/search_tool.py,tools/process_tool.py,tools/rsync_tool.py) are themselves the best reference implementations of this pattern — copy the closest match as a starting point.