Skip to content

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 path is given, it's resolved through the PathEntry mount table (or the flat model, if paths is empty) and the resolved Path is 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's required_permission, or require_approval_for when there's no path), guard() blocks on the configured approval backend before returning.
  • On any failure, guard() raises a MeroToolsError subclass — it never returns None to 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 bare except. 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() returns None if ctx is None and no path was checked in an unusual way, but in the normal case where a path was passed, it always returns the resolved path (even with ctx=None, per guard()'s own fallback — see its source in core/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.

Add it to tools/__init__.py:

from .word_count_tool import make_word_count_tool

__all__ = [
    ...,
    "make_word_count_tool",
]

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 PathEntry when a SecurityContext is 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.BaseModel with Field(..., 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 to guard().
  • [ ] MeroToolsError is 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=None means 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, and get_tools()'s builders dict.
  • [ ] 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 in SecurityContext.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(), adding require_approval_for to the SecurityContext is 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 SecurityContext checks 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.