Two Rust Clients for Gemma 4: Calling the Endpoint vs. Calling the MCP Server πŸ¦€ A developer published two Rust CLI demos, gemma-rust and gemma-rust-mcp, that query a self-hosted Gemma 4 E2B model through an OpenAI-compatible HTTP endpoint and through an MCP server, respectively. The HTTP client prints the raw model response and metadata, while the MCP client launches the rig's own Python MCP server and returns tool output as markdown, illustrating what an agent sees versus what the model said. Both clients run against llama-server on a GTX 1650 Ti and vLLM on an NVIDIA L4 in Cloud Run. This article provides a step by step guide to two small Rust CLIs that ask a self-hosted Gemma 4 E2B the same question. The first calls the model's OpenAI-compatible HTTP endpoint directly. The second is an MCP client: it launches the rig's own MCP server and asks through its tools. https://github.com/xbill9/gemma-rust https://github.com/xbill9/gemma-rust https://github.com/xbill9/gemma-rust-mcp https://github.com/xbill9/gemma-rust-mcp Both CLIs are demos, and their output is read by an audience. So neither hides anything behind a --verbose flag: every run prints the target, the health check, the request, the answer, the model's reasoning, token counts, latency, and whatever the server says about itself. They run against two very different deployments of the same model with the same code: llama-server on a 2021-era laptop GPU, a GTX 1650 Ti with 4 GiB, no auth The interesting part is what changes when the same question goes through MCP instead of HTTP. It is not the answer. Because they answer two different questions. gemma-rust shows what the model said. One HTTP call, the raw OpenAI-style response, every field printed. gemma-rust-mcp shows what an agent sees. An MCP client like Claude Code never touches the endpoint. It calls tools, and gets back whatever those tools choose to report. Writing a second client in Rust β€” one that is not Claude Code and not the Python SDK the servers were built with β€” is the fastest way to find out what those servers actually return. Neither one starts, stops or deploys anything. The rigs do that. gemma-rust ─────── HTTP reqwest ───────────────────────┐ β”œβ”€β”€β–Ά llama-server GTX 1650 Ti, local β”‚ vLLM NVIDIA L4, Cloud Run gemma-rust-mcp ─── MCP over stdio rmcp ──▢ server.py β”€β”€β”˜ Python, the rig's own The MCP path makes the same HTTP call in the end. It just makes it from inside a Python process that the Rust client launched, and hands back markdown instead of JSON. The strategy for building the two clients is an incremental step by step approach. First, a model server is brought up locally and checked with curl . Then the HTTP client is built and validated against it, including the two ways Gemma 4 returns an empty answer from a healthy server. The same binary is then pointed at Cloud Run. Then the rig's Python MCP server is installed, the MCP client is built, and the same question goes through the same two servers again β€” which is where the comparison comes from. nvcc β€” this one is a GTX 1650 Ti, CUDA 13.3 git , cmake , a C++ compiler, and curl hf CLI Use rustup : curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source ~/.cargo/env rustc --version rustc 1.98.1 48a229cea 2026-09-01 Anything recent works. The floors come from the dependencies' own rust-version : | Crate | Needs Rust | Needed by | |---|---|---| | reqwest 0.13.5 | 1.85.0 | gemma-rust | | clap 4.6.6 | 1.85 | both | | rmcp 3.3.0 | 1.88 | gemma-rust-mcp | Both crates are edition 2024. llama-server is the local model server. Build it from source, at the commit the rig runs: git clone https://github.com/ggml-org/llama.cpp ~/llama.cpp cd ~/llama.cpp git checkout 95ef7fc cmake -B build -DGGML CUDA=ON -DCMAKE CUDA ARCHITECTURES=75 -DCMAKE BUILD TYPE=Release cmake --build build --config Release -j --target llama-server ls build/bin/llama-server build/bin/llama-server 75 is Turing, which is what a GTX 1650 Ti is. Set your own card's compute capability there, or leave the flag off and let CMake detect it. The rig serves Google's QAT q4 0 GGUF of Gemma 4 E2B: hf auth login hf download google/gemma-4-E2B-it-qat-q4 0-gguf --local-dir ~/models/gemma-4-E2B-it-qat-q4 0 ls -l ~/models/gemma-4-E2B-it-qat-q4 0/ -rw-rw-r-- 1 xbill xbill 3349516256 Sep 3 13:19 gemma-4-E2B q4 0-it.gguf It fits a 4 GiB card because most of the file never leaves the host β€” the previous article https://dev.to/gde/gemma-4-on-an-old-4-gb-laptop-gpu-qat-takes-it-from-95-gib-to-16-b5l measures that. Run it in the foreground; Ctrl-C is the whole teardown: ~/llama.cpp/build/bin/llama-server \ -m ~/models/gemma-4-E2B-it-qat-q4 0/gemma-4-E2B q4 0-it.gguf \ --host 127.0.0.1 --port 8080 -ngl 99 -c 8192 From a second terminal: curl -s http://127.0.0.1:8080/health {"status":"ok"} 🟒 That is the whole server side for the local target. The rig wraps this same command as make serve , with its flags in tpu.env . cd ~ git clone https://github.com/xbill9/gemma-rust cd gemma-rust make prod Building release... Finished release profile optimized target s in 0.08s Binary: target/release/gemma-rust That time is an incremental rebuild; a clean one compiles the dependency tree first. The dependencies are few: dependencies anyhow = "1.0.104" clap = { version = "4.6.6", features = "derive", "env" } reqwest = { version = "0.13.5", default-features = false, features = "blocking", "json", "rustls" } rustyline = "18.0.1" serde = { version = "1.0.229", features = "derive" } serde json = "1.0.151" blocking is deliberate. One question, one answer β€” there is nothing to run concurrently, so there is no async runtime in the client's own code. Lint is the gate: make lint Linting code... Finished dev profile unoptimized + debuginfo target s in 0.09s That is cargo clippy --all-targets -- -D warnings and cargo fmt --check . make test runs and finds 0 tests : both crates are demos, validated by running them against live servers, which is what the rest of this article does. ./target/release/gemma-rust "In one sentence, what is a TPU?" == Target ============================================================ endpoint http://127.0.0.1:8080 target local llama.cpp rig auth none == Health ============================================================ GET /health 200 OK in 0 ms body {"status":"ok"} == Model ============================================================= served /home/xbill/models/gemma-4-E2B-it-qat-q4 0/gemma-4-E2B q4 0-it.gguf context 8192 tokens using /home/xbill/models/gemma-4-E2B-it-qat-q4 0/gemma-4-E2B q4 0-it.gguf first model the server lists == Request =========================================================== POST http://127.0.0.1:8080/v1/chat/completions prompt In one sentence, what is a TPU? max tokens 1024 == Answer ============================================================ A TPU Tensor Processing Unit is a specialized hardware accelerator designed by Google specifically to speed up the computationally intensive matrix operations required for training and running machine learning models. == Reasoning ========================================================= length 1327 chars 1. Identify the core concept: The user wants a one-sentence definition of a TPU Tensor Processing Unit . ... == Stats ============================================================= finish reason stop prompt tokens 25 cached tokens 7 completion tokens 330 total tokens 355 latency client 4786 ms tokens/s client 69.0 completion tokens / latency; includes network and prefill == Server timings llama.cpp ======================================== predicted ms 4615.20 predicted n 330 predicted per second 71.29 prompt ms 147.38 prompt n 18 ... == Response ========================================================== id chatcmpl-rTO4HJkYHsrE4WisG2mileq6Jmxn0R4f model /home/xbill/models/gemma-4-E2B-it-qat-q4 0/gemma-4-E2B q4 0-it.gguf system fingerprint b1-95ef7fc βœ… A one-sentence answer, and 1,327 characters of thinking in front of it. Gemma 4 on llama.cpp reasons by default, and that is where most of the 330 completion tokens went. Three decisions make one code path work on two servers that disagree about almost everything. The model id comes from the server. llama.cpp accepts any model value; vLLM returns 404 unless it is exactly the served id. The only default that works on both is the first id from /v1/models : js let model = served .first .and then |m| m "id" .as str .context "the server listed no models at /v1/models; pass --model" ? .to string ; Both reasoning fields are read. The two servers put Gemma's thinking in different places: derive Deserialize struct Message { content: Option