The actual problem
I have a landing page (I am helping my wife generate leads for her business). It has a Google Ads campaign driving traffic to it. It also has PostHog tracking what visitors do once they arrive. Two systems, two dashboards, two browser tabs I keep open and alt-tab between when I'm trying to figure out if the campaign is actually working.
The workflow looks like this: check Google Ads to see which keywords are spending money, switch to PostHog to see if those visitors are actually converting, try to hold both datasets in my head long enough to make a decision, fail, open a spreadsheet. It's not great.
What I actually wanted was to sit in Claude Code while working on the landing page and ask things like "which ad groups have the worst bounce rate?" and get an answer that combines Google Ads spend data with PostHog behavior data. I already had PostHog wired up as an MCP server. Google Ads was the missing piece.
And then there's the other side. When I'm not in the code, I want to use Claude Desktop as a marketing copilot. Pull up campaign reports, compare ad copy performance, get suggestions on budget allocation. Same data, different context.
So I needed the Google Ads MCP server running in both Claude Code and Claude Desktop. Sounds simple. It was not simple.
What MCP actually is (30 second version)
MCP (Model Context Protocol) lets you give Claude access to external tools. Instead of copying data from Google Ads and pasting it into a chat, you connect an MCP server that speaks the Google Ads API, and Claude can query it directly. Think of it like giving Claude read access to your ad account.
PostHog, Gmail, Google Calendar, they all have MCP servers now. Google Ads joined the party recently via an open source server from Google's marketing solutions team.
Getting the credentials
Before anything works, you need four things from Google:
- OAuth Client ID from Google Cloud Console (the long
...apps.googleusercontent.comstring) - OAuth Client Secret (the
xxXAas...value) - Refresh Token generated via the OAuth 2.0 Playground
- Developer Token from the Google Ads API Center (under Tools > Setup > API Center in your Google Ads manager account)
The refresh token is the annoying one. You generate it through Google's OAuth 2.0 Playground by authorizing the Google Ads API scope and exchanging the authorization code.
These go into a YAML file at ~/.google-ads.yaml:
client_id: "your-client-id.apps.googleusercontent.com"
client_secret: "GOCSPX--your-secret-here"
refresh_token: "1//your-refresh-token-here"
developer_token: "your-developer-token"
login_customer_id: "1234567890"
use_proto_plus: True
That last line, use_proto_plus: True, is not in any of the setup guides I found. The server crashes without it with a ValueError saying the key is missing, and the error message itself links to Google's protobuf messages guide which explains the option. So the fix was right there in the traceback, just not in any setup instructions
The login_customer_id is your Google Ads manager account ID (no dashes). If you only have a single account, use that account's ID.
Setting up Claude Code (the HTTP path)
Here's where things got interesting. The Google Ads MCP server from google-marketing-solutions/google_ads_mcp is built with FastMCP and the transport is hardcoded to streamable-http. It starts an HTTP server on port 8000, not a stdio process.
This matters because Claude Code and Claude Desktop handle MCP transports differently.
For Claude Code, HTTP works great:
claude mcp add -s user -t http google-ads-mcp "http://127.0.0.1:8000/mcp"
But you need the server actually running first. I installed it with pipx:
pipx install "git+https://github.com/google-marketing-solutions/google_ads_mcp.git"
Then created a launchd service so it starts automatically and survives reboots:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.google-ads-mcp</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/pipx</string>
<string>run</string>
<string>--spec</string>
<string>git+https://github.com/google-marketing-solutions/google_ads_mcp.git</string>
<string>run-mcp-server</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>GOOGLE_ADS_CREDENTIALS</key>
<string>/Users/yourusername/.google-ads.yaml</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/google-ads-mcp.log</string>
<key>StandardErrorPath</key>
<string>/tmp/google-ads-mcp.err</string>
</dict>
</plist>
Save that to ~/Library/LaunchAgents/com.google-ads-mcp.plist and load it:
launchctl load ~/Library/LaunchAgents/com.google-ads-mcp.plist
The server starts on boot, restarts if it crashes, and logs to /tmp/. Verify it's running:
claude mcp list
# google-ads-mcp: http://127.0.0.1:8000/mcp (HTTP) - ✓ Connected
Setting up Claude Desktop (the stdio detour)
Here's the plot twist. Claude Desktop doesn't support streamable-http transport. It only speaks stdio (and sse for some servers). When you try to point it at an HTTP URL, it shows you a polite error dialog and refuses to load the server.
The Google Ads MCP server hardcodes streamable-http in its source:
mcp_server.run(
transport="streamable-http",
show_banner=False,
)
No CLI flag to change it. No environment variable. Just hardcoded.
The fix: a tiny wrapper script that imports the same MCP server object but runs it in stdio mode. Since I already installed the package with pipx, I can use the venv's Python interpreter directly:
#!/Users/yourusername/.local/pipx/venvs/google-ads-mcp/bin/python3
"""Run Google Ads MCP server in stdio mode for Claude Desktop."""
import asyncio
import os
os.environ.setdefault(
"GOOGLE_ADS_CREDENTIALS",
os.path.expanduser("~/.google-ads.yaml")
)
from ads_mcp.coordinator import mcp_server
from ads_mcp.scripts.generate_views import update_views_yaml
from ads_mcp.tools import api
def main():
asyncio.run(update_views_yaml())
api.get_ads_client()
mcp_server.run(
transport="stdio",
show_banner=False,
)
if __name__ == "__main__":
main()
Save it to ~/.local/bin/google-ads-mcp-stdio, make it executable:
chmod +x ~/.local/bin/google-ads-mcp-stdio
Then in Claude Desktop's config (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"google-ads-mcp": {
"command": "/Users/yourusername/.local/bin/google-ads-mcp-stdio",
"args": []
}
}
}
Relaunch Claude Desktop. No error dialog. The MCP tools show up in the chat input.
The final architecture
┌─────────────────────────────────────────────────────┐
│ ~/.google-ads.yaml │
│ (shared credentials file) │
└──────────────────────┬──────────────────────────────┘
│
┌────────────┴────────────┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ HTTP Server │ │ stdio wrapper│
│ port 8000 │ │ script │
│ (launchd) │ │ (on demand) │
└──────┬──────┘ └──────┬──────┘
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Claude Code │ │Claude Desktop│
│ (dev work) │ │ (marketing) │
└─────────────┘ └─────────────┘
Two clients, two transports, same underlying MCP code, same credentials. Claude Code connects over HTTP to a persistent background server. Claude Desktop spawns its own stdio process on launch.
They don't share a server process, but they don't need to. There's no shared state. Both just authenticate against the Google Ads API independently.
Why this setup matters
The point isn't just "I connected a thing to another thing." It's about putting the right tools in the right context.
When I'm in Claude Code working on the landing page, I can ask "what search terms triggered clicks this week?" and cross-reference that with PostHog funnel data without leaving my terminal. The code changes I make are informed by real campaign data, not a tab I glanced at an hour ago.
When I'm in Claude Desktop doing marketing work, I can pull campaign reports, compare ad group performance, and brainstorm copy changes in a conversational flow. No spreadsheet intermediary.
Same data source. Different workflows. That's the whole idea behind MCP: you connect your tools once and use them wherever the context calls for it.
The gotchas
A few things that will save you time:
-
use_proto_plus: Trueis required in yourgoogle-ads.yaml. The server crashes with a confusingValueErrorwithout it. None of the setup guides mention this. -
The transport is hardcoded. The Google Ads MCP server only runs in
streamable-httpmode. Claude Desktop only supportsstdio. The wrapper script bridges this gap. -
Developer token access levels matter. A test account token can only query test accounts. If you need real campaign data, you'll need to apply for Basic Access through Google's API Center. The approval can take a few days.
-
launchd vs. manual process. Don't just background the server with
&. It won't survive a reboot, and if it crashes at 2 AM, it stays crashed. The launchd plist withKeepAlive: truehandles both. -
pipx caching. If you use
pipx runinstead ofpipx install, it creates cached venvs that can disappear. For a persistent service, install it properly.
What's next
For now, the plumbing is done. Both clients are connected, the server starts on boot, and I can ask Claude about my Google Ads account from anywhere. The hard part, as always, was getting the credentials right and figuring out that two different Claude clients need two different transport protocols for the same MCP server. Nobody warns you about that one.
I used this prompt to generate featured image. A split-screen digital illustration showing two computer interfaces connected to a central glowing node. On the left, a dark terminal/CLI environment (Claude Code) with green text. On the right, a clean chat interface (Claude Desktop) with a modern UI. Both connect via glowing data streams to a central hub labeled with the Google Ads logo. The aesthetic is technical but approachable, with a warm color palette of blues, greens, and Google's signature colors. Isometric perspective, clean lines, subtle circuit board patterns in the background. Homelab/developer workspace vibe.