Internal reference · Google Cloud Run

Deploying MCP Servers
on Google Cloud Run

A framework for multi-device, single-account access to self-hosted tools in Claude.

Version: 2.0  ·  2026-07-11  ·  Warren / Claude  ·  Validated: 30+ live calls, 0 transport errors  ·  Licensed CC BY-NC-SA 4.0

What this achieves

Reach self-hosted tools from any device, one Claude account

An MCP (Model Context Protocol) server is a small program that exposes tools — search, data lookups, actions in some external system — that Claude can call mid-conversation. This guide deploys one to Google Cloud Run, giving it a public, stable, always-on address, then connects it to Claude as a custom connector so any device signed into your account can reach it, with no VPN and no local install.

Read this overview first, then follow the Deploy tab in order. The rest — troubleshooting, maintenance, and a worked example — stays with you afterward as reference, not something you read start to finish again.

Licence and disclaimer

In this section, "the Work" means this guide and its worked-example appendix. By using, copying, or distributing the Work, you confirm you have read, understood, and agree to this Licence and disclaimer.

Disclaimer and governing law

The Work is a technical reference built from one real deployment, not a guarantee of correctness for your own setup or a substitute for your own security review. It is provided "as is," without warranty of any kind, and it can be wrong, especially about fast-moving platform details such as Cloud Run defaults or claude.ai's connector UI. Test any deployment before relying on it, and rotate any credential that has appeared in a shared chat, screenshot, or log. The Work is written for personal-scale, single-user deployments; production and multi-tenant use need their own security review, which this guide does not provide. To the maximum extent permitted by law, Dot11.media is not liable for any loss or damage arising from use or distribution of the Work. Nothing in this disclaimer limits or excludes liability for death or personal injury caused by negligence, for fraud or fraudulent misrepresentation, or for anything else that cannot lawfully be limited or excluded. Where the law gives you consumer rights that cannot be set aside, this disclaimer leaves them untouched. This disclaimer and the guide are governed by the law of England and Wales, and the courts of England and Wales have jurisdiction. If you use the Work from another country, any consumer rights under your own country's law that cannot be set aside still apply to you.

Licence

This work is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0). You may share and adapt it for non-commercial purposes, you must credit Dot11.media as the source, and any adaptation must be shared under these same terms. Full licence: creativecommons.org/licenses/by-nc-sa/4.0

© 2026 Dot11.media. Source: Dot11.media.

What this achieves

Reach self-hosted tools from any device, one Claude account

An MCP (Model Context Protocol) server is a small program that exposes a set of tools — search, data lookups, actions in some external system — that Claude can call mid-conversation. Some are hosted by the vendor already (Google Drive, Linear, GitHub all work this way out of the box). Others, including most self-built or reference implementations, are meant to be run by you.

The problem this guide solves: if that server only runs on your laptop, only that laptop can use it. This guide deploys it instead to Google Cloud Run, giving it a public, stable, always-on address. Once it's there, you add it to Claude as a custom connector — a one-time setup tied to your Claude account, not to any one device. After that, any machine where you can open claude.ai in a browser and sign in has full access to that tool, with no VPN, no local install, and nothing to reconfigure per device.

Applies to any server Nothing here is specific to one MCP server. The steps apply to any Dockerized MCP server exposing an HTTP mode — official reference implementations from a vendor, or one you've written yourself.

Who it's for

Written for a terminal-comfortable reader, new to GCP or MCP

No prior GCP project or billing setup is assumed. What is assumed: the MCP server itself already exists as source code with a working Dockerfile — writing an MCP server from scratch is a separate topic, not covered here.


Prerequisites

What you need before you start

A Google Cloud account

A project with billing linked, created fresh in the Deploy tab, or reused if one already exists.

💰

The Always Free tier

2M requests/month, 360,000 GB-seconds, 180,000 vCPU-seconds — covers personal-scale usage at zero cost. A billing account on file is still required (Feb 2026 policy change).

📦

The server's source

Containing a Dockerfile. Either official, cloned from the vendor's repo, or your own.

🔑

Its own credentials

Any upstream API key the server needs to do its job. Authenticates its outbound calls — unrelated to securing inbound access (Secure & Connect tab).

👥

The right Claude plan

Pro, Max, Team, or Enterprise if you'll run several of these — Free is capped at one custom connector.


Licence and disclaimer

Licence and disclaimer

In this section, "the Work" means this guide and its worked-example appendix. By using, copying, or distributing the Work, you confirm you have read, understood, and agree to this Licence and disclaimer.

Disclaimer and governing law

The Work is a technical reference built from one real deployment, not a guarantee of correctness for your own setup or a substitute for your own security review. It is provided "as is," without warranty of any kind, and it can be wrong, especially about fast-moving platform details such as Cloud Run defaults or claude.ai's connector UI. Test any deployment before relying on it, and rotate any credential that has appeared in a shared chat, screenshot, or log. The Work is written for personal-scale, single-user deployments; production and multi-tenant use need their own security review, which this guide does not provide. To the maximum extent permitted by law, Dot11.media is not liable for any loss or damage arising from use or distribution of the Work. Nothing in this disclaimer limits or excludes liability for death or personal injury caused by negligence, for fraud or fraudulent misrepresentation, or for anything else that cannot lawfully be limited or excluded. Where the law gives you consumer rights that cannot be set aside, this disclaimer leaves them untouched. This disclaimer and the guide are governed by the law of England and Wales, and the courts of England and Wales have jurisdiction. If you use the Work from another country, any consumer rights under your own country's law that cannot be set aside still apply to you.

Licence

This work is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0). You may share and adapt it for non-commercial purposes, you must credit Dot11.media as the source, and any adaptation must be shared under these same terms. Full licence: creativecommons.org/licenses/by-nc-sa/4.0

© 2026 Dot11.media. Source: Dot11.media.

One-time setup

Google Cloud setup, four steps

Skip ahead if a project with billing already exists.

1
Open the console

console.cloud.google.com, signed in with the account you want this tied to.

2
Select or create a project

Project dropdown, top left → select an existing project, or New Project.

3
Link billing

Billing in the left nav → link a billing account, adding a card if none is on file yet.

4
Set a budget alert

Budgets & alerts → create a small budget (e.g. £1) with an email alert. Free, and it's the early-warning system if usage ever exceeds the free tier.


Deploy

From source to a live URL

All commands run in Cloud Shell — the >_ icon, top right of the console. Nothing to install locally: gcloud, git, and docker are preloaded and already signed in as you.

# Confirm the right project — Cloud Shell usually auto-selects it; check the session banner first
gcloud config set project YOUR_PROJECT_ID

# One-time per project
gcloud services enable run.googleapis.com cloudbuild.googleapis.com

# Get the server source
git clone <repo-url>
cd <repo-directory>

Before deploying, check the Dockerfile for BuildKit-only syntax — the single most common first-attempt failure:

grep -n "mount=type=cache" Dockerfile

If that finds anything, Cloud Build's default builder will fail on it (the --mount option requires BuildKit). Strip it — it only affects build-cache speed, not runtime behaviour:

sed -i 's/--mount=type=cache,target=[^ ]* //' Dockerfile
grep -n "mount=type=cache" Dockerfile   # should now print nothing

Deploy from source — Cloud Run builds the image for you, no local Docker build required:

gcloud run deploy SERVICE_NAME \
  --source . \
  --region europe-west2 \
  --allow-unauthenticated \
  --concurrency=1 \
  --set-env-vars UPSTREAM_API_KEY=your_value,BIND_ADDRESS=0.0.0.0

Notes on the flags:

  • --allow-unauthenticated is required, not optional, for a claude.ai custom connector. Cloud Run's alternative is Google-signed IAM identity tokens, and claude.ai has no way to present those.
  • --concurrency=1 — see the Troubleshoot tab for why this matters more than it looks like it should. Worth setting from the first deploy.
  • BIND_ADDRESS=0.0.0.0 — many MCP HTTP servers default to loopback-only, which never receives Cloud Run's proxied traffic. Check the specific server's README for the exact variable name.
  • The first deploy will offer to enable Artifact Registry — accept, that's where the built image is stored.

A successful deploy prints a Service URL, e.g. https://SERVICE_NAME-XXXXXXXXX.europe-west2.run.app. That hostname is stable across future redeploys.

If the build fails

Get the actual error rather than guessing:

gcloud builds log BUILD_ID --region=europe-west2

BUILD_ID prints in the failed deploy's own output, or list recent ones: gcloud builds list --region=europe-west2 --limit=5.

If the server checks the incoming Host header

A common security practice — check the server's logs or README for something like ALLOWED_HOSTS. Redeploy once more now that the real hostname is known:

gcloud run services update SERVICE_NAME \
  --region europe-west2 \
  --update-env-vars ALLOWED_HOSTS=SERVICE_NAME-XXXXXXXXX.europe-west2.run.app

Secure it

Gate inbound access before you rely on this

An upstream API key the server holds only authenticates its own outbound calls. It does not stop anyone who finds the Cloud Run URL from calling the server itself and running up usage against that key. Assume the URL eventually leaks somewhere — a log, a screenshot, a shared chat — and gate inbound access explicitly.

Generate a token

openssl rand -hex 32

Save the output somewhere retrievable — one unbroken 64-character string, no label or prefix attached.

What the patch needs to do

Regardless of the server's language, add a check at the earliest point in the request-handling chain — before any MCP protocol logic runs:

1

Reads a secret token from an environment variable (e.g. MCP_AUTH_TOKEN).

2

Checks incoming requests for that token in two places: an Authorization: Bearer <token> header, and a ?token= query parameter.

3

Rejects with 401 and a JSON-RPC-shaped error body if neither matches.

4

Otherwise passes the request through unchanged.

Why the query string, too As of this writing, claude.ai's custom connector dialog has no field for custom request headers, only a URL. Query-string auth is currently the only way to actually deliver a static credential through that UI — see "Why there's no header field" below.

Worked examples, side by side

Node / Express
const authToken = process.env.MCP_AUTH_TOKEN;
if (authToken) {
  app.use((req, res, next) => {
    const header = req.headers.authorization;
    const queryToken = req.query.token;
    const headerOk = header === `Bearer ${authToken}`;
    const queryOk = typeof queryToken === "string" && queryToken === authToken;
    if (!headerOk && !queryOk) {
      res.status(401).json({
        jsonrpc: "2.0",
        error: { code: -32000, message: "Unauthorized" },
        id: null,
      });
      return;
    }
    next();
  });
}
Python / FastAPI
import os
from fastapi import Request
from fastapi.responses import JSONResponse

AUTH_TOKEN = os.environ.get("MCP_AUTH_TOKEN")

@app.middleware("http")
async def auth_gate(request: Request, call_next):
    if AUTH_TOKEN:
        header = request.headers.get("authorization")
        query_token = request.query_params.get("token")
        header_ok = header == f"Bearer {AUTH_TOKEN}"
        query_ok = query_token == AUTH_TOKEN
        if not header_ok and not query_ok:
            return JSONResponse(
                status_code=401,
                content={"jsonrpc": "2.0", "error": {"code": -32000, "message": "Unauthorized"}, "id": None},
            )
    return await call_next(request)

For any other language, the shape is the same: intercept before routing, check both locations, short-circuit with 401 on failure.

Finding the insertion point

grep -n "app\.\(use\|post\|all\)" <main-server-file>   # Express-style
# or the equivalent route-registration search for the framework in use

Read enough surrounding context to find a safe spot — ideally right after any existing Host-header check, before CORS or JSON parsing. Prefer a scripted patch over manual editing:

cat > /tmp/patch_auth.py << 'PYEOF'
path = "<main-server-file>"
with open(path) as f:
    content = f.read()

anchor = """<exact text immediately before your insertion point>"""
insert = """<anchor text> + <your new middleware>"""

count = content.count(anchor)
if count != 1:
    raise SystemExit(f"Expected exactly 1 match, found {count} — aborting.")

with open(path, "w") as f:
    f.write(content.replace(anchor, insert))
print("Patched successfully.")
PYEOF
python3 /tmp/patch_auth.py

Redeploy and verify both directions

gcloud run deploy SERVICE_NAME \
  --source . --region europe-west2 --allow-unauthenticated --concurrency=1 \
  --set-env-vars UPSTREAM_API_KEY=your_value,BIND_ADDRESS=0.0.0.0,ALLOWED_HOSTS=<hostname>,MCP_AUTH_TOKEN=<token>
# Expect 401 — no token supplied
curl -i -X POST https://<hostname>/mcp \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'

# Expect 200 — correct token via query string
curl -i -X POST "https://<hostname>/mcp?token=<token>" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'

A 200 on the second call, with a JSON body naming the server and its tools, confirms the deployment is genuinely working end to end.


Connect to claude.ai

Add the connector, three fields

1

Settings → Connectors → Add → Custom connector.

2

Name it, URL: https://<hostname>/mcp?token=<token>.

3

Leave the OAuth Client ID/Secret fields blank — different auth model, see below.

4

Add, then open a fresh chat and ask something that should route to it.

Why there's no header field

As of this writing, claude.ai's Add Custom Connector dialog exposes exactly three inputs: Name, URL, and an OAuth Client ID/Secret pair for connectors that use full OAuth. There is currently no field for arbitrary custom headers, unlike Claude Code and Claude Desktop's config-file-based setup, which do support them. A genuine current gap in the web UI, not a missing setting to hunt for.

The workaround: query-string token

Because the patch above already checks the query string as well as the header, the token can simply be embedded directly in the connector's URL field, and no header configuration is needed at all.

Trade-off worth knowing A query-string token is slightly less private than a header. It will appear in Cloud Run's own request logs, and in the connector's saved URL if that settings page is ever screenshotted or shared. Acceptable for a personal, single-user tool at this scale; worth reconsidering for anything higher-stakes.

Troubleshooting reference

Every error hit, and what it actually means

SymptomCauseFix
Regional Access Boundary... Gaia id not found on every gcloud command A preview access-boundary check gcloud runs that doesn't apply to personal Gmail accounts (intended for Workspace organisations) Ignore it — check the actual command result on the line below, not this one.
the --mount option requires BuildKit Dockerfile uses --mount=type=cache, unsupported by Cloud Build's default builder Strip it with sed (Deploy tab). Doesn't affect runtime behaviour.
Clean curl test passes, but Claude's connector shows repeated failures with no useful detail Chat-side error summaries are paraphrased, not the literal error Tail Cloud Run's own logs live while re-running the test (below).
Error: Already connected to a transport... The server holds one shared MCP Server instance for the whole process, reused across requests. Fine when strictly serialized; breaks when Cloud Run routes two overlapping requests to the same warm container (default concurrency up to 80). Common in quickly-built TypeScript MCP SDK servers. gcloud run services update SERVICE_NAME --region <region> --concurrency=1 first. Confirm via logs: multiple [server] listening lines under load. If it persists, the fix needs a per-session Server instance in the source — outside the scope of a deployment guide.
curl GET to /mcp returns Not Acceptable: Client must accept text/event-stream Expected — Streamable HTTP requires the client to declare it can accept a streamed response Not a bug. Test with a full POST + initialize payload instead.
claude.ai connector shows “Connection expired”; Connect opens a tab failing with oauth_error=mcp_registration Claude's reconnect flow assumes OAuth regardless of the connector's actual auth mechanism Don't use Connect/reconnect for a non-OAuth connector — see next row.
Need to change a custom connector's URL, token, or any setting claude.ai custom connectors currently have no edit function (confirmed via Anthropic's Help Center) Delete the connector entirely and re-add from scratch with corrected details.
Uncertain which secret value is actually live, after several redeploys Scrollback across a long session makes it easy to lose track Ask Cloud Run: gcloud run services describe SERVICE_NAME --region <region> --format=json, read spec.template.spec.containers[0].env.

Tailing logs live

gcloud beta run services logs tail SERVICE_NAME --region <region>

First run in a fresh Cloud Shell session may prompt to install a component:

sudo apt-get install google-cloud-cli-log-streaming

This self-install prompt fails inside Cloud Shell specifically, since component management is disabled there — normal, not an error. Cloud Shell is ephemeral, so this reinstalls every fresh session.


Maintenance

Rotating secrets and checking what's actually live

🔄

Rotating secrets

Fresh token + fresh upstream API key, redeployed together, connector URL updated in the same sitting — no window for drift.

🔍

Checking live config

gcloud run services describe ... --format=json, parsed for the env block — not memory.

💰

Cost

Stays within Cloud Run's Always Free allowance at personal-scale usage. The budget alert is the early-warning system.


Design notes

Judgment calls worth knowing if you extend this

  • A single clean curl test doesn't prove a server survives real usage. Load-test with several calls close together before calling any deployment done.
  • Chat-side error summaries paraphrase, not quote. Go to the actual Cloud Run log for anything beyond a first-pass symptom.
  • Official reference MCP servers aren't necessarily built for concurrent HTTP use. Many are written and tested primarily for local, single-client, stdio use.
  • claude.ai's connector auth story is currently OAuth-first, even for connectors that don't use OAuth at all.
  • Delete-and-recreate is the correct fix for a stuck custom connector, not a fallback. There's no edit path today.

Appendix A

Perplexity MCP — the deployment this guide came from

The concrete build every general-purpose step in this guide traces back to.

Deployment record
Deployment Record
File: 20260711-perplexityMcpDeployment-v1.0

Server: perplexityai/modelcontextprotocol (official), HTTP mode via included Dockerfile
Region: europe-west2 (London)
Env vars: PERPLEXITY_API_KEY, BIND_ADDRESS=0.0.0.0, ALLOWED_HOSTS, MCP_AUTH_TOKEN
Dockerfile fix: Yes — one --mount=type=cache instance on the npm install line, stripped
Concurrency bug: Yes — "Already connected to a transport", resolved with --concurrency=1
Root cause: Single shared Server instance in src/http.ts, reused across requests
Auth patch: Node/Express, inserted between Host-header check and CORS configuration
Validated with: 30+ real tool calls across three chat sessions spanning several hours,
  multiple Cloud Run instances spinning up and down correctly under concurrency=1,
  zero recurrence of the transport error after the fix

One data point, not a guarantee every server behaves identically — but every general-purpose step in the Deploy and Secure & Connect tabs traces back to something encountered building this one.