Releases: aaronsb/google-workspace-mcp
Release list
v4.1.0
Two bugs, both of which failed silently — one burned a CPU core forever, the other quietly returned part of your document and called it the whole thing.
Heads up: the server now exits when its client goes away
This is why 4.1.0 is a minor bump rather than a patch. A stdio MCP server is spawned by a client and is useful only to that client, so one that outlives its client now terminates itself. If you supervise this process expecting it to stay resident after its parent is gone, it no longer will — that is the fix, but it is a change in behavior.
manage_docs get output has also changed shape for multi-tab documents: it gains a **Tabs:** N line, a heading per tab, and a caveat line. If you parse that text, look at it before upgrading.
Fixed: orphaned servers pegged a CPU core indefinitely (#149)
After a client died uncleanly, two orphaned servers had each burned 31 minutes of CPU in 31 minutes of wall time. The crash guard logged to process.stderr; once the peer was gone that write raised the next uncaught exception, which re-entered the guard — perpetual motion built out of error handling, with ~70% of samples in stack formatting.
Three independent layers now prevent it (ADR-104): crash guards that cannot re-enter, an orphan watchdog keyed off the ppid captured at startup, and stdin end/close where the OS delivers it. On Linux this was invisible, because an orphan got stdin EOF and exited by accident of an empty event loop; it bit where EOF never arrives, which is where the leak and the spin turn out to be one bug.
Fixed: manage_docs get returned only the first tab (#152)
Google's body field is legacy — without includeTabsContent=true it holds the first tab only, and the response carries no hint that other tabs exist. A document holding one meeting transcript per tab returned 142 of its 780 lines and presented them as the complete file. No error, nothing to notice.
get now reads every tab, descends into nested subtabs, labels each one, and states how many it read. Measured on a real 3-tab document: 14,884 characters where it previously returned 10,213 — 31% of it was missing. Thanks to @Laralike for the report, which correctly identified the cause.
While fixing it, review caught the same defect class inside the fix — a tab that could not be read rendered as "this tab is empty", and a lone empty tab discarded a populated body. Both corrected before release.
Known gaps, tracked
manage_docswrite/insertTextstill target the first tab, and their indices are tab-1-relative.getnow says so in its output. (#157)- No way to request a single tab, so a many-tab document is one large response. (#158)
manage_scratchpadJSON-mode doc import still reads the first tab only — deliberately, because it is live-bound and the flag removes the field its sync paths address. (#155)- Windows startup was reported broken in #150; the code it described was already deleted in 4.0.0. If it still fails for you, please reopen with the crash output.
Install: the .mcpb bundle below is the one-click Claude Desktop install. Also on npm as @aaronsb/google-workspace-mcp.
Full changelog: v4.0.1...v4.1.0
v4.0.1
v4.0.1
Bug fixes. Every one of these was found by running the tools against a real account rather than by reading the code, and not one of them raised an error — each reported success while doing the wrong thing.
Thread listings had no senders and no subjects. manage_email getThread rendered every message in a thread like this:
**** —
Add full device config for QC Earbuds…
Gmail's metadataHeaders is a repeated query parameter — it must be sent once per value (?metadataHeaders=From&metadataHeaders=Subject). It was being sent as a single comma-joined string, so Google looked for the one header literally named "From,Subject,Date,To", found nothing, and returned a payload with no headers at all. The formatter was correct the whole time; it was being handed an empty response. The API descriptor already records which parameters are repeated, so the client now consults it.
Rate-limited reads rendered as blank rows. A message that could not be fetched came back as a row with no sender, no subject, and no date — indistinguishable from an empty email:
19f5129863afedf6 | | (no subject) |
Every failure was silently swallowed. A message we could not read now says so (⚠ could not load (429 rateLimitExceeded)) instead of pretending to be blank.
The client now retries throttled requests. It previously had no retry at all. Google throttles per user, not per process, so two clients signed in to the same account — a desktop app and an editor, say — share one quota, and a burst of perfectly valid reads can be refused. 429 and 5xx are now retried with exponential backoff and jitter, honouring Retry-After. Client errors (404 and friends) are not retried. Search hydration is also bounded to 8 concurrent reads instead of firing the whole page at once.
Snippets were HTML-escaped. Previews containing an apostrophe read as codename 'lando'. Now decoded.
Session context
The unread and "today" counts were not counts. They read resultSizeEstimate, which is an estimate in name and in fact: on a real mailbox it returned the same number — 201 — for is:unread (truly 135,824) and for mail received today (truly 30). Not an approximation; a constant, unrelated to the question.
Both numbers were wrong, and identically wrong, which also made the delta impossible: the session baseline and the current reading came from the same constant, so they always agreed. "No new unread emails since session start" was the only sentence that line could ever produce. It never worked.
Unread now comes from the INBOX label, which Gmail maintains exactly. Today's mail is counted.
Tasks
update failed on every call. It exposed no updatable fields, so the only request it could build was a PATCH with an empty body — which Google Tasks answers with a 500 Internal error encountered, an error that reads like an outage on Google's side and was not. This operation has never worked. It now takes title, notes, due and status, applies proper patch semantics (fields you don't pass are left alone), and an update naming no field is refused locally with a message saying which fields it takes.
create made blank, titleless tasks and createTaskList made nameless lists — title was not in the tool's schema, so passing one dropped it in silence. Both now take the fields you would expect.
Docs
get returned no document text, despite being described as "get document content and metadata". Google nests a document's text several levels down and puts nothing readable at the top level, so every document came back looking empty. It now returns the text, including the contents of tables.
create could only produce "Untitled document" — it now takes a title.
Drive
A file in the workspace could not be uploaded by its own name. A relative path resolved against the server's working directory — wherever your MCP client happened to launch it — while download, export and email attachments all treat a relative path as workspace-relative. Relative paths are now workspace-relative everywhere; absolute paths are unchanged.
Everywhere
Create operations now say what they created. The generic confirmation only recognised an identifier named exactly id; Google calls it documentId in Docs and spreadsheetId in Sheets, so creating a document returned, in full: Operation completed. It now names the identifier and the title.
For contributors
make check gained a guard asserting that every write operation is capable of carrying a request body — an operation cannot again ship structurally unable to do its job.
The coverage baseline had inverted logic: it inferred coverage from the presence of parameter gaps, so an operation with every parameter mapped had no gaps, and was recorded as uncovered because it was perfectly covered. Twenty-five already-implemented operations sat in coverage-baseline.json as work for contributors to pick up. Fixed, and the generator now refuses to write a baseline that contradicts its own report.
New: the full API surface — all 233 methods Google publishes across the seven APIs, what each does (in Google's own words), whether it is exposed, and a one-click link to request one that isn't.
v4.0.0
v4.0.0
⚠️ Breaking: the licence changes from MIT to Apache-2.0
From this release the project is licensed under the Apache License 2.0. Apache-2.0 adds an explicit patent grant and a requirement to state changes; it takes back nothing MIT permitted, so it is not a restriction on what you may do with the code. It is, however, a different licence, and if your organisation vets dependencies by licence it is a change worth knowing about — which is why this is a major version rather than a minor one.
The MIT history is preserved, not erased. Everything through v3.0.0 was MIT, and seven contributors besides the author hold copyright in that code. MIT permits sublicensing, so it redistributes cleanly under Apache-2.0 — its condition is that its notice travels with the code, and it does: LICENSE-MIT retains the original notice and NOTICE credits the contributors. Both the npm package and the .mcpb bundle carry all three files.
Related: package.json previously declared no licence field at all, so every version published to npm until now carried an unknown licence. It now declares Apache-2.0.
Fixed: operations that reported success and did nothing
These were found by driving all eleven tools against a real Google account. None of them raised an error — every one returned "Operation completed."
manage_tasks updatefailed on every call. It exposed no updatable fields, so the only request it could construct was aPATCHwith an empty body — which Google Tasks answers with a500 Internal error encountered, an error that reads like an outage on Google's side and was not. This operation has never worked. It now takestitle,notes,dueandstatus, applies proper patch semantics (fields you don't pass are left alone), and an update naming no field is refused locally with a message saying which fields it takes.manage_tasks createcreated blank, titleless tasks.titlewas not in the tool's schema, so passing one dropped it in silence. It now takestitle,notesanddue.manage_tasks createTaskListcreated nameless lists. Same cause; it now takestitle.manage_docs createcould only produce "Untitled document". It now takes atitle.manage_docs getreturned no document text, despite being described as "get document content and metadata". Google nests a document's text several levels down and puts nothing readable at the top level, so every document came back looking empty. It now returns the text, including the contents of tables.manage_drive uploadcould not upload a file from the workspace by name. A relative path resolved against the server's working directory — wherever your MCP client happened to launch it — whiledownload,exportand email attachments all treat a relative path as workspace-relative. Relative paths are now workspace-relative everywhere. Absolute paths are unchanged.- Create operations now say what they created. The generic confirmation only recognised an identifier literally named
id; Google calls itdocumentIdin Docs andspreadsheetIdin Sheets, so creating a document returned, in full: "Operation completed." It now names the identifier and the title.
A new build check (make check) asserts that every write operation is capable of carrying a request body, so an operation cannot again ship structurally unable to do its job.
Fixed: the coverage report contradicted the file it wrote
make coverage printed 60 of 233 methods covered while the baseline it wrote recorded 35, and nothing compared the two.
It inferred coverage from a proxy — an operation counted as covered only if it had a parameter gap. An operation with every parameter mapped has no gaps, so the best-covered operations were indistinguishable from uncovered ones and were persisted as gaps. Coverage was recorded as missing precisely where it was complete. Twenty-five already-implemented operations sat in coverage-baseline.json as uncovered work for contributors to pick up.
Coverage is now taken from the manifest directly, and the generator refuses to write a baseline that disagrees with its own report.
Packaging: one bundle, not five
Releases used to ship five .mcpb bundles named for five platforms. With no platform-specific binary in the payload, all five were the same bytes under different names — the same 3,191 files under an identical content hash — and the platform in the filename promised a guarantee the build never made.
Download google-workspace-mcp.mcpb. One bundle runs everywhere: macOS (Intel and Apple Silicon), Linux (x64 and ARM64), and Windows. There is nothing to choose.
Upgrading
No configuration changes. GWS_SAFETY_POLICY and the gws:// resource URIs are unchanged. Node 22.12 or newer, as in v3.0.0.
If you install the .mcpb bundle, grab the single google-workspace-mcp.mcpb asset rather than a platform-specific one.
v3.0.0
What's Changed
- docs(readme): add badges by @aaronsb in #125
- docs: API coverage page + README refresh by @aaronsb in #126
- feat(gmail): support send from aliases by @Lubrsy706 in #128
- chore(deps): patch critical sanitize-html XSS, clear production vulnerabilities by @aaronsb in #135
- fix(gmail): reply/replyAll honour attachments, html and draft (closes #132) by @aaronsb in #138
- Migrate test runner Jest → Vitest, and make the guards actually guard (ADR-101) by @aaronsb in #140
- Raise the Node floor to 22.12 and unpin sanitize-html (ADR-102) by @aaronsb in #141
New Contributors
- @Lubrsy706 made their first contribution in #128
Full Changelog: v2.7.1...v3.0.0
v2.7.1
v2.7.0
What's Changed
- fix(windows): use cmd.exe /c for .cmd binaries to fix argument quoting by @JeremyYowell in #112
- fix(calendar,drive): integrate PR #115 + scratchpad follow-up from #117 by @aaronsb in #118
- fix(calendar,drive): correct attendees flag, update schema, and share type by @DirectCash-Personal in #115
- fix(drive): listPermissions returns the permission list, not "No files found" (#114) by @aaronsb in #119
- fix(drive): copy honors
name; addupdateop for rename/move (#105) by @aaronsb in #120 - fix(sheets): create honors
title; read/getValues render row numbers (#113) by @aaronsb in #121 - feat(manifest): split manifest.yaml into per-service files (ADR-304, #108) by @aaronsb in #122
- feat(email): bodyFormat: 'html' returns sanitized HTML (#91, ADR-305) by @aaronsb in #123
- feat(scratchpad): live Docs batchUpdate sync for JSON mode (#79) by @aaronsb in #124
New Contributors
- @JeremyYowell made their first contribution in #112
- @DirectCash-Personal made their first contribution in #115
Full Changelog: v2.6.1...v2.7.0
v2.6.1
What's Changed
Full Changelog: v2.6.0...v2.6.1
v2.6.0
What's Changed
- fix(calendar): freebusy, agenda params, calendarId context (#98) by @aaronsb in #99
- fix(drive): create parent directories before export/download write by @aaronsb in #101
- fix(sheets): surface values + expand manage_sheets to 13 ops (#90) by @aaronsb in #103
- docs(adr): ADR-303 + ADR-304 for factory follow-ups by @aaronsb in #104
- feat(factory): implement ADR-303 — auto-append next-steps in generator by @aaronsb in #107
Full Changelog: v2.5.2...v2.6.0