This is a Smash Stories submission for the DEV Summer Bug Smash: a debugging story about the gap between "all tests pass" and "it actually works" — and the unlikely hero that closed it.
The project is a small MCP (Model Context Protocol) server that wraps Google's gemini-3.1-flash-lite-image
model. It exposes image generation and stateful image editing as four tools that any MCP-speaking agent can call — Claude Code, a Google ADK agent, and a Rust CLI all consume the same ~300-line Python server. (Full architecture write-up here.)
By every signal a developer normally trusts, it was healthy:
Then I wrote a demo script. It found a production bug in under a minute of runtime.
demo.sh
walks the stack live: discover the tools, generate an image, then do a stateful edit. To keep the demo cheap, step 2 requested the lowest quality tier the server documents:
cargo run --quiet -- generate "a tiny robot chef cooking ramen" 16:9 minimal
First run:
🔴 Image generation failed: Error code: 400 - {'error': {'message':
"'minimal' is not a supported thinking level for this model.
Allowed values are: low, high.", 'code': 'invalid_request'}}
Wait. The server's own validation had approved minimal
before sending it. Here's that validation:
SUPPORTED_THINKING_LEVELS = {"minimal", "low", "medium", "high"}
@mcp.tool()
def generate_image(
prompt: str, aspect_ratio: str = "1:1", thinking_level: str = "medium"
) -> str:
...
Four allowed values. The live API accepts two: low
and high
. And look at the default — medium
. That's the real smash-worthy find:
Every live call that didn't explicitly overrideThe validation layer wasn't validating the API's contract — it was validating a stale memory of it.thinking_level
was a guaranteed HTTP 400.
The suite mocks the Gemini client, as unit tests should:
@patch("server._get_client")
def test_generate_image_success(self, mock_get_client):
mock_client.interactions.create.return_value = mock_interaction
result = generate_image(prompt="test", thinking_level="medium")
self.assertIn("🟢 Image successfully saved!", result)
The mock returns success for any input — including inputs the real API rejects. The tests correctly proved "the server forwards medium
faithfully." Faithfully forwarding an invalid value is still a bug; it's just invisible from inside the mock boundary.
Two conditions had to align for this to ship:
SUPPORTED_THINKING_LEVELS
was a cached copy of a fact only the API owns. Cached copies drift.high
for quality — so the broken default and the two phantom values were never exercised. f(x)
being called a hundred times tells you nothing about f()
.Two lines of production code, plus the part that actually takes discipline — locking the discovery in so it can't regress:
-SUPPORTED_THINKING_LEVELS = {"minimal", "low", "medium", "high"}
+SUPPORTED_THINKING_LEVELS = {"low", "high"}
- prompt: str, aspect_ratio: str = "1:1", thinking_level: str = "medium"
+ prompt: str, aspect_ratio: str = "1:1", thinking_level: str = "low"
result = generate_image(prompt="test", thinking_level="medium")
self.assertIn("Unsupported thinking level 'medium'", result)
Then the sweep (three tool signatures, docstrings, the server's self-describing get_help
, every doc that repeated the wrong values) and a rebuild + push of the published Docker image, which had been shipping the bug to anyone who pulled it.
| Before | After | |
|---|---|---|
| Live call with default params | HTTP 400, every time | 🟢 image saved |
thinking_level="minimal" / "medium" |
||
| Approved locally, rejected remotely | Rejected locally with the allowed values named | |
| Test suite | 10/10 green (bug invisible) | 11 assertions incl. contract regression test |
Published image xbill9/nb2lite-mcp |
||
| Shipped the broken default | Rebuilt, pushed, verified live |
Elapsed time from first failure to fixed-image-on-Docker-Hub: about ten minutes — because the failing tool call came back as readable text (Allowed values are: low, high
) instead of a stack trace. Error messages that name the fix are half the debugging.
DEMO_FAST=1 ./demo.sh
).The whole project is built on Google AI, end to end:
gemini-3.1-flash-lite-image
store=True
previous_interaction_id
) are what make multi-turn image editing work: the demo's edit step adds a neon RAMEN sign to LlmAgent
on gemini-2.5-flash
MCPToolset
— Gemini calling Gemini, with the bug fix sitting in between.Allowed values are: low, high
) is what made this a ten-minute smash.xbill9/nb2lite-mcp