Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -43,15 +48,17 @@ 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
go.opentelemetry.io/otel v1.43.0 // indirect
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
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
8 changes: 6 additions & 2 deletions internal/cmd/gmail_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions internal/cmd/gmail_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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)
Expand Down
129 changes: 129 additions & 0 deletions internal/cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -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]"
}
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down