@simon-bouchard/google-docs-mcp
v0.6.0
Published
A local, stdio-based MCP server for reading and anchor-based editing of existing Google Docs.
Maintainers
Readme
google-docs-mcp
Status: early and actively developed. The core functionality below is tested and live-verified against real Google Docs, but expect rough edges. Server bugs are reported automatically (see Automatic issue reports); for anything else, use
/google-docs-mcp:feedbackor just ask.
A local, stdio-based MCP server that lets Claude Code (or any MCP client) read and edit
existing Google Docs. It authenticates as a Google Cloud service account, or, once the
Koios OAuth client exists, as you after a one-time
browser sign-in, and exposes read_document, read_comments and edit_document, plus
report_issue, which Claude uses to file the server's own bugs and limitations.
Edits are anchor-based: you address content by search text or heading text, never by raw
character offsets. All the operations in one edit_document call are sent as a single atomic
batchUpdate, guarded by the document's revision id, so an edit against a stale read is refused
rather than silently applied to changed content. Every anchor-resolving operation also echoes
back a short snippet of the text it actually touched, so a caller can confirm it hit the right
spot without a follow-up read.
Setup
Create a Google Cloud project. Any Google account works; it does not need to be attached to a Workspace organisation.
Enable the Google Docs API for that project (APIs & Services -> Library -> "Google Docs API" -> Enable). This step is easy to miss: having a project and a service account does not mean the API is enabled. If you skip it, every call fails with a
SERVICE_DISABLED403, which reads like "the doc isn't shared with me" but is not.Optional: enable the Google Drive API the same way, if you want Claude to read comments. It is only used for
read_commentsand for reading which checklist items are ticked, with a read-only scope that cannot change files or sharing. Without it,read_commentsreturnsdrive_api_disabled, checklist items come back withoutchecked, and everything else works.Enable the Google Sheets API the same way. It is only used to file issue reports into the shared feedback spreadsheet; without it, automatic issue reports and
/google-docs-mcp:feedbackcan't be filed, and everything else works.Create a service account (IAM & Admin -> Service Accounts). It needs no project roles -- its access comes entirely from document sharing.
Download a JSON key for it (Keys -> Add Key -> Create new key -> JSON) and install it:
mkdir -p ~/.config/google-docs-mcp mv ~/Downloads/<your-key>.json ~/.config/google-docs-mcp/service-account.json chmod 600 ~/.config/google-docs-mcp/service-account.jsonShare each Doc you want to read or edit with the service account's email (shown on the service account's details page, of the form
<name>@<project>.iam.gserviceaccount.com) as Editor. A service account can see nothing it has not been explicitly shared.Send the service account's email to the maintainer so it can be added as an editor of the shared feedback spreadsheet (also used by google-sheets-mcp). Without it, automatic issue reports from your sessions fail silently.
Signing in with your Google account (not yet available)
A second auth mode is built in: sign in once in the browser and the server acts as you, seeing exactly the files you can see, with no key file or sharing step. It waits on a Koios Workspace admin creating the Internal OAuth client; until then, use the service account above. Both modes can stay installed side by side.
Get the OAuth client (a Desktop app client in a Koios-owned Cloud project) and save its JSON at
~/.config/google-workspace-mcp/oauth-client.json, or setGOOGLE_WORKSPACE_MCP_CLIENT_ID(andGOOGLE_WORKSPACE_MCP_CLIENT_SECRET). Once the Koios client exists it will ship with the package and this step goes away.Sign in:
npx -y @simon-bouchard/google-docs-mcp loginThe browser opens Google's consent page; the token is saved at
~/.config/google-workspace-mcp/token.json(mode 600). The sign-in is shared with google-sheets-mcp, so signing in through either server covers both.Restart Claude Code so the server picks up the sign-in.
Which mode is used: a saved sign-in wins over a service account key. Set
GOOGLE_DOCS_MCP_AUTH=service_account (or oauth) to force one, for example to go back to the
service account without signing out. npx -y @simon-bouchard/google-docs-mcp logout deletes the
saved token; to also revoke access, remove the app at
https://myaccount.google.com/connections. When signed in, issue reports are written as you, so
the feedback spreadsheet must be editable by you rather than by a service account.
GOOGLE_WORKSPACE_MCP_CONFIG_DIR moves the shared directory.
Install
npm install -g @simon-bouchard/google-docs-mcpOr run it without installing, via npx (used directly in the registration command below).
Installing as a Claude Code plugin
Instead of registering the MCP server by hand, you can add this repo as a Claude Code plugin
source and install it as a plugin -- this also gives you the /google-docs-mcp:feedback command
(see Reporting feedback yourself):
claude plugin marketplace add https://bitbucket.org/koiosemployee/google-docs-mcp.git
claude plugin install google-docs-mcpThis still requires the service account setup (or a sign-in) above -- the plugin only changes how the server is registered and adds the feedback command, not the auth model.
To save memory, also install the server globally (npm install -g @simon-bouchard/google-docs-mcp).
The plugin then runs it directly instead of through npx, which otherwise keeps a ~75 MB
process alive for the whole session. Without a global install it falls back to npx, which
always fetches the latest version; with one, update it by re-running the same npm install -g.
Automatic issue reports
When the server hits one of its own bugs (an unexpected internal_error) or content it can't
handle yet (unsupported_content), the error tells Claude to file it in the background with
report_issue, and the server attaches what Claude can't see: its version, the failed call's
operations, the stack trace, and for bugs a structure summary of the document (counts of
paragraphs, headings, lists, tables, merged tables, embedded element kinds, header/footer; no
text). Claude also files, following the server's instructions, when an edit succeeded but did
something other than what was asked (its echoed context or a read-back shows it, or you point it
out; Claude reports even when unsure whose fault it is), or when you ask for a Google Docs task
that none of this server's tools can do (a limitation). Those reports carry the server's last
three calls, reduced the same way as below, and the structure summary of the last document they
touched.
Never reported: errors that are working as designed (concurrency_conflict is a safety guard;
anchor errors like ambiguous_match and request mismatches like invalid_input, such as a new
table row with the wrong number of cells, are fixable input), setup problems (auth, sharing, rate
limits, the Drive API not being enabled), network problems and Google outages, and requests meant for other tools or servers.
Reports go to the maintainers' feedback spreadsheet, shared with google-sheets-mcp (Bugs and
Limitations tabs, Source column auto, Server column google-docs-mcp), at most once per
problem per session. Claude mentions each one in a single line. What gets written is kept to
what's needed to reproduce the problem:
- The document id is removed; anchor and inserted text become their length (
[text, 12 chars]), and table content its shape ([3x2 cells]). - Claude describes document content instead of quoting it.
- Credentials are scrubbed from every field before anything is written (tokens, keys,
passwords,
Authorizationheaders, OAuth client secrets, private keys, long random strings).
To turn it off, set GOOGLE_DOCS_MCP_AUTO_REPORT=off. To let reports go through without a
permission prompt, allow report_issue (see Registering).
Reporting feedback yourself
If you installed this as a plugin, /google-docs-mcp:feedback [bug|limitation] [notes] drafts a
report from the conversation, shows it to you, and files it through report_issue after you
confirm, even when automatic reports are turned off. Anything you write after the kind word is
kept verbatim as Reporter notes, and the row's Source is manual with notes (or manual
without notes).
Reports from both servers are triaged in the same spreadsheet: each row stays for good and gets
a Status (in progress, fixed, won't fix, duplicate; empty means new) and a free-text
Resolution. The google-sheets-mcp README describes the columns and the maintainer setup. Here,
the destination is DEFAULT_FEEDBACK_SHEET_ID in src/reporting.ts, which users can override
with GOOGLE_DOCS_MCP_FEEDBACK_SHEET_ID (the older GOOGLE_DOCS_MCP_FEEDBACK_DOC_ID and
GOOGLE_DOCS_MCP_LIMITATIONS_DOC_ID are no longer read). The server needs the spreadsheets scope only to append
those rows. That also means the Google Sheets API must be enabled in your Cloud
project (setup step 4).
Registering with Claude Code
Register it as a user-scoped MCP server (mcpServers is not a valid settings.json field --
this is the actual mechanism):
claude mcp add --scope user google-docs -- npx -y @simon-bouchard/google-docs-mcp(If you installed it globally instead, -- google-docs-mcp works too, without npx.)
Tool permissions go in ~/.claude/settings.json. Tool names are mcp__<server name>__<tool>,
so with the command above (server name google-docs):
- To be prompted before every edit, add
mcp__google-docs__edit_documenttopermissions.ask. - For automatic issue reports to really happen in the background,
add
mcp__google-docs__report_issuetopermissions.allow; otherwise Claude Code stops to ask for permission the first time a report is filed. Run/mcpto see the exact server name if you installed it as a plugin.
{
"permissions": {
"allow": ["mcp__google-docs__report_issue"],
"ask": ["mcp__google-docs__edit_document"]
}
}What it can do
read_document
Returns the document's plain text, its heading outline, per-paragraph formatting (heading
level, list membership, spacing, border, background shading), each run's character style, and
the revision id needed for edit_document. Long runs of embedded non-text content (many images
in a row, for example) are compressed to a short [N embedded objects] marker in the returned
text, rather than repeating a placeholder character hundreds of times on every call.
Checklist items report list: "checklist" and checked (whether the box is ticked). The Docs
API doesn't expose ticked state, so the server reads it from Drive's Markdown export of the
document, only when the document has checklist items. If the export fails or doesn't line up
with the document, checked is left out rather than guessed.
header and footer report the document's default header/footer, in the same shape (text plus
per-paragraph formatting) as the body -- null for either one the document doesn't have.
embeddedElements lists where non-text content (images, page breaks, footnotes, equations,
horizontal rules, column breaks, section breaks, table of contents, and a few rarer link/auto-text
kinds) sits -- each entry gives its type, a short text snippet for context, and which segment
(body/header/footer) it's in. Check this before constructing a find/replace: the
placeholder character marking an embedded element's own spot in text is a single easy-to-miss
character, and a find spanning across one is refused outright rather than silently deleting it,
so knowing where these are up front avoids that round-trip.
format: "markdown" returns the tab as Markdown instead, from Drive's Markdown export: often a
third of the size or less, since it leaves out per-paragraph formatting, embeddedElements and
the header and footer. It still returns headings and revision_id, so edit_document can follow
directly, but find matches the document's plain text, so it has to be written without Markdown
syntax (**, backslash escapes, list markers, table pipes). Images show as [image] instead of
their embedded data, and a tab keeps its own footnotes. The export holds every tab, and each
tab's part is found by tab order and checked against that tab's text; if it doesn't line up,
the whole export comes back with markdown_covers_all_tabs: true. Like checklist ticked state,
it needs the Drive API.
section reads only the part of the tab under one heading (its exact text, as headings lists
it, matching a single heading): from that heading to the next heading of the same or a higher
level, subheadings included, in either format and without the header and footer. revision_id
is still the whole document's, so an edit can follow a section read exactly as it follows a full
one. In Markdown, the section's lines are checked against its text; if they don't line up, its
plain text comes back instead, with section_as_plain_text: true. On long documents this keeps
a read to the part Claude needs.
Document tabs
Every tool works on one tab at a time. read_document and edit_document take an optional tab,
a tab's title or id. Without it they use the tab a pasted link points at (.../edit?tab=t.abc),
or else the first tab. A document with several tabs also reports tab, the tab read, and tabs,
every tab with its id (a child tab carries its nestingLevel), so Claude can find the tab it
needs. An unknown tab, or a title several tabs share, is refused with the list of tabs. On a
document with several tabs, edit_document requires tab (unless the link names one), so an
edit never lands in the first tab by default: pass the same tab you read. Every operation in the
call goes to that tab, its header, footer and footnotes included, and the response echoes it. base_revision_id covers the whole document, so an edit in
another tab since the read also counts as a conflict.
read_comments
Lists the document's comments, oldest first: author, time, resolved status, text and replies
(including whether a reply resolved or reopened the comment). quoted_text is the text the
comment is attached to, and quoted_text_matches says how many times it appears in the
document now, so Claude knows whether it can use it directly as edit_document's find. A
count of 0 doesn't always mean the text changed: a quote spanning a table, image, line break
or footnote can be written differently in the document text. In a document with several tabs,
the count is the total over all tabs, quoted_text_tabs gives each tab holding the text with its
own count (occurrence counts within one tab), and unreadable_tabs lists any tab that couldn't
be searched.
Resolved comments are hidden unless include_resolved is true, deleted ones are never shown,
and at most 500 comments are returned (truncated says when there were more). Read only: this
server cannot reply to, resolve or create comments.
edit_document
Applies a list of operations atomically in one batchUpdate, guarded by base_revision_id.
Every operation that resolves a target locates it either by find (matched against the
document's plain text -- must match exactly once, or pass occurrence to pick the Nth match
when it legitimately repeats) or by heading (the exact text of a heading, which only needs to
be unique among headings, not the whole document). A find that matches nothing is retried
with whitespace normalized (catching a non-breaking space where you typed a regular one, or
similar) before failing, so the error can suggest the real near-match text instead of just
"not found."
replace_text, format_text, and set_paragraph_style also accept a segment: "header" |
"footer" parameter, which anchors find/occurrence against the document's default
header/footer instead of its body (not valid alongside heading -- heading-anchoring is
body-only). When segment is set, find and heading may both be omitted to target the
segment's entire content directly (whole-segment addressing). replace_text and
set_paragraph_style both work this way even on a freshly created, still-empty header/footer
(there's nothing yet to find in it) -- use replace_text to add its first text.
format_text needs the segment to already have text before it can style it this way.
Operation types:
replace_text-- replace found text (or a heading's own text) with new text. An emptyreplacedeletes the matched span outright.format_text-- apply character-level style (bold, italic, underline, strikethrough, font family/size, color) to found text, or make it a hyperlink withlink(a URL;nullremoves the link).link: "#<heading text>"links to a heading of the same tab instead, named by its exact text or a GitHub-style slug (#api-design); it must match one heading.read_documentreports a linked run'slink, a link to a heading of the tab as"#<heading text>".set_paragraph_style-- heading level, list membership (bulleted/numbered/checklist/none), line spacing, space before/after, border (color/width/padding, optionally narrowed to asidessubset for an accent-border look), and background shading.format_table_cells-- background color and border for a table cell (found by matching text inside it), a rectangular block of cells viarowSpan/columnSpan, or the whole table viawholeTable. Works in tables with merged cells too.insert_table-- create a new table (empty, or pre-filled via acontent2D array), appended at the document's end or placed before/after a heading or found paragraph.insert_table_row/insert_table_column-- grow an existing table, optionally filled with initial content, anchored to a cell found in it.delete_table_row/delete_table_column-- shrink an existing table.append_paragraph-- add a new paragraph at the end of the document.insert_after_heading/insert_before_heading-- add a new paragraph immediately after or before a heading.create_header/create_footer-- add a default header/footer to the document. Refuses if the document already has one (edit that instead, viasegment). Structure-only: the new segment starts out empty. Fill it with a followingreplace_textoperation that setssegmentand omits bothfindandheading(whole-segment addressing), which targets the segment's entire content directly and works even while it's still empty.format_textandset_paragraph_styleaccept the samesegmentwhole-segment addressing, butformat_textneeds the segment to already have text first.insert_page_break-- start a new page before or after a heading or found paragraph, or end the document with a break. Same placement asinsert_table. Not possible inside a table or a header/footer.add_footnote-- add a footnote with the given text right after found text. It must be the only operation in itsedit_documentcall: Google only returns the new footnote's id after creating it, so its text is a second write. If that write fails, the empty footnote is removed again.read_documentlists footnotes inembeddedElementswith their text.delete_paragraph-- delete a whole paragraph: the one a heading or found text is in, or oneoffsetparagraphs away from it. When the anchor is text inside a table, the offset counts from the table (1is the paragraph right after it), which reaches empty or image-only paragraphs thatfindcan't match.expectedText(""for an empty paragraph) refuses the deletion if the paragraph's text differs. The last paragraph and one right before a table are merged into the paragraph before them, which keeps its style. Not inside table cells.insert_markdown-- add formatted content written as Markdown (GitHub flavor), placed likeinsert_table: before or after a heading or found paragraph, or at the end of the document. Headings (levels 1-6), paragraphs, bulleted, numbered and checklist items (nested by indentation), bold, italic, strikethrough, inline code (Roboto Mono), links, fenced code blocks (monospace lines on a light gray background), block quotes (a left border), tables (header row in bold) and---rules (a bottom border, since the API can't insert a real horizontal rule). A nested list takes its top-level list's style, since Docs gives one list a single bullet style; checklist items start unticked; absolutehttp,httpsandmailtolinks are kept, and[text](#anchor)links to a heading already in the tab (its text URL-encoded, or a slug like#api-design); any other link, or an anchor naming no single heading (such as one added in the same call), stays plain text; an image becomes its alt text linked to the image; HTML tags and comments are dropped. Content placed after a paragraph that is followed by a table gets a paragraph of its own between the two. Appending fills an empty last paragraph rather than leaving a blank line, and no blank line is left before a table.merge_table_cells-- merge therowSpanxcolumnSpanblock of cells whose top-left cell holds the found text; their text moves into that cell. A block that cuts through an existing merged cell is refused (include the whole merged cell, or unmerge it first).unmerge_table_cells-- split the merged cell holding the found text back into its cells; the text stays in the first one, so a merged cell is found by that text.read_documentdoesn't show which cells are merged. Rows and columns can't be inserted or deleted in a table with merged cells, so unmerge first; a merge and a row/column change on the same table, or two overlapping merges, go in separate calls.
Every operation that pins ambient style (bold leaking from an adjacent run, a heading's style duplicating onto newly split text) explicitly resets it, so inserted or edited text always ends up exactly as styled as requested, never inheriting whatever happened to be nearby.
report_issue
Files a bug or a limitation into the shared feedback spreadsheet (see Automatic issue reports). Claude calls it on its own when a failed call's error says to, or when it hits a limitation; you don't need to call it yourself.
Non-goals
- No creating new Google Docs (only editing existing ones).
- No Drive file management (move, rename, delete, share).
- No Sheets or Slides (for Sheets, see google-sheets-mcp).
- No writing comments and no suggestions (tracked-changes) mode. Comments can be read (see
read_comments), but creating one reliably anchored to a specific text range isn't supported by the public API, and replying or resolving would need a Drive scope that can also change sharing. - No ticking or unticking checklist items: Google's API has no way to change a checkbox, so new
checklist items always start unticked. Ticked state can be read (see
read_document). - No image insertion, replacement, or resizing -- a document merely containing an image is fully supported (it's represented as a placeholder character rather than blocking the rest of the document), just not creating or editing one.
- No independent per-side border colors --
sidesnarrows a single border style to a subset of sides, not a different color per side.
Roadmap
- Ship the Koios OAuth client with the package. The
sign-in mode is built; it needs a
Koios Workspace admin to create an Internal OAuth client in a Koios-owned Cloud project. Once it
exists, it goes into
BUNDLED_OAUTH_CLIENT(src/oauth.ts, here and in google-sheets-mcp) so signing in needs no client file. The service-account mode stays as a fallback.
Developing
To work on the server itself rather than just install it:
git clone [email protected]:koiosemployee/google-docs-mcp.git
cd google-docs-mcp
npm install
npm run build
npm testnpm run dev runs it directly from TypeScript source (via tsx) without a build step, and
npm run smoke-test (with DOCS_MCP_TEST_DOC_ID set to a real scratch Google Doc's id or URL)
exercises read_document/read_comments/edit_document against a live document -- useful for confirming a
change works against the real API, not just the unit tests.
