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).
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:
| 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 ( |
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 |
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.
"""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.
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.
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:
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.
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));
}
}
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(new DocsctlLc4jTools())
.build();
Reference: LangChain4j — Tools.
| LangChain (Python) | Spring AI | LangChain4j | |
|---|---|---|---|
Declare |
|
|
|
Describe arguments |
Docstring |
|
|
Report a failure to the model |
Raise |
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 |
|
|
|
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.