gdocs-mcp
<p align="center">
<img src="https://raw.githubusercontent.com/dasasian/gdocs-mcp/main/assets/hero.svg" alt="gdocs-mcp — treat a Google Doc like a local file" width="900">
</p>
<p align="center">
<a href="https://www.npmjs.com/package/@dasasian/gdocs-mcp"><img alt="npm version" src="https://img.shields.io/npm/v/@dasasian/gdocs-mcp?style=flat-square&color=235a9b&label=npm"></a>
<a href="https://github.com/dasasian/gdocs-mcp/actions/workflows/ci.yml"><img alt="CI status" src="https://img.shields.io/github/actions/workflow/status/dasasian/gdocs-mcp/ci.yml?branch=main&style=flat-square&label=CI"></a>
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-235a9b?style=flat-square"></a>
<a href="https://modelcontextprotocol.io"><img alt="Model Context Protocol server" src="https://img.shields.io/badge/MCP-server-235a9b?style=flat-square"></a>
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-strict-3178c6?style=flat-square">
<img alt="Node 18+" src="https://img.shields.io/badge/node-18%2B-5fa04e?style=flat-square">
</p>
# @dasasian/gdocs-mcp
An [MCP](https://modelcontextprotocol.io) server that lets an AI agent (like Claude Code) treat a **Google Doc like a local file** — read it, edit it by content, review and resolve **suggestions**, manage **comments**, and work across **tabs** and **multiple Google accounts**.
> **Status:** beta. The tool surface is complete and every change is verified against the live API, not just unit tests.
## Why this exists
Most Google Docs MCP servers treat a doc as flat text. This one fills the gap nobody else does:
- **Suggestions you can read and act on.** `list_suggestions` shows each pending tracked change with Docs' own summary, the change in its sentence (`Pay within [30→45] days`), its author and time; `apply_suggestions` accepts or rejects each one on its own, as the check and the cross do in Docs. `read_doc` can render them inline as `<ins>/<del>`.
- **File-like editing.** `edit_doc` matches a unique snippet of text (like a local `Edit`) and rewrites it — indices are never exposed.
- **Comments on their text.** Read threads (author, time, the text each is on, replies, resolved status), reply, resolve, and add a comment on the text you quote, which stays on that text as it moves.
- **Tabs as sub-files.** A doc is a folder of tabs: `drive ls <doc>` lists them, `drive mv` renames, reorders and nests them.
- **Multiple accounts.** Authorize several Google accounts; pick a default per project.
## What you can ask for
You don't call tools. You ask in plain language and the agent drives them.
**Fill a template without rebuilding it**
> *"Copy the Proposal Template into /Clients/Acme, name it 'Acme Q3 Proposal', then fill in the client name, the dates, and the pricing table."*
The copy keeps the letterhead, the logo at its exact size, the fonts and the page setup — everything a markdown round-trip **cannot** rebuild. Then only the words that change are edited. The alternative is retyping a document you already have.
**A whole review pass in one instruction**
> *"Show me the pending suggestions as a diff, accept the typo fixes, reject anything that changes meaning, then reply to the open comments and resolve the ones that are settled."*
Four tools, one sentence, and the judgement in the middle is the part you wanted help with. Most Docs integrations cannot see tracked changes at all, let alone act on them safely.
**More:** publishing a folder of markdown as a Drive tree, auditing many docs for style drift, pulling a doc back into your repo with its images → **[docs/recipes.md](docs/recipes.md)**.
## Install
```sh
npm install -g @dasasian/gdocs-mcp
```
Or from source:
```sh
git clone https://github.com/dasasian/gdocs-mcp && cd gdocs-mcp
npm install && npm run build
npm link # makes `gdocs-mcp` available globally, same as the published package
```
Then follow the setup below exactly as an installed user would. (`npm link` symlinks the
`gdocs-mcp` binary to your build; see [CONTRIBUTING.md](CONTRIBUTING.md) for the dev workflow.)
## Setup
You need a Google Cloud OAuth client (one-time). Each user creates their own — this keeps your credentials yours and avoids Google app-verification for the restricted Drive scope.
1. **Create a project + enable APIs** (or use the [setup guide](docs/setup.md)):
```sh
gcloud projects create my-gdocs-mcp
gcloud config set project my-gdocs-mcp
gcloud services enable docs.googleapis.com drive.googleapis.com
```
2. **OAuth consent screen** (Cloud Console → APIs & Services → OAuth consent screen): User type **External**, add yourself as a **Test user**. For long-lived tokens, set publishing status to **In production** (avoids the 7-day refresh-token expiry).
3. **OAuth client** → Create credentials → **OAuth client ID** → **Desktop app**. Download the JSON and save it as `~/.config/gdocs-mcp/client_secret.json`.
4. **Authorize an account** (opens a browser):
```sh
gdocs-mcp add-account
```
Repeat for each Google account you want to use.
## Configure your MCP client
In a project's `.mcp.json` (Claude Code) or equivalent:
```jsonc
{
"mcpServers": {
"gdocs": {
"command": "gdocs-mcp",
"env": { "GDOCS_DEFAULT_ACCOUNT": "you@example.com" }
}
}
}
```
`GDOCS_DEFAULT_ACCOUNT` sets which authorized account this project uses by default — so a work project and a personal project can point at different accounts without re-authorizing. Any tool call can override it with an `account` argument.
Prefer it available in **every** project? Register once at user scope: `claude mcp add gdocs -s user -e GDOCS_DEFAULT_ACCOUNT=you@example.com -- gdocs-mcp`. Then a project can pin its own defaults with a `.gdocs-mcp.json` — the account, and a default `folder` for new docs:
```json
{ "account": "work@company.com", "folder": "https://drive.google.com/drive/folders/…" }
```
`write_doc` creates only from a path (`/Reports/Q3 Report`). Given a bare name (`Q3 Report`) it creates nothing: it refuses, tells the agent to tell you the doc will go in the default folder, and hands back the full path to call. A new doc at the top of My Drive (`/Q3 Report`) is refused once the same way, and the identical call repeated goes through if you want it there. The default folder's path is also in `write_doc`'s description, read when the server starts. With no default it says to name a folder (`~/Q3 Report`). See [docs/setup.md](docs/setup.md) for the full resolution order.
You don't have to edit that file by hand — just tell the agent *"make damithsc@gmail.com my default account for this project"* or *"make my Manuscripts folder the default here,"* and it writes the `.gdocs-mcp.json` for you via `set_project_default` (searching for the folder by name if needed).
## Tools
> The table below is the reference. For what to actually *ask for*, see [What you can ask for](#what-you-can-ask-for) above, or the full set in **[docs/recipes.md](docs/recipes.md)**.
| Tool | Description |
|---|---|
| `read_doc` | Read as markdown + inline HTML. Opens with a `<style>` block (the named styles as CSS: `p`, `h1`–`h6`, `.title`, `.subtitle`); a paragraph shows `style="…"` only where it differs from its rule — alignment, spacing, line height, indent — and text color/size/font come back as `<span style="…">`, so styling is visible and round-trips. Modes: `clean` · `tracked` (`<ins>/<del>`) · `accepted` · `rejected`. `segment`: `body` (default) / `header` / `footer` / `all` — a body read always reports what the headers/footers hold, so a letterhead never reads as empty. Document text that would parse as markup is marked literal — `<p>4. Term</p>`, `5 \* 3`, `\\` for a real backslash; a line that starts or ends with a space or tab reads as `<p>⇥Indented clause</p>` and the writer keeps every space and tab inside `<p>`, so a read written back gives the same document. A page break reads as `<div style="page-break-after:always"></div>` on its own line, and writes back as one. Every element in a paragraph reads (#58): a footnote as `[^1]` with its note as a `[^1]: …` line at the end, a date as `<time datetime="2026-11-15">Nov 15, 2026</time>`, a person or Drive link chip as `<a class="chip" href="mailto:jane@x.com">Jane Doe</a>`, a dropdown as `<input class="chip" list="kix.…" value="Draft">` with its list once as a `<datalist>` after the `<style>` block, a horizontal rule as `---`, a column break as `<span style="break-after:column"></span>`, an equation and a page-number field as an empty `<span class="equation">` / `<span class="page-number">` whose `title` says to edit them in Docs. A table of contents is not read |
| `edit_doc` | Replace a unique text snippet (string-anchored, markup-tolerant; a table matches as its rows, a pipe table or the HTML `<table>` of a styled one). `new_string` is the full markdown `write_doc` takes — headings, lists, tables, images, page breaks — with `contentFile` for long text and `baseDir` for relative image paths. **Inserting is an edit**: quote the anchor and write it back with the new content after it. Text that comes back unchanged is kept (its style, comments and suggestions stay), a match that ends in a table puts new blocks after the table, an anchor mid-paragraph splits the paragraph, and a new block carries its own style rather than its anchor's. An empty doc, tab, header or footer has no anchor: use `write_doc`. **`suggest: true`** sends the edit as suggestions for a person to accept or reject in Docs, and the result lists their ids in `suggestions`; it defaults to false, a direct edit. Same words with different markup is a style-only edit — no delete, no insert. A table edit that changes rows, columns or merges is a comparison: quote the rows around the change and write the rows you want, and only the difference is sent (`insertTableRow`, `deleteTableRow`, `insertTableColumn`, `deleteTableColumn`, `mergeTableCells`, `unmergeTableCells`, plus the text of the cells that changed), so the other cells keep their comments and style; a new row or column copies its neighbour's style, a moved row or column is a delete and an insert and asks (`confirmLoss`) when comments quote its text, and a change that cannot be read cleanly is refused with what to quote. A table is never rewritten whole. Editing a rule in the `<style>` block restyles the whole doc through one `updateNamedStyle`. A page break is a line of its own, so `new_string` `Title page\n<div style="page-break-after:always"></div>` puts the next paragraph on a new page, and an anchor can span one. **Elements**: one an edit gives back unchanged is kept (a person chip inside a sentence you reword, a footnote, a dropdown, a rule), one it drops is deleted, a new one is created (`createFootnote`, `insertDate`, `insertPerson`, `insertRichLink`, `insertDropdown`), and one the API cannot make (a column break, an equation, a page-number field, a link chip to anything but a Drive file) is refused before anything is sent, saying what to write instead. A footnote's note is edited by quoting its `[^1]: …` line, a dropdown's list by quoting its `<datalist>` line. A chip's text is Google's: a chip given other words is refused |
| `get_page_setup` / `set_page_setup` | Read / set document-level page setup: margins, page size (preset or explicit), orientation (File > Page setup) |
| `write_doc` | Write like the local `Write`: a path that names nothing is **created** — a doc in a folder (`/Work/Contract`), a tab in a doc (`/Work/Contract/Notes`), a child tab (`/Work/Contract/Part 2/Ch.4`) — and never asks. A path that names something is **replaced**, and the first call is refused with a loss summary: paragraphs, comments, suggestions, tab stops, links to bookmarks, page-number fields, section breaks, a table of contents, column breaks, equations, dates in a format the API can only read and link chips to anything but a Drive file, plus a `confirmLoss` string. Footnotes, person chips, dates, Drive link chips and dropdowns are written back; `---` is written as an empty paragraph with a thin grey bottom border. Tell the user; call again with that string to replace. If the doc changed in between (the string carries its revision and the counts), it is refused again. Markdown in (the same CSS `read_doc` shows: `<style>` block, `<p style>`, `<span style>`; an unsupported property fails the write before anything is sent; a page break is `<div style="page-break-after:always"></div>` on its own line, and `<hr style="page-break-before:always">`, `break-before: page` and `break-after: page` work too, while any other `<div>` or `<hr>` is refused); `contentFile` for long text; `baseDir` for relative image paths; a centered image is `<p style="text-align:center"><img …></p>`; returns `images` `{ src, objectId }`. Asks only when there is something to lose: an empty doc, tab, header or footer is written without the refusal, and `suggest: true` never asks, because nothing is lost until a person accepts (rejecting brings back the text, images, tables and comments as they were); it needs a path that already names something. `segment: "header" | "footer"` writes the page header or footer instead of the body: a missing default one is created, a missing first-page or even-page one (`page`) is refused with "Turn on Different first page in Docs", and one with content asks once like the body |
| `export_doc` | Export a doc to a local file — pdf (default), docx, odt, rtf, txt, html, epub, md (rendered server-side by Google). A doc path exports every tab; a tab path (`/Work/Contract/Ch.4`) exports just that tab (not its child tabs) through an undocumented, rate-limited Google endpoint, and is refused rather than widened if that fails |
| `list_suggestions` | Pending suggestions in document order: `id`, `summary` (Docs' words, e.g. `Replace: “30” with “45”`), `context` (the change in its sentence, `[deleted→inserted]`), `author` and `time` — `segment` to read a header/footer's |
| `apply_suggestions` | Accept or reject one or more suggestions, each on its own (Docs' `acceptSuggestion` / `rejectSuggestion`, in one batch), after `expectTitle` and each `expectContext` — the `title` and `context` `list_suggestions` gave — match the live doc. Style-only suggestions work; `segment` to resolve a header/footer's |
| `download_images` | Download a doc’s embedded images to a local folder (pairs with `read_doc`’s image markers — the inverse of publishing) |
| `list_comments` / `add_comment` / `resolve_comment` | Comments, written and read through the Docs API so each sits on a range. `list_comments` gives each comment's `quotedText`, `context` (the sentence around it), author, time, status, replies and `range`. `add_comment(quotedText)` comments on the one place the quote matches, found the way `edit_doc` finds `old_string` (two matches are refused with context); `tab` comes from `path`, a header or footer from `segment`; `replyTo` replies instead. `resolve_comment` resolves or reopens, and requires `expectQuotedText`, the `quotedText` `list_comments` gave |
| `drive` | Drive as a filesystem: `ls` `find` `mkdir` `cp` `mv`. Paths are `/` or `~` (My Drive), `/shared/<drive>`, `/shared-with-me`, `/lost+found`; anything else is an id. `cp` preserves what markdown can’t round-trip (headers/footers, image sizing, exact formatting), so prefer it over rebuilding a template. See [Drive as a filesystem](#drive-as-a-filesystem). A doc is a folder of its tabs: `ls <doc>` lists them, `ls <folder>` shows each doc's tab count, a path continues into tabs (`/Work/Contract/Part 2/Ch.4`), and `mv` renames, nests (`dst` is another tab), un-nests (`dst` is the doc) and, with `index`, reorders a tab (`mv <doc>/Summary <doc>/Summary` with `index: 0` reorders it in place). A tab cannot leave its doc and `cp` of a tab is refused |
| `list_permissions` / `share_doc` / `unshare_doc` | Sharing (`share_doc` handles both people and anyone-with-link). `list_permissions` names every audience, including domain-wide grants a Workspace adds on creation; `unshare_doc` revokes those by `permissionId`, and requires `expectRole` since a revocation appears in no version history |
| `list_accounts` | Authorized Google accounts |
| `set_project_default` / `get_project_config` | Set/show this project’s default account + folder (writes `.gdocs-mcp.json`) |
Every doc tool takes one `path` for the doc — an id, a URL, `/Work/Contract` or `/Work/Contract/Ch.4` — and an optional `account` (override the default). A doc with one tab needs no tab step; a doc with several is refused until one is named, and the refusal lists every tab's path. Two tabs (or a folder and a doc) with one name are refused with each candidate's id.
## Drive as a filesystem
Drive navigation is one tool speaking shell, because the model already knows shell. Arguments are positional and differ per command, exactly as they do in a terminal.
```jsonc
{ "cmd": "ls", "args": ["/Work/2026"] }
{ "cmd": "find", "args": ["quarterly report", "-type", "d"] }
{ "cmd": "mkdir", "args": ["-p", "/Work/2027/Q1"] }
{ "cmd": "cp", "args": ["/Work/Template", "/Work/2027/Q1/Report"] }
{ "cmd": "mv", "args": ["/Work/Roof", "/Archive"], "expectName": "Roof" }
```
| path | is |
|---|---|
| `/…` or `~/…` | My Drive |
| `/shared/<drive name>/…` | a shared drive |
| `/shared-with-me` | files others shared with you, which you never filed |
| `/lost+found` | files you own that are in **no** folder — see below |
| anything else | a Drive id or URL, so ids from any other tool paste straight in |
Flags and operands parse in any order (`cp -r a b`, `cp a b -r`, `cp a -r b`), and `--` ends the options.
**Three places Drive is not a filesystem.** The vocabulary is borrowed only where it is honest, and refuses where it is not:
- **Two files may share a name in one folder**, and **matching folds case**. No filesystem the model learned from does either, so it would not think to check. A path matching more than one thing is refused with the candidates listed, never guessed — and `cp`/`mv` refuse to *create* that state too, rather than manufacturing an ambiguity the resolver would then decline to resolve.
- **`cp -r` does not exist.** Drive's `files.copy` refuses folders (its own web UI cannot copy one either), so `cp` on a folder explains that rather than half-working.
- **`mv` into `/shared/…` gives the file away.** Shell `mv` across filesystems leaves you owning the file; moving into a shared drive transfers ownership to that organization and cannot be undone from your side. It requires `acceptOwnershipTransfer: true`.
**Paths see less than `find` does.** A file with no parent still opens and still turns up in a search, but no path can name it — nothing that browses the tree will ever show it. `find` is the complete view; `/lost+found` is where those files surface.
There is no `rm`. See [#47](https://github.com/dasasian/gdocs-mcp/issues/47).
## Known limitations
These are **Google-API constraints, not bugs** — the highlights are below; the complete reference (with the API reason and the workaround for each) is in **[docs/limitations.md](docs/limitations.md)**.
- **Some changes cannot be suggested.** `edit_doc` and `write_doc` take `suggest: true` to send an edit as suggestions, but Docs cannot suggest a new tab or doc, a table column width, deleting a header or footer, or a named range; an edit that needs one is refused before anything is sent, and is never split into a suggested part and a direct part. A header or footer that does not exist yet cannot be created as a suggestion either.
- **A comment made in Drive has no range,** only its quote, and the author is a display name, never an email. Docs has no comment on the whole doc, so `add_comment` needs a `quotedText`.
- **Images are inline only** (no floating/x,y positioning), and Google downscales/re-encodes embedded images, so pulled copies aren't byte-identical.
- **Headers and footers are separate content trees** — not part of the body. Every content tool (`read_doc`, `edit_doc`, `write_doc`, `list_suggestions`, `apply_suggestions`) takes `segment: "header" | "footer"` to reach them, and `page` when a doc defines more than one.
- **Style is CSS inside the markdown** — `read_doc` opens with a `<style>` block of the named styles and shows `style="…"` where a paragraph or run differs from its rule; `edit_doc` changes it (same words with new markup is a style-only edit) and the write tools accept the same CSS. It is a small set (`text-align`, `line-height`, `margin-*`, `text-indent`, `font-*`, `text-decoration`, `color`, in pt); anything else is refused before a request is sent. A table with nothing but Docs defaults reads as a pipe table; one with any cell or row style, set column widths or row heights, pinned header rows or merged cells reads as an HTML `<table>` with the same CSS (`border`, `padding`, `background`, `vertical-align`, `width` on `<col>`, `height` on `<tr>`, `colspan`, `rowspan`) and `edit_doc` changes it, a style-only edit sending only style requests. Lines follow CommonMark: a single newline inside a paragraph, list item or quote is a space, so a hard-wrapped file keeps its structure; a fenced or four-space-indented code block is one Courier New paragraph with its line breaks and spaces, and reads back as a fence; a `>` quote is a paragraph indented 36pt with a gray left border, a bare `>` line separating its paragraphs (`>>`, and a list or code block inside a quote, are refused).
See **[docs/limitations.md](docs/limitations.md)** for the full table, including how each is mitigated or surfaced.
## Roadmap
> The "manuscript sync" use case (chapter files ⇄ tabs, reviewing suggestions, merging) is intentionally **not** a server feature — an AI agent orchestrates it over these primitives. See [DESIGN.md](DESIGN.md) §10b.
## Development
```sh
npm install
npm run build # tsc
npm test # vitest
npm run typecheck
npm run eval:agent # a real agent does each task in eval/tasks/; graded from the doc
npm run eval:agent -- --full # Sonnet and Haiku, 3 runs each, before a merge
npm run eval:agent -- --tag tables
npm run eval:agent -- --check-graders # every grader must fail on an untouched doc
```
`eval:agent` is not part of `npm test`: it uses real model calls and a real Google
account. Each run creates its docs through the API, runs `claude -p` from a temp
folder with only the gdocs tools and no skills, reads the result through the API,
and trashes every doc it made, even after a failure. A run that fails
with "Quota exceeded" in its log is retried once on its own after 60 s and reported as a
quota hit, not a plain failure; a run that passed despite a quota error is a pass.
Each `claude -p` is killed, process group included, after 10 minutes and reported as a
failure ("timed out after 10 min"); `GDOCS_EVAL_RUN_TIMEOUT_MS` overrides that, for
testing only. Every Google call the harness itself makes (setup, grading, trashing) waits
60 s and retries up to 3 times on a quota error; a run whose harness call still fails is
reported as "could not run", apart from passes and failures, and still exits non-zero.
`--full` runs 3 at a time (5 by default) to keep that rare.
See [DESIGN.md](DESIGN.md) for the full architecture and the empirical findings behind it.
## Acknowledgements
Architecture and approach informed by prior open-source Google Docs MCP servers — notably [`@a-bonus/google-docs-mcp`](https://github.com/a-bonus/google-docs-mcp) and [`taylorwilsdon/google_workspace_mcp`](https://github.com/taylorwilsdon/google_workspace_mcp).
## License
[MIT](LICENSE) © Dasasian
TDQS
Scored across 19 tools
Most tools target clearly distinct resources or actions: document reading, snippet editing, comments, suggestions, permissions, page setup, and Drive navigation are well separated. The main potential confusion is between write_doc and edit_doc for writing markdown, and read_doc versus export_doc, though the descriptions make the intended boundaries mostly clear.
Nearly all tools use a consistent snake_case verb_noun pattern such as read_doc, edit_doc, list_comments, add_comment, and share_doc. The single noun-only tool, drive, is a deliberate command multiplexer but is the one clear deviation from the pattern.
At 19 tools the surface is slightly heavier than the ideal compact set, but the server covers a broad Google Docs domain including editing, comments, suggestions, sharing, page setup, export, Drive navigation, and configuration. Each tool corresponds to a meaningful operation, and drive usefully bundles several filesystem commands.
The tool set covers the core document lifecycle well: create, read, edit, replace, export, comments, suggestions, sharing, page setup, images, and Drive navigation. Notable gaps remain around deleting documents, tabs, or comments and around revision history, but these are relatively minor for the main Docs workflows.