Wrapping a CLI as an Agent Tool

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against langchain 1.4.3 on Python 3.12, Spring AI 2.0.1 on Spring Boot 4.1.1 and Java 21, LangChain4j 1.20.2, the Claude Code permissions documentation and the VS Code Copilot approvals documentation, together with the docsctl running example of this sub-section, as checked on 2026-09-30. The Python tools were invoked directly and the ShellToolMiddleware was constructed, the Java classes were compiled, and none of the agents was run against a live model, so no model output appears on this page. Frameworks and host settings change quickly; re-check the linked pages before relying on a detail.

There are two ways to put a command line in front of an agent without MCP. If you write the agent, you register tools in your framework, and each tool is a function that runs the binary. If somebody else wrote the agent (Claude Code, Copilot in VS Code), it runs the binary itself and you decide what it may run. The first is the subject of the next four sections, the second of the last two. Wrapping the same CLI as an MCP server, so that any host can use it, is on Wrapping a CLI as an MCP Server; the security rules that apply to all of these are on Security.

Narrow tools versus a generic shell

LangChain offers a generic way to give an agent a shell. ShellTool (from langchain_community.tools, named terminal) runs whatever command lines the model writes on the machine; the community package it lives in is marked as being sunset, and it needs the separate langchain-experimental package to work. ShellToolMiddleware (from langchain.agents.middleware) is the current form: it adds one tool, named shell by default, that drives a persistent shell session, with an execution_policy (HostExecutionPolicy for full host access, DockerExecutionPolicy for a separate container per run and CodexSandboxExecutionPolicy for the Codex CLI sandbox), redaction_rules for the output, a workspace_root and an env (see Middleware).

A generic shell for the agent, isolated in a container
from langchain.agents import create_agent
from langchain.agents.middleware import DockerExecutionPolicy, ShellToolMiddleware

agent = create_agent(
    "anthropic:claude-sonnet-5",
    tools=[],
    middleware=[
        ShellToolMiddleware(
            workspace_root="/workspace",
            execution_policy=DockerExecutionPolicy(image="python:3.12-slim"),
        ),
    ],
)

That is the right tool for a coding agent that has to explore a workspace and run whatever it needs, and only inside an isolation boundary such as the container above (see the LangChain built-in middleware reference). It is the wrong tool for "let the agent search the documentation". A narrow tool wins for the reasons that matter to a CLI:

Table 1. One tool per subcommand versus a generic shell
Concern Narrow tool per subcommand Generic shell tool

Arguments

A schema with types, ranges and descriptions; invalid values are rejected before any process starts.

One free-form string. The model must know the syntax, and every quoting mistake is a bug or a hole.

Injection surface

Fixed program, argument list built in code, values validated (see Security).

A shell that interprets the whole string: pipes, substitutions, redirections and every other program on the machine.

What it can reach

Exactly the subcommands you wrote. The dangerous half (publish without --dry-run, login) is simply not offered.

Everything the process user can do, bounded only by the isolation policy.

Approvals

Per tool: interrupt on the one write tool and let the reads run.

Per command string, which a reviewer has to parse.

Output

The wrapper decides what the model sees: a cap, a projection, the envelope on failure.

Whatever the command prints, including secrets, until a redaction rule catches them.

Context cost

Each tool description costs tokens, so keep the set small and the descriptions tight.

One short description; the cost moves to the --help calls and trial and error.

Audit and evaluation

Tool calls are structured records that can be replayed and tested.

Strings in a transcript.

The design guidance behind the left column is on Tool Design, and the agent-side controls (guardrails, tool permission scoping, approval interrupts) are on LangChain Guardrails & Security and Human-in-the-Loop.

A LangChain tool per subcommand

The @tool decorator turns a function into a tool; with parse_docstring=True the docstring’s Args section becomes the argument descriptions in the schema the model sees (see Tools). A ToolException with handle_tool_error set returns its message to the model instead of failing the run.

docsctl_tools.py — two narrow LangChain tools
"""One narrow LangChain tool per docsctl subcommand."""
import os
import re
import subprocess

from langchain.tools import ToolException, tool

DOCSCTL = "/usr/local/bin/docsctl"
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")


@tool(parse_docstring=True)
def docsctl_search(query: str, component: str | None = None, limit: int = 10) -> str:
    """Search the documentation and return matching pages as JSON.

    Args:
        query: Free-text query, for example "kalman filter".
        component: Optional component name to restrict the search, for example "irurueta-statistics".
        limit: Maximum number of results, 1 to 100.
    """
    if component is not None and not NAME.fullmatch(component):
        raise ToolException(f"invalid component name {component!r}")
    if not 1 <= limit <= 100:
        raise ToolException("limit must be between 1 and 100")
    argv = [DOCSCTL, "search", "--json", "--no-input", "--limit", str(limit)]
    if component:
        argv += ["--component", component]
    argv += ["--", query]                                    # argv list, "--" before the operand
    try:
        proc = subprocess.run(argv, shell=False, capture_output=True, text=True, timeout=25,
                              stdin=subprocess.DEVNULL, env={"PATH": "/usr/bin:/bin"})
    except subprocess.TimeoutExpired:
        raise ToolException("docsctl timed out; retry with a narrower query")
    if proc.returncode != 0:                                 # the CLI's error envelope already has a hint
        raise ToolException(f"docsctl exited {proc.returncode}: {proc.stderr.strip()[:2000]}")
    return proc.stdout


docsctl_search.handle_tool_error = True                      # return the ToolException message to the model


@tool
def docsctl_publish_plan(component: str) -> str:
    """Show what publishing a component would change (a dry run). It never publishes."""
    if not NAME.fullmatch(component):
        raise ToolException(f"invalid component name {component!r}")
    argv = [DOCSCTL, "publish", "--json", "--no-input", "--dry-run", "--component", component]
    proc = subprocess.run(argv, shell=False, capture_output=True, text=True, timeout=25,
                          stdin=subprocess.DEVNULL, env={"PATH": "/usr/bin:/bin"})
    if proc.returncode != 0:
        raise ToolException(f"docsctl exited {proc.returncode}: {proc.stderr.strip()[:2000]}")
    return proc.stdout


docsctl_publish_plan.handle_tool_error = True

The same rules as on the MCP page apply, because the boundary is the same: a fixed program path, an argument list and shell=False, -- before the free-text operand, a name allowlist that also rejects anything starting with -, a minimal environment, a timeout, and the CLI’s own error envelope returned to the model on failure. The second tool shows the design advantage of narrow tools: it wraps docsctl publish but only ever adds --dry-run, so the agent can plan a publication and cannot perform one. A real publication stays with a person, as described on Safety & Idempotency.

Attaching the tools to an agent, with an approval interrupt on the plan tool
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from docsctl_tools import docsctl_publish_plan, docsctl_search

agent = create_agent(
    "anthropic:claude-sonnet-5",
    tools=[docsctl_search, docsctl_publish_plan],
    checkpointer=InMemorySaver(),
    middleware=[HumanInTheLoopMiddleware(interrupt_on={"docsctl_publish_plan": True})],
)

The interrupt_on entry is optional for a dry run, and is shown to illustrate the mechanism for any tool that is not read-only: the agent pauses before the tool runs and resumes with a decision from a person, which is why a checkpointer is passed (see Human-in-the-Loop & Time Travel). Reference: LangChain — Tools.

Two other LangChain routes to the same CLI are worth knowing. The wrapper from the previous page can be consumed as an MCP server through the adapters described on LangChain MCP Adapters, and LangChain4j has an equivalent, on LangChain4j Agents & MCP.

Spring AI @Tool

A Spring AI tool is a method annotated with @Tool, whose parameters can carry @ToolParam descriptions; the JSON Schema is derived from the signature (see Tool Calling). The method below reuses runDocsctl, the ProcessBuilder helper of the MCP page, which builds an argument list, clears the environment, enforces a timeout with waitFor and an output cap, and throws an unchecked exception for a non-zero exit code.

DocsctlSpringAiTools.java (Spring AI 2.0.1)
package com.example;

import java.util.List;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

@Component
public class DocsctlSpringAiTools {

    @Tool(name = "docsctl_search",
            description = "Search the documentation of this site. Returns JSON with a results array; "
                    + "use a small limit and refine the query instead of paging through everything.")
    public String search(
            @ToolParam(description = "Free-text query, for example 'kalman filter'") String query,
            @ToolParam(description = "Number of results, 1 to 100", required = false) Integer limit) {
        int n = limit == null ? 10 : limit;
        if (n < 1 || n > 100 || query == null || query.isBlank() || query.length() > 200) {
            throw new IllegalArgumentException("query must be 1-200 characters and limit 1-100");
        }
        return DocsctlTools.runDocsctl(List.of("/usr/local/bin/docsctl", "search", "--json", "--no-input",
                "--limit", String.valueOf(n), "--", query));
    }
}

Register it on the client with defaultTools(…​) (every request) or tools(…​) (one request), as on the tool-calling page:

Registering the tool object on a ChatClient
ChatClient chat = builder.defaultTools(new DocsctlSpringAiTools()).build();

Note the difference from @McpTool: a @Tool method is called by your own application’s model loop, so the caller is your code and your users, and the trust boundary is the application (authorise the user before the tool runs; see Spring AI Security & Guardrails). An @McpTool method is called by whichever host connects. Reference: Spring AI — Tool Calling.

LangChain4j @Tool

LangChain4j’s @Tool takes the description as its value, and @P describes a parameter and can mark it not required (see LangChain4j). The class below calls the same helper.

DocsctlLc4jTools.java (LangChain4j 1.20.2)
package com.example;

import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import java.util.List;

public class DocsctlLc4jTools {

    @Tool("Search the documentation of this site. Returns JSON with a results array.")
    String docsctlSearch(@P("free-text query") String query,
                         @P(value = "number of results, 1 to 100", required = false) Integer limit) {
        int n = limit == null ? 10 : limit;
        if (n < 1 || n > 100 || query == null || query.isBlank() || query.length() > 200) {
            throw new IllegalArgumentException("query must be 1-200 characters and limit 1-100");
        }
        return DocsctlTools.runDocsctl(List.of("/usr/local/bin/docsctl", "search", "--json", "--no-input",
                "--limit", String.valueOf(n), "--", query));
    }
}
Registering the tool object on an AI service
Assistant assistant = AiServices.builder(Assistant.class)
        .chatModel(model)
        .tools(new DocsctlLc4jTools())
        .build();
Table 2. The same tool in each framework
LangChain (Python) Spring AI LangChain4j

Declare

@tool on a function

@Tool on a method

@Tool on a method

Describe arguments

Docstring Args with parse_docstring=True, or args_schema

@ToolParam(description, required)

@P(value, required)

Report a failure to the model

Raise ToolException with handle_tool_error

Throw an unchecked exception; by default its message goes back to the model, and a checked exception is rethrown

Throw an exception from the method; see the LangChain4j Tools reference for how errors are handled

Register

tools=[…​] on create_agent

defaultTools(…​) or tools(…​)

.tools(…​) on AiServices.builder

Using the CLI directly from an agent host

When the host is Claude Code or Copilot, there is no wrapper to write: the agent runs docsctl through the host’s shell tool. What you control is the permission layer, and a self-describing CLI (--help, docsctl schema --json, an agent skill; see Self-Describing Help) so the agent needs few attempts.

Claude Code

Claude Code matches Bash permission rules against the command text. Bash(docsctl ) matches any docsctl command (the trailing space and also match the bare command), so it would allow docsctl publish too. Put the * after the subcommand and list the read commands individually, ask for the write command and deny the credential commands:

.claude/settings.json
{
  "permissions": {
    "allow": [
      "Bash(docsctl search *)",
      "Bash(docsctl show *)",
      "Bash(docsctl list-components *)",
      "Bash(docsctl validate *)",
      "Bash(docsctl schema *)"
    ],
    "ask": [
      "Bash(docsctl publish *)"
    ],
    "deny": [
      "Bash(docsctl login *)",
      "Bash(docsctl logout *)"
    ]
  }
}

Rules are evaluated deny first, then ask, then allow, so a broad deny beats a narrower allow. Claude Code understands the shell operators (&&, ||, ;, |, |&, & and newlines) and requires each part of a compound command to match on its own, and it strips a fixed set of wrappers such as timeout. But the documentation is explicit that a Bash rule matches the command text as written and is not a security boundary around the program: a form such as /usr/local/bin/docsctl publish …​ or bash -c '…​' is a different string. Rules that try to constrain arguments are fragile for the same reason. For enforcement that does not depend on how the command is spelled, use the sandbox and a hook, both covered on Permissions & Settings, and give the CLI a credential that can only do what the agent needs (see Security). The rule syntax is documented at Claude Code — Configure permissions.

Copilot agent mode

Copilot in VS Code asks before it runs a terminal command, and the chat.tools.terminal.autoApprove setting decides which commands skip the prompt. Its keys are command names or, wrapped in slashes, regular expressions; true runs without confirmation and false always requires approval (it does not block the command).

settings.json (VS Code)
{
  "chat.tools.terminal.autoApprove": {
    "/^docsctl (search|show|list-components|validate|schema)\\b/": true,
    "/^docsctl (publish|login|logout)\\b/": false
  }
}

VS Code describes terminal auto-approval as a best-effort convenience, not a security boundary, and recommends sandboxing or containers for high-risk environments, so the same advice applies as for Claude Code: allow the read commands, keep the write command behind a prompt, and enforce the important limits in the credential and the environment. The pickers, the related settings and the comparison with Claude Code are on Permissions & Settings, and how hosts run agents non-interactively is on Headless & CI Automation. Reference: VS Code — Manage approvals and permissions.