From 80fe415e36871874807721b6d2e4a7806198a87d Mon Sep 17 00:00:00 2001 From: auroracapital Date: Fri, 22 May 2026 20:20:46 +0200 Subject: [PATCH 1/5] feat(mcp): add gog mcp subcommand with gog_exec tool Runs an MCP server over stdio exposing a single `gog_exec` tool that delegates to the same gog binary as a subprocess, giving AI agents full access to all gog subcommands. 60s timeout, 100KB stdout/stderr cap. Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 11 ++++- go.sum | 10 +++++ internal/cmd/mcp.go | 100 +++++++++++++++++++++++++++++++++++++++++++ internal/cmd/root.go | 1 + 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/mcp.go diff --git a/go.mod b/go.mod index 91565171..56592a8e 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,21 @@ module github.com/steipete/gogcli -go 1.26.2 +go 1.25.9 + +toolchain go1.26.2 require ( filippo.io/age v1.3.1 github.com/99designs/keyring v1.2.2 github.com/alecthomas/kong v1.15.0 + github.com/mark3labs/mcp-go v0.54.0 github.com/muesli/termenv v0.16.0 github.com/stretchr/testify v1.11.1 github.com/yosuke-furukawa/json5 v0.1.1 github.com/yuin/goldmark v1.8.2 golang.org/x/net v0.53.0 golang.org/x/oauth2 v0.36.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 google.golang.org/api v0.277.0 @@ -33,6 +37,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect @@ -43,7 +48,10 @@ require ( github.com/mtibben/percent v0.2.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect @@ -51,7 +59,6 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.50.0 // indirect - golang.org/x/sys v0.43.0 // indirect google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect diff --git a/go.sum b/go.sum index 623e64f0..76468844 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -63,6 +65,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA= +github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= @@ -76,10 +80,16 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosuke-furukawa/json5 v0.1.1 h1:0F9mNwTvOuDNH243hoPqvf+dxa5QsKnZzU20uNsh3ZI= github.com/yosuke-furukawa/json5 v0.1.1/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go new file mode 100644 index 00000000..fd39c10e --- /dev/null +++ b/internal/cmd/mcp.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// McpCmd runs an MCP server over stdio, exposing gog functionality to AI agents. +type McpCmd struct{} + +const ( + mcpMaxOutputBytes = 100 * 1024 // 100 KB + mcpExecTimeout = 60 * time.Second +) + +func (c *McpCmd) Run(_ context.Context) error { + self, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve executable: %w", err) + } + + s := server.NewMCPServer( + "gog", + VersionString(), + server.WithToolCapabilities(false), + ) + + gogExec := mcp.NewTool("gog_exec", + mcp.WithDescription("Execute any gog subcommand and return its output. "+ + "Pass the same arguments you would type after 'gog' on the command line. "+ + "Example: {\"args\": [\"gmail\", \"list\"]} or {\"args\": [\"calendar\", \"events\", \"--today\"]}."), + mcp.WithArray("args", + mcp.Required(), + mcp.WithStringItems(), + mcp.Description("Subcommand and flags, e.g. [\"gmail\", \"list\", \"--limit\", \"10\"]"), + ), + ) + + s.AddTool(gogExec, func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args, err := req.RequireStringSlice("args") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid args: %v", err)), nil + } + + ctx, cancel := context.WithTimeout(context.Background(), mcpExecTimeout) + defer cancel() + + //nolint:gosec + cmd := exec.CommandContext(ctx, self, args...) + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + runErr := cmd.Run() + + exitCode := 0 + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = 1 + } + } + + result := map[string]any{ + "stdout": mcpTruncate(stdoutBuf.String(), mcpMaxOutputBytes), + "stderr": mcpTruncate(stderrBuf.String(), mcpMaxOutputBytes), + "exit_code": exitCode, + } + b, err := json.Marshal(result) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("marshal result: %v", err)), nil + } + return mcp.NewToolResultText(string(b)), nil + }) + + srv := server.NewStdioServer(s) + return srv.Listen(context.Background(), os.Stdin, os.Stdout) +} + +// mcpTruncate caps s to maxBytes, appending a marker if trimmed. +func mcpTruncate(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + b := []byte(s)[:maxBytes] + // Walk back to a valid UTF-8 boundary. + for len(b) > 0 && b[len(b)-1]&0xC0 == 0x80 { + b = b[:len(b)-1] + } + return string(b) + "\n... [output truncated at 100 KB]" +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bcaafdbe..26aaeadf 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -99,6 +99,7 @@ type CLI struct { VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"` Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"` Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"` + Mcp McpCmd `cmd:"" name:"mcp" help:"Run an MCP server over stdio (exposes gog to AI agents)"` } type exitPanic struct{ code int } From c6964c4db80e0a415461f7f505b7245a7995c75a Mon Sep 17 00:00:00 2001 From: auroracapital Date: Fri, 22 May 2026 20:57:31 +0200 Subject: [PATCH 2/5] feat(gmail): make search query optional, default to in:inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows 'gog gmail list' and 'gog gmail messages list' to run without a positional query argument — defaults to in:inbox so the bare command shows the user's inbox. Also adds a -q/--query flag as an alias for the positional argument for ergonomic scripting. Applies to both GmailSearchCmd (gmail list) and GmailMessagesSearchCmd (gmail messages list) which shared the same UX pattern. Co-Authored-By: Claude Opus 4.7 --- internal/cmd/gmail_messages.go | 8 ++++++-- internal/cmd/gmail_search.go | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index 832c1349..20bb3a73 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -25,7 +25,8 @@ type GmailMessagesCmd struct { } type GmailMessagesSearchCmd struct { - Query []string `arg:"" name:"query" help:"Search query"` + Query []string `arg:"" optional:"" name:"query" help:"Search query (default: in:inbox)"` + QueryFlag string `name:"query" short:"q" help:"Search query (alias for the positional argument)"` Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"` Page string `name:"page" aliases:"cursor" help:"Page token"` All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` @@ -48,7 +49,10 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro } query := strings.TrimSpace(strings.Join(c.Query, " ")) if query == "" { - return usage("missing query") + query = strings.TrimSpace(c.QueryFlag) + } + if query == "" { + query = "in:inbox" } svc, err := newGmailService(ctx, account) diff --git a/internal/cmd/gmail_search.go b/internal/cmd/gmail_search.go index 9d30137f..65fb98e0 100644 --- a/internal/cmd/gmail_search.go +++ b/internal/cmd/gmail_search.go @@ -12,7 +12,8 @@ import ( ) type GmailSearchCmd struct { - Query []string `arg:"" name:"query" help:"Search query"` + Query []string `arg:"" optional:"" name:"query" help:"Search query (default: in:inbox)"` + QueryFlag string `name:"query" short:"q" help:"Search query (alias for the positional argument)"` Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"` Page string `name:"page" aliases:"cursor" help:"Page token"` All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` @@ -30,7 +31,10 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { } query := strings.TrimSpace(strings.Join(c.Query, " ")) if query == "" { - return usage("missing query") + query = strings.TrimSpace(c.QueryFlag) + } + if query == "" { + query = "in:inbox" } svc, err := newGmailService(ctx, account) From 8070c5b357b3e9dabc49b1a66eb1d7f1f196c3ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 06:54:54 +0000 Subject: [PATCH 3/5] fix(mcp): valid UTF-8 truncation and honor tool cancel context Use utf8.Valid when trimming output so truncated slices never end with an orphan lead byte. Derive exec CommandContext from the MCP handler context so client cancellation ends subprocess work before the hard timeout. --- internal/cmd/mcp.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index fd39c10e..07dad3a6 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "time" + "unicode/utf8" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -44,13 +45,13 @@ func (c *McpCmd) Run(_ context.Context) error { ), ) - s.AddTool(gogExec, func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + s.AddTool(gogExec, func(reqCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args, err := req.RequireStringSlice("args") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("invalid args: %v", err)), nil } - ctx, cancel := context.WithTimeout(context.Background(), mcpExecTimeout) + ctx, cancel := context.WithTimeout(reqCtx, mcpExecTimeout) defer cancel() //nolint:gosec @@ -93,7 +94,7 @@ func mcpTruncate(s string, maxBytes int) string { } b := []byte(s)[:maxBytes] // Walk back to a valid UTF-8 boundary. - for len(b) > 0 && b[len(b)-1]&0xC0 == 0x80 { + for len(b) > 0 && !utf8.Valid(b) { b = b[:len(b)-1] } return string(b) + "\n... [output truncated at 100 KB]" From a42f7cdc4d53f057325a303b14355ea8174b9d5b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 07:05:44 +0000 Subject: [PATCH 4/5] fix(mcp): propagate CLI safety flags to gog_exec subprocesses Prepend --gmail-no-send, --enable-commands, and --disable-commands from the parent invocation so MCP-spawned gog runs honor the same restrictions as the parent process. --- internal/cmd/mcp.go | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 07dad3a6..fc588b72 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "strings" "time" "unicode/utf8" @@ -22,12 +23,34 @@ const ( mcpExecTimeout = 60 * time.Second ) -func (c *McpCmd) Run(_ context.Context) error { +// mcpChildSafetyArgs returns global argv fragments that must be prepended to +// every gog_exec subprocess so CLI-level safety restrictions from the parent +// process cannot be bypassed. +func mcpChildSafetyArgs(flags *RootFlags) []string { + if flags == nil { + return nil + } + var out []string + if flags.GmailNoSend { + out = append(out, "--gmail-no-send") + } + if s := strings.TrimSpace(flags.EnableCommands); s != "" { + out = append(out, "--enable-commands="+s) + } + if s := strings.TrimSpace(flags.DisableCommands); s != "" { + out = append(out, "--disable-commands="+s) + } + return out +} + +func (c *McpCmd) Run(_ context.Context, flags *RootFlags) error { self, err := os.Executable() if err != nil { return fmt.Errorf("resolve executable: %w", err) } + safetyPrefix := mcpChildSafetyArgs(flags) + s := server.NewMCPServer( "gog", VersionString(), @@ -54,8 +77,12 @@ func (c *McpCmd) Run(_ context.Context) error { ctx, cancel := context.WithTimeout(reqCtx, mcpExecTimeout) defer cancel() + childArgs := make([]string, 0, len(safetyPrefix)+len(args)) + childArgs = append(childArgs, safetyPrefix...) + childArgs = append(childArgs, args...) + //nolint:gosec - cmd := exec.CommandContext(ctx, self, args...) + cmd := exec.CommandContext(ctx, self, childArgs...) var stdoutBuf, stderrBuf bytes.Buffer cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf From e7427cec582fc151be89295259220d97ab23e306 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 07:17:34 +0000 Subject: [PATCH 5/5] fix(mcp): append safety argv after user args so Kong cannot override Kong resolves duplicate global flags last-wins; user-supplied MCP args must not be able to follow and override --enable-commands, --disable-commands, or --gmail-no-send enforced by the parent MCP process. --- internal/cmd/mcp.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index fc588b72..c177ff46 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -23,9 +23,10 @@ const ( mcpExecTimeout = 60 * time.Second ) -// mcpChildSafetyArgs returns global argv fragments that must be prepended to -// every gog_exec subprocess so CLI-level safety restrictions from the parent -// process cannot be bypassed. +// mcpChildSafetyArgs returns global argv fragments that must be appended after +// user-supplied args for every gog_exec subprocess. Kong resolves duplicate +// flags last-wins, so placing safety flags last prevents MCP clients from +// overriding restrictions. func mcpChildSafetyArgs(flags *RootFlags) []string { if flags == nil { return nil @@ -49,7 +50,7 @@ func (c *McpCmd) Run(_ context.Context, flags *RootFlags) error { return fmt.Errorf("resolve executable: %w", err) } - safetyPrefix := mcpChildSafetyArgs(flags) + safetySuffix := mcpChildSafetyArgs(flags) s := server.NewMCPServer( "gog", @@ -77,9 +78,9 @@ func (c *McpCmd) Run(_ context.Context, flags *RootFlags) error { ctx, cancel := context.WithTimeout(reqCtx, mcpExecTimeout) defer cancel() - childArgs := make([]string, 0, len(safetyPrefix)+len(args)) - childArgs = append(childArgs, safetyPrefix...) + childArgs := make([]string, 0, len(args)+len(safetySuffix)) childArgs = append(childArgs, args...) + childArgs = append(childArgs, safetySuffix...) //nolint:gosec cmd := exec.CommandContext(ctx, self, childArgs...)