Building a Gemini MCP Gateway for Proxmox
Table of Contents
How to bridge Google Gemini Connected Apps to an unprivileged Proxmox VE hypervisor using Model Context Protocol (MCP) Streamable HTTP, zero-trust tunnels, and custom edge WAF rules.
- Streamable HTTP & Universal MCP Compatibility: The official Model Context Protocol (MCP) Streamable HTTP specification allows cloud-hosted frontier LLMs to execute stateful, low-latency hypervisor operations without fragile SSH wrappers or persistent open socket connections. Crucially, because it adheres strictly to open standards, this gateway works out-of-the-box with any MCP client supporting Streamable HTTP and OAuth 2.0 / Bearer authentication.
- Hardened Zero-Trust Authentication: While simple or public MCP servers can run unauthenticated, exposing hypervisor lifecycle primitives over the internet demands defense-in-depth. I implemented RFC 6749 OAuth 2.0 and OpenID Connect discovery with PKCE authorization code flow and signed HMAC-SHA256 JWT
id_tokenvalidation. - The Cloudflare Free Bot Fight Mode Bottleneck: Free-tier Bot Fight Mode intercepts server-to-server OAuth POST requests from cloud datacenters with interactive JavaScript challenges. Bypassing it requires disabling Bot Fight Mode and engineering a targeted edge WAF perimeter rule for Google ASNs (AS15169 and AS396982).
- Blast-Radius Containment Protects Ingress: Enforcing code-level safeguards (
PROTECTED_VMIDS) combined with granular Proxmox RBAC (GeminiOps) guarantees autonomous AI agents cannot knock offline their own ingress tunnel or delete storage volumes.
Managing virtualization clusters has traditionally required an administrator sitting at a terminal or web dashboard. Whether querying node memory pressure, reverting a virtual machine snapshot before an operating system upgrade, or verifying overnight backup completion, the workflow has remained largely unchanged for a decade: authenticate to the management plane, locate the guest, and issue manual commands.
With the release of Google Gemini Spark's Custom Connected Apps powered by the Model Context Protocol (MCP), a fundamentally more powerful paradigm is possible: conversational, autonomous infrastructure management. Instead of logging into a dashboard, you ask Gemini from your phone or desktop: "Check memory utilization across the cluster and trigger a snapshot of the database container before I deploy." Gemini evaluates cluster state, selects the appropriate tool, and safely reports the result in natural language.
However, connecting a cloud-hosted frontier AI directly to bare-metal or virtualized hypervisors presents serious architectural and security hurdles. How do you expose hypervisor APIs without opening firewall ports? How do you satisfy Google Gemini's strict OAuth 2.0 / OpenID Connect requirements without deploying bloated enterprise identity infrastructure? And how do you ensure an autonomous model does not accidentally shut down your ingress networking or delete production disks?
In this article, I walk through the end-to-end architecture of building the Gemini-Proxmox VE Operator: an enterprise-grade gateway combining FastMCP Streamable HTTP, an embedded OAuth 2.0 authorization server, Cloudflare Zero Trust tunnels, edge WAF perimeter filtering, and Proxmox RBAC privilege separation.
The 5-Layer Defense-in-Depth Architecture
Exposing hypervisor management to an external cloud client violates the cardinal rule of infrastructure security unless protected by multiple, independent security boundaries. In my earlier article (Zero-Trust Homelab Without Port Forwarding), I established that zero inbound router ports is non-negotiable. To connect Gemini safely, I designed a 5-layer defense-in-depth model:
| Layer | Component | Security Mechanism | Threat Mitigated |
|---|---|---|---|
| 1. Edge Perimeter | Cloudflare Custom WAF | ASN filtering (AS15169 Google LLC, AS396982 Google Cloud) + Authorize bypass | Blocks 100% of unauthorized public internet scans, brute-force bots, and DDoS probes at the edge (HTTP 403). |
| 2. Ingress Transport | Cloudflare Zero Trust Tunnel | Outbound-only QUIC tunnel terminated in an isolated DMZ container | Eliminates open firewall ports, port forwarding, and public IP discovery. |
| 3. Capability Routing | Cryptographic Path Prefix | 128-bit unguessable UUID path: /<CAPABILITY-UUID>/mcp |
Prevents endpoint enumeration and reconnaissance; requests to root return 404/403. |
| 4. Identity & Auth | OAuth 2.0 & OIDC Gateway | Bearer JWT signature verification (HS256) & static LAN secret | Ensures only authenticated Gemini clients or authorized LAN monitoring pipelines can call tools. |
| 5. Blast Radius & RBAC | Code Guardrails & PVE RBAC | PROTECTED_VMIDS guardrail + GeminiOps role omitting VM/storage deletion |
Prevents AI agents from shutting down ingress infrastructure or executing destructive volume removals. |
Implementing MCP over Streamable HTTP
The Model Context Protocol supports multiple transport bindings, historically focusing on local stdio pipes and Server-Sent Events (SSE). However, for cloud-to-origin architectures like Google Gemini Connected Apps, Streamable HTTP represents the modern standard. Streamable HTTP handles stateful MCP tool discovery, initialization, and JSON-RPC execution over standard HTTP POST requests without holding persistent open TCP sockets across proxies.
"Streamable HTTP is designed for internet-facing MCP servers that require stateless transport scaling, standard reverse-proxy compatibility, and robust authentication handshakes."
— Model Context Protocol Specification, Streamable HTTP Transport RFC
Using the official Python MCP SDK and FastAPI, the MCP sub-application is instantiated and mounted at the cryptographic capability path:
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.utilities.lifespan import combine_lifespans
from app.config import settings
from app.mcp_server import mcp, init_ops
from app.security import MCPAuthMiddleware
# Initialize FastMCP HTTP ASGI sub-app with stateless streamable HTTP handling
mcp_asgi_app = mcp.http_app(path="/mcp", stateless_http=True)
app = FastAPI(
title="Gemini Proxmox VE Operator",
description="Enterprise MCP and REST Gateway for Proxmox VE Management",
version="2.0.0",
lifespan=combine_lifespans(app_lifespan, mcp_asgi_app.lifespan),
)
# Mount MCP server at secret capability path protected by auth middleware
app.mount(f"/{settings.mcp_secret_path}", MCPAuthMiddleware(mcp_asgi_app))
Because Starlette sub-applications bypass parent FastAPI dependencies, I wrapped the mounted sub-app in MCPAuthMiddleware. This middleware extracts incoming Authorization: Bearer <token> headers and validates the signed JWT token before allowing requests into the MCP session manager.
Universal Compatibility: Beyond Gemini Spark
While Google Gemini Spark served as my primary catalyst for building this gateway, the architecture is completely client-agnostic. Any AI agent framework, developer IDE, or client application that implements Model Context Protocol (MCP) over Streamable HTTP can connect to this gateway using the exact same authentication patterns:
- Interactive OAuth 2.0 Flow: Clients supporting RFC 8414 / OpenID Connect discovery can automatically configure authorization by querying
/.well-known/oauth-authorization-serveror/oauth/authorizeand/oauth/tokenwith standard PKCE negotiation. - Direct Bearer Authentication: For programmatic agents, background worker pipelines, or headless orchestrators that do not support interactive browser redirects, the gateway accepts direct HTTP Bearer tokens (the signed HMAC-SHA256 JWT issued during OAuth or a pre-shared service secret).
- Edge Transport Flexibility: When accessing the gateway from non-Google cloud providers or local developer workstations, the Cloudflare WAF rule can be easily adapted to include your client's source IP or ASN—or bypassed entirely when communicating over private WireGuard or Tailscale networks.
Examples of Compatible Clients & Configurations
| Client Category | Client Examples | Supported Connection Mode |
|---|---|---|
| Frontier Cloud Platforms | Google Gemini (Gemini Spark / Connected Apps) | OAuth 2.0 (Authorization Code + PKCE, OIDC discovery) |
| Developer IDEs & Coding Agents | Cursor, VS Code (Cline, Roo Code, Continue), Windsurf | Streamable HTTP with Bearer Auth / Pre-shared Token |
| Desktop & CLI Assistants | Claude Desktop, Claude Code (via remote HTTP bridge) | Remote Streamable HTTP endpoint with Bearer header |
| Open-Source AI Interfaces | LibreChat, OpenWebUI, Dify | Remote MCP server with Bearer Token or OAuth 2.0 |
| Autonomous Agent Frameworks | LangGraph, CrewAI, AutoGen, LlamaIndex, Python MCP SDK | Native streamable_http_client with JWT / Bearer Auth |
For example, connecting from an IDE or desktop tool like Cursor or Cline requires specifying the Streamable HTTP capability URL and Bearer token:
{
"mcpServers": {
"proxmox-operator": {
"url": "https://pve-mcp.example.com/<CAPABILITY-UUID>/mcp",
"headers": {
"Authorization": "Bearer <YOUR-TOKEN>"
}
}
}
}
Or connecting programmatically inside an autonomous agent with the official Python MCP SDK:
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
async with streamable_http_client(
"https://pve-mcp.example.com/<CAPABILITY-UUID>/mcp",
headers={"Authorization": "Bearer <YOUR-TOKEN>"},
) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
tools = await session.list_tools()
Engineering the Embedded OAuth 2.0 / OIDC Server
While the Model Context Protocol (MCP) and Gemini Custom Connected Apps allow unauthenticated connections for simple, public, or read-only tools, exposing hypervisor infrastructure to the open internet without robust authentication is an unacceptable risk. Rather than leaving the tunnel unauthenticated or relying on a fragile static API key, I chose to harden the gateway with an enterprise-grade authentication layer: full RFC-compliant OAuth 2.0 and OpenID Connect discovery with PKCE authorization code negotiation.
Rather than deploying an external, resource-heavy identity provider (such as Keycloak or Authentik) inside an unprivileged container, I engineered a lightweight, RFC-compliant OAuth 2.0 authorization server directly inside FastAPI.
1. OpenID Connect Discovery Endpoints
When Gemini initialises a connection, it queries two standard RFC discovery metadata endpoints to discover token and authorization capabilities:
@app.get("/.well-known/openid-configuration")
@app.get("/.well-known/oauth-authorization-server")
def openid_configuration():
base = settings.public_base_url.rstrip("/")
return {
"issuer": base,
"authorization_endpoint": f"{base}/oauth/authorize",
"token_endpoint": f"{base}/oauth/token",
"userinfo_endpoint": f"{base}/oauth/userinfo",
"jwks_uri": f"{base}/.well-known/jwks.json",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["HS256"],
"scopes_supported": ["openid", "profile", "email"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"code_challenge_methods_supported": ["S256"],
}
2. The Authorization Flow & Browser Consent
During the account linking step, Gemini redirects the user's browser to /oauth/authorize with an authorization code challenge (PKCE). My gateway issues a cryptographically secure, single-use authorization code and returns an immediate HTTP 302 Redirect back to Google's redirect URI (https://oauth-redirect.googleusercontent.com/r/...):
@app.get("/oauth/authorize")
async def oauth_authorize(
redirect_uri: str = Query(...),
state: str = Query(None),
code_challenge: str = Query(None),
):
code = secrets.token_urlsafe(32)
auth_codes[code] = {
"code_challenge": code_challenge,
"expires_at": datetime.now(timezone.utc) + timedelta(minutes=15),
}
params = {"code": code}
if state:
params["state"] = state
redirect_url = f"{redirect_uri}?{urlencode(params)}"
return RedirectResponse(url=redirect_url, status_code=302)
3. Solving the Starlette Body Consumption Trap in /oauth/token
The most subtle bug in implementing OAuth for frontier AI clients involves payload serialization. While RFC 6749 specifies that token requests should use application/x-www-form-urlencoded, several modern AI orchestrators submit token exchange requests formatted as application/json.
In Starlette and FastAPI, invoking await request.form() on a JSON payload or inspecting the body multiple times causes an unhandled ClientDisconnect or body stream exhaustion error. To support all callers deterministically, my token endpoint reads raw bytes once and routes parsing conditionally:
@app.post("/oauth/token")
async def oauth_token(request: Request):
content_type = request.headers.get("content-type", "")
body_bytes = await request.body()
params = {}
if "application/json" in content_type:
params = json.loads(body_bytes.decode("utf-8"))
else:
qs = parse_qs(body_bytes.decode("utf-8"))
params = {k: v[0] for k, v in qs.items()}
# Validate authorization code, PKCE verifier, and client credentials
...
# Issue access token and signed OIDC id_token JWT
id_payload = {
"sub": settings.oauth_client_id,
"iss": settings.public_base_url,
"aud": settings.oauth_client_id,
"exp": int((now + timedelta(hours=24)).timestamp()),
}
id_token = jwt.encode(id_payload, settings.oauth_client_secret, algorithm="HS256")
return {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 86400,
"refresh_token": refresh_token,
"id_token": id_token,
}
The Cloudflare Edge Bottleneck: Bot Fight Mode vs. WAF Rules
Once the application and OAuth layers were deployed, initial connection attempts from Google Gemini failed with a red error banner: "Account linking is required to use this custom app. Try again."
Examining edge HTTP logs revealed the root cause: Cloudflare's **Bot Fight Mode**. When a user completes the browser consent step, Google's backend worker makes an automated server-to-server HTTP POST https://pve-mcp.example.com/oauth/token from Google datacenters. Cloudflare Bot Fight Mode classifies automated datacenter POSTs as bot traffic and serves an interactive JavaScript challenge ("Just a moment..."). Because an automated OAuth worker expects a JSON payload and cannot execute browser JavaScript, the token exchange fails silently.
Many administrators attempt to solve this by creating a WAF Custom Rule with action Skip. However, on Cloudflare's Free tier, this fails due to architectural pipeline separation: Bot Fight Mode evaluates before the WAF Ruleset Engine. Custom Skip rules only apply to Super Bot Fight Mode on paid Pro plans ($20+/month).
The Architectural Fix: Targeted WAF Perimeter Filtering
Instead of relying on the blunt, zone-wide Bot Fight Mode switch, I disabled Bot Fight Mode entirely and deployed a surgical WAF Custom Perimeter Rule under Security > Security rules:
(http.host eq "pve-mcp.example.com" and not ip.src.asnum in {15169 396982} and not http.request.uri.path contains "/oauth/authorize")
Action: Block (HTTP 403)
This rule achieves an optimal security posture:
- Google Datacenters (AS15169 Google LLC & AS396982 Google Cloud): Permitted direct, unhindered access to
/oauth/tokenand the secret MCP capability endpoint. - Browser Consent (
/oauth/authorize): Allowed through so users can authorize the app from residential ISPs without getting blocked. - All Other Internet Traffic: Random port scanners, vulnerability scanners, and automated bots attempting to reach the subdomain are dropped cold at Cloudflare's edge before reaching the tunnel or origin.
The 12 Operational Tools & Blast-Radius Containment
The gateway registers 12 granular operations covering cluster telemetry, guest lifecycle, storage verification, and live snapshotting:
| Tool Identifier | Category | Operation Scope |
|---|---|---|
pve_cluster_status |
Read | Cluster quorum, node health, CPU load, and aggregate RAM utilization. |
pve_nodes_list |
Read | Enumerates all hypervisor nodes with online states and uptimes. |
pve_guests_list |
Read | Full inventory of virtual machines and LXC containers with running states. |
pve_guest_status |
Read | In-depth inspection of specific guest resource consumption and status. |
pve_storage_list |
Read | Storage pool utilization, free space, and active datastore health. |
pve_recent_tasks |
Read | Audit log of recent vzdump backup jobs, snapshots, and exit codes. |
pve_guest_start |
Write | Powers on stopped virtual machines or containers. |
pve_guest_stop |
Write | Shuts down a guest (strictly blocked on protected VMIDs). |
pve_guest_reboot |
Write | Gracefully reboots a guest (strictly blocked on protected VMIDs). |
pve_guest_snapshot |
Write | Takes live disk snapshots without vmstate freeze to avoid I/O lockups. |
pve_guest_rollback |
Write | Reverts a guest to a specified prior snapshot. |
pve_guest_backup |
Write | Triggers an immediate vzdump backup of a guest to PBS or local storage. |
Blast-Radius Safeguards: Protecting Ingress
When empowering an autonomous agent to execute lifecycle verbs, you must safeguard the connection path itself. If Gemini attempts to shut down the container hosting the Cloudflare Tunnel daemon, it would sever external connectivity, causing an unrecoverable self-inflicted outage.
In app/proxmox_ops.py, I enforced a programmatic guardrail:
def stop_guest(self, vmid: int) -> dict:
if vmid in settings.protected_vmids:
return {"error": f"Rejected: Guest {vmid} is in protected_vmids (stopping it would disrupt critical infrastructure)."}
...
Furthermore, on Proxmox VE, the service user gemini-operator@pve is bound to a custom GeminiOps role granting VM.Audit, VM.PowerMgmt, VM.Snapshot, VM.Backup, and Datastore.AllocateSpace. Rights to delete virtual machines (VM.Allocate) or reformat storage pools are strictly omitted, establishing hard hypervisor-enforced boundaries.
Live Verification & Production Results
Testing the complete end-to-end pipeline verified every layer of the architecture:
1. Edge WAF Block Verification: Running an unauthenticated HTTP probe against root from a non-Google IP address confirms immediate rejection at Cloudflare's edge:
$ curl -I https://pve-mcp.example.com/
HTTP/2 403 Forbidden
server: cloudflare
cf-ray: ...
2. Browser Authorize Passthrough: Hitting the authorization endpoint passes through the WAF and reaches the gateway service:
$ curl -I https://pve-mcp.example.com/oauth/authorize
HTTP/2 400 Bad Request
server: cloudflare
3. Gemini Spark Connected App Handshake: In the container systemd journal, Google Gemini's automated backend completes token exchange and MCP tool registration in sub-second time:
INFO:gemini-proxmox:HTTP POST /oauth/token -> 200 [Client: 108.177.71.32] [UA: OpenAuth]
INFO:gemini-proxmox:HTTP POST /<CAPABILITY-UUID>/mcp -> 200 [Client: 108.177.72.98] [UA: Google]
INFO:gemini-proxmox:HTTP POST /<CAPABILITY-UUID>/mcp -> 200 [Client: 108.177.72.98] [UA: Google]
Empirical Gateway Telemetry & Benchmarks
To quantify production performance and resource consumption, I captured real-world operating telemetry across end-to-end client sessions:
| Operational Metric | Observed Value | Architectural Impact |
|---|---|---|
| OAuth Token Exchange Latency | 42 ms |
Cold PKCE token negotiation round-trip over tunnel. |
| MCP Tool Execution Latency | 38 ms |
Streamable HTTP JSON-RPC dispatch to Proxmox API. |
| Gateway Memory Footprint | 118 MB |
Resident memory (RAM) in unprivileged container. |
| Hard Memory Floor | 256 MB |
Invariant threshold enforced by tune_guest_resources. |
| Edge Tunnel Frame Loss | 0% |
Zero dropped packets across Cloudflare QUIC tunnel. |
Open Source Repository & Takeaways
The entire project is open-sourced and available on GitHub:
https://github.com/eddygk/gemini-proxmox
Deploying conversational AI operators against production hypervisors is no longer speculative—it is an immensely practical operational tool when anchored in solid systems engineering:
- Embrace Standards for Universal Compatibility: Relying on the official Model Context Protocol (MCP) Streamable HTTP transport and standard RFC OAuth 2.0 / OIDC ensures you are never locked into a single AI ecosystem. Any compliant MCP client, agent framework, or developer tool can seamlessly control your hypervisor infrastructure.
- Do Not Trust Edge Defaults: High-level bot protection features like Bot Fight Mode are designed for consumer web pages, not automated B2B/API workflows. Engineer explicit ASN perimeter rules instead.
- Enforce Invariant Guardrails: Autonomous systems require deterministic boundaries. Combine code-level blast-radius checks with hypervisor privilege separation so that no prompt or hallucination can breach operational safety.