Skip to content

Commit e683a4e

Browse files
NagyViktclaude
andcommitted
PR 19: gads mcp serve — Model Context Protocol bridge over stdio
Lets Claude Code, Cursor, Codex, and any other MCP-speaking agent drive gads via the standard wire format instead of shelling out to bash. gads mcp serve # read-only (default — 14 tools) gads mcp serve --allow-mutations # also 4 mutate tools Implements the minimum surface: initialize, tools/list, tools/call. Each tool maps to a gads CLI invocation; output is captured as JSON and returned in MCP `content` blocks. Read-only tools exposed: list_campaigns, list_conversions, list_account_assets, list_adgroups, list_keywords, list_negative_keywords, list_search_terms, stats, stats_vs_prev, health, suggest, audit_log, snapshot_list, asset_performance. Mutate tools (behind --allow-mutations): set_status, set_budget, add_negative_keyword, snapshot_save. Each auto-snapshots + audit-logs via the existing CLI hooks; the MCP wrapper doesn't bypass any safety. Subprocess invokes the script by absolute path so MCP works whether or not `gads` is on PATH globally. Smoke-tested live: tools/call list_campaigns returned 2-item array; stats returned object with period/campaigns/totals keys. All 35 pytest still pass. Wire-up for Claude Code config: { "mcpServers": { "gads": { "command": "/usr/local/bin/gads", "args": ["mcp", "serve"] } } } Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a34719f commit e683a4e

1 file changed

Lines changed: 176 additions & 1 deletion

File tree

gads

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ _COMMANDS_MANIFEST = [
295295
("commands", "meta", False, "Self-describing command manifest", "gads commands"),
296296
("init", "meta", False, "Interactive wizard to create your first profile", "gads init"),
297297
("auth-check", "meta", False, "Preflight: dev token + ADC + customer reach","gads auth-check"),
298+
("mcp serve", "meta", False, "MCP server bridge (read-only by default)", "gads mcp serve"),
298299

299300
("add-sitelink", "mutate", True, "Create single account-level sitelink", "gads add-sitelink \"My Link\" https://example.com/page --apply"),
300301
("add-callout", "mutate", True, "Create single account-level callout", "gads add-callout \"Free shipping\" --apply"),
@@ -3531,6 +3532,172 @@ def cmd_cleanup_orphans(args):
35313532
print("\nDone. Re-run `gads cleanup-orphans` to verify.")
35323533

35333534

3535+
# ─── MCP server bridge ────────────────────────────────────────────────────
3536+
#
3537+
# `gads mcp serve` runs a minimal Model Context Protocol server over
3538+
# stdio. Lets Claude Code, Cursor, Codex etc. drive gads via the
3539+
# standard MCP wire format instead of shelling out.
3540+
#
3541+
# We expose the safe read-only commands as MCP tools by default; mutate
3542+
# commands are gated behind `--allow-mutations` so an agent can't pause
3543+
# a campaign without the human explicitly opting in.
3544+
3545+
# Each tool spec:
3546+
# subcmd: list of args BEFORE any flag args (subcommand name + positionals)
3547+
# args: ordered list of (positional_or_flag_name, kind="positional"|"flag")
3548+
# json: whether to add `--format json` as a top-level flag
3549+
# apply: whether to add `--apply` (mutations only)
3550+
_MCP_READ_TOOLS = {
3551+
"list_campaigns": {"subcmd": ["list-campaigns"], "args": [], "json": True},
3552+
"list_conversions": {"subcmd": ["list-conversions"], "args": [], "json": False},
3553+
"list_account_assets": {"subcmd": ["list-account-assets"], "args": [], "json": True},
3554+
"list_adgroups": {"subcmd": ["list-adgroups"], "args": [("campaign","flag"), ("status","flag")], "json": True},
3555+
"list_keywords": {"subcmd": ["list-keywords"], "args": [("campaign","flag"), ("ad_group","flag"), ("status","flag")], "json": True},
3556+
"list_negative_keywords": {"subcmd": ["list-negative-keywords"], "args": [("campaign","flag")], "json": True},
3557+
"list_search_terms": {"subcmd": ["list-search-terms"], "args": [("campaign","flag"), ("days","flag"), ("limit","flag")], "json": True},
3558+
"stats": {"subcmd": ["stats"], "args": [("days","flag"), ("start","flag"), ("end","flag"), ("campaign","flag")], "json": True},
3559+
"stats_vs_prev": {"subcmd": ["stats-vs-prev"], "args": [("days","flag"), ("campaign","flag")], "json": True},
3560+
"health": {"subcmd": ["health"], "args": [], "json": True},
3561+
"suggest": {"subcmd": ["suggest"], "args": [], "json": True},
3562+
"audit_log": {"subcmd": ["audit-log"], "args": [("tail","flag")], "json": True},
3563+
"snapshot_list": {"subcmd": ["snapshot", "list"], "args": [], "json": True},
3564+
"asset_performance": {"subcmd": ["asset-performance"], "args": [("asset_group","flag")], "json": True},
3565+
}
3566+
3567+
_MCP_MUTATE_TOOLS = {
3568+
"set_status": {"subcmd": ["set-status"], "args": [("resource_type","positional"), ("resource_id","positional"), ("status","positional")], "apply": True, "json": False},
3569+
"set_budget": {"subcmd": ["set-budget"], "args": [("campaign_id","positional"), ("daily","flag")], "apply": True, "json": False},
3570+
"add_negative_keyword": {"subcmd": ["add-negative-keyword"], "args": [("campaign","positional"), ("text","positional"), ("match_type","flag")], "apply": True, "json": False},
3571+
"snapshot_save": {"subcmd": ["snapshot", "save"], "args": [("label","positional")], "apply": False, "json": False},
3572+
}
3573+
3574+
3575+
def _mcp_run(tool_name, params, allow_mutations):
3576+
"""Invoke gads as a subprocess and capture JSON output."""
3577+
spec = _MCP_READ_TOOLS.get(tool_name) or (
3578+
_MCP_MUTATE_TOOLS.get(tool_name) if allow_mutations else None
3579+
)
3580+
if not spec:
3581+
return {"error": f"unknown tool '{tool_name}'"}
3582+
script_path = os.path.abspath(sys.argv[0])
3583+
3584+
# Argument order matters: top-level flags BEFORE subcommand.
3585+
argv = [script_path]
3586+
if spec.get("json"):
3587+
argv.extend(["--format", "json"])
3588+
argv.extend(spec["subcmd"])
3589+
3590+
# Positionals first (in declared order), then flags
3591+
for argname, kind in spec.get("args", []):
3592+
v = params.get(argname)
3593+
if v is None or v == "":
3594+
continue
3595+
if kind == "positional":
3596+
argv.append(str(v))
3597+
else:
3598+
argv.extend([f"--{argname.replace('_', '-')}", str(v)])
3599+
if spec.get("apply"):
3600+
argv.append("--apply")
3601+
3602+
try:
3603+
out = subprocess.run(argv, capture_output=True, text=True, check=False)
3604+
return {
3605+
"stdout": out.stdout,
3606+
"stderr": out.stderr,
3607+
"exit_code": out.returncode,
3608+
}
3609+
except Exception as e:
3610+
return {"error": str(e)}
3611+
3612+
3613+
def _mcp_tool_definitions(allow_mutations):
3614+
"""Build the JSON list returned by MCP tools/list."""
3615+
def _schema(spec):
3616+
return {
3617+
"type": "object",
3618+
"properties": {
3619+
argname: {"type": "string"}
3620+
for (argname, _kind) in spec.get("args", [])
3621+
},
3622+
}
3623+
3624+
tools = []
3625+
for name, spec in _MCP_READ_TOOLS.items():
3626+
tools.append({
3627+
"name": name,
3628+
"description": f"Read-only: gads {' '.join(spec['subcmd'])}",
3629+
"inputSchema": _schema(spec),
3630+
})
3631+
if allow_mutations:
3632+
for name, spec in _MCP_MUTATE_TOOLS.items():
3633+
tools.append({
3634+
"name": name,
3635+
"description": f"Mutation (auto-snapshotted): gads {' '.join(spec['subcmd'])}",
3636+
"inputSchema": _schema(spec),
3637+
})
3638+
return tools
3639+
3640+
3641+
def cmd_mcp(args):
3642+
"""Speak Model Context Protocol over stdio. One JSON-RPC message per
3643+
line on stdin; one response per line on stdout. Implements the
3644+
minimum surface: initialize, tools/list, tools/call."""
3645+
allow_mut = args.allow_mutations
3646+
sys.stderr.write(
3647+
f"gads-mcp ready · "
3648+
f"{len(_MCP_READ_TOOLS)} read + "
3649+
f"{len(_MCP_MUTATE_TOOLS) if allow_mut else 0} write tool(s) exposed\n"
3650+
)
3651+
sys.stderr.flush()
3652+
3653+
for raw in sys.stdin:
3654+
raw = raw.strip()
3655+
if not raw:
3656+
continue
3657+
try:
3658+
msg = json.loads(raw)
3659+
except Exception as e:
3660+
sys.stdout.write(json.dumps({
3661+
"jsonrpc": "2.0", "id": None,
3662+
"error": {"code": -32700, "message": f"parse error: {e}"}}) + "\n")
3663+
sys.stdout.flush()
3664+
continue
3665+
3666+
msg_id = msg.get("id")
3667+
method = msg.get("method")
3668+
params = msg.get("params") or {}
3669+
3670+
if method == "initialize":
3671+
resp = {
3672+
"protocolVersion": "2024-11-05",
3673+
"serverInfo": {"name": "gads-mcp", "version": "0.1.0"},
3674+
"capabilities": {"tools": {}},
3675+
}
3676+
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": resp}) + "\n")
3677+
elif method == "tools/list":
3678+
sys.stdout.write(json.dumps({
3679+
"jsonrpc": "2.0", "id": msg_id,
3680+
"result": {"tools": _mcp_tool_definitions(allow_mut)},
3681+
}) + "\n")
3682+
elif method == "tools/call":
3683+
tool_name = params.get("name")
3684+
tool_args = params.get("arguments") or {}
3685+
result = _mcp_run(tool_name, tool_args, allow_mut)
3686+
sys.stdout.write(json.dumps({
3687+
"jsonrpc": "2.0", "id": msg_id,
3688+
"result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]},
3689+
}) + "\n")
3690+
elif method == "notifications/initialized":
3691+
# No-op notification; no response expected.
3692+
pass
3693+
else:
3694+
sys.stdout.write(json.dumps({
3695+
"jsonrpc": "2.0", "id": msg_id,
3696+
"error": {"code": -32601, "message": f"method not found: {method}"},
3697+
}) + "\n")
3698+
sys.stdout.flush()
3699+
3700+
35343701
# ─── Entry point ──────────────────────────────────────────────────────────
35353702

35363703
def main():
@@ -3557,6 +3724,14 @@ def main():
35573724
p.add_argument("--skip-customer", action="store_true",
35583725
help="Don't try to reach the API; just check env + ADC")
35593726
p.set_defaults(func=cmd_auth_check)
3727+
3728+
mcp = sub.add_parser("mcp", help="Model Context Protocol server bridge")
3729+
mcp_sub = mcp.add_subparsers(dest="mcp_action", required=True)
3730+
m_serve = mcp_sub.add_parser("serve",
3731+
help="Speak MCP over stdio (read-only by default)")
3732+
m_serve.add_argument("--allow-mutations", action="store_true",
3733+
help="Also expose mutating tools (set-status, set-budget, etc.)")
3734+
m_serve.set_defaults(func=cmd_mcp)
35603735
sub.add_parser("list-campaigns").set_defaults(func=cmd_list_campaigns)
35613736
sub.add_parser("list-conversions").set_defaults(func=cmd_list_conversions)
35623737
sub.add_parser("list-recommendations").set_defaults(func=cmd_list_recommendations)
@@ -3841,7 +4016,7 @@ def main():
38414016

38424017
args = parser.parse_args()
38434018
# These commands don't need a customer; everything else does.
3844-
_no_customer = ("list-profiles", "commands", "init", "auth-check")
4019+
_no_customer = ("list-profiles", "commands", "init", "auth-check", "mcp")
38454020
if args.cmd not in _no_customer:
38464021
args.customer = _resolve_customer(args)
38474022

0 commit comments

Comments
 (0)