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/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) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go new file mode 100644 index 00000000..c177ff46 --- /dev/null +++ b/internal/cmd/mcp.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + "unicode/utf8" + + "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 +) + +// 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 + } + 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) + } + + safetySuffix := mcpChildSafetyArgs(flags) + + 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(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(reqCtx, mcpExecTimeout) + defer cancel() + + childArgs := make([]string, 0, len(args)+len(safetySuffix)) + childArgs = append(childArgs, args...) + childArgs = append(childArgs, safetySuffix...) + + //nolint:gosec + cmd := exec.CommandContext(ctx, self, childArgs...) + 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 && !utf8.Valid(b) { + 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 }