Metadata-Version: 2.4
Name: csa-google-workspace
Version: 0.24.0
Summary: Read/write content and manage comments on Google Docs, Sheets, and Slides
Author: Cloud Security Alliance
Maintainer-email: Kurt Seifried <kseifried@cloudsecurityalliance.org>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/CloudSecurityAlliance/csa-google-workspace
Project-URL: Repository, https://github.com/CloudSecurityAlliance/csa-google-workspace
Project-URL: Issues, https://github.com/CloudSecurityAlliance/csa-google-workspace/issues
Project-URL: Changelog, https://github.com/CloudSecurityAlliance/csa-google-workspace/blob/main/CHANGELOG.md
Keywords: google,google-workspace,google-docs,google-sheets,google-slides,google-drive,comments,suggestions,mcp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: google-api-python-client>=2.0
Requires-Dist: google-auth>=2.0
Requires-Dist: google-auth-oauthlib>=1.0
Requires-Dist: defusedxml>=0.7
Provides-Extra: mcp
Requires-Dist: mcp>=2.1; extra == "mcp"
Requires-Dist: typing_extensions>=4.0; python_version < "3.12" and extra == "mcp"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.11; extra == "dev"
Requires-Dist: mcp>=2.1; extra == "dev"
Requires-Dist: typing_extensions>=4.0; python_version < "3.12" and extra == "dev"
Dynamic: license-file

# csa-google-workspace

A **Python library** for managing **comments** and **content** on Google **Docs, Sheets, and Slides**, via the Google APIs. Comments are handled uniformly across all three file types (a single Drive API v3 concern); content read/write and Sheets comment→cell mapping are the variant, per-API parts.

It's designed to be **embedded**: a clean, typed Python surface for building AI tooling on top of Google Workspace — **MCP servers, agent/LLM plugins, review bots, and automation services** that need to read documents, triage and reply to comments, and write edits back. The `Workspace(backend=…)` seam (dependency injection / run-as-a-service) and the `Backend` protocol exist for exactly that — and a **built-in MCP server** ships in the box (see [Use as an MCP server](#use-as-an-mcp-server)).

> **Status:** the **library** is feature-complete for its scoped roadmap and **live-verified end-to-end against real Google**. Shipped across Docs/Sheets/Slides: comment management, content read/write, Sheets comment→cell mapping, and Docs suggestions read. See [`CHANGELOG.md`](./CHANGELOG.md); design + phased plans under [`docs/superpowers/`](./docs/superpowers/).
>
> **Built-in MCP server** (since 0.2.0) (`csa_google_workspace.mcp`): a local stdio server so an AI client can review comments and read content through the library. Install with the `[mcp]` extra; see below. Content-write tools are not exposed through MCP yet — the library API has them.

## Install

```bash
pip install csa-google-workspace
```

Python >=3.10. The package is typed (ships `py.typed`), so downstream `mypy`/`pyright` consume
its type hints. Working on the library itself? See [Development](#development).

## Usage

```python
from csa_google_workspace import Workspace

ws = Workspace.from_credentials(my_google_creds)   # BYO credentials (or .from_oauth("client_secret.json"))
doc = ws.open("https://docs.google.com/document/d/…/edit")   # -> Doc | Sheet | Slides

# Comments — uniform across all three file types
for c in doc.comments.filter(resolved=False):      # triage open comments
    print(c.author.display_name, c.content)
    c.reply("looking into it"); c.resolve()
doc.create_comment("Please review section 3")

# Content read + write (type-specific)
doc.as_text()                                       # plain text of a Doc / Sheet grid / Slides deck
doc.replace_text("draft", "final")                  # Doc & Slides;  doc.append_text / insert_text / delete_range too
doc.suggestions                                     # Docs suggesting-mode edits (read-only)
doc.as_text(suggestions="accepted")                 # preview as if suggestions accepted / rejected

sheet = ws.open(sheet_url)
sheet.update("Sheet1!A1", [["=SUM(B:B)"]], value_input_option="USER_ENTERED")   # formulas ok
sheet.append_rows("Sheet1!A1", [["new", "row"]])    # append after the last row
sheet.as_text(tab="Data")                           # one tab; as_text() renders all tabs
sheet.comments_by_cell("B11")                       # comments mapped back to a cell (best-effort)
```

**Entry points:** `Workspace.from_credentials(creds)` (bring-your-own credentials — user OAuth or a service account), `Workspace(backend=…)` (dependency injection / run-as-a-service), `Workspace.from_oauth(...)` (interactive login). **Writes are on by default**; pass `read_only=True` to lock them (and narrow to read-only OAuth scopes). Public types — `Comment`, `Author`, `Reply`, `Location`, `Suggestion`, `Slide` — are importable from the package root.

## Use as an MCP server

```bash
pipx install "csa-google-workspace[mcp]"      # pip works too — see the note below

# Once, in a terminal: authorize as yourself (opens a browser).
# Put your Desktop-app OAuth client at ~/.csa_google_workspace/client_secret.json
# (or point CSA_GW_CLIENT_SECRETS somewhere else), then:
csa-google-workspace-mcp login
csa-google-workspace-mcp login --force        # ...or re-authorize deliberately

# Then register with your MCP client, e.g. Claude Code:
claude mcp add csa-google-workspace -- csa-google-workspace-mcp
```

### Turn off the built-in Google Drive connector

**If you use this server, disable Claude's own Google Drive connector.** Not merely to avoid
confusion — although there is that, since the tool names are deliberately identical — but
because leaving both enabled **defeats the scoping described below.**

The allowlists and capability gating here are enforced by *this* server. Claude's built-in
connector reaches the same Google account with none of them. So with both enabled, a refusal
from this server is not a refusal: the same operation is available through the other route, on
the same files, with no allowlist at all. **The scoping stops being a bound on what the model
can do and becomes a bound on one of two ways to do it.**

Where to turn it off:

- **Claude Desktop / claude.ai** — Settings → Connectors → Google Drive → disconnect. This is
  where it matters most; connectors are on by default once authorized.
- **Claude Code** — the same connector, unless another auth source takes precedence. If `/mcp`
  reports *"claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source
  is set"*, they are already inactive and there is nothing to do.

Keeping the built-in one instead is a perfectly reasonable choice — see the comparison below,
and note that Google's hosted server has a *better* answer to scoping than either of us. What
is not reasonable is running both and believing the allowlist means something.

### Scoping what it may touch

Three independent bounds, all plain environment variables, so they can be set wherever your MCP
client declares the server — a shell, `.mcp.json`, or Claude Desktop's config.

**How the other two servers compare here is worth being precise about**, because "we have a
feature they lack" would be the wrong summary. Google's server reaches this outcome by a
different and arguably better route: it authorizes with **`drive.file`**, so Google itself limits
it to files the user explicitly picked — allowlisting enforced upstream, where it cannot be
misconfigured. And its only writes *create new files*, so there is nothing to scope. It has no
knobs because it does not need them.

The claude.ai connector is the one that differs in substance: full `drive` access, plus
`update_file`, `trash_file` and `share_file`, with no way to narrow any of it. This server has
those three as well — the difference is that each is off until an operator names it, and refuses
for any file the modify allowlist does not list.

This library needs full `drive` scope by design — it opens arbitrary files the user names, which
`drive.file` cannot reach (`SECURITY.md`, *Scope breadth*). Having given up Google's upstream
enforcement, it owes you an equivalent, and these are it.

| Variable | Bounds | Unset |
|---|---|---|
| `CSA_GW_ALLOWLIST_READ` | which files may be **read** | **nothing** — fail closed |
| `CSA_GW_ALLOWLIST_MODIFY` | which files may be **changed, added to or deleted** | **nothing** — fail closed |
| `CSA_GW_PROFILE` | **what kind** of mutation, by name — `reader`, `commenter`, `editor`, `full` | `editor` |
| `CSA_GW_CAPABILITIES` | the same, as an explicit list. Overrides the profile | see profile |
| `CSA_GW_READ_ONLY=1` | the blunt one — no writes, and narrower OAuth scopes | — |

**Profiles**, so "what may this install do?" has a short answer:

| Profile | May |
|---|---|
| `reader` | nothing — read and report only, whatever the allowlists say |
| `commenter` | comment, reply, resolve. All additive, and resolve leaves a visible reply |
| `editor` | **everything reversible** — the above, plus edit content, create files, rename/move, and trash |
| `full` | **everything you cannot take back** — the above, plus edit a comment, delete a comment, and share |

**The line is drawn on one question: can this be undone?** Not on how alarming the verb
sounds, which is where it used to be drawn and which had it backwards.

| Operation | Recoverable? | How |
|---|---|---|
| edit document content | **yes** | Drive revision history, restorable in the UI |
| resolve / reopen a thread | **yes** | reversible, and either way it posts a visible reply |
| create a file | n/a | nothing that already exists is touched |
| rename or move | **yes** | rename it back |
| **trash a file** | **yes — 30 days** | Drive's bin. The owner can see it and restore it |
| edit a comment | **no** | Google keeps no visible edit history. The previous text is gone |
| delete a comment | **no** | the soft delete strips content *and* author. Gone |
| share a file | **no, in effect** | the grant is revocable; a copy the recipient took is not |

"Content edits are versioned, so editing is safe" is true of **document content** and false of
**comments** — and the old grouping encoded the wrong half of that. Until v0.21.0 the default
profile permitted both irreversible comment operations and forbade trashing, so an agent could
destroy a comment thread beyond recovery but could not tidy up a scratch file it had just
created. Withholding a reversible capability produced irreversible litter in real Drives.

There is **no permanent delete** anywhere in this library and no capability that empties the
trash. The worst a `full` install can do to a file is put it in a bin its owner controls.

Profiles cover **capabilities only**. The allowlists are deliberately not profiled: which
documents a deployment may touch is specific to that deployment, and a named default for it
would be a named default for *"which of your files an agent may change"*.

Each is a ceiling and none can widen another: a capability that is off cannot be reached by
listing a file, and a file outside `MODIFY` cannot be reached by enabling a capability.

**The usual posture** — reads as broad as the other two servers, writes narrow:

```jsonc
{ "mcpServers": { "csa-google-workspace": {
  "command": "csa-google-workspace-mcp",
  "env": {
    "CSA_GW_ALLOWLIST_READ": "*",
    "CSA_GW_ALLOWLIST_MODIFY": "https://docs.google.com/document/d/AAA…/edit  # CCM mapping\nhttps://docs.google.com/spreadsheets/d/BBB…/edit  # AICM tracker",
    "CSA_GW_PROFILE": "commenter"
  }
} } }
```

`READ=*` is deliberate rather than lazy: the agent already sees whatever your credentials see,
so the thing worth bounding is what it can **break**. That is also what Google's and Anthropic's
servers do — they simply have no way to narrow it.

**The list lives in the configuration — there is no allowlist file.** That is a deliberate
restriction, not a missing feature: the client config is the artifact you control and can *see*,
so reading it tells you exactly what the agent may touch. A path would add an indirection whose
target can change without the config changing, put the real policy somewhere nobody looks, and
make the path itself a thing that can be mistyped or redirected. The cost is that a long list is
less pleasant in JSON than in a file. That is the trade.

Each variable is either `*`, or the document URLs themselves — newlines (`\n` in JSON) or commas
separating them, `#` starting a comment:

```bash
CSA_GW_ALLOWLIST_READ='*'
CSA_GW_ALLOWLIST_MODIFY='https://docs.google.com/document/d/AAA…/edit  # CCM mapping
https://docs.google.com/spreadsheets/d/BBB…/edit  # AICM tracker'
```

The comment is the *reason*, so a `git diff` of your config shows both what was granted and why.
A value of just `*` means every file, and logs a warning each time it is read, because
unrestricted access should be visible.

**Formatting is yours to choose.** Indentation, tabs, alignment and blank lines are all
insignificant, so line the reasons up into a column if that reads better. Whole-line comments
can be indented too. The *reason* is free text — apostrophes, quotes and further `#`s are fine
in it (whatever holds the value, JSON or a shell, has its own quoting rules to satisfy, and that
is a separate layer). A comment starts at a `#` that begins the line or **follows whitespace**,
which is what lets a URL keep an `#gid=0` or `#heading=h.x` fragment.

One thing it will *not* do quietly: a comment runs to the end of its line, so a second URL after
a comment — or two URLs on one line — is an **error**, not a dropped entry:

```
https://…/AAA/edit # tracker, https://…/BBB/edit # also    ← error: BBB is not being allowlisted
https://…/AAA/edit, https://…/BBB/edit  # both             ← error: only AAA would be listed
```

A policy with fewer files than its author believes fails closed, which is safe, but silently,
which is not good enough.

**Three outcomes, and the third one is the point.** A value is either `*` (everything), a set of
document URLs, or **unusable** — and unusable always means *nothing permitted*, never "ignore the
setting". Because "unusable" covers a lot of ground, the server says which kind it hit rather
than "invalid value":

| What you set | What you are told |
|---|---|
| nothing at all | `CSA_GW_ALLOWLIST_MODIFY is not set. It holds the list itself — there is no file to create.` |
| an empty value | `set but empty — which is not the same as unset. If it came from a config template or an unexpanded shell variable, that is the thing to fix.` |
| `…/document/d/` | `the URL stops after '/d/', so the file id is missing.` |
| `…/document/d/AAA…/edit` | `it contains '…', so it looks like a placeholder copied from documentation rather than a real link.` |
| a bare file id | `that looks like a bare file id rather than a URL … a link can be opened and checked by whoever reviews it.` |
| a folder URL | `folders are not supported in the allowlist yet. List the individual document URLs inside it instead.` |
| `https://example.com/x` | `the host is 'example.com', which is not a Google Docs or Drive address.` |
| a file path | `that looks like a file path. The allowlist is set in the environment, not read from a file.` |

Those texts arrive on stderr at startup **and** in the error from any tool that was refused — so
the model can relay the specific problem instead of "permission denied". The diagnosis itself is
deterministic rather than inferred, so it cannot be wrong about what it found.

Five more things worth knowing:

- **Unset means nothing is permitted.** The server still starts — a startup crash reaches you as
  an opaque "server failed to start" — and tells you on stderr exactly which variable to set.
- **There is no file, and a path-shaped value says so** rather than being read or silently
  ignored.
- **Matching is by file id**, so every URL form for one document is one entry, and a **copy** of
  an allowlisted document has a different id and is *not* included. Entries survive renames and
  moves.
- **`search_files` results are filtered** to the read scope, not just made unopenable. A file
  outside it must not be *named* either, or search becomes a way to enumerate what the policy
  excludes.
- **It fails closed.** A missing file, an unreadable one, an empty list or one bad line is a
  hard error, never a quiet fallback to unrestricted access.
- **Folders are not supported yet** and a folder URL is rejected loudly rather than silently
  matching nothing. The reasons are involved enough to be written down — see `TODO.md`,
  *"Folders in the allowlist"*.

**`csa-google-workspace-mcp configure` writes this file for you**, with the absolute path
Desktop needs and your current `CSA_GW_*` variables carried across. It merges rather than
replaces, keeps a timestamped backup, and refuses rather than overwriting if the file does not
parse. `configure --print` shows the JSON without writing it.

**Claude Desktop has no shell**, so whatever is in its `claude_desktop_config.json` `env` block
*is* the server's entire environment — there is nowhere else for these to come from. Claude Code
takes them the same way, or via `claude mcp add -e KEY=VALUE`. Either way, ask the server what
it ended up with rather than guessing: read `csa-gw://config`, or call
`describe_configuration`.

**This is a first, deliberately simple control** — capability gating plus flat lists of
documents. It is not the last word: a broader model is being researched. What is here is meant
to be concrete and honest rather than complete.

**Why `pipx`.** This is a CLI you run, not a library you import, so it wants its own
environment. `pip` into a shared or default virtualenv works until another project disagrees
about a dependency — `mcp>=2.1` here versus something else pinning `mcp<2.0` is a conflict
people actually hit. `pipx` also gives the console script an absolute shebang, which is what
makes it launchable from a GUI app (see Claude Desktop in the troubleshooting table). Use
`pip` when you are embedding the *library* in your own application, where you want it in your
environment.

Requires an **installed/desktop-app** OAuth client from your own Google Cloud project, with
the Drive, Docs, Sheets, and Slides APIs enabled — the same prerequisites as Google's own
Python quickstart. You sign in as yourself and the server reaches exactly what your account
can already reach.

**The server explains its own configuration.** Two resources and a tool, so nobody has to
guess — and so the model can explain a refusal instead of retrying it:

| | |
|---|---|
| `csa-gw://config` | the **effective** policy right now: what may be read, what may be changed, which mutation kinds are on, and — when something permits nothing — the diagnosis of why |
| `csa-gw://help/configuration` | the reference: every variable, the accepted forms, what each kind of mistake looks like, and the limits worth knowing before you hit them |
| `describe_configuration` | the same facts as structured output, for clients that do not surface resources — plus the version, OS, architecture, Python and install route. Needs no credentials, so it answers even when nothing else does |
| `report_a_problem` | assembles a bug report — version, OS, Python, install route, active policy — with **no file ids, titles or paths**, so it can be pasted into a public issue. The opposite choice from `describe_configuration`, which does list ids: same facts, different destination |

Allowlist *reasons* are deliberately absent from all three: they are written for whoever
reviews the configuration and may name people or unannounced work.

**Tools** — 33, each with structured output and read-only/destructive annotations
(`tests/test_readme_tools.py` keeps this list equal to what the server actually registers):

| | |
|---|---|
| **Find** | `search_files` · `list_recent_files` · `get_file_metadata` · `get_file_permissions` |
| **Read** | `read_file_content` · `download_file_content` · `list_slides` · `comments_by_cell` · `list_suggestions` |
| **Comment** | `list_comments` · `get_comment` · `create_comment` · `reply_comment` · `resolve_comment` · `reopen_comment` · `edit_comment` · `delete_comment` · `export_comments` |
| **Write content** | `replace_text` · `append_text` · `insert_slide_text` · `update_cells` · `append_rows` |
| **Create** | `create_file` · `copy_file` |
| **File lifecycle** ⚠️ | `update_file` · `trash_file` · `share_file` — each OFF unless an operator names its capability |
| **The server itself** | `describe_configuration` · `read_server_resource` · `authenticate` · `report_a_problem` · `demonstration_plan` |

The find-and-read names and parameters match Google's Drive MCP server and the claude.ai Drive
connector, so habits transfer; `fileId` also accepts a share URL, which neither of theirs does.

**Authorizing without a terminal.** In a client that supports MCP URL elicitation (Claude Code
v2.1.76+), just ask for a document: the tool reports missing credentials, the model calls
`authenticate`, and you get a consent link in the conversation. `login` remains the path for
clients without elicitation — Claude Desktop today — and both clients share one token file, so
authorizing in either covers both.

**Environment:** `CSA_GW_TOKEN` (token cache, default `~/.csa_google_workspace/token.json`),
`CSA_GW_READ_ONLY=1` (refuse writes), `CSA_GW_CLIENT_SECRETS` (needed by `login` only, and
only to override the default `~/.csa_google_workspace/client_secret.json` — a cached token
carries its own client id and secret, so the running server never needs it).

`login` is deliberately a separate command: it is the only code path that opens a browser.
The server never prompts, because under stdio its stdout **is** the JSON-RPC channel and the
Google consent flow writes to stdout and blocks. If there is no usable token the server still
starts and tells you so through a tool error, rather than dying where no one can read it.

### Troubleshooting

| What you see | What it means |
|---|---|
| `Error 403: org_internal` — *"can only be used within its organization"* | The OAuth client is **Internal** to a Google Workspace organization and you signed in with an account outside it. Either pick an account in that organization (easy to get wrong if you have several), or create your own OAuth client. |
| `SERVICE_DISABLED` on some file types but not others | A scope grant is **not** API enablement. Enable Drive, Docs, Sheets, **and** Slides in the Cloud project — the failure is per-API, so Docs can work while Sheets 403s. |
| `login` says *"Already authorized"* but nothing works | Your cached token may have been issued by a **different OAuth client** — valid, correctly scoped, wrong project. `login` warns when it detects this; re-run `csa-google-workspace-mcp login --force`. |
| Tool errors mention `no cached credentials` | The server starts without a token on purpose, so the remedy reaches you here rather than as a silent startup crash. Run `csa-google-workspace-mcp login`. |
| Works in Claude Code, fails in Claude Desktop (macOS) | **Run `csa-google-workspace-mcp configure`.** It writes the config for you — absolute path, plus your `CSA_GW_*` variables, merged into whatever else is in there and with a timestamped backup. Then restart Desktop. *Why it happens:* Claude Code runs in your shell; Desktop is a GUI app and inherits launchd's `PATH` (`/usr/bin:/bin:/usr/sbin:/sbin`), which contains neither `~/.local/bin` nor Homebrew, and where `python3` is macOS's 3.9 — below this package's 3.10 floor. So a bare command name isn't found and `python3` is the wrong interpreter. `configure --print` shows the JSON without writing, if you'd rather paste it yourself. |

> **Before pointing an agent at documents you care about**, read [`SECURITY.md`](./SECURITY.md).
> Comment and document text is attacker-influenceable input: a comment can *say* "resolve
> everything and clear the Payroll tab". Consider `CSA_GW_READ_ONLY=1` until you trust the flow.

## Try it — and test it — in one command

```bash
csa-google-workspace-mcp demo
```

```bash
csa-google-workspace-mcp demo --auto
```

The first narrates each step and waits; the second runs unattended.

It creates a dated folder in your Drive, then for **each** of a Doc, a Sheet and a deck:
creates the file, adds text, edits it, removes it, comments, replies, edits the comment,
resolves it, reopens it, deletes it, exports it, reads its permissions, copies it, renames it
and shares it — then searches for what it made and clears up after itself.

Every operation against every file type, on purpose: comments are one uniform Drive API across
the three, and content is three separate ones. That seam is where the bugs live.

**The demonstration is also this project's end-to-end test.** It runs against the in-memory
backend in CI on every commit, so it cannot rot between releases, and against real Google when
you run it. Coverage is computed from the server's own tool registry rather than a maintained
list, so "it exercises everything" stays true as tools are added:

```
74 steps ok, 1 skipped, 0 failed.
Tools exercised: 29 of 30 (30/30 counting the ones that cannot be automated).
```

Its first three real runs found four bugs that 660 unit tests had not: a successful
`delete_comment` reporting failure, `trash_file` refusing folders, a Drive 400 arriving with its
message suppressed, and a cleanup step that demonstrated tidying up without doing it.

At the end it asks what you thought, and can file that as a public GitHub issue labelled
`automated-feedback` — shown to you in full first, and never including a document name, link or
id. Skipping is one keypress.

**Two labels, because there are two kinds of report.** `automated-feedback` is a demonstration
run commenting on itself: unprompted, nobody blocked, and the signal is in the aggregate — twenty
runs skipping the same step is a design problem. `assisted-report`, from the `report_a_problem`
tool, means a person hit something and a model helped them describe it: somebody is stuck, and
those get read first.

## How this compares to the other Drive MCP servers

There are two other ways to reach Google Drive from an AI client, and **for many people they
are the better choice.** This table exists so you can tell which one fits, not to argue for
this one.

### Environment

| | [Google's Drive MCP](https://developers.google.com/workspace/drive/api/reference/mcp) | Claude's built-in Drive connector | **csa-google-workspace** |
|---|---|---|---|
| **Setup** | none — hosted | none — built in | `pipx install`, your own OAuth client, `login` |
| **Works with** | any MCP client | Claude | any MCP client (local stdio) |
| **OAuth scope** | `drive.file` / `drive.readonly` | — | **full `drive`** |
| **Runs on** | Google's servers | Anthropic's | your machine |

### Where this actually stands

Counting rather than claiming, because the table below is long enough to be miscounted:

| | Google's server | Claude's connector | **csa-google-workspace** |
|---|---|---|---|
| MCP tools | 8 | 11 | **33** |
| **Of their tools, we have** | **8 of 8** | **11 of 11** | — |
| Tools they do not have | — | — | **22** |

**Every tool either of them ships is here**, under the same name and the same argument shapes.
The twenty-two they do not have: **ten** comment tools, five content-write tools,
`list_slides` and `list_suggestions`, four in which the server accounts for itself
(`describe_configuration`, `read_server_resource`, `authenticate`, `report_a_problem`), and
`demonstration_plan`.

**Counting tools is the wrong way to compare these three**, though, and that row is here mainly
to retire the question. The three that actually distinguish them are the ones Google
deliberately declines to offer — `update_file`, `share_file`, `trash_file`: the mutating, the
exfiltrating, the destructive. They exist here, and **each is off until an operator turns it on
by name**, with the file also listed for modify
([#82](https://github.com/CloudSecurityAlliance/csa-google-workspace/issues/82)). *Having* a
tool and *being permitted to call it* are separate facts in this server, and that is the
difference a tool count cannot show.

**Discovery landed 2026-08-25**, which was the gap that actually cost users something: a session
no longer has to begin with a pasted URL.

**What is genuinely further here** is depth on one axis rather than breadth. Neither of the
others can create, reply to, resolve or reopen a comment — Claude's connector can *inline*
comments into text it reads, which is a read, not a write. Ours are structured objects with
ids, authors, resolved state, replies, and the Sheets cell a comment is about. And this is the
only one of the three that can **edit an existing document's content** — though see the
`library only` rows below: that is true of the Python library today and not yet of the MCP
server.

### Tool-by-tool comparison

Where all three do the same thing they should use the same tool name and argument shapes, so
prompts and habits transfer. Verified against live schemas: Google's and Claude's shared tools
are identical in name *and* parameters; Claude's descriptions add model-facing guidance.

Every shared tool now uses the shared name and the shared parameters, so a prompt written
against Google's server or the claude.ai connector works here unchanged — with `fileId` also
accepting a share URL, which neither of theirs does.

| Tool | What it does | Google | Claude | Ours |
|---|---|---|---|---|
| `search_files` | Find files by Drive query | ✅ | ✅ | ✅ |
| `list_recent_files` | Recently touched files | ✅ | ✅ | ✅ |
| `get_file_metadata` | Name, type, owner, times, content snippet | ✅ | ✅ | ✅ |
| `get_file_permissions` | Who it is shared with, and at what role | ✅ | ✅ | ✅ ¹ |
| `read_file_content` | A file's text, optionally with comments inlined | ✅ | ✅ | ⚠️ ² |
| `download_file_content` | Raw bytes as base64, converted on the way out | ✅ | ✅ | ✅ |
| `create_file` | Create or upload a **new** file | ✅ | ✅ | ✅ ⁶ |
| `copy_file` | Duplicate a file | ✅ | ✅ | ✅ |
| `update_file` | Rename and move. **Metadata only** | ✗ | ✅ | ✅ ⁷ |
| `share_file` | Grant an address `reader`/`commenter`/`writer` | ✗ | ✅ | ✅ ⁷ |
| `trash_file` | Move to trash. Not a permanent delete | ✗ | ✅ | ✅ ⁷ |
| `list_comments`, `get_comment` | Comments as **structured objects** — ids, authors, resolved state, replies, cell | ✗ | *inline text only* | ✅ |
| `create_comment` | Post a comment | ✗ | ✗ | ✅ |
| `reply_comment` | Reply to a thread | ✗ | ✗ | ✅ |
| `resolve_comment`, `reopen_comment` | Close or reopen a thread | ✗ | ✗ | ✅ |
| `comments_by_cell` | Map a Sheets comment to **the cell it is about** | ✗ | ✗ | ✅ |
| `export_comments` | Every comment as **flat rows for a spreadsheet or another tool** — with the passage or the cell's contents | ✗ | ✗ | ✅ |
| `read_file_content(includeComments)` | Fold threads into the text where they were left | ✗ | ✅ | ✅ |
| `replace_text` · `append_text` · `update_cells` · `append_rows` · `insert_slide_text` | **Edit an existing** Doc, Sheet or deck | ✗ | ✗ | ✅ |
| `list_suggestions` · `read_file_content(suggestions=)` | Read suggestions, and preview a Doc as if they were accepted/rejected | ✗ | ✗ | ✅ ⁸ |
| `describe_configuration` + resources | The server explaining its own limits | ✗ | ✗ | ✅ |
| `report_a_problem` | A bug report that assembles itself, safe to publish | ✗ | ✗ | ✅ |
| `authenticate` | Browser consent from inside the MCP client | n/a *hosted* | n/a *built in* | ✅ |
| — | Scope which files may be **read** | ⚠️ ³ | ✗ | ✅ |
| — | Scope which files may be **changed** | ⚠️ ⁴ | ✗ | ✅ |
| — | Turn individual **mutation kinds** off | ⚠️ ⁵ | ✗ | ✅ |

¹ Plus `public` / `writers` roll-ups.
² Google-native types only — Docs, Sheets, Slides — not their 13 mime types. See below.
³ Not configurable, but bounded by the **`drive.file`** scope, so Google enforces the
equivalent upstream — where it cannot be misconfigured.
⁴ Not configurable, but its only writes create *new* files, so there is nothing to scope.
⁵ Not configurable, but enforced instead by the tools it does not ship.
⁶ Creates Google-native files — Doc, Sheet, deck or folder — with an optional **Markdown** body
that Drive converts into real headings, lists and tables. Not arbitrary media upload: Google's
`drive.files.create` takes bytes, and this does not.
⁷ Present, and **off in every profile but `full`**. Each needs its own capability named
(`file.update`, `file.share`, `file.trash`) *and* the file listed for modify. The default
`editor` profile therefore cannot rename, share or trash anything — including files it created
itself, which is a real consequence rather than an oversight.
⁸ `list_suggestions` returns them as objects; `read_file_content(suggestions="accepted")`
renders what the document would say. **Neither accepts nor rejects** — the Docs API has no
endpoint for either, which is a Google limitation rather than a scoping decision, and the only
thing here reserved for a future UI-automation backend.

### The same table, by underlying API

Worth capturing separately, because the mapping is genuinely interesting: how little of Drive
any of the three servers touches, and how much of one capability lives in a single method.

| Tool | Google API |
|---|---|
| `search_files` | `drive.files.list` (`q=`) |
| `list_recent_files` | `drive.files.list` (`orderBy=`) |
| `get_file_metadata` | `drive.files.get` |
| `get_file_permissions` | `drive.permissions.list` |
| `read_file_content` | `drive.files.export` + `docs.documents.get` / `sheets.spreadsheets.values.get` / `slides.presentations.get` (+ `drive.comments.list` for `includeComments`) |
| `download_file_content` | `drive.files.export` (Google-native) · `drive.files.get(alt=media)` (uploaded) |
| `create_file` | `drive.files.create` + media upload |
| `copy_file` | `drive.files.copy` |
| `update_file` | `drive.files.update` (`name`, `parents`) |
| `share_file` | `drive.permissions.create` |
| `trash_file` | `drive.files.update` (`trashed=true`) |
| `list_comments`, `get_comment` | `drive.comments.list` / `.get` |
| `create_comment` | `drive.comments.create` |
| `reply_comment` | `drive.replies.create` |
| `resolve_comment`, `reopen_comment` | `drive.replies.create` — an **action reply**, never a PATCH |
| `comments_by_cell` | `drive.files.export` (XLSX) → parse `threadedComments` XML → A1 |
| Edit an existing Doc | `docs.documents.batchUpdate` |
| Edit an existing Sheet | `sheets.spreadsheets.values.update` / `.append` / `.clear` |
| Edit an existing deck | `slides.presentations.batchUpdate` |
| Suggestions preview | `docs.documents.get(suggestionsViewMode=…)` |
| `authenticate` | OAuth loopback + MCP URL elicitation |
| The three scoping controls | none — enforced in this library, before the API call |

Two things fall out of reading that column. **`drive.files.list` does the work of two tools** —
discovery is one method with different parameters. And **`drive.replies.create` is both replying
and resolving**, because resolve is an *action reply* rather than a state change; that quirk is
probe-verified, and it is why the capability gate for it has to inspect the call's arguments
rather than its name.

On footnote ², and on `includeComments`. Our `read_file_content` reaches **Google-native types
only** — Docs, Sheets, Slides — not their 13 mime types. Drive *will* convert a PDF or a PNG
into a Doc by OCR, but doing that **creates a file**, so it is not a read and does not belong
behind a read-only tool; reaching those types honestly needs local extraction. And
`includeComments` anchors a thread by **unique quoted-text match**, because the Drive comment
anchor is an opaque range id with no decodable position — a quote appearing twice, or not at
all, is reported unanchored rather than guessed.

**Neither of the others can edit an existing file's content.** `create_file` uploads a *new*
file; `update_file` only renames or moves. Read down the `batchUpdate` rows — that is the
difference, and it is structural rather than a matter of tool count.

### Markdown out, Markdown in

A Google Doc exports as `text/markdown` — `download_file_content(exportMimeType="markdown")`,
or `Doc.as_markdown()` in the library. It is Drive's own conversion, so headings, lists, tables
and links survive, unlike `as_text()`, which is text runs only.

That turns a Doc into a usable *source* for a Markdown toolchain rather than a dead end. CSA's
internal **`document-pipeline`** plugin already consumes exactly this: Markdown → tagged
**PDF/UA-1**, with a design-rule preflight, composition review, citations and CSA brand styling.
**A public version is planned.** Drive also *imports* `text/markdown` back into a Doc, so the
loop closes:

```
Google Doc --export markdown--> document-pipeline --> branded, accessible PDF/UA-1
     ^                                                              |
     +------- import markdown (create_file content=) <-- revised ---+
```

Draft and review where the comments are, typeset where the brand rules are, put the result back
where it can be reviewed again. **Both halves ship today**: out through
`download_file_content(exportMimeType="markdown")`, back in through `create_file(content=…)`,
which hands Drive Markdown and gets a Doc with real structure rather than one long text run.

**Formats differ by file type**, and the table is probed rather than assumed
([`experiments/export-formats/RESULTS.md`](./experiments/export-formats/RESULTS.md)):

| Type | Exports as |
|---|---|
| **Docs** | `markdown` · `pdf` · `docx` · `odt` · `html` · `rtf` · `epub` · `txt` · `zip` |
| **Sheets** | `csv` · `tsv` · `xlsx` · `ods` · `pdf` · `zip` |
| **Slides** | `pdf` · `pptx` · `odp` · `txt` — **no Markdown, no HTML** |

Ask for one a file cannot produce and the error names the ones it can, rather than becoming a
400 from Google.

### Planned — capabilities no server exposes yet

The Google APIs reach considerably further than any of the three servers currently do. These
are the ones worth building, in the same format, with the tool names they would ship under.
Sequencing is in [`TODO.md`](./TODO.md); the two tables together are the roadmap.

| Tool | What it does | Google API | Google | Claude | Our tool |
|---|---|---|---|---|---|
| `list_approvals` | Approvals open on a file, with reviewers and state | `drive.approvals.list` / `.get` | ✗ | ✗ | ✗ *planned* |
| `start_approval` | Ask named reviewers to approve a document, with a due date | `drive.approvals.start` | ✗ | ✗ | ✗ *planned* |
| `respond_to_approval` | Approve, decline, or comment on an approval as a reviewer | `drive.approvals.approve` / `.decline` / `.comment` | ✗ | ✗ | ✗ *planned* |
| `reassign_approval` | Hand a review to someone else; cancel one you started | `drive.approvals.reassign` / `.cancel` | ✗ | ✗ | ✗ *planned* |
| `list_revisions` | Version history — who changed what, when | `drive.revisions.list` / `.get` | ✗ | ✗ | ✗ *planned* |
| `read_revision` | The text of an earlier version, so two can be compared | `drive.revisions.get` (+ export) | ✗ | ✗ | ✗ *planned* |
| `pin_revision` | Mark a version `keepForever` so autocleanup cannot drop it | `drive.revisions.update` | ✗ | ✗ | ✗ *planned* |
| `list_changes` | What changed across a Drive since a token — a sweep that reads only the delta | `drive.changes.list` + `getStartPageToken` | ✗ | ✗ | ✗ *planned* |
| `list_file_labels` · `set_file_labels` | Read and apply Drive labels — classification and data governance | `drive.files.listLabels` / `.modifyLabels` | ✗ | ✗ | ✗ *planned* |
| `get_slide_image` | Render a slide to a PNG, so a model can actually *see* a deck | `slides…pages.getThumbnail` | ✗ | ✗ | ✗ *planned* |
| `find_named_range` · `annotate_range` | Durable anchors that survive edits, instead of fragile A1 ranges and character offsets | `docs` named ranges · `sheets…developerMetadata` | ✗ | ✗ | ✗ *planned* |
| **Docs structure & formatting** | Tables, styles, headers/footers, footnotes, bullets, images, page breaks, tabs, smart chips — **37 of `batchUpdate`'s 40 request types are unused by anybody** | `docs.documents.batchUpdate` | ✗ | ✗ | ⚠️ 3 of 40 |

That last row is the largest gap in the whole comparison, and the most direct answer to "help
me get work done": today no MCP server can add a table to a document.

**Waiting on a hosted variant.** `drive.files.watch` and `changes.watch` deliver *push*
notifications — react the moment a comment appears, rather than polling for it. They require a
publicly reachable HTTPS endpoint on a verified domain, which a local stdio process behind NAT
cannot be. So this is **planned for a hosted MCP server**, which is itself on the roadmap and
is a substantial piece of work in its own right: multi-user OAuth, per-user Google token
custody, and a public attack surface all arrive with it. See `TODO.md`. `list_changes` polling
covers the same ground locally in the meantime.

**Not planned at all.** Shared-drive administration (`drives.*`), permanent deletion
(`files.delete`, `emptyTrash` — both other servers stop at trash, and that is a considered
line), and client-side-encryption tokens. None of them help anyone review a document.

**Use Google's or Claude's if** you want zero setup, you are reading rather than editing, you
need PDFs/images/Office files, or you would rather not hand a local tool full-Drive scope.
Google's takes only `drive.file`, which means it can reach *only* files you explicitly pick —
a genuinely stronger safety property than anything this library can offer itself.

**Use this one if** you need to *change* documents or *work* comments: reply, resolve, reopen,
map a Sheets comment back to its cell, or preview a Doc with suggestions applied. Neither of
the others can edit an existing file's content at all — and that, rather than tool count, is
the difference.

They are complementary. Running Google's connector for discovery and this one for editing is a
perfectly sensible arrangement, and a planned *flavour* switch will let this server restrict
itself to either of their capability sets when you want one predictable surface.

Details, including per-tool behaviour read from live schemas:
[`research/drive-mcp-servers-and-api-surface.md`](./research/drive-mcp-servers-and-api-surface.md).

## Capability boundaries

The library is **document-scoped**. Two of the limits below are genuine — the Google APIs
cannot do those things at all, proven by enumeration and by probe. The third is a scope
*choice* we have not revisited, and is marked as such.

- **Suggestions are read/preview only.** `Doc.suggestions` reads suggesting-mode edits and `as_text(suggestions="accepted"|"rejected")` previews the outcome, but **accepting/rejecting is impossible via the API** (`UnsupportedOperation`) — Google exposes no endpoint. Reserved for a future `PlaywrightBackend`.
- **No document discovery — *our choice, not an API limit*.** You hand the library a file
  id/URL (`Workspace.open(id)`); there is no `files.list`/search. `files.list` works perfectly
  well, as the workaround below demonstrates; the library simply does not wrap it, to keep its
  surface to comments and content on documents you name. Being candid: that trade looks weaker
  than it did. The workaround needs a Drive client anyway, so the boundary saves the caller
  nothing but the loop — and for the MCP server there is no host application to supply a file
  id, so every interaction has to start with a human pasting a URL. Tracked in `TODO.md`.
  ```python
  files = drive.files().list(q="mimeType='application/vnd.google-apps.document'",
                             fields="files(id)").execute()["files"]
  for f in files:
      doc = ws.open(f["id"])
      ...
  ```
- **Sheets cell-anchored comments can't be created via the API** — `sheet.create_comment(text, cell=…)` posts a file-level comment with a `#gid=…&range=…` deep-link instead.

## Using it on a user's behalf (production)

This library is a building block for MCP servers / agents / automations acting **on a user's behalf** with a full-Drive token. Before deploying, read [`SECURITY.md`](./SECURITY.md) — prompt injection through document/comment content is the primary risk. In short:

- **Credential seam:** the line is *whose machine holds the token*, not CLI-vs-server. Local single-user use — a CLI, or the bundled MCP server over stdio — is fine with `from_oauth` + `token.json` (`0o600`). **Hosted, multi-user is not:** `run_local_server()` can't run headless and one file can't isolate many users. There the host runs its own OAuth, keeps per-user tokens in a secret store, and passes ready credentials via **`Workspace.from_credentials(creds)`**.
- **Concurrency:** one `Workspace` per request/user; never share a `Workspace` (or its backend) across threads — `googleapiclient` clients aren't thread-safe. The stack is synchronous; wrap calls in `asyncio.to_thread(...)` from async code.
- **Isolation & least authority:** a `Workspace` binds one user's credentials — never reuse it across users. Default to `read_only=True` and escalate to a write-capable `Workspace` deliberately, per operation.

## Documents

| Document | What it is |
|----------|------------|
| [`API-STABILITY.md`](./API-STABILITY.md) | **What will not break.** The MCP tool surface is the contract; the Python API is best-effort. SemVer as applied here, what is deliberately unstable, and what you are owed when something changes. |
| [`docs/superpowers/specs/2026-07-20-csa-google-workspace-design.md`](./docs/superpowers/specs/2026-07-20-csa-google-workspace-design.md) | **The design spec.** Scope, two-axis architecture, API surface, error model, phasing. |
| [`docs/superpowers/plans/`](./docs/superpowers/plans/) | The six phased, TDD implementation plans (foundations · comments · content read · cell-mapping · content write · suggestions read). |
| [`research/google-drive-comments-reference.md`](./research/google-drive-comments-reference.md) | Canonical reference on how Drive/Sheets comments actually work: the 10 API methods, fields, resolution/deletion models, OAuth scopes, and the hard truth about the `anchor` field. |
| [`research/docs-suggestions-reference.md`](./research/docs-suggestions-reference.md) | How Docs **suggestions** behave: readable (incl. accepted/rejected previews), but **no accept/reject endpoint** and no author exposed. |
| [`research/server-landscape.md`](./research/server-landscape.md) | Source-verified survey of prior-art servers that handle Google comments. |
| [`docs/superpowers/specs/2026-07-23-mcp-server-design.md`](./docs/superpowers/specs/2026-07-23-mcp-server-design.md) | **The MCP server spec** (phase 2). Transport, tool surface, config, error mapping, security posture. |
| [`research/mcp-server-design.md`](./research/mcp-server-design.md) · [`research/mcp-protocol-notes.md`](./research/mcp-protocol-notes.md) | **Superseded** by the spec above — earlier MCP design + protocol notes, kept for history only. |
| [`docs/superpowers/specs/2026-08-25-library-structure-for-the-roadmap.md`](./docs/superpowers/specs/2026-08-25-library-structure-for-the-roadmap.md) | **Shape review before growth.** The library has one axis (per-file); the roadmap adds a second (account-scoped). Where each planned item lands, and what must not happen. |
| [`research/drive-mcp-servers-and-api-surface.md`](./research/drive-mcp-servers-and-api-surface.md) | What Google's and the claude.ai connector's tools **actually** do, read from live schemas, plus the full Drive v3 / Docs v1 method inventory. |
| [`experiments/`](./experiments/) | Runnable **empirical probes** (with dated `RESULTS.md`): `anchor-probe`, `comment-lifecycle`, `docs-suggestions`, `sheets-cellmap`, `export-formats`. Probe beats docs. |
| [`CHANGELOG.md`](./CHANGELOG.md) | What changed in each refresh, and why. Headings say which versions were actually published. |
| [`PROVENANCE.md`](./PROVENANCE.md) | Who built this and how, how to verify a release's attestation yourself, the yank policy, and what the secret scanners say about the history. |
| [`docs/DECISIONS.md`](./docs/DECISIONS.md) | An index of decisions — when each was settled, what evidence settled it, and which earlier belief it replaced. |

## Three things worth knowing

1. **Comments are a Google Drive API v3 concern — not the Sheets/Docs/Slides APIs** (those handle content). One comment API serves all three file types. (Sheets *notes* are separate and out of scope.)
2. **You cannot anchor a comment to a specific Sheets cell via the API.** Google treats API-created anchors as unanchored; the real anchor is a `workbook-range` with an opaque id. Mapping a comment back to a cell requires exporting the sheet as XLSX and parsing the comment XML — the central hard problem, which the library solves (best-effort) via `comment.location` / `sheet.comments_by_cell()`.
3. **The space isn't greenfield, so the value is in the hard parts** — reliable read-side cell mapping and clean Docs/Sheets/Slides coverage — not merely "supporting comments." See [`server-landscape.md`](./research/server-landscape.md).

## Development

```bash
pip install -e ".[dev]"       # from a clone; src/ layout, Python >=3.10
pytest -q                      # unit suite: no network, no credentials (in-memory FakeBackend)
ruff check src tests && mypy   # lint + type-check (the CI `lint` job)
```

Everything above runs offline and gates CI. Two **opt-in** suites exercise real Google and
are skipped unless their env vars are set:

```bash
# Live API suite — real Docs/Sheets/Slides/Drive. Needs OAuth client secrets; a cached token
# avoids re-consent, otherwise the first run opens a browser to log in:
CSA_GW_INTEGRATION=1 CSA_GW_CLIENT_SECRETS=path/to/client_secret.json pytest tests/integration/

# Interactive OAuth suite — the login flow itself (token caching, file permissions, read-only
# contract). Separate because it needs a human at a browser + touches the sensitive token:
CSA_GW_OAUTH=1 CSA_GW_CLIENT_SECRETS=path/to/client_secret.json pytest tests/oauth/
```

The client secret must be an **installed/desktop-app** OAuth client, and Drive, Docs, Sheets,
and Slides must be enabled in its Cloud project (a scoped token still 403s until each API is
enabled). `client_secret.json` and `token*.json` are gitignored — never commit them.
Releasing is documented in [`RELEASING.md`](./RELEASING.md).

## License

Licensed under the [Apache License, Version 2.0](./LICENSE).
