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.
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.
console.cloud.google.com, signed in with the account you want this tied to.
Project dropdown, top left → select an existing project, or New Project.
Billing in the left nav → link a billing account, adding a card if none is on file yet.
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-unauthenticatedis 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:
Reads a secret token from an environment variable (e.g. MCP_AUTH_TOKEN).
Checks incoming requests for that token in two places: an Authorization: Bearer <token> header, and a ?token= query parameter.
Rejects with 401 and a JSON-RPC-shaped error body if neither matches.
Otherwise passes the request through unchanged.
Worked examples, side by side
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();
});
}
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.pyRedeploy 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
Settings → Connectors → Add → Custom connector.
Name it, URL: https://<hostname>/mcp?token=<token>.
Leave the OAuth Client ID/Secret fields blank — different auth model, see below.
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.
Troubleshooting reference
Every error hit, and what it actually means
| Symptom | Cause | Fix |
|---|---|---|
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
curltest 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 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.