Free lesson · GenAI Application Engineering
Deploy MCP tool servers as sidecars with external-secrets-operator
Build a Kubernetes deployment running MCP tool servers alongside FastAPI as sidecars. Implement mcp-sidecar.yaml adding an MCP server container to the pod spec, running mcp_server.py with the mcp Python SDK, exposing search_documents, query_database, and execute_code tools over stdio transport. Create SharedToolRegistry discovering MCP tools from sidecars via health probes and registering them with the tool-calling pipeline. Build external-secret.yaml using external-secrets-operator CRD syncing from GCP Secret Manager for OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, TOGETHER_API_KEY, DATABASE_URL, and REDIS_PASSWORD. Implement SecretRotationHandler watching for updates and hot-reloading clients without restart. Create validate_secrets.py returning SecretValidationReport Pydantic model. Build GET /v1/deploy/preflight validating sidecars, secrets, and connections.
Course: Full-Stack GenAI Applications · Chapter 18 · Production Deployment on Cloud Run & GKE
Free to read — no subscription required.
Introduction
When you deploy a GenAI application that calls external tools, the MCP tool server needs credentials it must never bake into its image, and the FastAPI app needs that tool server reachable without crossing network boundaries or adding latency. Teams that wire this up ad hoc end up with secrets sprawled across manifests and tool servers that silently break the moment a credential rotates—pages fire, agents start returning hallucinated empty results, and the on-call engineer spends an hour matching Secret versions to pod restart times. By the end of this lesson, you'll be able to deploy an MCP tool server as a Kubernetes sidecar alongside a FastAPI pod, wire its credentials through external-secrets-operator to GCP Secret Manager, and survive secret rotation without dropping requests.
Key Terminology
- MCP sidecar: a tool-server container co-located with the FastAPI application container in the same Kubernetes pod, sharing a network namespace so the app reaches the tool server on
localhostinstead of crossing the cluster network. - external-secrets-operator (ESO): a Kubernetes controller that watches
ExternalSecretcustom resources and reconciles their referenced upstream secrets (e.g. GCP Secret Manager) into native KubernetesSecretobjects on a defined refresh interval. - SecretStore: an ESO custom resource that declares where secrets come from and how ESO authenticates to that provider — for the GCP backend, it pins the project ID and the Workload-Identity-bound service account ESO uses to call Secret Manager.
Concepts
Three ideas drive this lesson: (1) the sidecar topology that keeps MCP tool calls on localhost rather than crossing the cluster network, (2) the ESO → Workload Identity → projected-volume pipeline that delivers rotated credentials to the sidecar without baking them into the image, and (3) the readiness ordering required so the FastAPI container does not accept traffic before the MCP sidecar can serve tool invocations.
Operational Considerations
Running an MCP sidecar in production is not just about getting the manifests to apply cleanly — the two operational properties that determine whether the deployment survives a rotation or a rolling update are how new secret material reaches the running sidecar and how startup ordering between the FastAPI container and the sidecar is guaranteed before traffic arrives. The two subsections below cover each in turn.
Secret Rotation Without Downtime
When ESO detects a changed secret value during its refresh interval, it updates the Kubernetes Secret. Containers using envFrom require a pod restart to pick up new environment variables—Kubernetes does not hot-reload environment variables. Two strategies mitigate this:
-
Volume-based consumption: Mount the Secret as a file volume instead of
envFrom. Kubernetes automatically updates mounted Secret files (with a propagation delay of up to the kubelet sync period, typically 60 seconds). The MCP server reads credentials from disk on each connection pool refresh rather than caching them from environment variables at startup. -
Stakater Reloader: Deploy the Reloader controller, which watches for Secret changes and triggers rolling restarts of Deployments that reference the changed Secret. This works with
envFromand requires no application code changes, but introduces brief unavailability during the rollout—mitigated by the pod disruption budgets configured in another goal.
Health Checks and Startup Ordering
The MCP sidecar must be ready before the FastAPI application begins processing requests that require tool invocation. Kubernetes does not guarantee container startup order within a pod. Use these strategies:
- Set the MCP sidecar's
readinessProbewith a shorterinitialDelaySeconds(5 seconds) than the application container's probe (10 seconds), giving the sidecar a head start - Implement a startup check in the FastAPI application that polls
localhost:3001/healthin a retry loop before marking itself ready—this is more reliable than relying on timing
Code Walkthrough
Architecture: MCP Sidecar with External Secrets
The following diagram illustrates how the FastAPI application container communicates with the MCP tool server sidecar over localhost, while the external-secrets-operator synchronizes credentials from GCP Secret Manager into a Kubernetes Secret mounted into the sidecar.
GKE clusters require a secure secret pipeline to feed API keys and database credentials into MCP server sidecars without baking them into container images. This architecture uses external-secrets-operator (ESO) to pull secrets from GCP Secret Manager, authenticate via Workload Identity (mcp-sa@project.iam), and reconcile them into a Kubernetes SecretStore and ExternalSecret with a one-hour sync policy. The resulting K8s Secret (mcp-tool-secrets) is volume-mounted directly into the MCP Server Sidecar container running alongside FastAPI on port 3001.
Code snippet mermaid
Loading diagram...
- Line 1: Declares a Mermaid flowchart with top-to-bottom (TB) layout direction.
- Lines 2-5: Defines a subgraph named "GCP Platform" containing two nodes:
GSMrepresenting GCP Secret Manager (storing db-password and api-keys) andWIrepresenting Workload Identity bound to a specific IAM service account. - Lines 7-18: Defines a subgraph named "GKE Cluster" containing the
ESO(external-secrets-operator) node and a nested subgraph for the app namespace. - Lines 9-17: Defines the "app namespace" subgraph containing the
SecretStore(configured with a GCP provider), anExternalSecret(with a 1-hour sync policy), a Kubernetes Secret namedmcp-tool-secrets, and a nested Pod subgraph. - Lines 13-16: Defines the "Pod: genai-app" subgraph containing two containers: a FastAPI application container listening on port 8000 and an MCP Server sidecar container listening on port 3001 via stdio/SSE transport.
- Line 20: Draws an edge showing the external-secrets-operator fetching secrets from GCP Secret Manager.
- Line 21: Draws an edge showing Workload Identity providing authentication to the external-secrets-operator.
- Line 22: Draws an edge showing the external-secrets-operator reconciling with the SecretStore resource.
- Line 23: Draws an edge connecting the SecretStore to the ExternalSecret, indicating the ExternalSecret references the SecretStore as its provider.
- Line 24: Draws an edge showing the ExternalSecret creating and updating the Kubernetes Secret (
mcp-tool-secrets). - Line 25: Draws an edge showing the Kubernetes Secret being volume-mounted into the MCP Server sidecar container.
- Line 26: Draws an edge showing the FastAPI container communicating with the MCP Server sidecar over localhost:3001 using the JSON-RPC protocol.
The separation of concerns is critical: the FastAPI container knows nothing about secret storage backends. The MCP sidecar reads credentials from a mounted volume that ESO keeps synchronized. When a secret rotates in GCP Secret Manager, ESO updates the Kubernetes Secret, and a volume reload delivers the new value to the sidecar—no pod restart required for projected volumes.
Building the MCP Tool Server
The MCP server exposes tools that the FastAPI application invokes during LLM agent execution. The following implementation defines MCPToolServer using the mcp Python SDK's Server class and the @server.tool() decorator to register a database query tool. The server reads credentials from environment variables injected by Kubernetes, establishes a connection pool using asyncpg, and exposes the tool over the stdio transport via mcp.server.stdio.run_server. The query_database function validates incoming SQL against an allow-list of statement prefixes to prevent write operations, returning results as structured JSON that the LLM can interpret. This pattern keeps the tool server stateless except for the connection pool, making it horizontally scalable within the sidecar pattern.
Code snippet python
1# mcp_server.py — MCP tool server for database queries 2import os 3import json 4import asyncio 5import asyncpg 6from mcp.server import Server 7from mcp.server.stdio import run_server 8from mcp.types import Tool, TextContent 9 10ALLOWED_PREFIXES = ("SELECT", "WITH", "EXPLAIN") 11 12server = Server("db-query-tool") 13pool: asyncpg.Pool | None = None 14 15async def get_pool() -> asyncpg.Pool: 16 global pool 17 if pool is None: 18 pool = await asyncpg.create_pool( 19 host=os.environ["DB_HOST"], 20 port=int(os.environ.get("DB_PORT", "5432")), 21 user=os.environ["DB_USER"], 22 password=os.environ["DB_PASSWORD"], 23 database=os.environ["DB_NAME"], 24 min_size=2, 25 max_size=10, 26 ) 27 return pool 28 29@server.tool() 30async def query_database(sql: str, params: list | None = None) -> list[TextContent]: 31 """Execute a read-only SQL query and return results as JSON.""" 32 normalized = sql.strip().upper() 33 if not any(normalized.startswith(p) for p in ALLOWED_PREFIXES): 34 raise ValueError( 35 f"Only read queries allowed. Got: {normalized[:20]}" 36 ) 37 38 db_pool = await get_pool() 39 async with db_pool.acquire() as conn: 40 async with conn.transaction(readonly=True): 41 rows = await conn.fetch(sql, *(params or [])) 42 result = [dict(row) for row in rows] 43 44 return [TextContent(type="text", text=json.dumps(result, default=str))] 45 46@server.tool() 47async def list_tables() -> list[TextContent]: 48 """List all user tables in the public schema.""" 49 db_pool = await get_pool() 50 async with db_pool.acquire() as conn: 51 rows = await conn.fetch( 52 "SELECT table_name FROM information_schema.tables " 53 "WHERE table_schema = 'public' ORDER BY table_name" 54 ) 55 tables = [row["table_name"] for row in rows] 56 57 return [TextContent(type="text", text=json.dumps(tables))] 58 59if __name__ == "__main__": 60 asyncio.run(run_server(server))
- Lines 1-5: Import the required modules—
asyncpgfor PostgreSQL connectivity,mcp.server.Serverfor the MCP protocol handler,run_serverfor the stdio transport loop, andTool/TextContentfrommcp.typesfor type-safe tool definitions - Line 7: Define
ALLOWED_PREFIXESas a tuple of SQL statement prefixes that the query tool permits, restricting operations to read-only statements like SELECT, WITH, and EXPLAIN - Lines 9-10: Instantiate the MCP
Serverwith the name"db-query-tool"and declare the module-levelpoolvariable initialized to None, following the lazy initialization pattern - Lines 13-24: The
get_poolcoroutine implements connection pool creation. It readsDB_HOST,DB_PORT,DB_USER,DB_PASSWORD, andDB_NAMEfrom environment variables—these values originate from the Kubernetes Secret mounted by ESO. The pool maintains 2–10 connections, balancing idle resource consumption against burst capacity - Lines 27-39: The
query_databasetool function is registered via the@server.tool()decorator. It first normalizes the SQL to uppercase and checks whether it starts with an allowed prefix. If the check fails, it raises a ValueError with a descriptive message. The function acquires a connection from the pool, opens a read-only transaction for safety, executes the query with optional parameters, and converts rows to dictionaries usingdict(row)before serializing to JSON - Lines 42-51: The
list_tablestool provides schema introspection, queryinginformation_schema.tablesfor the public schema. This gives the LLM agent the ability to discover available tables before constructing queries - Lines 54-55: The entry point calls
asyncio.run(run_server(server)), which starts the stdio transport loop that reads JSON-RPC messages from stdin and writes responses to stdout—the communication channel the FastAPI sidecar uses
Kubernetes Manifests: Sidecar Pod + ExternalSecret
The pod specification co-locates the FastAPI application and the MCP server in a single pod, sharing a network namespace so the application reaches the MCP server on localhost. The following Python script builds both halves of the deployment in one place: a V1Deployment with two containers (the primary genai-app running FastAPI on port 8000 and the mcp-db-tool sidecar running on port 3001), plus the ESO SecretStore and ExternalSecret resources that synchronize credentials from GCP Secret Manager into the mcp-tool-secrets Kubernetes Secret the sidecar consumes. Keeping both in one module lets CI/CD pipelines render the entire pipeline atomically and guarantees the key names in SECRET_MAPPINGS stay aligned with the env_from lookups in the deployment.
Code snippetpython
1# k8s_manifests.py — MCP sidecar pod + ESO secret pipeline 2from kubernetes import client 3 4GCP_PROJECT = "genai-prod-project" 5KSA_NAME = "mcp-workload-sa" 6SECRET_NAME = "mcp-tool-secrets" 7 8def build_mcp_sidecar_deployment( 9 app_image: str, 10 mcp_image: str, 11 replicas: int = 3, 12) -> client.V1Deployment: 13 """Build a Deployment with MCP sidecar consuming ESO-managed secrets.""" 14 secret_volume = client.V1Volume( 15 name="mcp-secrets", 16 secret=client.V1SecretVolumeSource( 17 secret_name=SECRET_NAME, 18 default_mode=0o400, 19 ), 20 ) 21 22 mcp_sidecar = client.V1Container( 23 name="mcp-db-tool", 24 image=mcp_image, 25 command=["python", "mcp_server.py"], 26 env_from=[ 27 client.V1EnvFromSource( 28 secret_ref=client.V1SecretEnvSource(name=SECRET_NAME) 29 ) 30 ], 31 ports=[client.V1ContainerPort(container_port=3001)], 32 resources=client.V1ResourceRequirements( 33 requests={"cpu": "100m", "memory": "128Mi"}, 34 limits={"cpu": "500m", "memory": "256Mi"}, 35 ), 36 readiness_probe=client.V1Probe( 37 http_get=client.V1HTTPGetAction(path="/health", port=3001), 38 initial_delay_seconds=5, 39 period_seconds=10, 40 ), 41 volume_mounts=[ 42 client.V1VolumeMount( 43 name="mcp-secrets", 44 mount_path="/etc/mcp-secrets", 45 read_only=True, 46 ) 47 ], 48 ) 49 50 app_container = client.V1Container( 51 name="genai-app", 52 image=app_image, 53 ports=[client.V1ContainerPort(container_port=8000)], 54 env=[ 55 client.V1EnvVar(name="MCP_SERVER_URL", value="http://localhost:3001"), 56 client.V1EnvVar(name="MCP_TRANSPORT", value="sse"), 57 ], 58 resources=client.V1ResourceRequirements( 59 requests={"cpu": "500m", "memory": "512Mi"}, 60 limits={"cpu": "2000m", "memory": "1Gi"}, 61 ), 62 readiness_probe=client.V1Probe( 63 http_get=client.V1HTTPGetAction(path="/healthz", port=8000), 64 initial_delay_seconds=10, 65 period_seconds=15, 66 ), 67 ) 68 69 return client.V1Deployment( 70 metadata=client.V1ObjectMeta(name="genai-app", labels={"app": "genai"}), 71 spec=client.V1DeploymentSpec( 72 replicas=replicas, 73 selector=client.V1LabelSelector(match_labels={"app": "genai"}), 74 template=client.V1PodTemplateSpec( 75 metadata=client.V1ObjectMeta(labels={"app": "genai"}), 76 spec=client.V1PodSpec( 77 service_account_name=KSA_NAME, 78 containers=[app_container, mcp_sidecar], 79 volumes=[secret_volume], 80 ), 81 ), 82 ), 83 ) 84 85SECRET_MAPPINGS = [ 86 {"gcp_name": "mcp-db-host", "k8s_key": "DB_HOST"}, 87 {"gcp_name": "mcp-db-password", "k8s_key": "DB_PASSWORD"}, 88 {"gcp_name": "mcp-db-name", "k8s_key": "DB_NAME"}, 89 {"gcp_name": "mcp-db-user", "k8s_key": "DB_USER"}, 90] 91 92SECRET_STORE = { 93 "apiVersion": "external-secrets.io/v1beta1", 94 "kind": "SecretStore", 95 "metadata": {"name": "gcp-secret-store", "namespace": "genai"}, 96 "spec": { 97 "provider": { 98 "gcpsm": { 99 "projectID": GCP_PROJECT, 100 "auth": { 101 "workloadIdentity": { 102 "clusterLocation": "us-central1", 103 "clusterName": "genai-cluster", 104 "serviceAccountRef": {"name": KSA_NAME}, 105 } 106 }, 107 } 108 } 109 }, 110} 111 112EXTERNAL_SECRET = { 113 "apiVersion": "external-secrets.io/v1beta1", 114 "kind": "ExternalSecret", 115 "metadata": {"name": "mcp-tool-external", "namespace": "genai"}, 116 "spec": { 117 "refreshInterval": "1h", 118 "secretStoreRef": {"name": "gcp-secret-store", "kind": "SecretStore"}, 119 "target": { 120 "name": SECRET_NAME, 121 "creationPolicy": "Owner", 122 "deletionPolicy": "Retain", 123 }, 124 "data": [ 125 { 126 "secretKey": m["k8s_key"], 127 "remoteRef": {"key": m["gcp_name"], "version": "latest"}, 128 } 129 for m in SECRET_MAPPINGS 130 ], 131 }, 132}
- Module constants (
GCP_PROJECT,KSA_NAME,SECRET_NAME): single source of truth shared by the Deployment, SecretStore, and ExternalSecret. The Kubernetes ServiceAccount is bound to a GCP IAM service account via Workload Identity—ESO uses this binding to fetch secrets without JSON key files secret_volume: aV1Volumebacked by the Kubernetes Secret that ESO populates.default_mode=0o400sets owner-read-only file permissions, so only the container's process UID can read credentialsmcp_sidecar: usesenv_fromwith aV1SecretEnvSourceto inject every key frommcp-tool-secretsas environment variables—the names match theos.environlookups inmcp_server.py. Conservative resource requests (100m CPU, 128Mi memory) keep the sidecar from skewing HPA metrics. The/healthprobe ensures the MCP server is initialized before the pod receives trafficapp_container: receivesMCP_SERVER_URL=http://localhost:3001(cheap because of the shared pod network namespace) andMCP_TRANSPORT="sse". Resource limits are higher because FastAPI handles request parsing, LLM orchestration, and response streamingV1Deploymentspec: ties both containers and the secret volume to a pod template whoseservice_account_nameis the Workload-Identity-bound KSA referenced by the SecretStoreSECRET_MAPPINGS: declarative list mapping GCP Secret Manager names to Kubernetes Secret keys. Thek8s_keyvalues match the env-var namesmcp_server.pyreads, so the GCP secret names can rotate without touching the applicationSECRET_STORE: ESO'sSecretStorecustom resource. Thegcpsmprovider authenticates viaworkloadIdentity, eliminating JSON key files entirely—the GKE node's metadata server issues tokensEXTERNAL_SECRET: a one-hourrefreshIntervalmeans ESO polls GCP Secret Manager every 60 minutes for rotated values.creationPolicy: "Owner"lets ESO create the Kubernetes Secret;deletionPolicy: "Retain"keeps it alive even if someone accidentally deletes the ExternalSecret, preventing a cascading outage. Thedatalist-comprehension generates one entry per mapping, all referencing the"latest"version (pin to specific version numbers in production for auditability)
Do's and Don'ts
Do's
- ✓Do wire MCP sidecar credentials through
external-secrets-operatorwith aSecretStore+ExternalSecretbacked by Workload Identity, then mount the resultingmcp-tool-secretsK8s Secret as a projected volume — projected volumes deliver rotated values to the running container without a pod restart, eliminating the window where agents silently return empty results while a credential version mismatch is hunted down. - ✓Do enforce the
ALLOWED_PREFIXEStuple ("SELECT","WITH","EXPLAIN") inquery_databasebefore handing any SQL to theasyncpgpool — the LLM that calls this tool can construct arbitrary strings; without the allow-list check, a tool advertised as read-only becomes an unrestricted database write path the agent can exploit inadvertently or an attacker can exploit via prompt injection. - ✓Do keep the MCP
Serverinstance and itsasyncpgconnection pool (min_size=2,max_size=10) as the only sidecar state, and expose the tool over stdio transport viamcp.server.stdio.run_server— this keeps the sidecar horizontally scalable and limits the FastAPI container's coupling to a singlelocalhost:3001JSON-RPC call, so the tool server can be replaced or restarted without touching the application container.
Don'ts
- ✗Don't hardcode database credentials or API keys in the Deployment manifest's
env:block or bake them into the container image — secret rotation then requires either an image rebuild or a manifest redeploy, and the gap between the GCP Secret Manager update and the pod restart is exactly the window that triggers agent hallucinations and on-call pages described in the introduction. - ✗Don't expose the MCP tool server via a Kubernetes
Serviceand route FastAPI requests across the cluster network instead of overlocalhost:3001— the entire point of the sidecar pattern is that both containers share the pod's network namespace, giving you zero cross-node latency and no additional network boundary to secure; pulling them apart re-introduces the latency and forces you to manage mTLS or network policies that the localhost channel makes unnecessary. - ✗Don't omit the
normalized.startswith(p)allow-list check or replace it with a blocklist of disallowed keywords — SQL keyword blocklists are trivially bypassed with comment injection or whitespace tricks, while the prefix-basedALLOWED_PREFIXEScheck on the.strip().upper()form rejects anything that isn't a read statement before it ever reaches the connection pool.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.
From · cancel anytime
More free lessons in Full-Stack GenAI Applications
- Ch 10Build Llama Guard 4 content classifier
- Ch 14Build a semantic cache with Redis + embedding similarity
- Ch 16Build OpenTelemetry distributed trace pipelines
- Ch 16Manage prompt template versions with Langfuse
- Ch 16Use Pydantic AI + Logfire as an alternative observability stack
- Ch 18Deploy FastAPI to Cloud Run with auto-scaling
- Ch 18Deploy MCP tool servers as sidecars with external-secrets-operatorYou are here