From CLI to MCP in 20 Minutes
Demystifying MCP Servers
Yaacov Zamir <yzamir@redhat.com>
DevConf.CZ 2026
[~1 min]
Welcome everyone. This is a hands-on, demo-heavy session — we're going to trace the
full path from a raw language model all the way to a working AI agent, and we'll do
it live.
The idea is simple: if you understand each building block, the full picture clicks.
We start with running a model locally in Python, wrap it in an API, give it tools,
then show how to turn any CLI into an MCP server. By the end, you'll see a complete
agent loop in action.
Let's look at the roadmap.
Agenda
2
This session includes:
Language Models
Basic Building Blocks
Inference Servers
OpenAI API, De Facto Standard
Skills or Tools
What Is Best for My Use Case
From CLI to MCP
Adding MCP Support to My CLI
[~1 min]
Four sections, each building on the last.
Language Models — we run raw Python to see what a model actually does at the lowest level.
Inference Servers — we wrap that model in an HTTP API and show how tool calling works.
Skills or Tools — we discuss when a bash shell is enough vs. when you need a proper MCP server.
From CLI to MCP — we take a real CLI tool, expose it as an MCP server, and run a live
agent session end to end.
The progression is deliberate: raw weights, then API, then tools, then MCP, then agent.
Let's dive in.
Language Models
Language Models
Text Completion
Template Completion
[~15 sec — transition slide]
Let's start at the foundation. What does a language model actually do when you
give it text?
Language Models
Language Models
Base Model, Text Completion
from transformers import AutoModelForCausalLM, AutoTokenizer
model_path = "ibm-granite/granite-4.1-8b-base"
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForCausalLM.from_pretrained(model_path, device_map="auto")
prompt = "If you want to migrate a virtual machine to KubeVirt,"
outputs = model.generate(tokenizer(prompt, return_tensors="pt").to("mps"))
print(tokenizer.decode(outputs[0]))
# Output: " you must first prepare the source environment.
# This book covers migrations from VMware vSphere ..."
* Language models complete texts.
[~2 min — DEMO: python demo/a1-base-model.py]
We load IBM Granite 4.1 8B Base — that's the raw, pre-trained model. No instruction
tuning, no chat formatting — just the weights trained on internet text.
We give it an unfinished sentence: "If you want to migrate a virtual machine to
KubeVirt," — and it just continues the text. The output reads like a paragraph from a
documentation guide, because that's exactly the pattern the model was trained on.
This is all a base model does: predict the most likely next tokens. Think of it as
autocomplete on steroids. It doesn't follow instructions — it continues patterns.
Useful, but not controllable. That's why we need instruction tuning.
Language Models
Language Models
Text Completion — Predict the Next Word
Language Models
Instruct Model
Fine-Tuned to Follow Instructions
chat = [{"role": "user", "content": "How do I list VM migrations?"}]
prompt = tokenizer.apply_chat_template(chat, add_generation_prompt=True)
print(prompt)
# <|start_header|>user<|end_header|>
# How do I list VM migrations?<|eot|>
# <|start_header|>assistant<|end_header|>
outputs = model.generate(tokenizer(prompt, return_tensors="pt").to("mps"))
print(tokenizer.decode(outputs[0]))
* We can fine-train language models to read hints and play roles.
[~2 min — DEMO: python demo/a2-instruct-model.py]
Same Granite 4.1 architecture, same 8 billion parameters — but fine-tuned on
instruction-response pairs.
The key difference is structure. We wrap our input in a chat template with role
markers. The tokenizer injects special control tokens — start_header, end_of_turn —
that act as syntactic boundaries.
The model recognizes "this is an instruction, I should respond helpfully" instead of
"this is text I should continue."
The result: instead of writing a paragraph, it gives us a direct command. We've gone
from autocomplete to a useful assistant.
But running Python scripts locally is heavy and stateful. We need to decouple the model
from our application. That's where inference servers come in.
Language Models
Instruct Model
Fine-Tuned to Complete User–Assistant Templates
Inference Servers
Inference Servers
Template with Tools
From Template to API
OpenAI API
[~30 sec — transition slide]
Running raw Python works for experiments, but it's stateful, heavy, and impossible
to share across applications.
An inference server solves this. It loads the model once and exposes it as a stateless
HTTP API — the OpenAI-compatible JSON format that LM Studio, vLLM, Ollama, and
OpenAI itself all speak.
Same model, but now it's a network service. Let's see what this looks like with curl.
Inference Servers
Conversational Mode
What Happens Without Tools?
curl http://localhost:1234/v1/chat/completions -d '{
"messages": [
{"role": "user", "content": "Fetch migration plans inside the cluster"}
],
"tools": []
}'
# Output: "I cannot access the cluster directly.
# Please run: kubectl mtv get plans"
* The /v1/chat/completions endpoint is the OpenAI-compatible API — the de facto standard spoken by LM Studio, vLLM, Ollama, and others.
[~2 min — DEMO: bash demo/b1-curl-empty-tools.sh]
Notice the URL: /v1/chat/completions — the OpenAI-compatible endpoint. We're hitting
a local model, but the JSON shape is identical to what you'd send to OpenAI.
We ask "Fetch migration plans" with an empty tools array. The model has no tools
available, so it does the best it can: responds with text, telling us the kubectl
command we should run ourselves.
The model knows it can't reach the cluster. It's helpful but passive — it can only
advise, not act.
What if we told it about a tool it could use?
Inference Servers
Tool Usage
Fine-Tuned to Complete Text by Choosing a Tool
Inference Servers
Tool Usage
The Request — Including a List of Available Tools
curl http://localhost:1234/v1/chat/completions -d '{
"messages": [{"role": "user", "content": "Fetch migration plans"}],
"tools": [{"type": "function", "function": {
"name": "mtv_read",
"description": "Query MTV resources (read-only)",
"parameters": {
"properties": {"command": {"type": "string"}, "flags": {"type": "object"}},
"required": ["command"]
}
}}]
}'
* This model was trained to use tools and can pick the correct tool for a task.
[~1.5 min — DEMO: bash demo/b2-curl-with-tools.sh]
Same exact prompt — "Fetch migration plans." But now we include a tools array with a
schema for mtv_read, a function that can query migration resources.
This is the critical shift. The model was trained to recognize tool schemas. When it
sees one that matches the user's intent, its behavior changes completely.
Instead of generating helpful text, it will generate a structured function call.
Let's look at what comes back.
Inference Servers
Tool Usage
The Response — A Structured Tool Call Instead of Text
{"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"function": {"name": "mtv_read",
"arguments": "{\"command\": \"get plan\"}"}
}]
},
"finish_reason": "tool_calls"
}]}
[~1.5 min]
Look at this response. The content field is null — no text at all. Instead, the model
returned a tool_calls array with the function name "mtv_read" and arguments
{"command": "get plan"}.
The finish_reason changed from "stop" to "tool_calls" — the model is signaling:
"Don't show this to the user. Execute this function and feed me the result."
The model is no longer talking to us — it's calling our code. This is the foundation
of every AI agent: model plus tool execution in a loop.
But who handles that tool call? We need a server on the other end.
The model is no longer talking to us — it's calling our code. This is the foundation
of every AI agent: model plus tool execution in a loop.
But when do you actually need a structured tool server versus just giving the agent
a bash shell? Let's talk about that.
Skills or Tools
Skills or Tools
Do You Really Need Your Own MCP?
How to Use Existing Tools
How to Write Your Own Tools
[~15 sec — transition slide]
We've seen models and tools. Now the practical question: do you always need an
MCP server, or can you just give the agent a bash shell?
Skills or Tools
Bash Tool vs. MCP Server
One Shell to Rule Them All, or Dedicated Servers?
# Bash tool: the agent runs arbitrary commands
bash -c "kubectl mtv get plan | grep -i fail"
# → unrestricted, unpredictable, no schema
// MCP server: the agent calls a typed function
{"method": "tools/call",
"params": {"name": "mtv_read",
"arguments": {"command": "get plan"}}}
// → sandboxed, discoverable, self-describing
* Bash is like eval() on untrusted input; MCP is like a type-safe RPC contract. MCP tools are sandboxed, self-describing, and discoverable — no manual scraping needed.
[~2 min]
Here's the tradeoff, side by side.
A bash tool is simple — the agent runs arbitrary shell commands. But it's like
calling eval() on untrusted input: unrestricted, unpredictable, no schema validation.
An MCP server is like a type-safe RPC contract. The agent can only call predefined
functions with validated parameters. It's sandboxed, discoverable through tools/list,
and self-describing.
For quick prototyping and trusted environments, bash works fine. For production agents
handling real workloads, you want the guardrails of MCP.
But if you do go the bash route — what makes it actually work? It comes down to
how your CLI presents itself. Let's look at that.
Skills or Tools
Bash Tool vs. MCP Server
Many Tools vs. One Shell with a System Prompt
Skills or Tools
Agent-Ready CLIs
Semantic Help for Agentic Flow
$ oc mtv help
Migration Toolkit for Virtualization (MTV) CLI.
Migrate virtual machines from VMware vSphere, oVirt (RHV),
OpenStack, and OVA to KubeVirt on OpenShift/Kubernetes.
Available Commands:
create Create resources
delete Delete resources
get Get resources
health Check the health of the MTV/Forklift system
mcp-server Start the MCP (Model Context Protocol) server
* AI agents can use --help to learn how to use new CLI commands they don't know.
[~2 min]
So if you pick the skills-plus-bash path, here's what makes it work.
This is real output from oc mtv help. Notice how clean it is — a strong semantic
header tells the agent what this tool does, and clean command verbs like get, create,
health map directly to operational intents.
When an agent gets a bash shell, the first thing it does is run --help. If your CLI
has well-structured help text like this, the agent can figure out how to use it on
its own — no MCP server needed.
This is what we call an "agent-ready CLI" — designed so both humans and machines
can parse it. Skills plus bash plus good help text gets you surprisingly far.
But what if you want the full structured approach? Let's see what a minimal MCP
server actually looks like.
Skills or Tools
Your First MCP Server
Implementing the Tool Call
from fastmcp import FastMCP
import subprocess
mcp = FastMCP("mtv")
@mcp.tool()
def get_plan(namespace: str = "default") -> str:
"""Get migration plans from the cluster"""
return subprocess.run(
["kubectl", "mtv", "get", "plan", "-n", namespace],
capture_output=True, text=True).stdout
mcp.run(transport="sse", port=8081)
* MCP (Model Context Protocol) standardizes how tools are discovered and called, regardless of model or server.
[~1.5 min — DEMO: show demo/c1-fastmcp-server.py]
This is the simplest possible MCP server — seven lines of real code.
Decorate a Python function with @mcp.tool(), and FastMCP generates the JSON schema
automatically from the type hints and docstring. It handles the protocol transport
and advertises the tool to any connecting client.
Under the hood, get_plan just shells out to kubectl mtv — the same CLI command the
model would have told us to run manually. But now it's wrapped in a standard protocol.
This is MCP — Model Context Protocol. It standardizes how tools are discovered and
called, regardless of which model or framework you use.
Now let's see how to add MCP support to an existing CLI without writing any server
code at all.
From CLI to MCP
From CLI to MCP
From CLI to MCP
The MCP Protocol
Demo
[~30 sec — transition slide]
This is where it all comes together. We take kubectl-mtv — a real CLI tool — and
expose it as an MCP server.
The same binary serves both roles. Run it normally for human-readable tables. Start
it in MCP mode and it becomes a daemon speaking Model Context Protocol over HTTP.
No external scripts, no separate containers, no integration drift. We'll show two
paths: automated scanning with cli2mcp, and the embedded Go MCP server.
From CLI to MCP
So You Want to Implement MCP?
Add Custom Code to Your CLI, or Just Automate Using --help
# Scan the CLI's --help output → generate MCP tools
cli2mcp scan kubectl-mtv -o mtv.tools.json
# Start an MCP server exposing those tools (streamable HTTP)
cli2mcp serve mtv.tools.json -t streamable-http
MCP client
|
cli2mcp scan kubectl-mtv → .json |
| |
cli2mcp serve <+
|
subprocess.run(["kubectl-mtv", ...])
* Works with any CLI: auto-detects GNU, Cobra, and plain --help styles.
[~2 min — DEMO: bash demo/d1-cli2mcp.sh]
The fastest path from any CLI to MCP — two commands.
"cli2mcp scan" reads your CLI's --help output, walks every subcommand, and generates
a JSON file with tool schemas — names, descriptions, argument types. It auto-detects
the help format: GNU (argparse, click), Cobra (kubectl, oc, docker), or plain (curl).
"cli2mcp serve" starts an MCP server from that JSON. Every entry becomes a callable
tool. When the model calls a tool, cli2mcp runs the underlying CLI command via
subprocess — the flow diagram shows the full pipeline.
No code changes to your CLI. No SDK integration. Just scan and serve. This works
with any CLI that has reasonable --help output — curl, git, kubectl, you name it.
Now let's look at what happens on the wire when a client connects to this MCP server.
From CLI to MCP
Establishing the SSE Channel
Step 1
curl -i -N http://localhost:8081/sse
HTTP/1.1 200 OK
Content-Type: text/event-stream
Connection: keep-alive
event: endpoint
data: /message?sessionId=session_9a7c3b21
[~1.5 min — DEMO: bash demo/d2-mcp-flow.sh (step 1)]
The MCP HTTP transport uses Server-Sent Events. The client opens a persistent GET
connection to /sse. The server keeps it alive and streams an endpoint event with a
unique session ID.
This is your channel: incoming events flow down this GET connection, outgoing
requests go as standard HTTP POSTs to that session endpoint.
Simple, elegant pattern — one persistent channel for receiving, regular POSTs for
sending. Let's use it.
From CLI to MCP
Handshake and Tool Discovery
Step 2
# Initialize handshake
curl -X POST "$ENDPOINT" -d '{"jsonrpc": "2.0",
"method": "initialize", "id": 1,
"params": {"protocolVersion": "2024-11-05",
"clientInfo": {"name": "demo", "version": "1.0"}}}'
# Discover available tools
curl -X POST "$ENDPOINT" -d '{"jsonrpc": "2.0",
"method": "tools/list", "id": 2}'
[~1.5 min — DEMO: bash demo/d2-mcp-flow.sh (step 2)]
With the SSE stream active, we POST an "initialize" request — the MCP handshake.
The server accepts instantly; replies stream back down the GET channel.
Then we send "tools/list". The server responds with the full schema of every
available tool — names, parameters, types, descriptions.
This is service discovery. The agent now knows exactly what it can do, what parameters
each tool expects, and what types they require. No manual scanning, no help parsing.
The server just tells you.
From CLI to MCP
Tool Execution
HTTP vs. CLI
# Path A: via MCP HTTP POST
curl -X POST "$ENDPOINT" -d '{"jsonrpc": "2.0",
"method": "tools/call", "id": 3,
"params": {"name": "mtv_read",
"arguments": {"command": "get plan"}}}'
# Path B: via CLI
kubectl mtv get plans
[~2 min — DEMO: bash demo/d2-mcp-flow.sh (step 3)]
Side-by-side comparison. On top: an MCP tools/call via HTTP POST. On bottom: the
same operation as a regular CLI command.
Same logic, same authentication, same data. The human gets a formatted table; the
machine gets structured JSON through MCP.
This duality is the key insight: one codebase, two interfaces. Your CLI serves
humans, your MCP server serves agents.
Now — let's put it all together and see a real agent use these tools.
From CLI to MCP
Live Agent Session
An Agent Picks the Right Tool for the Job
From CLI to MCP
Live Agent Session
Tracing with mtv-agent
mtv-agent run --dump-http-dir ./dumps
# User: "Why is migration plan 'plan-01' failing?"
# LLM request/response dumps saved to: ./dumps/
# {"model":"instruct", "messages":[...],
# "tools":[{"name":"mtv_read"}]}
[~3 min — DEMO: bash demo/d3-agent.sh]
This is the payoff. mtv-agent ties everything we've built up into a live agent loop.
We use --dump-http-dir to save every HTTP round-trip to ./dumps/ so we can
inspect them live: the system prompt, the tool schemas sent to the model, the
model's tool_calls response, the MCP execution, and the final answer.
Ask it "Why is migration plan plan-01 failing?" and watch the full cycle: user
question → tool schema packaging → LLM inference → tool_calls response → MCP
execution → result synthesis → human-readable answer.
Every building block we covered is visible in this trace: the inference server API
from Section B, the tool schemas from the curl demos, the MCP protocol from Section D.
We've traced the complete path — from raw model weights in Python, through a
standard API, through structured tool calling, to a production-ready agent talking
to your cluster through typed, sandboxed MCP tools.
Thank you — I'd love to take your questions.
From CLI to MCP
References
Tools Used in This Talk
Links to the three open-source tools we used today. All repos are public —
feel free to try them out, open issues, or contribute.
From CLI to MCP
Cat
A Cat
Thank You
Questions?