I wanted ChatGPT to be able to query the Google Ads accounts I already have access to without copying reports into a chat or putting one shared Google user behind the integration.

Google publishes an official Google Ads MCP server and an integration guide. The project already gives an MCP client read-only access to Google Ads data, and it supports remote HTTP operation through FastMCP’s Google OAuth proxy.

What I wanted was a deployment I could leave running: hosted on Google Cloud Run, with OAuth state surviving restarts, secrets outside the container, and each person authenticating with their own Google account.

This article documents the deployment I actually made in September 2026. I kept the exact source revision and the relevant shell commands because I mainly want this to remain useful six months from now, when I have inevitably forgotten why a particular IAM role or environment variable exists.

What we are building

The final request path looks roughly like this:

MCP client
   |
   | OAuth / Streamable HTTP
   v
Google Cloud Run
   |
   +-- Google Ads MCP
   |      |
   |      +-- FastMCP OAuth proxy
   |      +-- Google OAuth
   |
   +-- Firestore
   |      |
   |      +-- persistent OAuth state
   |
   v
Google Ads API

The important architectural decision is that Google authentication happens per user. The Cloud Run service is shared, but when somebody connects the MCP server from a compatible client, that person goes through Google OAuth with their own Google identity.

Google Ads permissions therefore continue to follow the permissions already granted to that Google user.

Why I did not just run it locally

The official project supports a much simpler local setup. For a single developer, that may be all you need: run the MCP server locally, configure credentials and point an MCP client at the process.

I wanted a different shape:

  • one remote MCP URL instead of one local process per machine;
  • OAuth authentication per person;
  • a deployment usable from ChatGPT, which connects to remote MCP servers rather than directly to a local stdio process;
  • OAuth state that survives Cloud Run revisions and multiple instances;
  • secrets managed outside the Docker image;
  • a pinned server revision so I can reproduce the deployment later.

The trade-off is obvious: this is more infrastructure.

Local, minimal Cloud Run, or persistent Cloud Run

Google’s current documentation includes both local approaches and a Cloud Run deployment using the OAuth proxy. My setup is therefore not a replacement for Google’s guide; it is a more explicit, persistent version of the remote deployment.

Local / stdio Minimal Cloud Run This deployment
Shared remote endpoint No Yes Yes
Per-user Google OAuth Not as a shared service Yes Yes
Firestore OAuth persistence No Optional Yes
Persistent JWT signing key No Recommended Yes
Token encryption key No Optional Yes
Secret Manager No Not required by the minimal example Yes
Dedicated runtime service account No Optional Yes
Explicit IAM Minimal Depends on setup Yes
Source revision pinned Depends Often latest in examples Yes
Protocol verification Manual Manual Included below
Operational complexity Low Medium Higher

ChatGPT requirements

At the time of writing, ChatGPT Free does not support this custom-MCP developer-mode workflow.

OpenAI’s current documentation says Pro users can connect custom MCP servers with read/fetch permissions in developer mode, while full MCP support, including write/modify actions, is available in beta to Business, Enterprise and Edu. The official Google Ads MCP server is read-only today, so the read/fetch capability is the relevant part for this tutorial.

The exact ChatGPT UI can also differ by plan and by whether you are working inside a managed workspace. Business and Enterprise/Edu may introduce admin, publishing and access-control steps that a Pro user will not see.

OpenAI changes these capabilities fairly quickly, so check the current MCP and developer-mode documentation before starting.

Prerequisites

For this deployment I used:

  • a Google Cloud project with billing enabled;
  • Google Ads API access;
  • a Google OAuth 2.0 Web client;
  • a Google Ads manager account because my access spans multiple customer accounts;
  • Google Cloud Shell;
  • a ChatGPT plan/workspace that can connect the required custom MCP, or another compatible MCP client;
  • the official googleads/google-ads-mcp repository.

My Cloud Run and Firestore region is:

europe-southwest1

There is no requirement to use Madrid. I chose it because it is the region I wanted for this project.

1. Set the Google Cloud project and region

I did the deployment from Cloud Shell.

First, capture the active project and define the region:

export PROJECT_ID="$(gcloud config get-value project)"
export REGION="europe-southwest1"

gcloud config set run/region "$REGION"

A couple of useful sanity checks:

gcloud auth list
gcloud billing projects describe "$PROJECT_ID"

The billing command should confirm that billing is enabled for the project.

2. Enable the Google Cloud APIs

I enabled the services used by this deployment in one command:

gcloud services enable \
  googleads.googleapis.com \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  artifactregistry.googleapis.com \
  secretmanager.googleapis.com \
  firestore.googleapis.com

The Google Ads MCP itself needs the Google Ads API. The remaining services are for building the image, running it, storing secrets and persisting OAuth state.

3. Create Firestore for OAuth state

FastMCP can use different storage backends for OAuth state. Memory is convenient for local experiments but is not a good fit for a Cloud Run service that may restart or scale to more than one instance.

I created the default Firestore database in Native mode, Standard edition, in the same region:

gcloud firestore databases create \
  --database="(default)" \
  --location="$REGION" \
  --edition=standard \
  --type=firestore-native

Then verified it:

gcloud firestore databases list

4. Create the Google OAuth Web client

The remote server needs a Google OAuth Web application client.

In Google Auth Platform I created a Web client for the MCP service and copied its Client ID.

At this stage the final callback URL does not exist yet because Cloud Run has not assigned the service URL. We will return to the OAuth client after the first deployment and add:

https://YOUR_CLOUD_RUN_HOST/auth/callback

FastMCP uses this fixed callback to complete the upstream Google OAuth flow. The MCP client’s own callback is a separate part of the OAuth proxy flow and is handled through dynamic client registration.

5. Define the deployment variables

Back in Cloud Shell:

export SERVICE_NAME="google-ads-mcp"
export OAUTH_CLIENT_ID="YOUR_GOOGLE_OAUTH_CLIENT_ID"

If you access advertiser accounts through a Google Ads manager account, keep the manager customer ID nearby. I configure it later, after the core service is healthy.

For the first Cloud Run revision I deliberately do not invent a service hostname. Cloud Run will give us the canonical URL after the service is created, and then we will set GOOGLE_ADS_MCP_BASE_URL to that exact value.

6. Store the runtime secrets in Secret Manager

I did not want OAuth or signing secrets in the Docker image or written directly into the deploy command.

Create the three secrets used by the final deployment:

gcloud secrets create google-ads-oauth-client-secret \
  --replication-policy="automatic"

gcloud secrets create google-ads-mcp-jwt-signing-key \
  --replication-policy="automatic"

gcloud secrets create google-ads-mcp-storage-encryption-key \
  --replication-policy="automatic"

Add the Google OAuth client secret without echoing it back to the terminal:

read -s -p "OAuth Client Secret: " OAUTH_SECRET

printf '%s' "$OAUTH_SECRET" | \
  gcloud secrets versions add google-ads-oauth-client-secret --data-file=-

unset OAUTH_SECRET

Generate a persistent FastMCP JWT signing key and a key for encrypted OAuth storage:

openssl rand -base64 48 | \
  gcloud secrets versions add google-ads-mcp-jwt-signing-key --data-file=-

openssl rand -base64 32 | \
  gcloud secrets versions add google-ads-mcp-storage-encryption-key --data-file=-

A 2026 change: developer tokens are no longer part of a new setup

Google sunset Google Ads developer tokens on September 9, 2026. Existing clients can still send the old header for compatibility, but Google says it is optional and ignored by the API servers.

API access levels are now associated with the Google Cloud project that owns the OAuth credentials.

The pinned MCP revision in this article still contains compatibility references to GOOGLE_ADS_DEVELOPER_TOKEN. For a new deployment today, I would not create or publish a developer token.

See Google’s developer token sunset documentation for the current model.

7. Create a dedicated Cloud Run service account

Rather than run the container as a broad default identity, I created a dedicated runtime account:

gcloud iam service-accounts create google-ads-mcp-runner \
  --display-name="Google Ads MCP Cloud Run"

export RUN_SA="google-ads-mcp-runner@${PROJECT_ID}.iam.gserviceaccount.com"

The service needs to read and write the Firestore-backed OAuth state:

gcloud projects add-iam-policy-binding "$PROJECT_ID" \
  --member="serviceAccount:${RUN_SA}" \
  --role="roles/datastore.user"

8. Grant the runtime service account access to every secret

This step needs to happen before the first Cloud Run deployment.

gcloud secrets add-iam-policy-binding google-ads-oauth-client-secret \
  --member="serviceAccount:${RUN_SA}" \
  --role="roles/secretmanager.secretAccessor"

gcloud secrets add-iam-policy-binding google-ads-mcp-jwt-signing-key \
  --member="serviceAccount:${RUN_SA}" \
  --role="roles/secretmanager.secretAccessor"

gcloud secrets add-iam-policy-binding google-ads-mcp-storage-encryption-key \
  --member="serviceAccount:${RUN_SA}" \
  --role="roles/secretmanager.secretAccessor"

The first time I performed the deployment I had omitted this permission for the storage-encryption secret. That was just a missing setup step, so I have placed it correctly here rather than making the failed deploy part of the tutorial.

If you created the optional developer-token secret, grant the same role to that secret as well.

9. Create Artifact Registry

The Docker image needs somewhere to live:

gcloud artifacts repositories create mcp-servers \
  --repository-format=docker \
  --location="$REGION" \
  --description="Docker images for MCP servers"

10. Clone the official server and pin the revision

I deliberately did not build whatever happened to be on main that day.

The revision I deployed was:

7a40eae9655194a84d5291c63af817bb20d02ea8

Clone the repository and check out that exact commit:

cd ~

git clone https://github.com/googleads/google-ads-mcp.git
cd google-ads-mcp

git checkout 7a40eae9655194a84d5291c63af817bb20d02ea8
git rev-parse HEAD

Pinning the revision makes the rest of this article reproducible. The upstream project will keep changing; this article describes the version I actually deployed.

11. Add the Firestore dependency to the Docker image

At this revision, the Dockerfile installed the base package. The Firestore storage backend needs the Firestore extra.

I changed the install line with:

sed -i 's|uv pip install --system \.|uv pip install --system .[firestore]|' Dockerfile

Verify it:

grep -n "uv pip install" Dockerfile

The relevant line should now be:

RUN uv pip install --system .[firestore]

12. Build the image with Cloud Build

I tagged the image with the short source commit instead of latest:

export IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/mcp-servers/google-ads-mcp:7a40eae"

Build and push it:

gcloud builds submit \
  --tag "$IMAGE" \
  .

Then verify that Artifact Registry contains the image:

gcloud artifacts docker images list \
  "${REGION}-docker.pkg.dev/${PROJECT_ID}/mcp-servers"

The build from my session installed google-ads-mcp==0.0.3, FastMCP 4.0.5 and the Firestore dependencies from that pinned source tree.

13. Deploy the first Cloud Run revision

For the first revision I deliberately do not set GOOGLE_ADS_MCP_BASE_URL yet. Cloud Run has not assigned the service URL, so inventing the hostname is unnecessary.

The OAuth metadata will be provisional on this first revision; we fix it immediately after reading Cloud Run’s actual URL.

gcloud run deploy "$SERVICE_NAME" \
  --image="$IMAGE" \
  --region="$REGION" \
  --platform=managed \
  --service-account="$RUN_SA" \
  --allow-unauthenticated \
  --set-env-vars="GOOGLE_PROJECT_ID=${PROJECT_ID},GOOGLE_ADS_MCP_OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID},GOOGLE_ADS_MCP_STORAGE_TYPE=firestore,FASTMCP_HOST=0.0.0.0" \
  --set-secrets="GOOGLE_ADS_MCP_OAUTH_CLIENT_SECRET=google-ads-oauth-client-secret:latest,GOOGLE_ADS_MCP_JWT_SIGNING_KEY=google-ads-mcp-jwt-signing-key:latest,GOOGLE_ADS_MCP_STORAGE_ENCRYPTION_KEY=google-ads-mcp-storage-encryption-key:latest"

Why is Cloud Run public?

--allow-unauthenticated can look wrong at first glance.

Here it is intentional: Cloud Run has to accept the HTTP requests that begin the MCP/OAuth negotiation. Authentication is enforced by FastMCP at the application layer.

We will verify this explicitly below: an anonymous request reaches the service, but /mcp responds with 401 Unauthorized and tells the client where the OAuth metadata lives.

14. Set Cloud Run’s actual URL as the MCP base URL

Once the service exists, ask Cloud Run for its canonical status URL:

export ACTUAL_URL="$(gcloud run services describe "$SERVICE_NAME" \
  --region="$REGION" \
  --format='value(status.url)')"

echo "$ACTUAL_URL"

Update the MCP server so its OAuth metadata and redirects use that exact host:

gcloud run services update "$SERVICE_NAME" \
  --region="$REGION" \
  --update-env-vars="GOOGLE_ADS_MCP_BASE_URL=${ACTUAL_URL}"

If your access to advertiser accounts is through a manager account, add its customer ID as a second update:

gcloud run services update "$SERVICE_NAME" \
  --region="$REGION" \
  --update-env-vars="GOOGLE_ADS_LOGIN_CUSTOMER_ID=YOUR_MANAGER_CUSTOMER_ID"

Use the numeric customer ID without hyphens.

Then check the latest revision:

gcloud run services describe "$SERVICE_NAME" \
  --region="$REGION" \
  --format="table(status.url,status.latestReadyRevisionName,status.conditions[0].status)"

I wanted the status to be True before touching the ChatGPT side.

15. Add the final Google OAuth redirect URI

Now return to the OAuth Web client in Google Auth Platform.

Add the exact Cloud Run URL plus FastMCP’s callback path:

https://YOUR_CLOUD_RUN_HOST/auth/callback

Save the client.

16. Verify that the server started correctly

Cloud Run logs are the first useful check:

gcloud run services logs read "$SERVICE_NAME" \
  --region="$REGION" \
  --limit=50

If you want to tail logs live and your installed gcloud does not accept gcloud run services logs tail, the command I used was:

gcloud beta run services logs tail "$SERVICE_NAME" \
  --region="$REGION"

The deployment I made showed FastMCP starting with the Streamable HTTP transport on:

http://0.0.0.0:8080/mcp

That confirms the container is running, but it does not yet prove the OAuth discovery flow is correct.

17. Check the MCP protected-resource metadata

Query the MCP protected-resource discovery endpoint:

curl -sS \
  "$ACTUAL_URL/.well-known/oauth-protected-resource/mcp" \
  | python3 -m json.tool

The important parts are:

{
  "resource": "https://YOUR_CLOUD_RUN_HOST/mcp",
  "authorization_servers": [
    "https://YOUR_CLOUD_RUN_HOST/"
  ],
  "scopes_supported": [
    "openid",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/userinfo.profile",
    "https://www.googleapis.com/auth/adwords"
  ],
  "bearer_methods_supported": [
    "header"
  ]
}

This was one of the most useful checks during setup: if the advertised resource or authorization server points to the wrong hostname, fix the base URL before debugging the MCP client.

18. Check the OAuth authorization-server metadata

Next:

curl -sS \
  "$ACTUAL_URL/.well-known/oauth-authorization-server" \
  | python3 -m json.tool

My deployment exposed the expected OAuth endpoints:

{
  "issuer": "https://YOUR_CLOUD_RUN_HOST/",
  "authorization_endpoint": "https://YOUR_CLOUD_RUN_HOST/authorize",
  "token_endpoint": "https://YOUR_CLOUD_RUN_HOST/token",
  "registration_endpoint": "https://YOUR_CLOUD_RUN_HOST/register",
  "grant_types_supported": [
    "authorization_code",
    "refresh_token"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ]
}

At this point Cloud Run, FastMCP and the OAuth discovery layer were all behaving as expected.

19. A 401 from /mcp is good news

The next check looks like a failure but is exactly what I wanted:

curl -i "$ACTUAL_URL/mcp"

Expected:

HTTP/2 401
WWW-Authenticate: Bearer ...

The WWW-Authenticate header should point back to:

/.well-known/oauth-protected-resource/mcp

That tells an MCP client: the server exists, this resource is protected, and here is the metadata needed to start authentication.

I also tested an MCP initialize request manually:

curl -i -X POST "$ACTUAL_URL/mcp" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  --data '{
    "jsonrpc":"2.0",
    "id":1,
    "method":"initialize",
    "params":{
      "protocolVersion":"2026-07-28",
      "capabilities":{},
      "clientInfo":{
        "name":"manual-test",
        "version":"1.0"
      }
    }
  }'

Without a bearer token, that correctly returned 401 as well.

20. Connect the MCP server to ChatGPT

The remote MCP URL is simply:

https://YOUR_CLOUD_RUN_HOST/mcp

In ChatGPT, enable developer mode/custom MCP support for the relevant account or workspace and create the custom app using that URL.

The exact UI can change, so I do not want the article to depend too heavily on button labels. The key distinction that cost me time is this:

creating or publishing the app is not the same as connecting your own user to it.

21. The “0 actions enabled” problem

This was the final confusing part of my setup.

ChatGPT showed:

0 actions enabled

and would not let me refresh the app actions because the application had to be connected first.

At that point I had already verified:

  • the Cloud Run revision was healthy;
  • /.well-known/oauth-protected-resource/mcp returned valid metadata;
  • /.well-known/oauth-authorization-server returned valid metadata;
  • /mcp returned the expected OAuth challenge.

So rebuilding the container or changing Cloud Run was the wrong direction.

The fix was to open the app in ChatGPT, choose Connect, and complete the Google OAuth flow for my own user.

Once I did that, the MCP tools became available.

22. Why the OAuth proxy is useful with multiple users

This is the main reason I chose this architecture.

The Cloud Run service has one Google OAuth application configuration, but it does not force everyone through one Google Ads user.

Each user completes Google OAuth individually. The access token used for Google Ads therefore represents that user, and list_accessible_customers returns the customer IDs directly accessible to the user authenticating the call.

That gives a much cleaner model for a team:

Manuel -> Google OAuth -> Manuel's Google Ads access
User B -> Google OAuth -> User B's Google Ads access
User C -> Google OAuth -> User C's Google Ads access

There is one remote MCP service to operate, but Google remains the source of truth for account access.

23. Verify end-to-end Google Ads access

The final test is not simply “does ChatGPT show the app?”

It is whether a request can make the full round trip:

ChatGPT
-> remote MCP endpoint
-> OAuth proxy
-> user's Google authorization
-> Google Ads MCP
-> Google Ads API
-> accessible customer accounts

A natural first query is to ask which Google Ads customers are available to the authenticated user.

That exercises list_accessible_customers and confirms that the user-specific Google authorization is actually being used.

From there, the MCP server can execute read-only Google Ads queries for campaign and performance analysis.

24. Using it from clients other than ChatGPT

Nothing in the Cloud Run deployment is intrinsically ChatGPT-specific.

The official Google Ads MCP project documents configurations for other MCP clients, and the remote server exposes standard MCP/OAuth discovery endpoints.

The practical requirement is that the client supports the transport and OAuth flow used by the remote server. Client implementations can differ, so I would test each one instead of assuming identical behavior from a common mcpServers configuration shape.

Troubleshooting

Cloud Run says it cannot access a secret

Check the runtime service account, not your own Cloud Shell user:

gcloud secrets get-iam-policy YOUR_SECRET_NAME

Every secret referenced by the service must be readable by the Cloud Run service account.

Firestore imports fail during the image build

Confirm the Dockerfile installs:

.[firestore]

rather than only the base package.

OAuth metadata advertises the wrong hostname

Read the actual Cloud Run URL:

gcloud run services describe "$SERVICE_NAME" \
  --region="$REGION" \
  --format='value(status.url)'

and update GOOGLE_ADS_MCP_BASE_URL.

/mcp returns 401

Before authentication, that is expected. Inspect the WWW-Authenticate header and the protected-resource metadata rather than treating the status code alone as a failure.

ChatGPT shows zero actions

Verify that the app is connected for the current ChatGPT user and that the Google OAuth flow has been completed.

Security and maintenance notes

A few things I would keep exactly the same if I rebuilt this:

  • use a dedicated Cloud Run service account;
  • keep secrets in Secret Manager;
  • never bake OAuth secrets into the image;
  • use a persistent signing key across Cloud Run revisions;
  • encrypt persisted OAuth state;
  • pin the Google Ads MCP source version used for the image;
  • keep the raw terminal history private;
  • redact client IDs, customer IDs and credentials from screenshots when they do not add instructional value.

One thing I would add for a long-lived deployment is a periodic cleanup strategy for expired Firestore OAuth-state records.

Final result

The finished service gives me one remote Google Ads MCP endpoint that I can connect to ChatGPT while preserving per-user Google permissions.

The deployment is more involved than running the MCP server locally, but each extra component has a reason:

  • Cloud Run gives me the remote endpoint;
  • FastMCP OAuth proxy bridges MCP clients to Google OAuth;
  • Google OAuth authenticates every user separately;
  • Firestore keeps OAuth state durable;
  • Secret Manager keeps credentials out of the image and repository;
  • a dedicated service account and IAM limit what the runtime can access;
  • a pinned source revision makes the deployment reproducible.

Most importantly, I now have the commands and the reasoning documented in one place instead of relying on my Cloud Shell history next time I need to rebuild it.