Skip to content

Instantly share code, notes, and snippets.

@serhato
Last active June 2, 2026 08:33
Show Gist options
  • Select an option

  • Save serhato/37419147b55594f5348c4aef9f3e0790 to your computer and use it in GitHub Desktop.

Select an option

Save serhato/37419147b55594f5348c4aef9f3e0790 to your computer and use it in GitHub Desktop.
A simple solution for Google Ads MCP to work properly with Claude Code

Getting the Google Ads connector working in Claude Cowork

The official Google Ads MCP connector works fine in Claude Code, but in Claude Cowork filtered queries fail. This gist explains the issue and a small wrapper script that fixes it, without modifying the connector itself.

The symptom

The connector loads and authenticates. Simple reads work. But the moment you ask for anything filtered or sorted, for example "spend by campaign last month," it fails with:

Input should be a valid list [type=list_type, input_value='[...]', input_type=str]

Meanwhile the exact same connector, same credentials, works perfectly in Claude Code.

Why it happens

An MCP client decides how to send each argument based on the tool's schema. The Google Ads search tool declares its filter and sort parameters (conditions and orderings) as arrays without a typed items entry. Faced with an untyped array, Cowork serializes the value into a string instead of a real list. The connector then rejects the string.

You can see the split clearly:

Parameter Schema What Cowork sends Result
fields array with typed items a real list works
conditions array, untyped a JSON string rejected
orderings array, untyped a JSON string rejected

The solution: wrap the connector

Instead of changing the connector, put a tiny script in front of it. The script reads each request, turns those stringified arrays back into real lists, and passes everything else through untouched. Then you point Cowork at the script instead of the connector.

Cowork  ->  ga_mcp_proxy.py  ->  the real Google Ads connector
            (repairs the request
             on the way through)

It is non invasive and reversible. Point the config back at the connector to undo it.

Files in this gist

  • ga_mcp_proxy.py : the wrapper script (no secrets, safe to share).

Setup

1. Save the script somewhere on your machine, for example:

C:\Users\YOURNAME\ga_mcp_proxy.py

Open it and set REAL_EXE to the path of your real connector (or set a GA_MCP_REAL_EXE environment variable instead).

2. Point Cowork at the script. In Claude Desktop go to Settings, Developer, Edit Config, and change the connector entry.

Before:

"google-ads": {
  "command": "C:\\Users\\YOURNAME\\.local\\bin\\google-ads-mcp.exe",
  "args": [],
  "env": { "...your credentials..." }
}

After:

"google-ads": {
  "command": "python",
  "args": ["C:\\Users\\YOURNAME\\ga_mcp_proxy.py"],
  "env": { "...your credentials, unchanged..." }
}

3. Restart. Fully quit Claude Desktop from the system tray, reopen, and start a new Cowork session. Filtered queries now work.

Bonus gotcha: the official env is incomplete

Google's setup guide shows an env block with only two keys:

"env": {
  "GOOGLE_PROJECT_ID": "YOUR_PROJECT_ID",
  "GOOGLE_ADS_DEVELOPER_TOKEN": "YOUR_DEVELOPER_TOKEN"
}

That was not enough for me. This is the env that actually worked:

"env": {
  "GOOGLE_APPLICATION_CREDENTIALS": "C:\\path\\to\\application_default_credentials.json",
  "GOOGLE_PROJECT_ID": "YOUR_PROJECT_ID",
  "GOOGLE_CLOUD_PROJECT": "YOUR_PROJECT_ID",
  "GOOGLE_ADS_DEVELOPER_TOKEN": "YOUR_DEVELOPER_TOKEN",
  "GOOGLE_ADS_LOGIN_CUSTOMER_ID": "YOUR_MANAGER_MCC_ID",
  "FASTMCP_LOG_LEVEL": "CRITICAL",
  "FASTMCP_DISABLE_BANNER": "1"
}

What the extra keys do:

  • GOOGLE_APPLICATION_CREDENTIALS: path to your credentials file. Run gcloud auth application-default login first to create it. The official sample leaves this out.
  • GOOGLE_CLOUD_PROJECT: set it to the same value as GOOGLE_PROJECT_ID. The Google auth library reads GOOGLE_CLOUD_PROJECT for the billing and quota project, and GOOGLE_PROJECT_ID alone is effectively ignored by it.
  • GOOGLE_ADS_LOGIN_CUSTOMER_ID: your manager (MCC) account ID, needed when the account you query sits under a manager account.
  • FASTMCP_*: optional. They just keep the connector's startup output quiet.

Safety note

Never publish your config file. It holds your credentials and developer token. The script in this gist contains no secrets and is safe to share. The values above are placeholders, so replace them with your own and keep the filled in version private.

Credit

The wrapper idea came from a second AI assistant after the first kept telling me it could not be done in Cowork. Worth remembering: when one assistant hits a wall, ask another.

#!/usr/bin/env python3
# A small "wrapper" that sits in front of the Google Ads MCP connector and
# repairs the filter/sort values Claude Cowork sends as text instead of as a
# list. Point Cowork at THIS file instead of the connector .exe (see README).
#
# No secrets live in this file, so it is safe to share. The only thing you may
# need to edit is REAL_EXE below (the path to your real connector), or set a
# GA_MCP_REAL_EXE environment variable instead.
import os, sys, json, threading, subprocess
# Path to the real Google Ads connector. Change YOURNAME, or override with the
# GA_MCP_REAL_EXE environment variable.
REAL_EXE = os.environ.get(
"GA_MCP_REAL_EXE",
r"C:\Users\YOURNAME\.local\bin\google-ads-mcp.exe",
)
# The arguments Cowork stringifies and we need to turn back into lists.
ARRAY_ARGS = ("conditions", "orderings")
def repair(line: bytes) -> bytes:
"""Turn any stringified array args back into real lists. Anything that is
not a tool call (or does not need fixing) passes through unchanged."""
try:
msg = json.loads(line)
except Exception:
return line # not JSON, pass it straight through
try:
if msg.get("method") == "tools/call" and isinstance(msg.get("params"), dict):
args = msg["params"].get("arguments")
if isinstance(args, dict):
changed = False
for key in ARRAY_ARGS:
val = args.get(key)
if isinstance(val, str):
try:
parsed = json.loads(val) # text -> real list
except Exception:
continue
if isinstance(parsed, list):
args[key] = parsed
changed = True
if changed:
return (json.dumps(msg) + "\n").encode("utf-8")
except Exception:
pass
return line
def pump_in(src, dst):
try:
for line in src:
dst.write(repair(line)); dst.flush()
finally:
try:
dst.close()
except Exception:
pass
def pump_out(src, dst):
for line in src:
dst.write(line); dst.flush()
def main():
proc = subprocess.Popen([REAL_EXE], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
for target, a in (
(pump_in, (sys.stdin.buffer, proc.stdin)),
(pump_out, (proc.stdout, sys.stdout.buffer)),
(pump_out, (proc.stderr, sys.stderr.buffer)),
):
threading.Thread(target=target, args=a, daemon=True).start()
sys.exit(proc.wait())
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment