From 918f4e7d81ff384fcebb589412974bc797739364 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 15:45:17 +0100 Subject: [PATCH 1/2] feat(mcp): add typed allowlisted server Co-authored-by: auroracapital --- CHANGELOG.md | 1 + docs/commands.generated.md | 1 + docs/commands/README.md | 4 +- docs/commands/gog-mcp.md | 50 +++++ docs/commands/gog.md | 1 + docs/index.md | 1 + docs/mcp.md | 64 +++++++ docs/spec.md | 15 +- go.mod | 7 +- go.sum | 14 ++ internal/cmd/mcp.go | 344 +++++++++++++++++++++++++++++++++ internal/cmd/mcp_test.go | 219 +++++++++++++++++++++ internal/cmd/mcp_tools.go | 381 +++++++++++++++++++++++++++++++++++++ internal/cmd/root.go | 1 + internal/cmd/sheets.go | 9 +- 15 files changed, 1108 insertions(+), 4 deletions(-) create mode 100644 docs/commands/gog-mcp.md create mode 100644 docs/mcp.md create mode 100644 internal/cmd/mcp.go create mode 100644 internal/cmd/mcp_test.go create mode 100644 internal/cmd/mcp_tools.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b19f28ae..9d47a00f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- MCP: add a typed, allowlisted `gog mcp` stdio server with read-only defaults and explicit write-tool opt-in. (#637) — thanks @auroracapital. - Docs: add `docs table-column-width` to set fixed native table column widths or reset columns to evenly distributed sizing. (#631) — thanks @sebsnyk. ### Fixed diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 491b3cdf..2ecd7446 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -435,6 +435,7 @@ Generated from `gog schema --json`. - [`gog maps (map) places (place) details (get,info,show) [flags]`](commands/gog-maps-places-details.md) - Get Place details - [`gog maps (map) places (place) search (find) ... [flags]`](commands/gog-maps-places-search.md) - Search Places by text - [`gog maps (map) reverse-geocode (reverse) --lat=STRING --lng=STRING [flags]`](commands/gog-maps-reverse-geocode.md) - Convert coordinates to an address + - [`gog mcp [flags]`](commands/gog-mcp.md) - Run a typed, allowlisted MCP server over stdio - [`gog me [flags]`](commands/gog-me.md) - Show your profile (alias for 'people me') - [`gog meet (meeting) [flags]`](commands/gog-meet.md) - Google Meet - [`gog meet (meeting) create (new) [flags]`](commands/gog-meet-create.md) - Create a meeting space diff --git a/docs/commands/README.md b/docs/commands/README.md index 3c0194ee..d1dbeaa8 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 582. +Generated pages: 583. ## Top-level Commands @@ -30,6 +30,7 @@ Generated pages: 582. - [gog logout](gog-logout.md) - Remove a stored refresh token (alias for 'auth remove') - [gog ls](gog-ls.md) - List Drive files (alias for 'drive ls') - [gog maps](gog-maps.md) - Google Maps +- [gog mcp](gog-mcp.md) - Run a typed, allowlisted MCP server over stdio - [gog me](gog-me.md) - Show your profile (alias for 'people me') - [gog meet](gog-meet.md) - Google Meet - [gog open](gog-open.md) - Print a best-effort web URL for a Google URL/ID (offline) @@ -486,6 +487,7 @@ Generated pages: 582. - [gog maps places details](gog-maps-places-details.md) - Get Place details - [gog maps places search](gog-maps-places-search.md) - Search Places by text - [gog maps reverse-geocode](gog-maps-reverse-geocode.md) - Convert coordinates to an address + - [gog mcp](gog-mcp.md) - Run a typed, allowlisted MCP server over stdio - [gog me](gog-me.md) - Show your profile (alias for 'people me') - [gog meet](gog-meet.md) - Google Meet - [gog meet create](gog-meet-create.md) - Create a meeting space diff --git a/docs/commands/gog-mcp.md b/docs/commands/gog-mcp.md new file mode 100644 index 00000000..a565d29d --- /dev/null +++ b/docs/commands/gog-mcp.md @@ -0,0 +1,50 @@ +# `gog mcp` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Run a typed, allowlisted MCP server over stdio + +## Usage + +```bash +gog mcp [flags] +``` + +## Parent + +- [gog](gog.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) | +| `--allow-tool`
`--tool` | `[]string` | | Tool or service allowlist (default: all read-only tools). Examples: gmail.*,docs_get,sheets | +| `--allow-write` | `bool` | | Expose write tools. Write tools must also match --allow-tool when that flag is set. | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--list-tools` | `bool` | | Print enabled MCP tools as JSON and exit | +| `--max-output-bytes` | `int` | 102400 | Max stdout/stderr bytes captured per tool call | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--timeout-seconds` | `int` | 60 | Per-tool subprocess timeout | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog](gog.md) +- [Command index](README.md) diff --git a/docs/commands/gog.md b/docs/commands/gog.md index c5ce8883..234f5fa1 100644 --- a/docs/commands/gog.md +++ b/docs/commands/gog.md @@ -40,6 +40,7 @@ gog [flags] - [gog logout](gog-logout.md) - Remove a stored refresh token (alias for 'auth remove') - [gog ls](gog-ls.md) - List Drive files (alias for 'drive ls') - [gog maps](gog-maps.md) - Google Maps +- [gog mcp](gog-mcp.md) - Run a typed, allowlisted MCP server over stdio - [gog me](gog-me.md) - Show your profile (alias for 'people me') - [gog meet](gog-meet.md) - Google Meet - [gog open](gog-open.md) - Print a best-effort web URL for a Google URL/ID (offline) diff --git a/docs/index.md b/docs/index.md index e5826dac..2401b22a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,6 +46,7 @@ gog slides create-from-markdown "Weekly update" --content-file slides.md - **Trying it.** [Install](install.md) → [Quickstart](quickstart.md). Five minutes from `brew install` to your first authenticated query. - **Wiring up an agent.** [Safety Profiles](safety-profiles.md) and the bundled [`gog` agent skill](https://github.com/openclaw/gogcli/blob/main/.agents/skills/gog/SKILL.md). Lock the binary down before handing it to a model. +- **Serving MCP tools.** [MCP server](mcp.md) exposes typed, allowlisted tools for agent clients without a generic command bridge. - **Persisting auth and state.** [Paths and State](paths.md) covers `GOG_HOME`, per-kind directories, XDG paths, and legacy compatibility. - **Running Workspace at scale.** [Auth Clients](auth-clients.md) for service accounts, named OAuth clients, and domain-wide delegation. - **Managing Workspace.** [Workspace Admin](workspace-admin.md) covers user creation, cleanup, organizational units, and group administration. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..268a6ff8 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,64 @@ +# MCP server + +`gog mcp` runs a typed Model Context Protocol server over stdio. + +The MCP surface is deliberately narrower than the CLI. It registers typed tools +such as `gmail_search`, `docs_get`, and `sheets_read_range`; it does not expose a +generic command runner. Agents cannot send arbitrary `gog` argv through MCP. +Raw Google API dumps are also intentionally omitted from MCP tools when they +would bypass untrusted-content wrapping. + +## Defaults + +By default, only read-only tools are exposed: + +```bash +gog --account you@example.com mcp +``` + +List the tools that would be registered: + +```bash +gog --account you@example.com mcp --list-tools +``` + +Narrow the surface to specific services or tools: + +```bash +gog --account you@example.com mcp --allow-tool gmail.*,docs_get,sheets_read_range +``` + +Expose write tools explicitly: + +```bash +gog --account you@example.com mcp --allow-write --allow-tool docs_write,sheets_update_range +``` + +## Safety model + +- No generic `gog_exec` or shell bridge. +- Read-only tools are the default. +- Write tools require `--allow-write`. +- `--allow-tool` can restrict by exact tool name, service name, or service + wildcard, for example `gmail.*`. +- Parent root context is inherited: `--account`, `--home`, `--client`, JSON + output, `--wrap-untrusted`, `--no-input`, and command allow/deny safety flags. +- Tool calls run as subprocesses with a timeout and bounded stdout/stderr. + +## Initial tool set + +Read tools: + +- `gmail_search` +- `gmail_get_message` +- `gmail_get_thread` +- `drive_search` +- `drive_get` +- `docs_get` +- `sheets_read_range` +- `calendar_events` + +Write tools, hidden unless `--allow-write`: + +- `docs_write` +- `sheets_update_range` diff --git a/docs/spec.md b/docs/spec.md index 635fdaf4..e4671c7f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -30,7 +30,20 @@ This replaces the existing separate CLIs (`gmcli`, `gccli`, `gdcli`) and the Pyt - Preserving legacy command names/flags/output formats - Importing existing `~/.gmcli`, `~/.gccli`, `~/.gdcli` state -- Running an MCP server (this is a CLI) +- Exposing the whole CLI through a generic MCP command-execution bridge + +## MCP server + +`gog mcp` runs a typed MCP server over stdio for agent clients that need a +permissioned Google Workspace tool surface. It intentionally does not expose a +generic shell/argv bridge. Each MCP tool has a fixed schema and maps to a +specific `gog` operation. + +MCP defaults are read-only. Write tools are hidden unless the server is started +with `--allow-write`, and `--allow-tool` can further narrow the registered tool +set by tool name or service prefix. Parent root context such as `--account`, +`--home`, output mode, `--no-input`, untrusted wrapping, and command safety +flags is preserved for subprocess calls. ## Language/runtime diff --git a/go.mod b/go.mod index 91565171..40e7d2eb 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,14 @@ 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.1 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 +35,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 +46,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 +57,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..54eb67cc 100644 --- a/go.sum +++ b/go.sum @@ -28,10 +28,14 @@ github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMF github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA= github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -43,6 +47,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 +69,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.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0= +github.com/mark3labs/mcp-go v0.54.1/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 +84,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..e9cefd1f --- /dev/null +++ b/internal/cmd/mcp.go @@ -0,0 +1,344 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + "unicode/utf8" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type McpCmd struct { + AllowTool []string `name:"allow-tool" aliases:"tool" sep:"," help:"Tool or service allowlist (default: all read-only tools). Examples: gmail.*,docs_get,sheets"` + AllowWrite bool `name:"allow-write" help:"Expose write tools. Write tools must also match --allow-tool when that flag is set."` + ListTools bool `name:"list-tools" help:"Print enabled MCP tools as JSON and exit"` + TimeoutSeconds int `name:"timeout-seconds" help:"Per-tool subprocess timeout" default:"60"` + MaxOutputBytes int `name:"max-output-bytes" help:"Max stdout/stderr bytes captured per tool call" default:"102400"` +} + +type mcpToolRisk string + +const ( + mcpRiskRead mcpToolRisk = "read" + mcpRiskWrite mcpToolRisk = "write" +) + +type mcpToolSpec struct { + Name string + Service string + Risk mcpToolRisk + Description string + Options []mcp.ToolOption + BuildArgs func(mcp.CallToolRequest) ([]string, error) +} + +type mcpCommandResult struct { + Tool string `json:"tool"` + Service string `json:"service"` + Risk string `json:"risk"` + ExitCode int `json:"exit_code"` + Stdout any `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` +} + +func (c *McpCmd) Run(_ context.Context, flags *RootFlags) error { + self, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve executable: %w", err) + } + if c.TimeoutSeconds <= 0 { + return usage("--timeout-seconds must be greater than zero") + } + if c.MaxOutputBytes <= 0 { + return usage("--max-output-bytes must be greater than zero") + } + + tools := mcpEnabledTools(*c) + if len(tools) == 0 { + return usage("no MCP tools enabled") + } + if c.ListTools { + return mcpPrintTools(tools) + } + + baseArgs := mcpParentRootArgs(flags) + safetySuffix := mcpParentSafetyArgs(flags) + timeout := time.Duration(c.TimeoutSeconds) * time.Second + maxOutputBytes := c.MaxOutputBytes + + s := server.NewMCPServer("gog", VersionString(), server.WithToolCapabilities(false)) + for _, spec := range tools { + tool := spec + opts := append([]mcp.ToolOption{ + mcp.WithDescription(tool.Description), + mcp.WithReadOnlyHintAnnotation(tool.Risk == mcpRiskRead), + mcp.WithDestructiveHintAnnotation(tool.Risk == mcpRiskWrite), + mcp.WithIdempotentHintAnnotation(tool.Risk == mcpRiskRead), + mcp.WithOpenWorldHintAnnotation(true), + mcp.WithSchemaAdditionalProperties(false), + }, tool.Options...) + s.AddTool(mcp.NewTool(tool.Name, opts...), func(reqCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + childCommandArgs, buildErr := tool.BuildArgs(req) + if buildErr != nil { + result := mcp.NewToolResultError(buildErr.Error()) + result.IsError = true + return result, nil + } + return mcpRunGogTool(reqCtx, mcpRunOptions{ + self: self, + tool: tool, + baseArgs: baseArgs, + commandArgs: childCommandArgs, + safetySuffix: safetySuffix, + timeout: timeout, + maxOutputBytes: maxOutputBytes, + accessToken: directAccessToken(flags), + }), nil + }) + } + return server.ServeStdio(s) +} + +type mcpRunOptions struct { + self string + tool mcpToolSpec + baseArgs []string + commandArgs []string + safetySuffix []string + timeout time.Duration + maxOutputBytes int + accessToken string +} + +func mcpRunGogTool(reqCtx context.Context, opts mcpRunOptions) *mcp.CallToolResult { + ctx, cancel := context.WithTimeout(reqCtx, opts.timeout) + defer cancel() + + args := make([]string, 0, len(opts.baseArgs)+len(opts.commandArgs)+len(opts.safetySuffix)) + args = append(args, opts.baseArgs...) + args = append(args, opts.safetySuffix...) + args = append(args, opts.commandArgs...) + + //nolint:gosec // argv comes from typed tool schemas, not model-supplied shell text. + cmd := exec.CommandContext(ctx, opts.self, args...) + if strings.TrimSpace(opts.accessToken) != "" { + cmd.Env = append(os.Environ(), "GOG_ACCESS_TOKEN="+opts.accessToken) + } + stdoutBuf := newMCPLimitedBuffer(opts.maxOutputBytes) + stderrBuf := newMCPLimitedBuffer(opts.maxOutputBytes) + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + runErr := cmd.Run() + exitCode := 0 + if runErr != nil { + exitCode = 1 + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + exitCode = exitErr.ExitCode() + } + } + if ctx.Err() == context.DeadlineExceeded { + exitCode = 124 + } + + result := mcpCommandResult{ + Tool: opts.tool.Name, + Service: opts.tool.Service, + Risk: string(opts.tool.Risk), + ExitCode: exitCode, + Stdout: parseMCPStdout(stdoutBuf.String()), + Stderr: stderrBuf.String(), + } + callResult := mcp.NewToolResultStructuredOnly(result) + if exitCode != 0 { + callResult.IsError = true + } + return callResult +} + +func parseMCPStdout(s string) any { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return nil + } + var v any + dec := json.NewDecoder(strings.NewReader(trimmed)) + dec.UseNumber() + if err := dec.Decode(&v); err == nil { + return v + } + return s +} + +func mcpParentRootArgs(flags *RootFlags) []string { + args := []string{"--json", "--wrap-untrusted", "--no-input", "--color=never"} + if flags == nil { + return args + } + if s := strings.TrimSpace(flags.Home); s != "" { + args = append(args, "--home", s) + } + if s := strings.TrimSpace(flags.Account); s != "" { + args = append(args, "--account", s) + } + if s := strings.TrimSpace(flags.Client); s != "" { + args = append(args, "--client", s) + } + if flags.ResultsOnly { + args = append(args, "--results-only") + } + if s := strings.TrimSpace(flags.Select); s != "" { + args = append(args, "--select", s) + } + if flags.DryRun { + args = append(args, "--dry-run") + } + return args +} + +func mcpParentSafetyArgs(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.EnableCommandsExact); s != "" { + out = append(out, "--enable-commands-exact="+s) + } + if s := strings.TrimSpace(flags.DisableCommands); s != "" { + out = append(out, "--disable-commands="+s) + } + return out +} + +func mcpEnabledTools(cmd McpCmd) []mcpToolSpec { + all := mcpAllTools() + allow := splitCommaValues(cmd.AllowTool) + out := make([]mcpToolSpec, 0, len(all)) + for _, tool := range all { + if tool.Risk == mcpRiskWrite && !cmd.AllowWrite { + continue + } + if len(allow) > 0 && !mcpToolAllowed(tool, allow) { + continue + } + out = append(out, tool) + } + return out +} + +func splitCommaValues(values []string) []string { + var out []string + for _, value := range values { + for _, part := range strings.Split(value, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + } + return out +} + +func mcpToolAllowed(tool mcpToolSpec, allow []string) bool { + for _, pattern := range allow { + switch pattern { + case "*", literalAll, string(tool.Risk), tool.Name, tool.Service: + return true + } + if strings.HasSuffix(pattern, ".*") && strings.TrimSuffix(pattern, ".*") == tool.Service { + return true + } + } + return false +} + +func mcpPrintTools(tools []mcpToolSpec) error { + items := make([]map[string]string, 0, len(tools)) + for _, tool := range tools { + items = append(items, map[string]string{ + "name": tool.Name, + "service": tool.Service, + "risk": string(tool.Risk), + "description": tool.Description, + }) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(map[string]any{"tools": items}) +} + +func requireMCPString(req mcp.CallToolRequest, key string) (string, error) { + value, err := req.RequireString(key) + if err != nil { + return "", err + } + if value = strings.TrimSpace(value); value == "" { + return "", fmt.Errorf("empty %s", key) + } + return value, nil +} + +func clampMCPInt(value, minValue, maxValue int) int { + if value < minValue { + return minValue + } + if maxValue > 0 && value > maxValue { + return maxValue + } + return value +} + +type mcpLimitedBuffer struct { + buf bytes.Buffer + maxBytes int + truncated bool +} + +func newMCPLimitedBuffer(maxBytes int) mcpLimitedBuffer { + return mcpLimitedBuffer{maxBytes: maxBytes} +} + +func (b *mcpLimitedBuffer) Write(p []byte) (int, error) { + if b.maxBytes <= 0 { + b.truncated = true + return len(p), nil + } + remaining := b.maxBytes - b.buf.Len() + if remaining <= 0 { + b.truncated = true + return len(p), nil + } + if len(p) > remaining { + _, _ = b.buf.Write(p[:remaining]) + b.truncated = true + return len(p), nil + } + _, _ = b.buf.Write(p) + return len(p), nil +} + +func (b *mcpLimitedBuffer) String() string { + raw := b.buf.Bytes() + for len(raw) > 0 && !utf8.Valid(raw) { + raw = raw[:len(raw)-1] + } + out := string(raw) + if !b.truncated { + return out + } + return out + "\n... [output truncated]" +} diff --git a/internal/cmd/mcp_test.go b/internal/cmd/mcp_test.go new file mode 100644 index 00000000..0c0c87c8 --- /dev/null +++ b/internal/cmd/mcp_test.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestMCPEnabledToolsDefaultReadOnly(t *testing.T) { + tools := mcpEnabledTools(McpCmd{}) + if len(tools) == 0 { + t.Fatal("expected default tools") + } + for _, tool := range tools { + if tool.Risk != mcpRiskRead { + t.Fatalf("default enabled write tool %s", tool.Name) + } + } + if hasMCPTool(tools, "docs_write") { + t.Fatal("docs_write should require --allow-write") + } + if !hasMCPTool(tools, "gmail_search") { + t.Fatal("gmail_search should be enabled by default") + } +} + +func TestMCPEnabledToolsAllowWriteAndFilter(t *testing.T) { + tools := mcpEnabledTools(McpCmd{AllowWrite: true, AllowTool: []string{"docs.*"}}) + if !hasMCPTool(tools, "docs_get") || !hasMCPTool(tools, "docs_write") { + t.Fatalf("expected docs read and write tools, got %#v", toolNames(tools)) + } + if hasMCPTool(tools, "gmail_search") { + t.Fatalf("gmail tool leaked through docs filter: %#v", toolNames(tools)) + } +} + +func TestMCPParentArgsPreserveContextAndSafety(t *testing.T) { + flags := &RootFlags{ + Home: "/tmp/gog-home", + Account: "bot@example.com", + Client: "test-client", + ResultsOnly: true, + Select: "messages", + DryRun: true, + GmailNoSend: true, + EnableCommands: "gmail.search,docs.cat", + EnableCommandsExact: "mcp,gmail.messages.search", + DisableCommands: "drive.delete", + } + base := strings.Join(mcpParentRootArgs(flags), "\x00") + for _, want := range []string{"--json", "--wrap-untrusted", "--no-input", "--color=never", "--home\x00/tmp/gog-home", "--account\x00bot@example.com", "--client\x00test-client", "--results-only", "--select\x00messages", "--dry-run"} { + if !strings.Contains(base, want) { + t.Fatalf("base args missing %q in %#v", want, mcpParentRootArgs(flags)) + } + } + safety := strings.Join(mcpParentSafetyArgs(flags), "\x00") + for _, want := range []string{"--gmail-no-send", "--enable-commands=gmail.search,docs.cat", "--enable-commands-exact=mcp,gmail.messages.search", "--disable-commands=drive.delete"} { + if !strings.Contains(safety, want) { + t.Fatalf("safety args missing %q in %#v", want, mcpParentSafetyArgs(flags)) + } + } +} + +func TestMCPToolBuildArgsTypedOnly(t *testing.T) { + tool := findMCPTool(t, "sheets_update_range") + args, err := tool.BuildArgs(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "spreadsheet_id": "sheet1", + "range": "Sheet1!A1:B1", + "values_json": `[[1,2]]`, + "input": "RAW", + "args": []any{"drive", "delete", "file"}, + }, + }}) + if err != nil { + t.Fatal(err) + } + got := strings.Join(args, " ") + if strings.Contains(got, "drive delete") { + t.Fatalf("generic args leaked into typed tool argv: %#v", args) + } + want := []string{"sheets", "update", "--values-json", "[[1,2]]", "--input", "RAW", "--", "sheet1", "Sheet1!A1:B1"} + if strings.Join(args, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("args = %#v, want %#v", args, want) + } +} + +func TestMCPDocsWritePreservesTextWhitespace(t *testing.T) { + tool := findMCPTool(t, "docs_write") + args, err := tool.BuildArgs(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "document_id": "doc1", + "text": " indented\n", + }, + }}) + if err != nil { + t.Fatal(err) + } + for i, arg := range args { + if arg == "--text" && i+1 < len(args) { + if args[i+1] != " indented\n" { + t.Fatalf("text = %q", args[i+1]) + } + return + } + } + t.Fatalf("missing --text in %#v", args) +} + +func TestMCPDocsWriteRejectsNeitherAppendNorReplace(t *testing.T) { + tool := findMCPTool(t, "docs_write") + _, err := tool.BuildArgs(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "document_id": "doc1", + "text": "hello", + "append": false, + "replace": false, + }, + }}) + if err == nil || !strings.Contains(err.Error(), "append=false") { + t.Fatalf("expected append=false error, got %v", err) + } +} + +func TestMCPSheetsUpdateRejectsFileExpansion(t *testing.T) { + tool := findMCPTool(t, "sheets_update_range") + _, err := tool.BuildArgs(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "spreadsheet_id": "sheet1", + "range": "Sheet1!A1", + "values_json": "@/tmp/secret.json", + }, + }}) + if err == nil || !strings.Contains(err.Error(), "literal JSON") { + t.Fatalf("expected literal JSON error, got %v", err) + } +} + +func TestMCPSheetsUpdatePreservesLargeJSONNumbers(t *testing.T) { + tool := findMCPTool(t, "sheets_update_range") + args, err := tool.BuildArgs(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "spreadsheet_id": "sheet1", + "range": "Sheet1!A1", + "values_json": `[[1234567890123456789]]`, + "input": "RAW", + }, + }}) + if err != nil { + t.Fatal(err) + } + for i, arg := range args { + if arg == "--values-json" && i+1 < len(args) { + if args[i+1] != `[[1234567890123456789]]` { + t.Fatalf("values_json = %q", args[i+1]) + } + return + } + } + t.Fatalf("missing --values-json in %#v", args) +} + +func TestMCPSheetsUpdateRejectsTrailingJSON(t *testing.T) { + tool := findMCPTool(t, "sheets_update_range") + _, err := tool.BuildArgs(mcp.CallToolRequest{Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "spreadsheet_id": "sheet1", + "range": "Sheet1!A1", + "values_json": `[[1]] garbage`, + }, + }}) + if err == nil || !strings.Contains(err.Error(), "trailing content") { + t.Fatalf("expected trailing content error, got %v", err) + } +} + +func TestMCPLimitedBufferCapsDuringWrite(t *testing.T) { + buf := newMCPLimitedBuffer(5) + n, err := buf.Write([]byte("hello world")) + if err != nil { + t.Fatal(err) + } + if n != len("hello world") { + t.Fatalf("Write returned %d", n) + } + got := buf.String() + if !strings.HasPrefix(got, "hello") || !strings.Contains(got, "truncated") { + t.Fatalf("unexpected buffer: %q", got) + } +} + +func hasMCPTool(tools []mcpToolSpec, name string) bool { + for _, tool := range tools { + if tool.Name == name { + return true + } + } + return false +} + +func toolNames(tools []mcpToolSpec) []string { + out := make([]string, 0, len(tools)) + for _, tool := range tools { + out = append(out, tool.Name) + } + return out +} + +func findMCPTool(t *testing.T, name string) mcpToolSpec { + t.Helper() + for _, tool := range mcpAllTools() { + if tool.Name == name { + return tool + } + } + t.Fatalf("missing tool %s", name) + return mcpToolSpec{} +} diff --git a/internal/cmd/mcp_tools.go b/internal/cmd/mcp_tools.go new file mode 100644 index 00000000..d3f4a8c9 --- /dev/null +++ b/internal/cmd/mcp_tools.go @@ -0,0 +1,381 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "github.com/mark3labs/mcp-go/mcp" +) + +func mcpAllTools() []mcpToolSpec { + return []mcpToolSpec{ + mcpGmailSearchTool(), + mcpGmailGetMessageTool(), + mcpGmailGetThreadTool(), + mcpDriveSearchTool(), + mcpDriveGetTool(), + mcpDocsGetTool(), + mcpSheetsReadRangeTool(), + mcpCalendarEventsTool(), + mcpDocsWriteTool(), + mcpSheetsUpdateRangeTool(), + } +} + +func mcpGmailSearchTool() mcpToolSpec { + return mcpToolSpec{ + Name: "gmail_search", + Service: "gmail", + Risk: mcpRiskRead, + Description: "Search Gmail messages with Gmail query syntax. Returns message summaries and optional sanitized bodies.", + Options: []mcp.ToolOption{ + mcp.WithString("query", mcp.Description("Gmail search query, e.g. newer_than:7d from:person@example.com"), mcp.Required()), + mcp.WithInteger("max", mcp.Description("Maximum results"), mcp.DefaultNumber(10), mcp.Min(1), mcp.Max(100)), + mcp.WithBoolean("include_body", mcp.Description("Include decoded message body"), mcp.DefaultBool(false)), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + query, err := requireMCPString(req, "query") + if err != nil { + return nil, err + } + args := []string{"gmail", "messages", "search", "--max", strconv.Itoa(clampMCPInt(req.GetInt("max", 10), 1, 100))} + if req.GetBool("include_body", false) { + args = append(args, "--include-body") + } + return append(args, "--", query), nil + }, + } +} + +func mcpGmailGetMessageTool() mcpToolSpec { + return mcpToolSpec{ + Name: "gmail_get_message", + Service: "gmail", + Risk: mcpRiskRead, + Description: "Get one Gmail message by ID. Sanitized content is enabled by default.", + Options: []mcp.ToolOption{ + mcp.WithString("message_id", mcp.Description("Gmail message ID"), mcp.Required()), + mcp.WithBoolean("sanitize_content", mcp.Description("Strip URLs/HTML and omit raw payloads"), mcp.DefaultBool(true)), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + messageID, err := requireMCPString(req, "message_id") + if err != nil { + return nil, err + } + args := []string{"gmail", "get"} + if req.GetBool("sanitize_content", true) { + args = append(args, "--sanitize-content") + } + return append(args, "--", messageID), nil + }, + } +} + +func mcpGmailGetThreadTool() mcpToolSpec { + return mcpToolSpec{ + Name: "gmail_get_thread", + Service: "gmail", + Risk: mcpRiskRead, + Description: "Get one Gmail thread by ID. Sanitized content is enabled by default.", + Options: []mcp.ToolOption{ + mcp.WithString("thread_id", mcp.Description("Gmail thread ID"), mcp.Required()), + mcp.WithBoolean("sanitize_content", mcp.Description("Strip URLs/HTML and omit raw payloads"), mcp.DefaultBool(true)), + mcp.WithBoolean("full", mcp.Description("Include full message bodies"), mcp.DefaultBool(false)), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + threadID, err := requireMCPString(req, "thread_id") + if err != nil { + return nil, err + } + args := []string{"gmail", "thread", "get"} + if req.GetBool("sanitize_content", true) { + args = append(args, "--sanitize-content") + } + if req.GetBool("full", false) { + args = append(args, "--full") + } + return append(args, "--", threadID), nil + }, + } +} + +func mcpDriveSearchTool() mcpToolSpec { + return mcpToolSpec{ + Name: "drive_search", + Service: "drive", + Risk: mcpRiskRead, + Description: "Search Google Drive files using text search or Drive query language.", + Options: []mcp.ToolOption{ + mcp.WithString("query", mcp.Description("Search text or Drive query"), mcp.Required()), + mcp.WithInteger("max", mcp.Description("Maximum results"), mcp.DefaultNumber(20), mcp.Min(1), mcp.Max(100)), + mcp.WithBoolean("raw_query", mcp.Description("Treat query as Drive query language"), mcp.DefaultBool(false)), + mcp.WithString("parent", mcp.Description("Optional parent folder/shared drive ID")), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + query, err := requireMCPString(req, "query") + if err != nil { + return nil, err + } + args := []string{"drive", "search", "--max", strconv.Itoa(clampMCPInt(req.GetInt("max", 20), 1, 100))} + if req.GetBool("raw_query", false) { + args = append(args, "--raw-query") + } + if parent := strings.TrimSpace(req.GetString("parent", "")); parent != "" { + args = append(args, "--parent", parent) + } + return append(args, "--", query), nil + }, + } +} + +func mcpDriveGetTool() mcpToolSpec { + return mcpToolSpec{ + Name: "drive_get", + Service: "drive", + Risk: mcpRiskRead, + Description: "Get Google Drive file metadata by ID.", + Options: []mcp.ToolOption{ + mcp.WithString("file_id", mcp.Description("Drive file ID"), mcp.Required()), + mcp.WithString("fields", mcp.Description("Optional Drive API field mask")), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + fileID, err := requireMCPString(req, "file_id") + if err != nil { + return nil, err + } + args := []string{"drive", "get"} + if fields := strings.TrimSpace(req.GetString("fields", "")); fields != "" { + args = append(args, "--fields", fields) + } + return append(args, "--", fileID), nil + }, + } +} + +func mcpDocsGetTool() mcpToolSpec { + return mcpToolSpec{ + Name: "docs_get", + Service: "docs", + Risk: mcpRiskRead, + Description: "Read a Google Doc as wrapped text, all tabs, or one tab.", + Options: []mcp.ToolOption{ + mcp.WithString("document_id", mcp.Description("Google Docs document ID"), mcp.Required()), + mcp.WithString("tab", mcp.Description("Optional tab title or ID")), + mcp.WithBoolean("all_tabs", mcp.Description("Read all tabs"), mcp.DefaultBool(false)), + mcp.WithInteger("max_bytes", mcp.Description("Maximum text bytes, 0 for unlimited"), mcp.DefaultNumber(2000000), mcp.Min(0)), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + docID, err := requireMCPString(req, "document_id") + if err != nil { + return nil, err + } + args := []string{"docs", "cat", "--max-bytes", strconv.Itoa(clampMCPInt(req.GetInt("max_bytes", 2000000), 0, 20_000_000))} + if tab := strings.TrimSpace(req.GetString("tab", "")); tab != "" { + args = append(args, "--tab", tab) + } + if req.GetBool("all_tabs", false) { + args = append(args, "--all-tabs") + } + return append(args, "--", docID), nil + }, + } +} + +func mcpSheetsReadRangeTool() mcpToolSpec { + return mcpToolSpec{ + Name: "sheets_read_range", + Service: "sheets", + Risk: mcpRiskRead, + Description: "Read values from a Google Sheets range.", + Options: []mcp.ToolOption{ + mcp.WithString("spreadsheet_id", mcp.Description("Google Sheets spreadsheet ID"), mcp.Required()), + mcp.WithString("range", mcp.Description("A1 notation or named range"), mcp.Required()), + mcp.WithString("render", mcp.Description("Value render option"), mcp.Enum("FORMATTED_VALUE", "UNFORMATTED_VALUE", "FORMULA")), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + spreadsheetID, err := requireMCPString(req, "spreadsheet_id") + if err != nil { + return nil, err + } + rangeSpec, err := requireMCPString(req, "range") + if err != nil { + return nil, err + } + args := []string{"sheets", "get"} + if render := strings.TrimSpace(req.GetString("render", "")); render != "" { + args = append(args, "--render", render) + } + return append(args, "--", spreadsheetID, rangeSpec), nil + }, + } +} + +func mcpCalendarEventsTool() mcpToolSpec { + return mcpToolSpec{ + Name: "calendar_events", + Service: "calendar", + Risk: mcpRiskRead, + Description: "List Google Calendar events from primary or selected calendars.", + Options: []mcp.ToolOption{ + mcp.WithString("calendar_id", mcp.Description("Calendar ID or selector; default primary")), + mcp.WithString("from", mcp.Description("Start time: RFC3339, date, or relative value")), + mcp.WithString("to", mcp.Description("End time: RFC3339, date, or relative value")), + mcp.WithBoolean("today", mcp.Description("Today only"), mcp.DefaultBool(false)), + mcp.WithBoolean("tomorrow", mcp.Description("Tomorrow only"), mcp.DefaultBool(false)), + mcp.WithInteger("days", mcp.Description("Next N days"), mcp.DefaultNumber(0), mcp.Min(0), mcp.Max(31)), + mcp.WithInteger("max", mcp.Description("Maximum results"), mcp.DefaultNumber(10), mcp.Min(1), mcp.Max(250)), + mcp.WithString("query", mcp.Description("Free text search")), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + args := []string{"calendar", "events"} + calendarID := strings.TrimSpace(req.GetString("calendar_id", "")) + for _, pair := range [][2]string{{"from", "--from"}, {"to", "--to"}, {"query", "--query"}} { + if v := strings.TrimSpace(req.GetString(pair[0], "")); v != "" { + args = append(args, pair[1], v) + } + } + if req.GetBool("today", false) { + args = append(args, "--today") + } + if req.GetBool("tomorrow", false) { + args = append(args, "--tomorrow") + } + if days := req.GetInt("days", 0); days > 0 { + args = append(args, "--days", strconv.Itoa(clampMCPInt(days, 1, 31))) + } + args = append(args, "--max", strconv.Itoa(clampMCPInt(req.GetInt("max", 10), 1, 250))) + if calendarID != "" { + args = append(args, "--", calendarID) + } + return args, nil + }, + } +} + +func mcpDocsWriteTool() mcpToolSpec { + return mcpToolSpec{ + Name: "docs_write", + Service: "docs", + Risk: mcpRiskWrite, + Description: "Write text to a Google Doc. Requires --allow-write on the MCP server.", + Options: []mcp.ToolOption{ + mcp.WithString("document_id", mcp.Description("Google Docs document ID"), mcp.Required()), + mcp.WithString("text", mcp.Description("Text or markdown to write"), mcp.Required()), + mcp.WithString("tab", mcp.Description("Optional tab title or ID")), + mcp.WithBoolean("append", mcp.Description("Append instead of replacing"), mcp.DefaultBool(true)), + mcp.WithBoolean("replace", mcp.Description("Replace all existing content"), mcp.DefaultBool(false)), + mcp.WithBoolean("markdown", mcp.Description("Convert markdown to Docs formatting"), mcp.DefaultBool(false)), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + docID, err := requireMCPString(req, "document_id") + if err != nil { + return nil, err + } + text, err := requireMCPText(req, "text") + if err != nil { + return nil, err + } + args := []string{"docs", "write", "--text", text} + reqArgs := req.GetArguments() + replace := req.GetBool("replace", false) + appendProvided := false + if reqArgs != nil { + _, appendProvided = reqArgs["append"] + } + appendMode := req.GetBool("append", true) + if replace && appendProvided && appendMode { + return nil, fmt.Errorf("append and replace are mutually exclusive") + } + switch { + case replace: + args = append(args, "--replace") + case appendMode: + args = append(args, "--append") + default: + return nil, fmt.Errorf("append=false requires replace=true to avoid implicit document replacement") + } + if req.GetBool("markdown", false) { + args = append(args, "--markdown") + } + if tab := strings.TrimSpace(req.GetString("tab", "")); tab != "" { + args = append(args, "--tab", tab) + } + return append(args, "--", docID), nil + }, + } +} + +func mcpSheetsUpdateRangeTool() mcpToolSpec { + return mcpToolSpec{ + Name: "sheets_update_range", + Service: "sheets", + Risk: mcpRiskWrite, + Description: "Update values in a Google Sheets range. Requires --allow-write on the MCP server.", + Options: []mcp.ToolOption{ + mcp.WithString("spreadsheet_id", mcp.Description("Google Sheets spreadsheet ID"), mcp.Required()), + mcp.WithString("range", mcp.Description("A1 notation or named range"), mcp.Required()), + mcp.WithString("values_json", mcp.Description("JSON 2D array of values"), mcp.Required()), + mcp.WithString("input", mcp.Description("Value input option"), mcp.Enum("RAW", "USER_ENTERED"), mcp.DefaultString("USER_ENTERED")), + }, + BuildArgs: func(req mcp.CallToolRequest) ([]string, error) { + spreadsheetID, err := requireMCPString(req, "spreadsheet_id") + if err != nil { + return nil, err + } + rangeSpec, err := requireMCPString(req, "range") + if err != nil { + return nil, err + } + valuesJSON, err := requireMCPLiteralValuesJSON(req, "values_json") + if err != nil { + return nil, err + } + input := strings.TrimSpace(req.GetString("input", "USER_ENTERED")) + if input == "" { + input = "USER_ENTERED" + } + return []string{"sheets", "update", "--values-json", valuesJSON, "--input", input, "--", spreadsheetID, rangeSpec}, nil + }, + } +} + +func requireMCPText(req mcp.CallToolRequest, key string) (string, error) { + value, err := req.RequireString(key) + if err != nil { + return "", err + } + if strings.TrimSpace(value) == "" { + return "", fmt.Errorf("empty %s", key) + } + return value, nil +} + +func requireMCPLiteralValuesJSON(req mcp.CallToolRequest, key string) (string, error) { + value, err := requireMCPText(req, key) + if err != nil { + return "", err + } + trimmed := strings.TrimSpace(value) + if trimmed == "-" || strings.HasPrefix(trimmed, "@") { + return "", fmt.Errorf("%s must be literal JSON, not stdin or @file input", key) + } + var rows [][]any + dec := json.NewDecoder(bytes.NewReader([]byte(trimmed))) + dec.UseNumber() + if unmarshalErr := dec.Decode(&rows); unmarshalErr != nil { + return "", fmt.Errorf("invalid %s JSON 2D array: %w", key, unmarshalErr) + } + var extra any + if extraErr := dec.Decode(&extra); extraErr != io.EOF { + return "", fmt.Errorf("invalid %s JSON 2D array: trailing content", key) + } + canonical, err := json.Marshal(rows) + if err != nil { + return "", fmt.Errorf("canonicalize %s: %w", key, err) + } + return string(canonical), nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ed56e84e..6face1b0 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -97,6 +97,7 @@ type CLI struct { ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"` Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"` Schema SchemaCmd `cmd:"" help:"Machine-readable command/flag schema" aliases:"help-json,helpjson"` + Mcp McpCmd `cmd:"" name:"mcp" help:"Run a typed, allowlisted MCP server over stdio"` 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"` diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index cb5d0c33..ebd110b9 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "strings" @@ -186,9 +187,15 @@ func (c *SheetsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return fmt.Errorf("read --values-json: %w", err) } - if unmarshalErr := json.Unmarshal(b, &values); unmarshalErr != nil { + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.UseNumber() + if unmarshalErr := dec.Decode(&values); unmarshalErr != nil { return fmt.Errorf("invalid JSON values: %w", unmarshalErr) } + var extra any + if extraErr := dec.Decode(&extra); extraErr != io.EOF { + return fmt.Errorf("invalid JSON values: trailing content") + } case len(c.Values) > 0: // Parse comma-separated rows, pipe-separated cells rawValues := strings.Join(c.Values, " ") From 5040c06ebb8b3483748473a9a22f239ed3349da4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 16:12:51 +0100 Subject: [PATCH 2/2] docs(mcp): expand server guide --- README.md | 25 ++++ docs/mcp.md | 374 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 366 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index b902604b..5095e12b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ It is built for terminals, shell scripts, CI, and coding agents: - multiple Google accounts and OAuth clients - OAuth, direct access tokens, ADC, and Workspace service accounts - runtime command allowlists/denylists and baked safety-profile binaries +- typed [MCP server](docs/mcp.md) for agent clients, read-only by default and + without a generic command runner - read-only audit/reporting commands for risky surfaces like Drive and Contacts - [generated docs](docs/commands/README.md) for every command @@ -23,6 +25,7 @@ Start here: - [Install](docs/install.md) - [Quickstart](docs/quickstart.md) - [Auth clients and service accounts](docs/auth-clients.md) +- [MCP server](docs/mcp.md) - [Command index](docs/commands/README.md) - [Gmail watch / Pub/Sub push](docs/watch.md) () @@ -411,6 +414,28 @@ gog --json gmail search 'newer_than:7d' For stricter agent deployments, build or download a baked safety-profile binary. See [docs/safety-profiles.md](docs/safety-profiles.md). +### MCP server + +`gog mcp` exposes a typed MCP stdio server for agent clients. It registers +specific Google tools such as `gmail_search`, `docs_get`, and +`sheets_read_range`; it does not expose a generic `gog_exec` or arbitrary +command bridge. + +```bash +# Read-only server. +gog --account you@gmail.com mcp + +# Docs tools only; writes require explicit opt-in. +gog --account you@gmail.com \ + --enable-commands-exact docs.cat,docs.write \ + mcp \ + --allow-write \ + --allow-tool docs.* +``` + +See [docs/mcp.md](docs/mcp.md) for client config, tool selection, safety +behavior, mcporter examples, and troubleshooting. + ## Auth and Accounts Docs: [Auth clients](docs/auth-clients.md), diff --git a/docs/mcp.md b/docs/mcp.md index 268a6ff8..d1205542 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,64 +1,372 @@ +--- +title: MCP server +description: "Expose typed, allowlisted gog tools to MCP clients without a generic command runner." +--- + # MCP server -`gog mcp` runs a typed Model Context Protocol server over stdio. +`gog mcp` runs a Model Context Protocol server over stdio. It is for agent +clients that need Google Workspace tools but should not receive a generic shell +or arbitrary `gog` command bridge. -The MCP surface is deliberately narrower than the CLI. It registers typed tools -such as `gmail_search`, `docs_get`, and `sheets_read_range`; it does not expose a -generic command runner. Agents cannot send arbitrary `gog` argv through MCP. -Raw Google API dumps are also intentionally omitted from MCP tools when they -would bypass untrusted-content wrapping. +The server registers a small set of typed tools such as `gmail_search`, +`docs_get`, and `sheets_read_range`. Each tool has a fixed schema, maps to one +specific `gog` operation, and returns a structured result containing the tool +name, service, risk level, exit code, parsed stdout, and stderr. -## Defaults +## Quick start -By default, only read-only tools are exposed: +Start a read-only server for one account: ```bash gog --account you@example.com mcp ``` -List the tools that would be registered: +List the tools this server would expose and exit: ```bash gog --account you@example.com mcp --list-tools ``` -Narrow the surface to specific services or tools: +Limit the server to Gmail search and Docs reads: ```bash -gog --account you@example.com mcp --allow-tool gmail.*,docs_get,sheets_read_range +gog --account you@example.com mcp \ + --allow-tool gmail_search,docs_get ``` -Expose write tools explicitly: +Expose Docs read/write tools: ```bash -gog --account you@example.com mcp --allow-write --allow-tool docs_write,sheets_update_range +gog --account you@example.com mcp \ + --allow-write \ + --allow-tool docs.* ``` -## Safety model +`--allow-write` is always required for write tools. A write tool that matches +`--allow-tool` is still hidden until `--allow-write` is present. + +## Why this is not `gog_exec` + +MCP clients are often LLM-driven. A generic "run this command" tool would expose +every current and future CLI behavior through one broad capability, including +commands that were not reviewed for MCP use. + +`gog mcp` uses a narrower contract: + +- no generic command execution tool +- no model-supplied argv passthrough +- fixed tool schemas with unknown fields rejected +- read-only tools by default +- write tools require explicit server startup flags +- existing `gog` account, auth, dry-run, no-input, and command safety flags are + preserved + +This keeps MCP useful for agents while making the permission surface visible at +server startup. + +## Tool selection + +By default, all read tools are registered and write tools are hidden. + +Use `--allow-tool` to narrow the registered set. Values can be comma-separated +or repeated: + +```bash +gog mcp --allow-tool gmail_search --allow-tool docs_get +gog mcp --allow-tool gmail_search,docs_get +``` -- No generic `gog_exec` or shell bridge. -- Read-only tools are the default. -- Write tools require `--allow-write`. -- `--allow-tool` can restrict by exact tool name, service name, or service - wildcard, for example `gmail.*`. -- Parent root context is inherited: `--account`, `--home`, `--client`, JSON - output, `--wrap-untrusted`, `--no-input`, and command allow/deny safety flags. -- Tool calls run as subprocesses with a timeout and bounded stdout/stderr. +Accepted selectors: -## Initial tool set +| Selector | Meaning | +| --- | --- | +| `gmail_search` | One exact tool | +| `gmail` | All Gmail tools allowed by risk mode | +| `gmail.*` | All Gmail tools allowed by risk mode | +| `read` | All read tools | +| `write` | All write tools, only when `--allow-write` is also set | +| `*` or `all` | All tools allowed by risk mode | + +Examples: + +```bash +# Read-only Gmail tools. +gog mcp --allow-tool gmail + +# Only Docs tools, including writes. +gog mcp --allow-write --allow-tool docs.* + +# Read-only server, but only Calendar and Sheets reads. +gog mcp --allow-tool calendar,sheets + +# All current write tools. Read tools are not included unless also selected. +gog mcp --allow-write --allow-tool write +``` + +## Initial tools Read tools: -- `gmail_search` -- `gmail_get_message` -- `gmail_get_thread` -- `drive_search` -- `drive_get` -- `docs_get` -- `sheets_read_range` -- `calendar_events` +| Tool | Purpose | +| --- | --- | +| `gmail_search` | Search Gmail messages with Gmail query syntax. | +| `gmail_get_message` | Read one Gmail message by ID. Sanitized content is on by default. | +| `gmail_get_thread` | Read one Gmail thread by ID. Sanitized content is on by default. | +| `drive_search` | Search Drive files by text or Drive query language. | +| `drive_get` | Read Drive file metadata by ID. | +| `docs_get` | Read a Google Doc as wrapped text, optionally one tab or all tabs. | +| `sheets_read_range` | Read values from a Sheets range. | +| `calendar_events` | List Calendar events. | Write tools, hidden unless `--allow-write`: -- `docs_write` -- `sheets_update_range` +| Tool | Purpose | +| --- | --- | +| `docs_write` | Append or replace Google Docs text, optionally as Markdown. | +| `sheets_update_range` | Update values in a Sheets range from a literal JSON 2D array. | + +The generated command reference for the server itself is +[`gog mcp`](commands/gog-mcp.md). + +## Client configuration + +MCP clients usually need a command and an argument list. Put account selection +and safety policy on the server command, not inside tool calls. + +Minimal stdio configuration: + +```json +{ + "command": "gog", + "args": ["--account", "you@example.com", "mcp"] +} +``` + +Read-only Docs and Sheets configuration: + +```json +{ + "command": "gog", + "args": [ + "--account", "you@example.com", + "--enable-commands-exact", "docs.cat,sheets.get", + "mcp", + "--allow-tool", "docs_get,sheets_read_range" + ] +} +``` + +Docs read/write configuration: + +```json +{ + "command": "gog", + "args": [ + "--account", "you@example.com", + "--enable-commands-exact", "docs.cat,docs.write", + "--no-input", + "mcp", + "--allow-write", + "--allow-tool", "docs.*" + ] +} +``` + +For headless services, set `GOG_KEYRING_BACKEND=file` and +`GOG_KEYRING_PASSWORD` on the MCP client process or service unit. A successful +interactive shell check does not prove the MCP client inherited those +variables; verify through the same process manager that launches the server. + +## mcporter examples + +List registered tools and their schemas: + +```bash +mcporter list \ + --stdio gog \ + --stdio-arg --account \ + --stdio-arg you@example.com \ + --stdio-arg mcp \ + --stdio-arg --allow-tool \ + --stdio-arg docs.* \ + --schema \ + --json +``` + +Dry-run a Docs write through MCP: + +```bash +mcporter call \ + --stdio gog \ + --stdio-arg --account \ + --stdio-arg you@example.com \ + --stdio-arg --dry-run \ + --stdio-arg mcp \ + --stdio-arg --allow-write \ + --stdio-arg --allow-tool \ + --stdio-arg docs_write \ + docs_write \ + '{"document_id":"DOCUMENT_ID","text":"MCP smoke test\n","append":true}' +``` + +Read a Sheet range: + +```bash +mcporter call \ + --stdio gog \ + --stdio-arg --account \ + --stdio-arg you@example.com \ + --stdio-arg mcp \ + --stdio-arg --allow-tool \ + --stdio-arg sheets_read_range \ + sheets_read_range \ + '{"spreadsheet_id":"SPREADSHEET_ID","range":"Sheet1!A1:C10"}' +``` + +Update a Sheet range: + +```bash +mcporter call \ + --stdio gog \ + --stdio-arg --account \ + --stdio-arg you@example.com \ + --stdio-arg mcp \ + --stdio-arg --allow-write \ + --stdio-arg --allow-tool \ + --stdio-arg sheets_update_range \ + sheets_update_range \ + '{"spreadsheet_id":"SPREADSHEET_ID","range":"Sheet1!A1:B1","values_json":"[[\"status\",\"ok\"]]","input":"RAW"}' +``` + +`sheets_update_range.values_json` must be literal JSON. MCP rejects `@file`, +`@-`, and `-` expansion forms so a model cannot cause the server process to +read arbitrary local files or stdin. + +## Safety model + +Tool calls run as subprocesses of the same `gog` executable. The server adds a +non-interactive, agent-oriented root context to every child command: + +- `--json` +- `--wrap-untrusted` +- `--no-input` +- `--color=never` + +The server also preserves selected parent root flags: + +- `--account` +- `--client` +- `--home` +- `--dry-run` +- `--results-only` +- `--select` +- direct access tokens + +And it preserves command safety flags: + +- `--gmail-no-send` +- `--enable-commands` +- `--enable-commands-exact` +- `--disable-commands` + +Use both MCP tool allowlists and command allowlists when the server is exposed +to an untrusted or semi-trusted agent: + +```bash +gog --account you@example.com \ + --enable-commands-exact docs.cat,docs.write \ + --disable-commands gmail.send,gmail.drafts.send \ + --gmail-no-send \ + mcp \ + --allow-write \ + --allow-tool docs.* +``` + +If a tool maps to a disabled command, the tool call returns a non-zero exit code +and the child command error in `stderr`. + +## Output shape + +Successful calls return structured MCP content shaped like: + +```json +{ + "tool": "docs_get", + "service": "docs", + "risk": "read", + "exit_code": 0, + "stdout": { + "documentId": "..." + }, + "stderr": "" +} +``` + +If a child command prints valid JSON, `stdout` is parsed as JSON with numeric +literals preserved. Otherwise `stdout` is returned as a string. Empty stdout is +omitted. + +If the child command exits non-zero, the MCP result is marked as an error and +includes the same structured fields with `exit_code` and `stderr`. + +## Limits and timeouts + +Each tool call has a subprocess timeout and bounded stdout/stderr capture: + +```bash +gog mcp --timeout-seconds 30 --max-output-bytes 262144 +``` + +Defaults: + +- timeout: 60 seconds +- max captured stdout/stderr: 102400 bytes each + +Use command-specific limits too. For example, `docs_get` has a `max_bytes` +argument, and search tools have `max` arguments. + +## Authentication + +The MCP server uses normal `gog` auth. Before wiring a client, verify the same +account and scopes from a shell: + +```bash +gog --account you@example.com auth doctor --check +gog --account you@example.com mcp --list-tools +``` + +Then verify through the MCP client entrypoint. In services and desktop MCP +clients, most auth failures are environment inheritance problems: missing +`GOG_ACCOUNT`, missing file-keyring password, different `GOG_HOME`, or a +different OAuth client selected by `--client`. + +## Troubleshooting + +`no MCP tools enabled` + +: Your `--allow-tool` filters excluded everything, or you selected only write + tools without `--allow-write`. + +`command "..." is disabled` + +: The MCP tool was registered, but the child `gog` command was blocked by + `--enable-commands`, `--enable-commands-exact`, `--disable-commands`, or a + baked safety profile. + +Tool missing in the client + +: Run `gog mcp --list-tools` with the same flags. If the tool is not listed, + fix `--allow-tool` or add `--allow-write` for write tools. If it is listed, + refresh or restart the MCP client. + +Auth works in Terminal but not in the MCP client + +: Compare `--account`, `--client`, `--home`, `GOG_HOME`, + `GOG_KEYRING_BACKEND`, and `GOG_KEYRING_PASSWORD` in the process that starts + the MCP server. + +Large output is truncated + +: Increase `--max-output-bytes`, narrow the request, or use tool arguments such + as `max`, `max_bytes`, date ranges, or Drive field masks.