{"slug": "2b-gemma-4-deployment-with-cloud-run-nvidia-l4-mcp-sdk-2-x-and-claude-code", "title": "2B Gemma 4 Deployment with Cloud Run, NVIDIA L4, MCP SDK 2.x, and Claude Code", "summary": "A developer published a step-by-step guide for deploying Google's 2B Gemma 4 model to Cloud Run with an NVIDIA L4 GPU, using vLLM for serving and a single-file Python MCP server to stage weights, deploy, health-check, benchmark, and tear down the service. The project also documents migrating the MCP server to the MCP Python SDK 2.x after an unbounded dependency caused a fresh pip install to break imports, as FastMCP was renamed to MCPServer.", "body_md": "This article provides a step by step deployment guide for Gemma 4 E2B to a Cloud Run hosted GPU enabled system. A suite of Python MCP tools is built to simplify management of the vLLM hosted deployment with Claude Code.\n\n[https://github.com/xbill9/gemma4-dev/tree/main/gpu-2B-cloudrun-devops-agent](https://github.com/xbill9/gemma4-dev/tree/main/gpu-2B-cloudrun-devops-agent)\n\nThis project is a DevOps/SRE assistant for a Gemma 4 model served by vLLM on Cloud Run with an NVIDIA L4 GPU. A single-file Python MCP server provides tools to stage the weights, deploy the service, check its health, benchmark it, and tear it down.\n\nCloud Run is the serverless option. There is no VM to provision and no driver to install: the service scales to zero when idle, and one `gcloud` command attaches the GPU.\n\nAlong the way the MCP server itself had to move to the MCP Python SDK 2.x, because a fresh `pip install` stopped it from starting. That migration is covered where it happened — in the MCP server section, before the deploy.\n\nThe strategy for starting MCP development for model management is a incremental step by step approach.\n\nFirst, the basic development environment is setup with the required system variables and a working Claude Code configuration.\n\nThen, the Python MCP server is brought up over stdio and validated with Claude Code in the local environment. That server then stages the model, deploys it to Cloud Run, and drives validation and a benchmark sweep against the live endpoint.\n\n`Requires-Python >=3.10`\n`us-east4`)`<project>-bucket` for the model weights\nClone the repository and switch to the Cloud Run directory:\n\n```\ncd ~\ngit clone https://github.com/xbill9/gemma4-dev\ncd gemma4-dev/gpu-2B-cloudrun-devops-agent\n```\n\nThen run **init.sh** once. It checks your `gcloud` login and application default credentials, asks for a project ID, installs the Python requirements, enables the Cloud Run, Secret Manager and related APIs, and grants the default compute service account its roles. It pauses on errors and waits for input, so run it in a terminal:\n\n```\nsource init.sh\n```\n\nIf your session times out or you need to reset your variables, run **set_env.sh**:\n\n```\nsource set_env.sh\nCurrent Environment:\n  GOOGLE_CLOUD_PROJECT=aisprint-491218\n  GOOGLE_CLOUD_LOCATION=us-east4\n  SERVICE_NAME=gpu-2b-l4-devops-agent\n  MODEL_NAME=/mnt/models/gemma-4-E2B-it\n  VLLM_BASE_URL=<unset — discovered via gcloud>\n\nCloud Run here is --no-allow-unauthenticated. If calls fail, run: source ./set_adc.sh\n```\n\n`VLLM_BASE_URL` can stay unset: the MCP server finds the service URL through `gcloud` when a tool first needs it.\n\nOne of the key features that the MCP libraries provide is abstracting various transport methods. The tool implementation is the same no matter which transport the MCP client uses to connect.\n\nThe simplest transport is stdio — the client launches the server as a local process and talks to it over stdin and stdout. Both must run in the same environment. In this project Claude Code is the MCP client.\n\nThe server is created in one line:\n\n```\n# Initialize MCP server (mcp 2.x; FastMCP was renamed MCPServer)\nmcp = MCPServer(\"Self-Hosted vLLM DevOps Agent\")\n```\n\nThat line used to say `FastMCP`. The next section is why it changed.\n\nNothing in the repository changed. A fresh install did. `requirements.txt` listed `mcp` with no version bound, so the next `pip install` resolved the 2.x line, and the server stopped importing:\n\n``` python\npython3 -c \"from mcp.server.fastmcp import FastMCP\"\nraise ModuleNotFoundError(_MESSAGE, name=__name__)\nModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide at https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver or pin 'mcp<2' to keep running v1 code.\n```\n\nIn Claude Code it showed up less helpfully, as a server listed with `Connection closed`: it died on the import before the handshake.\n\nThe error names two fixes. Pinning `mcp<2` is legitimate — the v1 line still gets critical fixes — but these projects install into one system Python with no virtualenvs, so a pin here is a downgrade for every other project on the machine. Migrating keeps the change inside the repository.\n\nThe whole code change is the import and the constructor:\n\n``` python\n-from mcp.server.fastmcp import FastMCP\n+from mcp.server.mcpserver import MCPServer\n ...\n-mcp = FastMCP(\"Self-Hosted vLLM DevOps Agent\")\n+mcp = MCPServer(\"Self-Hosted vLLM DevOps Agent\")\n```\n\n`@mcp.tool()`, `@mcp.resource()`, `mcp.run()` and every tool body stay as they are. Before editing, check the rest of the migration guide's list against your own server:\n\n```\ngrep -c \"^@mcp\\.\\(tool\\|resource\\)\" server.py\ngrep -A1 \"^@mcp\\.\" server.py | grep -c \"^def\"\ngrep -n \"get_running_loop\\|asyncio.run(\" server.py || echo \"(no matches)\"\ngrep -n \"^import httpx\" server.py; grep -n \"^httpx\" requirements.txt\npython\n28\n12\n(no matches)\n13:import httpx\n14:httpx\n```\n\nThree things to know from that output:\n\n`mcp` 2.x no longer installs `httpx`.` httpx2` instead. This server imports `requirements.txt` already declared it.`version=` to `MCPServer(...)`. Nothing breaks; it shows in the handshake below.\nThe requirement then says what the code needs — `mcp>=2` in place of the bare `mcp` line.\n\nThis project has a Claude Code hook that runs `ruff check --fix` after every edit. Change the import line first and, for a moment, `MCPServer` is imported but unused — so the hook deletes it:\n\n``` python\n--- server.py\n+++ server.py\n@@ -1,3 +1,2 @@\n-from mcp.server.mcpserver import MCPServer\n\n mcp = FastMCP(\"demo\")\n\nWould fix 1 error.\n```\n\nMake both changes in one edit, or change the usage first. Any editor that runs `ruff check --fix` on save does the same thing.\n\nThe project can be linted:\n\n```\nmake lint\nruff check .\nAll checks passed!\nruff format --check .\n14 files already formatted\nmypy .\nSuccess: no issues found in 6 source files\n```\n\nand tested:\n\n```\nmake test\n----------------------------------------------------------------------\nRan 28 tests in 1.151s\n\nOK\n```\n\nThe suite compares the registered tool set against a hard-coded list, so a tool that silently failed to register after the rename would fail here. ✅\n\nUnit tests call Python. A client speaks JSON-RPC over stdio, so test that too. **Hold stdin open with `sleep`** — with a bare `printf` pipe the server sees end-of-input and exits after answering only `initialize`:\n\n```\n{ printf '%s\\n' \\\n  '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"probe\",\"version\":\"0\"}}}' \\\n  '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}' \\\n  '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}' \\\n  '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"resources/list\",\"params\":{}}'; sleep 5; } \\\n  | python3 server.py 2>/dev/null\n```\n\nSummarised:\n\n```\ninitialize OK: name='Self-Hosted vLLM DevOps Agent' version='' proto 2025-06-18\ntools/list OK: 27 tools -> cloudrun_analyze_cloud_logging, cloudrun_analyze_gpu_logs, cloudrun_check_gpu_quotas, cloudrun_deploy, ...\nresources/list OK: ['config://vllm-deployment-template']\n```\n\n🟢 27 tools and the resource. Keep this snippet — it is the fastest way to tell \"my server is broken\" from \"my client config is broken.\"\n\nClaude Code reads `.mcp.json` in the project directory. It launches `server.py` with the system `python3`:\n\n```\n{\n  \"mcpServers\": {\n    \"cloudrun-devops\": {\n      \"command\": \"python3\",\n      \"args\": [\"/home/xbill/gemma4-dev/gpu-2B-cloudrun-devops-agent/server.py\"],\n      \"env\": {\n        \"GOOGLE_CLOUD_PROJECT\": \"aisprint-491218\",\n        \"GOOGLE_CLOUD_LOCATION\": \"us-east4\",\n        \"VLLM_BASE_URL\": \"https://gpu-2b-l4-devops-agent-289270257791.us-east4.run.app\",\n        \"MODEL_NAME\": \"/mnt/models/gemma-4-E2B-it\"\n      }\n    }\n  }\n}\n```\n\nCheck the connection from Claude Code to the local server:\n\n```\nclaude mcp get cloudrun-devops\ncloudrun-devops:\n  Scope: Project config (shared via .mcp.json)\n  Status: ✔ Connected\n```\n\nIf the server failed at startup earlier in the session, reconnect it from `/mcp` or start a new session to pick up the fixed code.\n\nThe MCP tools cover the whole lifecycle of the Cloud Run deployment. Every tool is prefixed `cloudrun_`. Abridged output of `cloudrun_get_help`:\n\n```\nThe server is running in CLOUD RUN mode targeting NVIDIA L4 GPU in region us-east4.\n\n🐳 Infrastructure & Deployment\n  cloudrun_deploy, cloudrun_destroy, cloudrun_status, cloudrun_update_scaling,\n  cloudrun_get_deployment_config, cloudrun_get_gpu_deployment_config, cloudrun_check_gpu_quotas\n📊 Model Management\n  cloudrun_list_vertex_models, cloudrun_list_bucket_models, cloudrun_save_hf_token,\n  cloudrun_get_vertex_ai_model_copy_instructions, cloudrun_get_huggingface_model_copy_instructions,\n  cloudrun_get_huggingfacehub_download_path\n📊 Monitoring & Status\n  cloudrun_get_metrics, cloudrun_get_system_status, cloudrun_get_endpoint,\n  cloudrun_get_endpoint_url, cloudrun_get_model_details, cloudrun_verify_model_health\n📈 Performance & Benchmarking\n  cloudrun_run_benchmark\n💬 Interaction & Diagnostics\n  cloudrun_query_gemma4, cloudrun_query_gemma4_with_stats, cloudrun_query,\n  cloudrun_analyze_cloud_logging, cloudrun_analyze_gpu_logs, cloudrun_suggest_sre_remediation\n```\n\nCloud Run mounts the bucket read-only at `/mnt/models` through GCS FUSE, so the weights go to GCS once. Download to a real disk rather than `/tmp`, which on this host is a RAM-backed tmpfs smaller than the model:\n\n```\nhf download google/gemma-4-E2B-it --local-dir ~/hf-downloads/gemma-4-E2B-it\ngcloud storage rsync ~/hf-downloads/gemma-4-E2B-it gs://aisprint-491218-bucket/gemma-4-E2B-it \\\n  --recursive --exclude='^\\.cache/'\ngcloud storage ls -l gs://aisprint-491218-bucket/gemma-4-E2B-it/\nAverage throughput: 41.4MiB/s\n      4954  2026-09-10T14:51:14Z  gs://aisprint-491218-bucket/gemma-4-E2B-it/config.json\n10246621918  2026-09-10T14:55:13Z  gs://aisprint-491218-bucket/gemma-4-E2B-it/model.safetensors\n  32169626  2026-09-10T14:51:34Z  gs://aisprint-491218-bucket/gemma-4-E2B-it/tokenizer.json\nTOTAL: 9 objects, 10278849571 bytes (9.57GiB)\n```\n\nThe bucket is shared with other models, and `cloudrun_list_bucket_models` shows the trap:\n\n```\n> cloudrun_list_bucket_models\n\n### Contents of GCS Bucket: aisprint-491218-bucket\n- gemma-2b-it/config.json (0.00 MB)\n- gemma-2b-it/model-00001-of-00002.safetensors (4716.15 MB)\n...\n- gemma-4-12B-it-qat-w4a16-ct/model.safetensors (9788.73 MB)\n...\n```\n\n**Check the architecture, not the folder name.** `gemma-2b-it/` looks like the answer and is the original Gemma, which the `gemma4` parsers cannot serve. `config.json` settles it:\n\n```\ngcloud storage cat gs://aisprint-491218-bucket/gemma-4-E2B-it/config.json \\\n  | python3 -c 'import json,sys; c=json.load(sys.stdin); t=c[\"text_config\"]; print(c[\"model_type\"], c[\"architectures\"], \"hidden\", t[\"hidden_size\"], \"layers\", t[\"num_hidden_layers\"])'\ngemma4 ['Gemma4ForConditionalGeneration'] hidden 1536 layers 35\n```\n\nThe `deploy-vllm` target in the `Makefile` is the single source of truth for the vLLM and Cloud Run flags. The ones that matter most:\n\n| Flag | Value | Why | \n|---|---|---|\n| `--gpu-type` | `nvidia-l4` | one L4 per instance | \n| `--no-gpu-zonal-redundancy` |  | the cheaper L4 SKU | \n| `--concurrency` | `4` | requests Cloud Run sends one instance | \n| `--max-num-seqs` | `8` | vLLM's batch ceiling | \n| `--tool-call-parser` ,`--reasoning-parser` | `gemma4` | Gemma 4 tool calling breaks without either | \n\nThe service is also `--no-allow-unauthenticated`, so every caller needs an identity token.\n\n```\nmake deploy\nDeploying container to Cloud Run service [gpu-2b-l4-devops-agent] in project [aisprint-491218] region [us-east4]\nDeploying new service...\nRouting traffic.....done\nDone.\nService [gpu-2b-l4-devops-agent] revision [gpu-2b-l4-devops-agent-00001-ssq] has been deployed and is serving 100 percent of traffic.\nService URL: https://gpu-2b-l4-devops-agent-289270257791.us-east4.run.app\n```\n\n`gcloud run deploy` returns only once the startup probe passes, and the probe waits `initialDelaySeconds=180` before its first check. Expect several minutes.\n\nThe default autoscales between zero and one instance, which means a cold GPU start after the service goes idle. A demo cannot wait for that. The `Makefile` takes a `SCALING` variable:\n\n```\nSCALING ?= auto\nifeq ($(SCALING),auto)\nSCALING_FLAGS = --scaling=auto --max-instances=1 --min-instances=0\nelse\nSCALING_FLAGS = --scaling=$(SCALING)\nendif\nmake deploy SCALING=1\ngcloud run services describe gpu-2b-l4-devops-agent --region us-east4 --format='yaml(metadata.annotations)'\nrun.googleapis.com/manualInstanceCount: '1'\n    run.googleapis.com/scalingMode: manual\n```\n\nOne L4 now runs until you change it. Plain `make deploy`, and the `cloudrun_deploy` and `cloudrun_update_scaling` tools, all pass `--scaling=auto` on purpose — a service stuck in manual scaling at zero returns 503 to every request. So any of them quietly takes a demo back to scale-to-zero.\n\nThe status can be checked with an MCP tool:\n\n```\n> cloudrun_get_system_status\n\n### 🌀 GPU Cloud Run System Status (gpu-2b-l4-devops-agent)\n- vLLM Health: 🟢 Online (https://gpu-2b-l4-devops-agent-289270257791.us-east4.run.app)\n- Cloud Run Service Status: 🟢 Ready\n👉 Next Step: Use cloudrun_query_gemma4 to interact with the model.\n```\n\nAsk vLLM what it loaded:\n\n```\ncurl -s -H \"Authorization: Bearer $(gcloud auth print-identity-token)\" \\\n  https://gpu-2b-l4-devops-agent-289270257791.us-east4.run.app/v1/models | python3 -m json.tool\n{\n    \"object\": \"list\",\n    \"data\": [\n        {\n            \"id\": \"/mnt/models/gemma-4-E2B-it\",\n            \"object\": \"model\",\n            \"owned_by\": \"vllm\",\n            \"max_model_len\": 16384\n        }\n    ]\n}\n```\n\nThe model id is the mount path, not the Hugging Face repo id. The container is started with `--model=/mnt/models/<path>`, so that path is the name the OpenAI API expects.\n\nThen the MCP health check:\n\n```\n> cloudrun_verify_model_health\n\n✅ Model health check PASSED.\nModel: /mnt/models/gemma-4-E2B-it\nResponse: 'Hello! Yes, I am working. I am Gemma 4, a Large La...'\nLatency: 0.87 seconds.\n```\n\nRight after the first deploy the same check took 2.48 seconds. That answer came through the whole stack: Claude Code called the tool over MCP, and the tool called vLLM on Cloud Run. 🟢\n\nAsk the agent for `cloudrun_run_benchmark` with its defaults: one warmup request, then 20 requests at each concurrency level of 1, 2, 4 and 8, up to 128 output tokens each, one fixed prompt at temperature 0.\n\n| Concurrency | Req/s | Tokens/s | Avg latency | P95 latency | \n|---|---|---|---|---|\n| 1 | 0.39 | 49.63 | 2.58 s | 2.59 s | \n| 2 | 0.75 | 95.36 | 2.68 s | 2.74 s | \n| 4 | 1.47 | 188.46 | 2.71 s | 2.78 s | \n| 8 | 1.48 | 189.53 | 4.86 s | 5.47 s | \n\nEvery request at every level succeeded. Three readings:\n\n**From 1 to 4, throughput scales almost linearly.** 188.46 tokens/s is 3.8x the single-stream 49.63 (arithmetic), while average latency moves from 2.58 s to 2.71 s. The L4 is nowhere near full at 4.\n\n**From 4 to 8, it stops.** Throughput rises 0.6% (arithmetic) while average latency goes from 2.71 s to 4.86 s. Half the requests are waiting.\n\n**The ceiling is a Cloud Run setting, not the GPU.** One instance accepts `--concurrency=4` requests at a time, and vLLM would batch up to `--max-num-seqs=8`. The next section checks that against the same chip with nothing in front of it.\n\nThe same checkpoint was served on the same chip — one NVIDIA L4 in an AWS EC2 `g6.2xlarge` — with vLLM and `--max-num-seqs 8`, and swept with `vllm bench serve` at the same concurrency levels and 128 output tokens:\n\n| Concurrency | Cloud Run L4 tok/s | EC2 g6 L4 tok/s |  | \n|---|---|---|---|\n| 1 | 49.63 | 46.09 | 🥇 Cloud Run | \n| 2 | 95.36 | 92.6 | 🥇 Cloud Run | \n| 4 | 188.46 | 175.83 | 🥇 Cloud Run | \n| 8 | 189.53 | 360.17 | 🥇 EC2 g6 | \n\n**Up to 4, the two are the same chip doing the same work** — within 8% of each other at every level, with Cloud Run slightly ahead.\n\n**At 8, the g6 keeps going.** It reaches 360.17 tokens/s, 1.9x Cloud Run's 189.53 (arithmetic), because nothing in front of vLLM caps admissions at 4. That is the direct evidence that Cloud Run's plateau is `--concurrency=4`, not the L4.\n\nRead the rows as a shape, not a leaderboard. The runs differ in engine version, KV-cache dtype, prompt and client location; the scope paragraph at the end lists each.\n\nCloud Run bills a GPU service by the second for the whole instance while it runs — GPU, CPU and memory — because the L4 requires `--no-cpu-throttling`. List prices in `us-east4` from the Cloud Billing catalog:\n\n| Component | Price | Per hour | \n|---|---|---|\n| NVIDIA L4, no zonal redundancy | $0.0001867 / s | $0.6721 | \n| 8 vCPU, instance-based | $0.000018 / vCPU-s | $0.5184 | \n| 32 GiB memory, instance-based | $0.000002 / GiB-s | $0.2304 | \n| **One instance** |  | **$1.4209** | \n\nThat is $34.10 a day in demo mode with one fixed instance (arithmetic). In the default mode an idle service scales to zero instances.\n\nPer million output tokens, from the benchmark (arithmetic):\n\n| Deployment | Tokens/s | $ / hour | $ / M tokens | \n|---|---|---|---|\n| Cloud Run, concurrency 1 | 49.63 | 1.4209 | 7.95 | \n| Cloud Run, concurrency 4 | 188.46 | 1.4209 | 2.09 | \n| Cloud Run, concurrency 8 | 189.53 | 1.4209 | 2.08 | \n| 🥇 EC2 g6 spot, concurrency 8 | 360.17 | 0.9412 | 0.73 | \n\nThe winner is the VM, on two counts: a lower hourly rate, and twice the throughput with no admission cap. Cloud Run's price is list and on-demand; the g6 price is a spot price and can be reclaimed. What Cloud Run buys is no VM to manage and zero cost while idle — for intermittent SRE work that is the number that matters.\n\n```\nmake destroy\n```\n\nNot run for this article — the demo service is still up. It deletes the Cloud Run service; the weights stay in the bucket for the next deploy.\n\nThe goal of this article was to deploy Gemma 4 E2B to a Cloud Run NVIDIA L4 GPU with vLLM, and to manage the whole lifecycle from Claude Code through a Python MCP server. The key to the solution was bringing the MCP server up and validating it locally before the deploy — which is where the move to the MCP SDK 2.x surfaced, and where it cost one import and one class name. The deployment results were:\n\n`make deploy` and passed the MCP health check in 0.87 seconds`--concurrency=4` is the ceiling — the same L4 on EC2 reached 360.17 tokens/s at 8\nScope: one Cloud Run instance with one NVIDIA L4 in `us-east4`, vLLM `v0.26.0-cu129`, Gemma 4 E2B with bf16 weights and fp8 KV cache, manual scaling at one fixed instance during the sweep. One sweep of 20 requests per level, a single fixed prompt, 128 max output tokens, driven over the internet from a laptop. The EC2 comparison is a separate run: vLLM 0.28.0, bf16 KV cache, 1,024-token random prompts, 8 to 32 requests per level, driven on the instance itself, spot-priced in `us-east-1`. Costs are arithmetic on list prices, not a bill. MCP server on mcp 2.2.0 and Python 3.14.7.\n\nThe strategy for using MCP for Gemma 4 GPU deployment to Cloud Run with Claude Code was validated with an incremental step by step approach.", "url": "https://wpnews.pro/news/2b-gemma-4-deployment-with-cloud-run-nvidia-l4-mcp-sdk-2-x-and-claude-code", "canonical_source": "https://dev.to/gde/2b-gemma-4-deployment-with-cloud-run-nvidia-l4-mcp-sdk-2x-and-claude-code-4ml3", "published_at": "2026-09-10 18:31:29+00:00", "updated_at": "2026-09-10 19:05:50.454711+00:00", "lang": "en", "topics": ["ai-infrastructure", "large-language-models", "mlops", "developer-tools", "ai-tools"], "entities": ["Gemma 4", "Cloud Run", "NVIDIA L4", "vLLM", "MCP Python SDK", "Claude Code", "Google Cloud", "MCPServer"], "alternates": {"html": "https://wpnews.pro/news/2b-gemma-4-deployment-with-cloud-run-nvidia-l4-mcp-sdk-2-x-and-claude-code", "markdown": "https://wpnews.pro/news/2b-gemma-4-deployment-with-cloud-run-nvidia-l4-mcp-sdk-2-x-and-claude-code.md", "text": "https://wpnews.pro/news/2b-gemma-4-deployment-with-cloud-run-nvidia-l4-mcp-sdk-2-x-and-claude-code.txt", "jsonld": "https://wpnews.pro/news/2b-gemma-4-deployment-with-cloud-run-nvidia-l4-mcp-sdk-2-x-and-claude-code.jsonld"}}