PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Learning paths> Agentic Engineering with Promptise Foundry
Lesson 28 / 71
39%

MCP vs CLI for AI Agents: Tokens, Security, When to Use Each

MCP vs CLI tools for AI agents: where the tokens go, prompt caching, per-user security and when to use each or both, with runnable code and real output.

Intermediate20 minModel Context Protocol (MCP)Cost ControlTool CallingLLM Caching
All 71 lessons↓
  1. ✓What Is an AI Agent? Definition, How It Works, Examples
  2. ✓How to Build an AI Agent in Python (2026 Guide)
  3. ✓What Is an Agent Harness? Parts and How to Build One
  4. ✓System Prompt Best Practices for AI Agents in Python
  5. ✓Best LLM for AI Agents: Test and Switch Models in Python
  6. ✓AI Agent YAML: Define and Run an Agent From a Config File
  7. ✓Streaming LLM Responses with FastAPI: Tokens and Tool Calls
  8. ✓Claude AI Agent with MCP: Use Anthropic Models in Promptise
  9. ✓Gemini AI Agent with MCP: Use Google Models in Promptise
  10. ✓Azure OpenAI Agent with MCP: Use Deployments in Promptise
  11. ✓OpenRouter AI Agent with MCP: Many Models in Promptise
  12. ✓Ollama AI Agent with MCP: Run Local Models in Promptise
  13. ✓LLM Semantic Cache and Model Fallback in Python
  14. ✓What Is MCP (Model Context Protocol)? A Developer's Guide
  15. ✓What Is Tool Calling? LLM Function Calling Explained
  16. ✓MCP Tools vs Resources vs Prompts (With Python Examples)
  17. ✓How to Connect MCP Servers to Your AI Agent in Python
  18. ✓Build a Production MCP Server in Python (Step by Step)
  19. ✓MCP Transports: stdio vs Streamable HTTP vs SSE (Python)
  20. ✓FastAPI MCP Server: Turn Your FastAPI App into MCP Tools
  21. ✓OpenAPI to MCP: Turn Any REST API into an MCP Server
  22. ✓MCP Authentication in Python: API Keys, JWT and OAuth
  23. ✓Build an Authenticated MCP Server for Your AI Agent (Python)
  24. ✓MCP Security Best Practices: A Checklist for MCP Servers
  25. ✓Testing MCP Servers in Python: pytest Without a Live Server
  26. ✓Too Many MCP Tools? LLM Tool Selection, Measured
  27. ✓MCP Server Caching, Timeouts and Circuit Breakers in Python
  28. 28MCP vs CLI for AI Agents: Tokens, Security, When to Use Each
  29. 29AI Agent Audit Logging: Tamper-Evident Logs in Python
  30. 30AI Agent Human in the Loop: Approve Tool Calls in Python
  31. 31AI Agent Prompt Injection: How to Defend Against It
  32. 32Build a Multi-Agent System in Python (and When Not To)
  33. 33Context Engineering for AI Agents: Context Window Management
  34. 34How to Deploy an AI Agent with Docker (2026 Guide)
  35. 35LLM Tool Rate Limiting: Per-Client Limits for MCP Servers
  36. 36MCP Long-Running Tasks: Background Jobs in Python
  37. 37MCP Server Monitoring: Prometheus, OpenTelemetry and Logs
  38. 38Python AI Agent Frameworks Compared: Which to Choose (2026)
  39. 39Self-Improving AI Agent: Learn From Tool Failures in Python
  40. 40AI Agent Identity: Authenticate Agents to MCP Servers
  41. 41Code-Action Agents in Python: One Program vs 37 Tool Calls
  42. 42Multi-Tenant AI Agents: Keep Each Customer's Data Apart
  43. 43What Is Agentic RAG? How It Differs from Classic RAG
  44. 44AI Agent Memory in Python: Short-Term and Long-Term
  45. 45Agentic RAG in Python: Give Your AI Agent a Search Tool
  46. 46AI Agent Reasoning Patterns in Python: 9 Patterns Tested
  47. 47Plan-and-Execute Agents in Python: Beyond the ReAct Loop
  48. 48Self-Modifying AI Agent in Python: Open Mode & Limits
  49. 49CrewAI Alternatives: 7 Options and How to Migrate (2026)
  50. 50Migrate from AutoGen: Agent Framework, Promptise and More
  51. 51What Is an LLM Guardrail? Types, Examples and Limits
  52. 52AI Agent Guardrails in Python: Block Injection, Redact PII
  53. 53AI Agent Code Execution Sandbox: Run LLM Code Safely
  54. 54AI Agent Production Checklist: What to Check Before Launch
  55. 55Approval Fatigue: Auto-Approve Safe AI Agent Actions
  56. 56What Is Human in the Loop AI? Approvals for AI Agents
  57. 57AI Agent Notifications: Webhook and Slack Alerts in Python
  58. 58AI Agent Observability: Trace Tool Calls, Tokens and Cost
  59. 59AI Agent Stuck in a Loop? How to Detect and Stop It
  60. 60How to Run, Debug and Deploy an AI Agent in Python
  61. 61AI Agent Cost Tracking: Tokens and Cost per Run and User
  62. 62How to Evaluate AI Agents: Evals, LLM Judges and pass^k
  63. 63AI Agent Cost Control: Budgets That Stop Runaway Spend
  64. 64How to Test LLM Prompts and AI Agents in Python
  65. 65AI Agent Evals in CI: Run Them on Every Pull Request
  66. 66Autonomous AI Agent Runtime: Budgets, Crash Recovery, API
  67. 67Event-Driven AI Agents: Webhook, Cron and File Triggers
  68. 68Durable AI Agents: Crash Recovery and Safe Resume
  69. 69Deploy AI Agents in Production: Long-Running Python Agents
  70. 70What Is an AI Agent Framework? When You Need One
  71. 71LangGraph Alternatives: 7 Options and How to Migrate (2026)

Lessons in this path

  1. ✓What Is an AI Agent? Definition, How It Works, Examples
  2. ✓How to Build an AI Agent in Python (2026 Guide)
  3. ✓What Is an Agent Harness? Parts and How to Build One
  4. ✓System Prompt Best Practices for AI Agents in Python
  5. ✓Best LLM for AI Agents: Test and Switch Models in Python
  6. ✓AI Agent YAML: Define and Run an Agent From a Config File
  7. ✓Streaming LLM Responses with FastAPI: Tokens and Tool Calls
  8. ✓Claude AI Agent with MCP: Use Anthropic Models in Promptise
  9. ✓Gemini AI Agent with MCP: Use Google Models in Promptise
  10. ✓Azure OpenAI Agent with MCP: Use Deployments in Promptise
  11. ✓OpenRouter AI Agent with MCP: Many Models in Promptise
  12. ✓Ollama AI Agent with MCP: Run Local Models in Promptise
  13. ✓LLM Semantic Cache and Model Fallback in Python
  14. ✓What Is MCP (Model Context Protocol)? A Developer's Guide
  15. ✓What Is Tool Calling? LLM Function Calling Explained
  16. ✓MCP Tools vs Resources vs Prompts (With Python Examples)
  17. ✓How to Connect MCP Servers to Your AI Agent in Python
  18. ✓Build a Production MCP Server in Python (Step by Step)
  19. ✓MCP Transports: stdio vs Streamable HTTP vs SSE (Python)
  20. ✓FastAPI MCP Server: Turn Your FastAPI App into MCP Tools
  21. ✓OpenAPI to MCP: Turn Any REST API into an MCP Server
  22. ✓MCP Authentication in Python: API Keys, JWT and OAuth
  23. ✓Build an Authenticated MCP Server for Your AI Agent (Python)
  24. ✓MCP Security Best Practices: A Checklist for MCP Servers
  25. ✓Testing MCP Servers in Python: pytest Without a Live Server
  26. ✓Too Many MCP Tools? LLM Tool Selection, Measured
  27. ✓MCP Server Caching, Timeouts and Circuit Breakers in Python
  28. 28MCP vs CLI for AI Agents: Tokens, Security, When to Use Each
  29. 29AI Agent Audit Logging: Tamper-Evident Logs in Python
  30. 30AI Agent Human in the Loop: Approve Tool Calls in Python
  31. 31AI Agent Prompt Injection: How to Defend Against It
  32. 32Build a Multi-Agent System in Python (and When Not To)
  33. 33Context Engineering for AI Agents: Context Window Management
  34. 34How to Deploy an AI Agent with Docker (2026 Guide)
  35. 35LLM Tool Rate Limiting: Per-Client Limits for MCP Servers
  36. 36MCP Long-Running Tasks: Background Jobs in Python
  37. 37MCP Server Monitoring: Prometheus, OpenTelemetry and Logs
  38. 38Python AI Agent Frameworks Compared: Which to Choose (2026)
  39. 39Self-Improving AI Agent: Learn From Tool Failures in Python
  40. 40AI Agent Identity: Authenticate Agents to MCP Servers
  41. 41Code-Action Agents in Python: One Program vs 37 Tool Calls
  42. 42Multi-Tenant AI Agents: Keep Each Customer's Data Apart
  43. 43What Is Agentic RAG? How It Differs from Classic RAG
  44. 44AI Agent Memory in Python: Short-Term and Long-Term
  45. 45Agentic RAG in Python: Give Your AI Agent a Search Tool
  46. 46AI Agent Reasoning Patterns in Python: 9 Patterns Tested
  47. 47Plan-and-Execute Agents in Python: Beyond the ReAct Loop
  48. 48Self-Modifying AI Agent in Python: Open Mode & Limits
  49. 49CrewAI Alternatives: 7 Options and How to Migrate (2026)
  50. 50Migrate from AutoGen: Agent Framework, Promptise and More
  51. 51What Is an LLM Guardrail? Types, Examples and Limits
  52. 52AI Agent Guardrails in Python: Block Injection, Redact PII
  53. 53AI Agent Code Execution Sandbox: Run LLM Code Safely
  54. 54AI Agent Production Checklist: What to Check Before Launch
  55. 55Approval Fatigue: Auto-Approve Safe AI Agent Actions
  56. 56What Is Human in the Loop AI? Approvals for AI Agents
  57. 57AI Agent Notifications: Webhook and Slack Alerts in Python
  58. 58AI Agent Observability: Trace Tool Calls, Tokens and Cost
  59. 59AI Agent Stuck in a Loop? How to Detect and Stop It
  60. 60How to Run, Debug and Deploy an AI Agent in Python
  61. 61AI Agent Cost Tracking: Tokens and Cost per Run and User
  62. 62How to Evaluate AI Agents: Evals, LLM Judges and pass^k
  63. 63AI Agent Cost Control: Budgets That Stop Runaway Spend
  64. 64How to Test LLM Prompts and AI Agents in Python
  65. 65AI Agent Evals in CI: Run Them on Every Pull Request
  66. 66Autonomous AI Agent Runtime: Budgets, Crash Recovery, API
  67. 67Event-Driven AI Agents: Webhook, Cron and File Triggers
  68. 68Durable AI Agents: Crash Recovery and Safe Resume
  69. 69Deploy AI Agents in Production: Long-Running Python Agents
  70. 70What Is an AI Agent Framework? When You Need One
  71. 71LangGraph Alternatives: 7 Options and How to Migrate (2026)
← Path overview

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.

[01]

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.

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
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.

>_Terminal
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.

Pythoncli_tool.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
"""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:

Pythonagents.py
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:

Output
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.0010

Every 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:

Pythonagents.py
1
2
3
4
5
6
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:

Output
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.0015

Every 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.

Pythonagents.py
1
2
3
4
5
6
7
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:

Output
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.0011

Each 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:

Pythonagents.py
1
2
3
4
5
6
7
8
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.

Output
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.0039

The 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:

Pythonsecure_server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
"""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:

Pythonsecure_check.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
"""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())
Output
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:

Pythonsecure_agent.py
1
2
3
4
5
6
7
8
9
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:

Output
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.0014

The 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

Text
1
2
3
4
5
6
7
8
9
10
11
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:

Pythonagents.py
1
2
3
4
5
6
7
8
9
10
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):

Python
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.

← PreviousMCP Server Caching, Timeouts and Circuit Breakers in PythonNext lesson →AI Agent Audit Logging: Tamper-Evident Logs in Python