@@ -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 ("\n Done. 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
35363703def 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