MCP vs CLI is a real choice when you give an AI agent tools. You can connect an MCP server with a typed tool for every action, or give the agent one shell tool and let it run commands like gh and git. The two differ in token cost, in what the model must already know, and in how well you can control what each user may do. This guide explains both, when to use each, and how to build and combine them with Promptise Foundry.
Should you use MCP or a CLI for an AI agent?
Use a CLI when one trusted user runs the agent on a well-known command-line tool, inside a sandbox you control, such as a coding agent on a developer's machine or a CI job. Use MCP when the agent serves many users, needs per-user permissions and an audit trail, or works with your own internal systems, which no model has seen in training. Many production agents use both: MCP for shared systems with permissions, and a read-only shell for local work.
A CLI is cheap in context. One tool definition replaces dozens, and the model brings its own knowledge of gh or git.
MCP schemas cost tokens on every call, but providers cache a stable tool list, and semantic tool selection can shrink it.
A CLI costs retries. Wrong flags, blocked commands and --help lookups each cost a model call.
MCP gives you identity. The server sees typed calls from a known user, so it can check roles per tool and log who did what.
A shell tool needs a boundary. An allowlist limits which commands run. A container limits what they can reach.
[02]
How MCP and CLI tools work in an agent
Both approaches use the same agent loop: the model gets the conversation and a list of tool definitions, asks for a tool call, reads the result, and repeats until it can answer. The difference is what sits in that tool list and who interprets the call.
MCP: a typed tool per action CLI: one tool, many commands
───────────────────────────── ─────────────────────────────
system prompt system prompt
tool definitions (all sent each call): tool definitions:
get_issue(owner, repo, issue_number) run_command(command: str)
add_issue_comment(owner, repo, ...)
update_issue(owner, repo, state, ...) model writes a command line:
list_pull_requests(...) ... 48 tools "gh issue close 25 --repo acme/webshop"
│ │
▼ ▼
MCP client → MCP server (validates the allowlist check → subprocess, ideally in a
arguments, checks the user, runs the container (the CLI parses the flags and
tool, returns JSON or a typed error) prints text or JSON, with an exit code)With MCP, the client asks the server for its tools (tools/list) and passes each tool's name, description and JSON Schema to the model. When the model picks a tool, the client sends tools/call with typed arguments, and the server validates them before anything runs. The MCP tools specification defines tool execution errors (isError: true) and says servers must validate inputs and enforce access controls. For the protocol's other building blocks, see MCP tools vs resources vs prompts.
With a CLI, the agent has one generic tool that takes a command line, and the model has to know the flags. For gh, git, kubectl or aws it usually does, from its training data. When it doesn't, it reads --help. Good CLIs help agents directly: gh prints structured output with --json and lists the valid fields when you leave them out (gh formatting docs).
[03]
Where do the tokens go with MCP and a CLI?
Tool definitions go with every model request. Here is the first request of one agent in four setups, from the runs in steps 1 to 4. The first three ran the same task (close #25 as a duplicate of #24):
Setup | Tools offered | Input tokens, first call |
|---|---|---|
CLI: one run_command tool | 1 | 266 |
MCP: GitHub-style server, all tools | 48 | 4,975 |
MCP with semantic tool selection | 12 | 1,336 |
Both: MCP with selection plus a read-only shell | 11 | 1,085 |
Most of the 4,975 tokens are schemas. Four effects decide what that costs over a whole task.
Schema overhead repeats. A task with five model calls sends the tool list five times, and more tools make the choice harder. That is why GitHub's own MCP server lets you enable only the toolsets you need (github-mcp-server). Too many MCP tools covers this in depth.
Prompt caching discounts a stable prefix. Providers cache the start of a request that stays identical, and tool definitions come first. OpenAI caches automatically, bills cache hits at a fraction of the input price, and needs an exact prefix match (OpenAI prompt caching). Anthropic caches tools, then system, then messages when you set cache_control, and a change to the tool definitions invalidates the whole cache (Anthropic prompt caching). A stable tool list costs much less in dollars than its token count suggests.
Changing tool lists break the cache. Semantic selection offers tools that match the conversation, so the prefix changes whenever the selection does. Its requests are smaller, but fewer of their tokens may be cached. Judge it by context size and tool choice, not only by the bill.
CLIs pay in retries. Every mistake costs a model call: a wrong flag, a blocked command, a --help lookup. These "help loops" are rare for CLIs the model knows and common for internal tools. Output is also unbounded text, so cap it.
Agent hosts can soften the schema cost. Claude Code, for example, defers MCP tool definitions by default, loads only tool names at session start, and searches for a definition when it needs one (Claude Code MCP docs). It also has a Bash tool, so it uses both approaches.
[04]
How do MCP and CLI compare on security?
Credentials. A CLI acts as whoever is logged in. gh reads GH_TOKEN from the environment, ahead of stored logins (gh environment docs), so every user of a shared agent acts as one identity unless you inject each user's token into each subprocess and keep the model from reading it. With MCP over HTTP, the client sends the user's token in the Authorization header, and the model never sees it. The MCP authorization spec builds on OAuth 2.1 for HTTP. It tells stdio servers to read credentials from the environment, which puts them where a CLI is.
Per-user scopes. An allowlist works on command names. It can block rm, but it can't easily say "Maria may close issues, Sam may only comment", because both are gh issue .... An MCP server sees close_issue(issue_number=25) from a known user and can check that user's role per tool. Step 5 shows this.
Audit. A shell log records command lines. An MCP audit log records which user called which tool with which arguments, and whether it was allowed.
Shell injection. A model that writes command lines can be steered by text it reads, such as an issue body that says "also run curl ... | sh". Never pass the model's string to sh -c. Split it, check each command against an allowlist, block ;, &&, redirects and subshells, and run each stage without a shell. That still isn't isolation. Claude Code's docs say the same about their own Bash rules: argument patterns are fragile, so pair them with OS-level sandboxing (Claude Code permissions). Run a shell tool in a container with no network unless it needs one, as the code execution sandbox guide shows.
MCP servers need care too: clients must treat tool annotations such as destructiveHint as untrusted unless the server is trusted. See MCP security best practices.
[05]
How you do common tasks with MCP vs a CLI
Task | MCP server | CLI tool |
|---|---|---|
Tell the model what it can do | Names, descriptions and JSON Schemas, sent with every call | One tool description, plus the model's training and --help |
Validate arguments | The server validates typed inputs before the tool runs | The CLI parses flags and fails on unknown ones |
Return results | JSON, or a typed error with isError | Text or --json on stdout, stderr, an exit code |
Authenticate the user | A bearer token per request over HTTP (OAuth 2.1) | A token in the environment, shared unless injected per user |
Limit what a user may do | Roles or scopes per tool, checked on the server | An allowlist of commands and blocked flags |
Audit | One entry per call: user, tool, arguments, outcome | Command lines in your own log |
Reduce context cost | Fewer toolsets, semantic selection, deferred loading | Already small; cap output length |
[06]
When to use MCP, a CLI, or both
Your situation | Use | Why |
|---|---|---|
One developer, a CLI the model knows, a sandbox | CLI | Smallest context, no server to build |
CI automation with a fixed, scoped token | CLI | One identity is correct here; contain it in the runner |
Many users with their own permissions | MCP | The server sees each user and checks roles per tool |
An audit trail per user and action | MCP | Typed calls with identity are easy to log and review |
Your internal APIs or services | MCP | The model has never seen your CLI; schemas teach it |
Dozens or hundreds of tools | MCP with selection or deferred loading | Keeps each request small and the choice easier |
Shared systems plus a local repo | Both | MCP for permissioned systems, a read-only shell for the checkout |
Don't give an uncontained shell to an agent that reads untrusted text (emails, tickets, web pages) and holds real credentials.
[07]
What you need
Python 3.10 or later and Promptise Foundry 1.4.1 or later, with the tool-optimization extra for semantic selection. It installs sentence-transformers and downloads a small embedding model once.
An OpenAI API key in OPENAI_API_KEY. The examples use openai:gpt-5-mini, and each run costs well under a cent.
About 25 minutes.
pip install "promptise[tool-optimization]>=1.4.1"The example works on a fixture repository, acme/webshop: a git checkout plus a JSON file with 30 issues and 6 pull requests, which the MCP server and the CLI share. The CLI is a local stand-in for gh with the same subcommands and flags, so the example runs offline. With the real gh, point the allowlist at its binary and give it a scoped token.
[08]
Step 1: Give an agent a safe, allowlisted CLI tool
A shell tool's main job is to refuse anything outside the allowlist before a process starts. This one allows a fixed set of binaries, makes git read-only, allows pipes and blocks everything else. It runs each pipeline stage without a shell, in an environment with no API keys, and caps the output.
"""One allowlisted shell tool for an agent: run a command (pipes allowed) in the repo checkout.
This is a local subprocess with an allowlist, not a container. Run it inside a container or VM
in production."""
import os
import shlex
import subprocess
from pathlib import Path
from langchain_core.tools import StructuredTool
HERE = Path(__file__).parent
WORKDIR = Path(os.environ.get("GHREPO", HERE / "fixture" / "webshop"))
BINARIES = { # command name -> absolute path; nothing else can run
"gh": str(HERE / "fakebin" / "gh"), # a local stand-in for the GitHub CLI (see below)
"git": "/usr/bin/git", "grep": "/usr/bin/grep", "ls": "/bin/ls", "cat": "/bin/cat",
"head": "/usr/bin/head", "wc": "/usr/bin/wc", "sort": "/usr/bin/sort", "jq": "/opt/homebrew/bin/jq",
}
GIT_READ_ONLY = {"log", "show", "diff", "grep", "ls-files", "blame", "status", "tag", "shortlog"}
BLOCKED = {";", "&", "&&", "||", ">", ">>", "<"}
def parse(command: str, allowed: set[str]) -> list[list[str]]:
"""Split a command line into pipeline stages and check every stage against the allowlist."""
lex = shlex.shlex(command, posix=True, punctuation_chars="|;&<>")
lex.whitespace_split = True
tokens = list(lex)
if any(t in BLOCKED or "$(" in t or "`" in t for t in tokens):
raise ValueError("only single commands and pipes (|) are allowed")
stages, cur = [], []
for t in tokens + ["|"]:
if t != "|":
cur.append(t)
continue
if not cur:
raise ValueError("empty pipeline stage")
if cur[0] not in allowed:
raise ValueError(f"command not allowed: {cur[0]}. Allowed: {', '.join(sorted(allowed))}")
if cur[0] == "git" and (len(cur) < 2 or cur[1] not in GIT_READ_ONLY):
raise ValueError(f"git is read-only here; allowed: {', '.join(sorted(GIT_READ_ONLY))}")
stages.append([BINARIES[cur[0]], *cur[1:]])
cur = []
return stages
def run(command: str, allowed: set[str]) -> str:
try:
stages = parse(command, allowed)
except ValueError as e:
return f"error: {e}"
env = {"PATH": "/usr/bin:/bin", "GHDB": os.environ.get("GHDB", ""), "GHREPO": str(WORKDIR),
"GIT_PAGER": "cat", "NO_COLOR": "1", "HOME": "/tmp"} # no API keys, no tokens
procs, prev = [], None
for argv in stages: # no shell: each stage is its own process, joined by pipes
p = subprocess.Popen(argv, cwd=WORKDIR, env=env, stdin=prev, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
if prev:
prev.close()
prev = p.stdout
procs.append(p)
try:
out, err = procs[-1].communicate(timeout=15)
except subprocess.TimeoutExpired:
for p in procs:
p.kill()
return "error: command timed out after 15s"
err = "".join(p.stderr.read() for p in procs[:-1]) + err
result = out + (("\n" if out else "") + err if err else "")
if procs[-1].returncode:
result += f"\n(exit code {procs[-1].returncode})"
return result[:6000] or "(no output)"
def make_run_command(allowed: set[str]) -> StructuredTool:
def run_command(command: str) -> str:
return run(command, allowed)
names = ", ".join(sorted(allowed))
return StructuredTool.from_function(
run_command,
description=(f"Run a command in the acme/webshop checkout and return its output. Available: {names}"
f"{' (gh is authenticated for acme/webshop)' if 'gh' in allowed else ''}; git is read-only. "
"Pipes (|) work; ;, &&, redirects and subshells do not. Use --help to learn a command."),
)Pass the tool to build_agent() with extra_tools:
shell = make_run_command({"gh", "git", "grep", "ls", "cat", "head", "wc", "sort", "jq"})
return await build_agent(model=MODEL, instructions=INSTRUCTIONS, extra_tools=[shell])agents.py prints each model call with its tool count, token usage and tool calls. Run the duplicate-issue task:
setup: cli
task: Issue #25 in acme/webshop duplicates #24. Close #25 with a comment saying it is a duplicate of #24.
call 1: 1 tools, 266 input (0 cached), 124 output
-> run_command(command="gh issue view 25 --repo acme/webshop --json number,title,body,state,comments...
<- { "number": 25, "title": "Duplicate orders when user double-clicks", "st...
call 2: 1 tools, 360 input (0 cached), 46 output
-> run_command(command="gh issue view 24 --repo acme/webshop --json number,title,body --jq '{number:...
<- { "number": 24, "title": "Duplicate orders on double click" }
call 3: 1 tools, 437 input (0 cached), 35 output
-> run_command(command='gh issue comment 25 --repo acme/webshop --body "Duplicate of #24"')
<- https://github.com/acme/webshop/issues/25#comment
call 4: 1 tools, 497 input (0 cached), 27 output
-> run_command(command='gh issue close 25 --repo acme/webshop')
<- ✓ Closed issue acme/webshop#25
call 5: 1 tools, 545 input (0 cached), 21 output
answer: Done — I commented "Duplicate of #24" on issue #25 and closed it.
total: 5 model calls, 2,105 input tokens (0 cached), 253 output, $0.0010Every request stayed small, from 266 to 545 tokens. The model knew gh issue view, --json, --jq, gh issue comment and gh issue close without reading any help. No request was long enough for OpenAI's cache, and at this size it didn't matter.
[09]
Step 2: Give the agent an MCP server
The MCP version connects to a GitHub-style server with 48 tools whose names and parameters follow the style of GitHub's official server. Each tool is a typed Python function with @server.tool(), like the ones in step 5. Some, such as get_issue and update_issue, do the job here. Many, such as list_workflow_runs or fork_repository, are there because a real GitHub server has them too.
The agent starts the server over stdio and discovers its tools:
GITHUB = StdioServerSpec(
command=sys.executable,
args=[str(HERE / "github_server.py")],
env={"GHDB": os.environ["GHDB"], "GHREPO": str(HERE / "fixture" / "webshop"), "PATH": "/usr/bin:/bin"},
)
return await build_agent(model=MODEL, instructions=INSTRUCTIONS, servers={"github": GITHUB})A stdio server starts with only the environment you pass in env, so list every variable it needs. The same task:
setup: mcp
task: Issue #25 in acme/webshop duplicates #24. Close #25 with a comment saying it is a duplicate of #24.
call 1: 48 tools, 4,975 input (4,608 cached), 162 output
-> get_issue(issue_number=25)
<- {"number": 25, "title": "Duplicate orders when user double-clicks", "sta...
call 2: 48 tools, 5,099 input (4,864 cached), 28 output
-> get_issue(issue_number=24)
<- {"number": 24, "title": "Duplicate orders on double click", "state": "op...
call 3: 48 tools, 5,218 input (4,992 cached), 39 output
-> add_issue_comment(issue_number=25, body='Closing as duplicate of #24.')
<- {"url": "https://github.com/acme/webshop/issues/25#comment"}
call 4: 48 tools, 5,287 input (5,120 cached), 32 output
-> update_issue(issue_number=25, state='closed')
<- {"number": 25, "title": "Duplicate orders when user double-clicks", "sta...
call 5: 48 tools, 5,373 input (5,120 cached), 23 output
answer: Done — I closed issue #25 and added a comment: "Closing as duplicate of #24."
total: 5 model calls, 25,952 input tokens (24,704 cached), 284 output, $0.0015Every call carried all 48 definitions, about 5,000 tokens, but more than 90% of each request came from the cache. Even the first call hit it, because an identical prefix had been sent in an earlier run. One stable tool list, shared by every request, is caching at its best. (The printout leaves out owner and repo.) Connect MCP servers to an AI agent covers the client side.
[10]
Step 3: Cut MCP schema cost with semantic tool selection
Semantic tool selection embeds every tool description locally. Before each model call it offers only the best matches for the conversation, plus a request_more_tools fallback. preserve_tools names tools that are always offered and never shortened.
return await build_agent(
model=MODEL, instructions=INSTRUCTIONS, servers={"github": GITHUB},
optimize_tools=ToolOptimizationConfig(
level=OptimizationLevel.SEMANTIC,
preserve_tools={"get_issue", "add_issue_comment", "update_issue"},
),
)The SEMANTIC level offers 8 tools per call (semantic_top_k) and shortens descriptions, except on preserved tools. The same task:
setup: mcp_semantic
task: Issue #25 in acme/webshop duplicates #24. Close #25 with a comment saying it is a duplicate of #24.
call 1: 12 tools, 1,336 input (1,152 cached), 98 output
-> get_issue(issue_number=25)
<- {"number": 25, "title": "Duplicate orders when user double-clicks", "sta...
call 2: 12 tools, 1,388 input (1,152 cached), 28 output
-> get_issue(issue_number=24)
<- {"number": 24, "title": "Duplicate orders on double click", "state": "op...
call 3: 12 tools, 1,502 input (0 cached), 39 output
-> add_issue_comment(issue_number=25, body='Closing as duplicate of #24.')
<- {"url": "https://github.com/acme/webshop/issues/25#comment"}
call 4: 12 tools, 1,540 input (1,408 cached), 32 output
-> update_issue(issue_number=25, state='closed')
<- {"number": 25, "title": "Duplicate orders when user double-clicks", "sta...
call 5: 12 tools, 1,657 input (1,536 cached), 23 output
answer: Done — I closed issue #25 and added a comment: "Closing as duplicate of #24."
total: 5 model calls, 7,423 input tokens (5,248 cached), 220 output, $0.0011Each call offered 12 tools: the 8 best matches, the 3 preserved tools and the fallback. The first request dropped from 4,975 to 1,336 tokens. Call 3 missed the cache: the selection follows the conversation, so the start of the request can change from call to call. Selection reliably keeps the context small, but whether it lowers your bill depends on caching and your tasks. Check the cost in dollars. The tool optimization docs list every setting.
[11]
Step 4: Combine MCP and a CLI in one agent
In the combined setup, GitHub goes through MCP, where a server can check each user's permissions, and the local checkout gets a read-only shell without gh, so the shell cannot change shared data. Both run_command and the comment tool are in preserve_tools, so selection always offers them:
shell = make_run_command({"git", "grep", "ls", "cat", "head", "wc"})
return await build_agent(
model=MODEL, servers={"github": GITHUB}, extra_tools=[shell],
instructions=INSTRUCTIONS + " Use run_command for the local checkout and the GitHub tools for "
"issues and pull requests.",
optimize_tools=ToolOptimizationConfig(level=OptimizationLevel.SEMANTIC,
preserve_tools={"run_command", "add_issue_comment"}),
)The task needs both sides: find a function, find the last commit that touched it, and comment on an issue.
setup: both
task: In acme/webshop, find the file that defines apply_discount and the commit that last changed it, then comment on issue #7 with both.
call 1: 11 tools, 1,085 input (0 cached), 32 output
-> run_command(command='grep -R "def apply_discount" -n')
<- ./src/cart.py:8:def apply_discount(total, code):
call 2: 11 tools, 1,052 input (0 cached), 28 output
-> run_command(command="sed -n '1,160p' src/cart.py")
<- error: command not allowed: sed. Allowed: cat, git, grep, head, ls, wc
call 3: 11 tools, 1,110 input (0 cached), 20 output
-> run_command(command='cat src/cart.py')
<- from decimal import Decimal def cart_total(items): return sum(Decimal(st...
call 4: 11 tools, 1,235 input (0 cached), 114 output
-> run_command(command="git log -n 1 --pretty=format:'%H%n%an%n%ae%n%ad%n%B' -- src/cart.py")
<- bb3d1b3161f52689ff649bdd463b03ea44011dc5 Maria Lopez maria@acme.dev Fri ...
call 5: 11 tools, 1,353 input (0 cached), 52 output
-> get_commit(sha='bb3d1b3161f52689ff649bdd463b03ea44011dc5', include_diff=False)
<- {"commit": "commit bb3d1b3161f52689ff649bdd463b03ea44011dc5\nAuthor: Mar...
call 6: 11 tools, 1,506 input (0 cached), 222 output
-> add_issue_comment(issue_number=7, body='The function apply_discount is defined in src/cart.py:\n\...
<- {"url": "https://github.com/acme/webshop/issues/7#comment"}
call 7: 11 tools, 1,794 input (0 cached), 349 output
answer: apply_discount is defined in src/cart.py; the last commit that changed it is bb3d1b3161f52689ff649bdd463b03ea44011dc5 by Maria Lopez (maria@acme.dev) on Fri Sep 11 10:00:00 2026 with message "Add VIP20 discount code". I have added this information as a comment on issue #7.
total: 7 model calls, 9,135 input tokens (0 cached), 817 output, $0.0039The model used grep and git log for the code and MCP tools for the commit and the comment. It tried sed, which isn't allowed, and recovered with cat at the cost of one call.
Why preserve the comment tool? An earlier version of this setup preserved only run_command. For the same task, the selection didn't offer add_issue_comment, so the model passed the comment text to update_issue as a new issue description, answered "Done." and posted no comment. On real GitHub that call would have overwritten the issue's description.
When the right tool is missing, a model may use the closest one it can see instead of calling request_more_tools. Preserve the write tools your main tasks need, and check writes by their effect.
[12]
Step 5: Run tools as the user with per-user scopes
Per-user control belongs on the MCP server, where the user's token arrives. This server requires a JWT, lets triage and maintainer users comment, lets only maintainers close issues, and writes a signed audit line for every call:
"""Per-user control on the MCP side: who may call which tool, and an audit line per call."""
import os
import ghdb
from promptise.mcp.server import AuditMiddleware, AuthMiddleware, JWTAuth, MCPServer
server = MCPServer(name="github", version="1.0.0")
jwt = JWTAuth(secret=os.environ["JWT_SECRET"]) # in production: JwksAuth against your identity provider
server.add_middleware(AuthMiddleware(jwt))
server.add_middleware(AuditMiddleware(os.environ.get("AUDIT_LOG", "audit.jsonl"),
hmac_secret=os.environ["AUDIT_SECRET"], include_args=True))
@server.tool(roles=["triage", "maintainer"], read_only_hint=False)
async def add_issue_comment(issue_number: int, body: str) -> dict:
"""Add a comment to an issue in acme/webshop."""
return ghdb.comment_issue(issue_number, body)
@server.tool(roles=["maintainer"], destructive_hint=True)
async def close_issue(issue_number: int) -> dict:
"""Close an issue in acme/webshop."""
return ghdb.update_issue(issue_number, state="closed")
if __name__ == "__main__":
server.run(transport="http", host="127.0.0.1", port=19310)Test the rules without a model. TestClient runs the full server pipeline, including authentication, guards and middleware:
"""Call the secured server as two users, without a model, and print the audit log."""
import asyncio
import json
import os
from promptise.mcp.server import TestClient
from secure_server import jwt, server
async def main():
client = TestClient(server)
for user, roles in [("maria", ["maintainer"]), ("sam", ["triage"])]:
token = jwt.create_token({"sub": user, "roles": roles})
auth = {"authorization": f"Bearer {token}"}
for tool, args in [("add_issue_comment", {"issue_number": 25, "body": "Duplicate of #24."}),
("close_issue", {"issue_number": 25})]:
result = await client.call_tool(tool, args, headers=auth)
print(f"{user:5} {tool:17} -> {' '.join(result[0].text.split())[:95]}")
print("\naudit log:")
for line in open(os.environ["AUDIT_LOG"]):
e = json.loads(line)
print({k: e.get(k) for k in ("client_id", "tool", "status", "error_code", "args")})
asyncio.run(main())maria add_issue_comment -> {"url": "https://github.com/acme/webshop/issues/25#comment"}
maria close_issue -> {"number": 25, "title": "Duplicate orders when user double-clicks", "state": "closed", "labels"
sam add_issue_comment -> {"url": "https://github.com/acme/webshop/issues/25#comment"}
sam close_issue -> { "error": { "code": "ACCESS_DENIED", "message": "Requires any of roles [maintainer], but clien
audit log:
{'client_id': 'maria', 'tool': 'add_issue_comment', 'status': 'ok', 'error_code': None, 'args': {'issue_number': 25, 'body': 'Duplicate of #24.'}}
{'client_id': 'maria', 'tool': 'close_issue', 'status': 'ok', 'error_code': None, 'args': {'issue_number': 25}}
{'client_id': 'sam', 'tool': 'add_issue_comment', 'status': 'ok', 'error_code': None, 'args': {'issue_number': 25, 'body': 'Duplicate of #24.'}}
{'client_id': 'sam', 'tool': 'close_issue', 'status': 'error', 'error_code': 'ACCESS_DENIED', 'args': {'issue_number': 25}}Sam, a triage user, can comment but not close. The denial is a structured ACCESS_DENIED error, logged next to the allowed calls. On the agent side, CallerContext(bearer_token=...) makes the agent call the HTTP server's tools with that user's token, over a session opened for that user:
agent = await build_agent(
model="openai:gpt-5-mini",
servers={"github": HTTPServerSpec(url="http://127.0.0.1:19310/mcp")},
instructions="You triage issues in acme/webshop. Answer in one sentence.",
)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": task}]},
caller=CallerContext(user_id=user, bearer_token=token), # tools run as this user
)Start python secure_server.py, then run the agent as Sam:
call 1: 196 input (0 cached), 388 output
-> add_issue_comment(issue_number=25, body='Duplicate of #24 — closing this issue.')
-> close_issue(issue_number=25)
<- {"url": "https://github.com/acme/webshop/issues/25#comment"}
<- { "error": { "code": "ACCESS_DENIED", "message": "Requires any of roles ...
call 2: 344 input (0 cached), 249 output
answer: I added a comment to issue #25 saying "Duplicate of #24 — closing this issue.", but I couldn't close the issue because I only have triage permissions (closing requires maintainer) — please close it or grant maintainer rights.
total: 2 model calls, 540 input tokens (0 cached), 637 output, $0.0014The agent was built without a user token, yet it acted as Sam, and the server refused the close. The model never saw the token, so no prompt can make it print one. With a CLI you would inject Sam's token into each subprocess, and gh would still enforce only GitHub's permissions, not your rules. For real identity providers, see MCP server authentication with JWT and OAuth.
[13]
The complete example
mcp-vs-cli/
├── fixture/ # git checkout and db.json
├── fakebin/gh # stand-in for the GitHub CLI
├── ghdb.py # data layer for both
├── github_server.py # MCP server, 48 tools (step 2)
├── cli_tool.py # run_command (step 1)
├── agents.py # four setups (steps 1-4)
├── report.py # prints calls and tokens
├── secure_server.py # roles and audit (step 5)
├── secure_check.py # role tests (step 5)
└── secure_agent.py # per-user agent (step 5)agents.py holds the four make_agent branches from steps 1 to 4 and a main() that runs one task:
async def main(setup: str, task: str):
agent = await make_agent(setup)
offered = ToolsOffered() # a callback from report.py that counts the tools on each model call
try:
result = await agent.ainvoke({"messages": [{"role": "user", "content": task}]},
config={"callbacks": [offered]})
finally:
await agent.shutdown()
print(f"setup: {setup}\ntask: {task}")
print_report(result, offered)Run it with GHDB pointing at a fresh copy of fixture/db.json: python agents.py cli "<task>", and the same for mcp, mcp_semantic and both.
[14]
Running it in production
Contain the shell tool
The allowlist decides which programs run. A container decides what they can reach. Promptise's sandbox runs commands in Docker with no network by default, a read-only root filesystem and resource limits, and gives the agent a sandbox_exec tool. build_agent raises an error when the sandbox can't start, so you never get an agent without one by accident. It needs pip install "promptise[sandbox]" and a running Docker daemon (sandbox docs):
agent = await build_agent(model=MODEL, sandbox={"network": "none", "memory_limit": "512M"})Keep credentials out of the model's reach
Give a CLI the narrowest token that works, such as a fine-grained token for one repository, and block commands that print the environment or read config files. For MCP over HTTP, use bearer_token for the agent's own identity and CallerContext for users, and set forward_caller_token=False on servers outside your trust boundary so your users' tokens never reach them (server specs).
Measure cost in dollars
Token counts mislead when caching is involved. Read usage_metadata on each AI message, including input_token_details.cache_read, as report.py does, and price cached input separately. Set a budget per run, as AI agent cost control shows.
Test the boundaries
Test the allowlist like any parser: ;, &&, backticks, $( ), redirects, git write commands, and binaries not on the list. Test MCP roles with TestClient, as secure_check.py does.
[15]
Common problems and fixes
Symptom | Cause | Fix |
|---|---|---|
The agent retries after command not allowed | The model reached for sed, ; or &&, which the allowlist blocks | Name the allowed commands in the tool description, or accept the occasional retry |
The agent reads --help over and over | The model doesn't know the CLI, or the tool hides its errors | Return stderr and the exit code; for internal tools, prefer MCP |
The context fills up after one command | The command printed a huge listing | Cap output in the tool; use --json with specific fields or --limit |
Every MCP call costs about 5,000 tokens | All tool schemas go with every call | Enable only the toolsets you need, or use semantic selection |
Semantic selection costs more than expected | The changing tool list breaks the prompt cache | Compare dollars, not tokens; preserve common tools |
The agent says "Done" but nothing changed | The right tool wasn't offered, so it used the closest one | Add the tool to preserve_tools; check writes by their effect |
A variable is missing in the MCP server | Stdio servers get only the env you pass | Pass it in StdioServerSpec(env={...}) |
Every user acts as the same account | The CLI uses the token in its environment | Use MCP over HTTP with CallerContext(bearer_token=...) |
[16]
Limitations
The example uses stand-ins. The gh CLI here is a local replica, and the MCP server mimics the style of GitHub's server but isn't it. Real schema sizes and real gh behavior differ.
One run per setup shows the mechanics, not averages. Tokens, cache hits and call counts vary between runs and models.
An allowlist is not a sandbox. The tool in step 1 is a local subprocess. Run it in a container for anything beyond a demo.
Promptise has no built-in allowlisted shell tool. Its sandbox gives the agent a shell inside a container. You write the allowlist yourself, as in step 1, or a small MCP server around the commands you trust.
[17]
Frequently asked questions
Is MCP or CLI better for AI agents?
Neither is better everywhere. A CLI is cheaper in context and needs no server when one trusted user works with a tool the model knows, inside a sandbox. MCP is better for many users, per-user permissions, an audit trail, or internal systems the model has never seen. Many agents use both.
Does MCP use more tokens than CLI tools?
Usually, yes. Every MCP tool definition goes to the model on every call, so a server with dozens of tools adds thousands of tokens per request, while a CLI tool adds one short definition. Prompt caching narrows the gap in dollars, because a stable tool list is cached. CLIs spend tokens on retries, wrong flags and help lookups instead.
How do I reduce MCP token usage?
Enable only the toolsets the agent needs. Then use semantic tool selection, which offers only the most relevant tools per call, or a host that defers tool definitions until needed. Check the result in dollars, because smaller requests that miss the prompt cache can cost more.
Are CLI tools safe to give an AI agent?
Only inside limits you enforce. Allow a fixed list of programs, block shell operators such as ;, && and redirects, and never pass the model's text to a shell. Then run the tool in a container with no network and no credentials the model shouldn't read. The allowlist limits which programs run; the container limits what they can reach.
What is a help loop in AI agents?
A help loop is when an agent keeps running --help or guessing flags to learn a command-line tool instead of doing the task, at one model call per attempt. It is rare with gh or git and common with internal tools. Clear errors and --json output shorten it. Typed MCP schemas avoid it.
Can an agent use MCP and CLI tools together?
Yes. In Promptise, pass MCP servers in servers= and a shell tool in extra_tools=. A common split is MCP for shared, permissioned systems and a read-only shell for the local checkout. With semantic selection, put the shell tool in preserve_tools.
[18]
Next steps
Shrink large tool lists: too many MCP tools? How tool selection works.
Build the server side properly: build a production MCP server in Python.
Lock down servers and tools: MCP security best practices.
Contain the shell: AI agent code execution sandbox.
Let the agent write programs instead of single commands: code action agents.
Manage what fills the context: context engineering for AI agents.
Reference: tool optimization, server specs and per-user tokens and MCP server auth in the Promptise docs.