mcp-gee-sweet 0.9.2.dev306__tar.gz → 0.9.2.dev313__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/PKG-INFO +1 -1
  2. mcp_gee_sweet-0.9.2.dev313/docs/design/doc-revision-export.md +126 -0
  3. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/index.md +1 -0
  4. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/known-limitations.md +31 -0
  5. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/drive_transfer.md +191 -0
  6. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/sheets_mgmt.md +94 -3
  7. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/roadmap.md +9 -8
  8. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/tools.md +1 -1
  9. mcp_gee_sweet-0.9.2.dev313/src/mcp_gee_sweet/tools/docs/markdown_export.py +371 -0
  10. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/drive/transfer.py +162 -45
  11. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/sheets/data.py +1 -1
  12. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/sheets/helpers.py +72 -24
  13. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/sheets/structure.py +66 -71
  14. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/sheets/test_helpers.py +38 -0
  15. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/sheets/test_structure.py +110 -19
  16. mcp_gee_sweet-0.9.2.dev313/tests/test_docs_revision_export.py +468 -0
  17. mcp_gee_sweet-0.9.2.dev306/src/mcp_gee_sweet/tools/docs/markdown_export.py +0 -141
  18. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/cleanup-worktrees.md +0 -0
  19. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/merge-pr.md +0 -0
  20. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/notify-kai.md +0 -0
  21. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/notify-partner.md +0 -0
  22. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/orchestrator.md +0 -0
  23. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/prep-for-pr.md +0 -0
  24. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/qa-kickoff.md +0 -0
  25. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/release.md +0 -0
  26. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/retro.md +0 -0
  27. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/roadmap-status.md +0 -0
  28. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/start-worktree.md +0 -0
  29. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/team-member.md +0 -0
  30. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/commands/verify-pr.md +0 -0
  31. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/settings.json +0 -0
  32. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/team-roles/amy.md +0 -0
  33. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/team-roles/aziz.md +0 -0
  34. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/team-roles/bob.md +0 -0
  35. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/team-roles/dev.md +0 -0
  36. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/team-roles/joy.md +0 -0
  37. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/team-roles/kai.md +0 -0
  38. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.claude/team-roles/qa.md +0 -0
  39. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.dockerignore +0 -0
  40. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  41. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  42. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  43. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.github/dependabot.yml +0 -0
  44. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.github/workflows/ci.yml +0 -0
  45. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.github/workflows/docs.yml +0 -0
  46. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.github/workflows/publish-dev.yml +0 -0
  47. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.github/workflows/release.yml +0 -0
  48. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.gitignore +0 -0
  49. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.pre-commit-config.yaml +0 -0
  50. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/.python-version +0 -0
  51. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/CHANGELOG.md +0 -0
  52. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/CLAUDE.md +0 -0
  53. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/CODE_OF_CONDUCT.md +0 -0
  54. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/CONTRIBUTING.md +0 -0
  55. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/Dockerfile +0 -0
  56. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/LICENSE +0 -0
  57. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/Makefile +0 -0
  58. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/README.md +0 -0
  59. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/SECURITY.md +0 -0
  60. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docker-compose.yml +0 -0
  61. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/assets/logo.png +0 -0
  62. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/auth.md +0 -0
  63. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/client-setup.md +0 -0
  64. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/configuration.md +0 -0
  65. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-async-tool-execution.md +0 -0
  66. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-auth-status-limitation-categories.md +0 -0
  67. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-cache-invalidation.md +0 -0
  68. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-chart-theming.md +0 -0
  69. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-comments-first-class.md +0 -0
  70. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-composite-tools.md +0 -0
  71. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-condition-type-docstring-generation.md +0 -0
  72. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-converted-doc-change-detection.md +0 -0
  73. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-dev-team.md +0 -0
  74. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-docs-formatting.md +0 -0
  75. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-fork.md +0 -0
  76. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-gmail-domain.md +0 -0
  77. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-grid-data-size-cap.md +0 -0
  78. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-local-fs-test-harness.md +0 -0
  79. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-mcp-v2-resource-context-access.md +0 -0
  80. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-pillow-image-dependency.md +0 -0
  81. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-prompt-qa-role.md +0 -0
  82. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-publishing.md +0 -0
  83. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-release-docs-roles.md +0 -0
  84. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-repositioning.md +0 -0
  85. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-response-size-cap-generalization.md +0 -0
  86. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-response-size-cap-reevaluation-519.md +0 -0
  87. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-review-finding-ticket-bar.md +0 -0
  88. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-supports-all-drives-lint-check.md +0 -0
  89. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-sync-folder-recursion.md +0 -0
  90. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-testing.md +0 -0
  91. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/decision-tool-error-visibility.md +0 -0
  92. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/index.md +0 -0
  93. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/decisions/role-file-history.md +0 -0
  94. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/blockquote-representation.md +0 -0
  95. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/borderless-table-columns.md +0 -0
  96. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/doc-to-markdown.md +0 -0
  97. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/docs-ast-pipeline.md +0 -0
  98. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/effectiveformat-spike.md +0 -0
  99. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/heading-anchor-resolution.md +0 -0
  100. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/image-conversion.md +0 -0
  101. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/local-file-access-policy.md +0 -0
  102. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/markdown-support.md +0 -0
  103. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/module-history.md +0 -0
  104. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/oauth-reauthorize-from-tool-call.md +0 -0
  105. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/phase3-theme-system.md +0 -0
  106. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design/sse-consent-off-event-loop.md +0 -0
  107. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/design.md +0 -0
  108. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/index.md +0 -0
  109. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/notes-read.md +0 -0
  110. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/README.md +0 -0
  111. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/qa-fixture-pixel.png +0 -0
  112. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  113. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  114. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-d213-dollar-escape.md +0 -0
  115. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  116. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc102-image-conversion.html +0 -0
  117. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc103-image-conversion.md +0 -0
  118. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc104-nested-lists.html +0 -0
  119. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc105-nested-lists-gfm.md +0 -0
  120. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc106-nested-lists-4space.md +0 -0
  121. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc135-paragraph-boundary.md +0 -0
  122. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc136-interrupted-block-boundary.md +0 -0
  123. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc137-inline-hr-no-block-ancestor.html +0 -0
  124. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc138-nbsp-spacer-paragraph.md +0 -0
  125. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc141-mismatched-list-tags.html +0 -0
  126. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc142-heading-anchors.md +0 -0
  127. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc144-anchor-in-table-cell.md +0 -0
  128. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures/tc-doc145-anchor-on-first-element.md +0 -0
  129. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/fixtures.template.md +0 -0
  130. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/gmail-test-plan.md +0 -0
  131. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/operations.yaml +0 -0
  132. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/playwright_oauth.md +0 -0
  133. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/.gitkeep +0 -0
  134. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-06-02.md +0 -0
  135. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-06-14.md +0 -0
  136. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-06-17.md +0 -0
  137. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-06-18.md +0 -0
  138. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
  139. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
  140. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-06-28.md +0 -0
  141. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-07-04-docs.md +0 -0
  142. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-07-04-drive-smoke.md +0 -0
  143. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-07-04-infra.md +0 -0
  144. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-07-04-sheets_read.md +0 -0
  145. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-07-04.md +0 -0
  146. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/results/2026-09-04.md +0 -0
  147. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/retro-v0.8.0.md +0 -0
  148. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/retro-v0.9.0.md +0 -0
  149. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/run.md +0 -0
  150. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/runs/README.md +0 -0
  151. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/runs/v0.8.0.md +0 -0
  152. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/runs/v0.8.1.md +0 -0
  153. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/runs/v0.9.0.md +0 -0
  154. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/setup.md +0 -0
  155. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/calendar.md +0 -0
  156. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/docs_content.md +0 -0
  157. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/docs_layout.md +0 -0
  158. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/docs_style.md +0 -0
  159. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/docs_tables.md +0 -0
  160. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/drive_activity.md +0 -0
  161. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/drive_files.md +0 -0
  162. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/drive_sharing.md +0 -0
  163. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/gmail.md +0 -0
  164. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/infra.md +0 -0
  165. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/sheets_charts.md +0 -0
  166. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/sheets_read.md +0 -0
  167. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/qa/tests/sheets_write.md +0 -0
  168. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/docs/style-guide.md +0 -0
  169. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/mkdocs.yml +0 -0
  170. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/pyproject.toml +0 -0
  171. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/pyrightconfig.json +0 -0
  172. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/scripts/check_supports_all_drives.py +0 -0
  173. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/scripts/gen_tool_docs.py +0 -0
  174. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/scripts/lane_context_hook.py +0 -0
  175. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/scripts/oauth_setup.py +0 -0
  176. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/scripts/qa_cancel_insert_local_images.py +0 -0
  177. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/scripts/qa_gmail_fixtures.py +0 -0
  178. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/scripts/setup_team.sh +0 -0
  179. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/scripts/write_team_mcp_configs.sh +0 -0
  180. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/server.json +0 -0
  181. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/.env.template +0 -0
  182. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/__init__.py +0 -0
  183. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/auth.py +0 -0
  184. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/cache.py +0 -0
  185. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/http_transport.py +0 -0
  186. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/reauth.py +0 -0
  187. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/server.py +0 -0
  188. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/sse_shutdown.py +0 -0
  189. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  190. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/atomic_write.py +0 -0
  191. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/cache.py +0 -0
  192. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/calendar.py +0 -0
  193. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/concurrency.py +0 -0
  194. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
  195. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/anchors.py +0 -0
  196. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/ast.py +0 -0
  197. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/ast_to_markdown.py +0 -0
  198. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/comments.py +0 -0
  199. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/content.py +0 -0
  200. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/doc_to_ast.py +0 -0
  201. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/editing.py +0 -0
  202. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/emitter.py +0 -0
  203. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/html_parser.py +0 -0
  204. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/images.py +0 -0
  205. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/indices.py +0 -0
  206. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
  207. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/named_ranges.py +0 -0
  208. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/style.py +0 -0
  209. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
  210. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  211. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
  212. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
  213. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  214. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/gmail.py +0 -0
  215. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/pagination.py +0 -0
  216. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/response_limits.py +0 -0
  217. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  218. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/__init__.py +0 -0
  219. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/conftest.py +0 -0
  220. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/drive/__init__.py +0 -0
  221. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/drive/test_activity.py +0 -0
  222. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/drive/test_files.py +0 -0
  223. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/drive/test_sharing.py +0 -0
  224. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/drive/test_transfer.py +0 -0
  225. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/fakes.py +0 -0
  226. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/integration/__init__.py +0 -0
  227. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/integration/conftest.py +0 -0
  228. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/integration/test_local_fs_harness.py +0 -0
  229. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/sheets/__init__.py +0 -0
  230. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/sheets/test_data.py +0 -0
  231. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_atomic_write.py +0 -0
  232. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_auth.py +0 -0
  233. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_cache.py +0 -0
  234. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_calendar.py +0 -0
  235. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_check_supports_all_drives.py +0 -0
  236. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_concurrency.py +0 -0
  237. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_anchors.py +0 -0
  238. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_comments.py +0 -0
  239. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_content.py +0 -0
  240. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_core.py +0 -0
  241. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_editing.py +0 -0
  242. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_images.py +0 -0
  243. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_indices.py +0 -0
  244. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_layout.py +0 -0
  245. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_markdown_export.py +0 -0
  246. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_named_ranges.py +0 -0
  247. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_style.py +0 -0
  248. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_docs_tables.py +0 -0
  249. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_gen_tool_docs.py +0 -0
  250. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_gmail.py +0 -0
  251. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_lane_context_hook.py +0 -0
  252. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_qa_gmail_fixtures.py +0 -0
  253. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_reauth.py +0 -0
  254. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_response_limits.py +0 -0
  255. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_server.py +0 -0
  256. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_server_json.py +0 -0
  257. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_sse_shutdown.py +0 -0
  258. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/tests/test_team_mcp_configs.py +0 -0
  259. {mcp_gee_sweet-0.9.2.dev306 → mcp_gee_sweet-0.9.2.dev313}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-gee-sweet
3
- Version: 0.9.2.dev306
3
+ Version: 0.9.2.dev313
4
4
  Summary: MCP server for Google Workspace — Sheets, Drive, Docs (HTML or Markdown), and Calendar.
5
5
  Project-URL: Homepage, https://github.com/khuisman/mcp-gee-sweet
6
6
  Project-URL: Bug Tracker, https://github.com/khuisman/mcp-gee-sweet/issues
@@ -0,0 +1,126 @@
1
+ # Design: Google Doc Revisions as Markdown (`export_revision`)
2
+
3
+ **Status:** Implemented. Issue #959. Live-verified against real Drive/Docs API calls via scratch
4
+ scripts per `CLAUDE.md`'s "Verify a ticket's API premise live" convention (2026-10-11).
5
+
6
+ ## Problem
7
+
8
+ `export_revision` handled only Sheets (XLSX export → cell values). `list_revisions` already
9
+ listed Doc revisions, but nothing could read one, so "what changed in this Doc since revision N"
10
+ couldn't be answered through the server. The issue asked for a Doc revision as Markdown "through
11
+ the same converter `get_doc_as_markdown` uses", so that diffing two revisions' exports shows only
12
+ the edit.
13
+
14
+ ## The constraint
15
+
16
+ `get_doc_as_markdown` walks `documents().get()` into the AST (`doc-to-markdown.md`). The Docs API
17
+ reads only a document's **current** state; it has no revision parameter. Drive is the only source
18
+ of an old revision, and only as a file from the revision's `exportLinks`. Measured on a Doc
19
+ revision: `text/markdown`, `text/x-markdown`, `text/html`, `text/plain`, DOCX, ODT, RTF, PDF, EPUB,
20
+ and a zipped HTML.
21
+
22
+ ## Options measured
23
+
24
+ Each option was run against the same Doc, written through `write_doc_content` with headings, bold
25
+ and italic, inline code, a fenced code block, a blockquote, nested ordered/bulleted lists, task
26
+ items, a table, an image, and heading links.
27
+
28
+ 1. **Drive's own `text/markdown` export.** Lossy: inline code became plain text, the fenced code
29
+ block became soft-broken plain lines, the blockquote became a plain paragraph, a nested bullet
30
+ inside a numbered list became a separate numbered item with a literal `\-`, and the image was
31
+ inlined as a base64 `data:` URI (10KB for one small logo). It's a different dialect from
32
+ `get_doc_as_markdown`, and a much less faithful one.
33
+ 2. **`text/html` export → this project's `html_parser` → AST.** Google's export carries styling in
34
+ CSS classes (`.c3{font-weight:700}`), which `html_parser` doesn't read, so bold, italic and code
35
+ fonts would be lost without a CSS resolver. Importing that HTML back into a Doc (to reuse the
36
+ Docs-side converter) was also measured: it split the code block into one fence per line and
37
+ turned every list item after the blockquote into a quote.
38
+ 3. **DOCX export → import as a temporary Google Doc → `document_to_ast` → `ast_to_markdown`.**
39
+ The output was **identical** to `get_doc_as_markdown` on the live Doc, except for the two
40
+ import-specific values below. The zipped-HTML export can't be imported (400).
41
+
42
+ **Chosen: (3).** It is the same converter, not an equivalent one, so everything
43
+ `get_doc_as_markdown` supports and documents carries over unchanged.
44
+
45
+ ## What differs on every import, and how it's made stable
46
+
47
+ Measured across two imports of the same DOCX:
48
+
49
+ - **Inline image `contentUri`.** A new URL each import; the image bytes are identical (same
50
+ SHA-256). `_stabilize_images` fetches each image (concurrency 8) while the temporary Doc still
51
+ exists and replaces its `contentUri` with `image:sha256-<first 16 hex digits>`, so an unchanged
52
+ image doesn't show in a diff and a changed one does. A failed fetch renders
53
+ `image:unavailable`. No viewable URL is returned: PR #988's QA round found the temporary Doc's
54
+ image URLs 404 (anonymously and authenticated) once it's deleted, so a returned URL would be
55
+ dead on arrival. (One earlier probe got a 200 right after deletion; it doesn't hold.)
56
+ - **Heading IDs and the document ID.** Every heading gets a new `headingId`, and
57
+ `doc_to_ast._link_url` rebuilds an ID-only heading link (the kind the Docs UI creates) as a URL
58
+ containing the temporary Doc's ID. That changes on every export and points at a deleted Doc.
59
+ `_stabilize_internal_links` rewrites a heading link to `#<slug>`, using anchors.py's GitHub-style
60
+ slug with `-1`/`-2` duplicate suffixes, so the link survives a round trip through the write
61
+ side's anchor resolver. The slug list holds only paragraphs that render as Markdown headings,
62
+ classified by `doc_to_ast._paragraph_to_node` itself (`Heading`, or a TITLE/SUBTITLE
63
+ `NamedBlock`): a bulleted heading renders as a list item and a heading in a table cell as cell
64
+ text, and counting them shifted every later duplicate's suffix onto the wrong heading (PR #988
65
+ QA round 1). A link to a heading that doesn't render as one is dropped, following the project's
66
+ rule that a wrong link is worse than none.
67
+ - **Bookmark links are dropped.** A bookmark has no text to name it, and any `#fragment` made up
68
+ for one can be fuzzy-matched by `anchors.py` to a heading when the Markdown is written back
69
+ (`#bookmark-1` → the second heading titled "Bookmark"; PR #988 QA round 1).
70
+ - URL links written by this project's own write side are already absolute URLs and pass through
71
+ unchanged.
72
+
73
+ ## The temporary Doc
74
+
75
+ - Created in the default folder (`lc.folder_id`), like every other create path, so a service
76
+ account works when a Shared Drive is configured. A storageQuotaExceeded error renders with the
77
+ shared `_SA_QUOTA_ERROR` text. Who can see it there, and its 30 days in the trash when the
78
+ identity can only trash, is tracked in #989.
79
+ - Uploaded resumable: Drive takes a multipart upload only up to 5 MB, and a revision with
80
+ embedded images can exceed it.
81
+ - Named `export_revision temp (<file_id> r<revision_id>)`, so an orphan is identifiable.
82
+ - Created inside `anyio.CancelScope(shield=True)`, per `CLAUDE.md`'s cancellation rule: a
83
+ cancellation can't land after Drive created the Doc but before its ID is recorded.
84
+ - Removed in a shielded `finally`: deleted when `capabilities.canDelete` allows it, else trashed
85
+ (a Content manager on a Shared Drive can only trash, #876). The removal retries a 5xx or 429
86
+ (`num_retries=3`), since a failed removal leaves a copy of the revision in Drive. A failed removal
87
+ never hides the export: the result gets a `warning` naming the Doc's ID.
88
+ - Because it writes to Drive, `export_revision` is no longer annotated `readOnlyHint`; it's
89
+ `destructiveHint=False`, since it changes nothing of the caller's.
90
+ - About 5–6 seconds per call, measured, on top of the image fetches.
91
+
92
+ ## Errors found while measuring
93
+
94
+ - The export endpoint answers rapid calls with HTTP 429 and an HTML page, without raising. The
95
+ Sheets path handed that page straight to openpyxl. `_fetch_revision_export` checks the status
96
+ for both paths.
97
+ - Docs folds the latest revision into the next one while editing continues: revision 18 was
98
+ listed, an edit 90 seconds later produced 19, and 18 then returned 404. A second run with a
99
+ 5-minute gap merged 19 into 20 the same way. `export_revision` turns a "Revision not found" 404
100
+ into an error telling the caller to call `list_revisions` again. Any other 404, such as a bad
101
+ file ID ("File not found"), is re-raised unchanged. A Doc created by Drive import keeps its
102
+ import revision separate (as #814 measured).
103
+ - `revisions.list` returns at most 200 revisions per page by default (discovery document), and
104
+ `list_revisions` read only the first page. It now pages through every revision via the same
105
+ `_list_revisions` helper `sync_folder` uses.
106
+ - A Doc `local_path` is checked with `_validate_local_destination` before the export, so a bad
107
+ destination fails before the temporary Doc round trip.
108
+
109
+ ## Live verification
110
+
111
+ Against a Doc imported through `upload_file(convert_to_doc=True)`, then edited by inserting one
112
+ word: exporting revision 1 (the import) and revision 2 and diffing the two `markdown` values gave
113
+ exactly one changed line, the edited paragraph. On a `write_doc_content` Doc with every construct
114
+ above plus two `Setup` headings linked by ID, the export matched `get_doc_as_markdown` except the
115
+ image line and the two links (`#setup`, `#setup-1`). With the first `Setup` bulleted, it rendered
116
+ as `- Setup`, the link to it was dropped, and the link to the second rendered as `#setup`.
117
+
118
+ ## What this does NOT do
119
+
120
+ - **Comments.** They aren't part of a revision, so there's no `include_comments`.
121
+ - **Match `get_doc_as_markdown` byte for byte.** Heading links and images differ by design (above).
122
+ Diff a revision against another revision's export, not against the live Doc's
123
+ `get_doc_as_markdown`.
124
+ - **Show an old revision's images.** Only their hashes; see above.
125
+ - **Multi-tab documents: not measured.** How Drive's DOCX export handles a Doc's extra tabs
126
+ wasn't checked; `document_to_ast` reads only the imported Doc's body.
@@ -15,3 +15,4 @@ These are more granular than the [decision records](../decisions/index.md), whic
15
15
  | [SSE OAuth Consent Off the Event Loop](sse-consent-off-event-loop.md) | 2026-10-01 | #833: the SSE consent wait on a daemon thread with a stoppable callback server, shared across connections, ended by sse_starlette's shutdown flag on SIGTERM |
16
16
  | [Re-authorizing OAuth from the Failing Tool Call](oauth-reauthorize-from-tool-call.md) | 2026-10-06 | #873/#906: a revoked or missing token offers the consent link from the failing call (URL elicitation, or the link in the `ToolError`), detected at `thread_http`'s transport so tools that catch errors are covered, adopted in place on every connection |
17
17
  | [Local-File Access Policy](local-file-access-policy.md) | 2026-10-10 | #796: `LOCAL_FILE_ACCESS=full\|roots\|off` resolved at import from argv (plus an HTTP-request tripwire for launches argv can't see), one `check_read`/`check_write` at each choke point and per entry in folder walks, an identity-based (`samefile`) deny-list of the server's own credential/config/cache/log files, `@tool(local_only=True)` unregistering nine tools in `off` mode, mode and roots reported in `server://auth-status` and the refusal |
18
+ | [Google Doc Revisions as Markdown](doc-revision-export.md) | 2026-10-11 | #959: `export_revision` on a Doc via DOCX export → temporary Doc → `get_doc_as_markdown`'s own converter (Drive's Markdown and HTML exports measured lossy), heading links and image URLs rewritten so two exports diff cleanly, shielded temp-Doc cleanup |
@@ -120,6 +120,37 @@ raw Docs API via `batch_update` passthrough) instead. Related: docs/design/doc-t
120
120
 
121
121
  ---
122
122
 
123
+ ### `export_revision` on a Google Doc — links, images, and vanishing revision IDs
124
+
125
+ **What:** `export_revision` (#959) returns a Doc revision as Markdown through the same converter
126
+ as `get_doc_as_markdown`, with these differences and gaps:
127
+
128
+ 1. A link to a heading renders as a GitHub-style `#slug` anchor, not as a docs.google.com URL.
129
+ A link to a bookmark, or to a heading that doesn't render as one (a bulleted heading, or one
130
+ in a table cell), is dropped and leaves its text. An image renders as
131
+ `image:sha256-<hash>`, with no viewable URL: the temporary Doc's image URLs stop working
132
+ once it's deleted, so an old revision's images can't be viewed this way.
133
+ 2. The export doesn't include comments, which aren't part of a revision.
134
+ 3. A revision ID from `list_revisions` can stop existing: Docs folds a recent revision into the
135
+ next one while editing continues (measured: edits 90 seconds and 5 minutes later both merged it).
136
+ The export then fails with an error saying to list revisions again.
137
+ 4. Each call creates and removes a temporary Google Doc in the default folder, which takes
138
+ several seconds and needs storage there (a service account needs a Shared Drive default
139
+ folder). If removal fails, the result's `warning` names the Doc to delete by hand.
140
+
141
+ **Why:** The Docs API reads only a document's current state, so the revision is exported as DOCX
142
+ and imported into a temporary Doc to be read. That Doc's ID, heading IDs, and image URLs are new
143
+ on every import, so (1) replaces them with values that stay the same between exports and keep
144
+ diffs clean. A bookmark has no text to make a stable anchor from, and an invented one could be
145
+ matched to the wrong heading if the Markdown is written back. (3) is how Drive keeps Docs
146
+ revision history.
147
+
148
+ **Workaround:** For (1), diff one revision's export against another's, not against
149
+ `get_doc_as_markdown` output. For (3), export a revision only after editing has settled.
150
+ Related: docs/design/doc-revision-export.md.
151
+
152
+ ---
153
+
123
154
  ## Google Drive
124
155
 
125
156
  ### Service account cannot create files in personal Drive
@@ -1933,11 +1933,202 @@ export_revision(rev 6, no range) → sheet="Sheet1", range=null, values = full 5
1933
1933
  - Returns a clear error: "No XLSX export available for this revision"
1934
1934
  - Does not crash
1935
1935
 
1936
+ **Superseded by #959:** a Google Doc revision now exports as Markdown (TC-D280), so this case
1937
+ checks a file that is neither a Sheet nor a Doc. Run it as:
1938
+
1939
+ **Setup:** Create a scratch Google Slides file named `qa-959-slides` in `DRIVE_FOLDER_ID`, and
1940
+ type one word on its first slide in the Slides UI (Playwright) so it has a revision.
1941
+
1942
+ **Tool calls**
1943
+ 1. `list_revisions(file_id="<slides-id>")`, then
1944
+ `export_revision(file_id="<slides-id>", revision_id="<last revisionId>")`
1945
+
1946
+ **Checks**
1947
+ - Raises a clear error: "No XLSX or DOCX export available for revision <id>. export_revision
1948
+ supports Google Sheets and Google Docs files only."
1949
+ - Does not crash
1950
+
1951
+ **Teardown:** `delete_file(file_id="<slides-id>")`.
1952
+
1953
+ The 2026-09-04 Result below predates #959 and tested a Google Doc.
1954
+
1955
+ **Result (2026-10-11, PR #988 round 1, `8b223c5`) ✅ PASS** — Ran via `mcp-gee-sweet-kit` (OAuth). The Slides file was created blank by a scratch-script `files().create(mimeType=presentation)` rather than through the Slides UI; `list_revisions` still listed revision 1. `export_revision` raised exactly "No XLSX or DOCX export available for revision 1. export_revision supports Google Sheets and Google Docs files only." Slides file trashed.
1956
+
1936
1957
  **Result (2026-09-04) ✅ PASS**
1937
1958
  export_revision({DOC_ID}, rev 3) → error "No XLSX export available for revision 3. The file may not be a Google Sheets file." Clear error, no crash.
1938
1959
 
1939
1960
  ---
1940
1961
 
1962
+ ### TC-D280: Export two Google Doc revisions as Markdown; the diff shows only the edit (issue #959) ⚠️ requires-oauth
1963
+
1964
+ **Background:** The Docs API can't read an old revision, so `export_revision` exports it as DOCX,
1965
+ imports that into a temporary Doc, and converts it with `get_doc_as_markdown`'s own converter.
1966
+ Docs folds the latest revision into the next one while editing continues: during #959, edits 90
1967
+ seconds and 5 minutes later both merged it. A Doc created by Drive import keeps its import
1968
+ revision separate (#814), so the diff below uses one.
1969
+
1970
+ **Setup**
1971
+ 1. `create_doc(title="qa-959-rev", content_format="markdown", content=<below>)` with:
1972
+ ````
1973
+ # Release Notes
1974
+
1975
+ Intro with **bold**, *italic*, `inline code`, and [external](https://example.com).
1976
+
1977
+ ## Setup
1978
+
1979
+ ```
1980
+ pip install thing
1981
+ thing --init
1982
+ ```
1983
+
1984
+ > A quoted note.
1985
+
1986
+ 1. First step
1987
+ 2. Second step
1988
+
1989
+ - [ ] open task
1990
+ - [x] done task
1991
+
1992
+ | Key | Value |
1993
+ |-----|-------|
1994
+ | a | 1 |
1995
+
1996
+ ![logo](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)
1997
+ ````
1998
+ Then `list_revisions(file_id="<rev-id>")` and take the last `revisionId` as R.
1999
+ 2. `upload_file(name="qa-959-diff.md", source_format="markdown", convert_to_doc=True,
2000
+ content="# Notes\n\nIntro with **bold** text.\n\n## Next\n\nClosing paragraph.\n")`.
2001
+ Then `find_in_doc(doc_id="<diff-id>", query="Intro with")` and
2002
+ `insert_doc_text(doc_id="<diff-id>", insertions=[{"index": <start_index + 6>, "text": "EDITED "}])`.
2003
+ 3. Wait about a minute, then `list_revisions(file_id="<diff-id>")`: A is the first revision
2004
+ (the import), B the last. If only one revision is listed, wait another minute and re-list.
2005
+
2006
+ **Tool calls**
2007
+ 1. `export_revision(file_id="<rev-id>", revision_id="<R>")`
2008
+ 2. `get_doc_as_markdown(doc_id="<rev-id>")`
2009
+ 3. `export_revision(file_id="<diff-id>", revision_id="<A>")`
2010
+ 4. `export_revision(file_id="<diff-id>", revision_id="<B>")`
2011
+ 5. `search_files(query="export_revision temp")`
2012
+
2013
+ **Checks**
2014
+ - Call 1's `markdown` has the heading, bold/italic, `` `inline code` ``, a fenced code block, a
2015
+ `> ` blockquote, the numbered list, both task items, the table, and the image as
2016
+ `![](image:sha256-<16 hex>)`. The result has no `images` key: the temporary Doc's image URLs
2017
+ stop serving once it's deleted, so none is returned (PR #988 round 1).
2018
+ - Call 2's `markdown` equals call 1's except the image line (call 2 has a URL).
2019
+ - Diffing call 3's `markdown` against call 4's shows exactly one changed line: `Intro with` →
2020
+ `Intro EDITED with`.
2021
+ - Call 5 returns no temporary Doc for `<rev-id>` or `<diff-id>`: each was removed. `search_files`
2022
+ also lists trashed files, so a hit from an earlier run is fine if `get_file_metadata` shows
2023
+ `trashed: true`.
2024
+
2025
+ **Teardown:** `delete_file` on both `<rev-id>` and `<diff-id>`.
2026
+
2027
+ **Result (2026-10-11, PR #988 round 1, `8b223c5`) ❌ FAIL (sent back)** — Ran via `mcp-gee-sweet-kit` (OAuth). `qa-959-rev` listed revisions 1 and 5; R=5. `qa-959-diff.md` listed revisions 1 and 2 about 10 seconds after the edit (no wait needed). Call 1's `markdown` had every listed construct and the image as `![](image:sha256-9237fdb37f9592f6)`. Call 2's `markdown` matched it byte for byte except the image line. Calls 3 and 4 differed in exactly one line, `Intro with` → `Intro EDITED with`. Call 5 returned `[]`. **Failed check:** the `images[0].url` from call 1 returned **HTTP 404** both to an anonymous `curl` and to an OAuth-authenticated `AuthorizedSession.get`, while the live Doc's own image URL from call 2 returned 200 `image/png` anonymously. A repeat export, fetched within seconds of the call returning, also returned 404. The temporary Doc's `contentUri` stops serving once that Doc is deleted, so every `images` URL is already dead when the tool returns (contradicts `docs/design/doc-revision-export.md`'s "the URL still serves after the temporary Doc is deleted"). Both Docs trashed.
2028
+
2029
+ **Result (2026-10-11, PR #988 round 2, `15d778d`) ✅ PASS** — Ran via `mcp-gee-sweet-kit` (OAuth). `qa-959-rev` listed revisions 1 and 5; R=5. `qa-959-diff.md` listed 1 and 2 about 10 seconds after the edit. Call 1 returned `revisionId`, `modifiedTime`, `markdown` and no `images` key; `markdown` had every listed construct and the image as `![](image:sha256-9237fdb37f9592f6)` (same hash as round 1). Call 2 matched call 1 byte for byte except the image line. Calls 3 and 4 differed in exactly one line, `Intro with` → `Intro EDITED with`. Call 5 listed no temporary Doc from this pass; its one hit was a temporary Doc from the Dev's 15:15 probe of another Doc, already `trashed: true` (the trash branch working; `search_files` lists trashed files, so the check now says so). Both Docs trashed.
2030
+
2031
+ ---
2032
+
2033
+ ### TC-D281: Doc revision heading links render as `#slug` and stay stable (issue #959) ⚠️ requires-oauth
2034
+
2035
+ **Setup**
2036
+ 1. `create_doc(title="qa-959-links", content_format="markdown", content="# Top\n\n## Setup\n\nFirst.\n\n## Setup\n\nJump here and back here.\n")`
2037
+ 2. Link text to the headings by ID, the way the Docs UI does. No tool sets an ID-only link
2038
+ (`style_doc_range`'s `link_url` sets a URL), so use the Docs UI in Playwright (select "Jump",
2039
+ Insert link → Headings → the first `Setup`; "back" → the second `Setup`), or a scratch script
2040
+ per `CLAUDE.md`'s "MCP restart" note sending `updateTextStyle` with
2041
+ `textStyle: {"link": {"headingId": "<id>"}}`. `get_doc_structure(doc_id="<id>")` gives both
2042
+ headings' `headingId`s.
2043
+ 3. `list_revisions(file_id="<id>")` and take the last `revisionId`.
2044
+ 4. Make a second Doc, `qa-959-bullet`, the same way (steps 1–2), then turn its first `## Setup`
2045
+ into a bulleted list item: `create_paragraph_bullets` over that heading's range (indices from
2046
+ `get_doc_structure`). `list_revisions` on it and take the last `revisionId`.
2047
+
2048
+ **Tool calls**
2049
+ 1. `export_revision(file_id="<id>", revision_id="<last>")`
2050
+ 2. The same call again.
2051
+ 3. `export_revision(file_id="<bullet-id>", revision_id="<bullet last>")`
2052
+
2053
+ **Checks**
2054
+ - Call 1's `markdown` contains `[Jump](#setup)` and `[back](#setup-1)`, and no
2055
+ `docs.google.com` URL.
2056
+ - Call 2's `markdown` is identical to call 1's.
2057
+ - Call 3's `markdown` renders the first `Setup` as a list item (`- Setup`), leaves "Jump" as
2058
+ plain text (its target doesn't render as a heading), and contains `[back](#setup)`: the
2059
+ bulleted heading takes no slug (PR #988 round 1).
2060
+
2061
+ **Teardown:** `delete_file` on both Docs.
2062
+
2063
+ **Result (2026-10-11, PR #988 round 1, `8b223c5`) ✅ PASS** — Ran via `mcp-gee-sweet-kit` (OAuth). Links set by a scratch script (`updateTextStyle` with `link: {headingId}`). Revision 4: both calls returned `[Jump](#setup) here and [back](#setup-1) here.` with no `docs.google.com` URL, byte-identical. **Extra probe (sent back as a review finding):** a second Doc with the first `## Setup` turned into a bulleted list item (`createParagraphBullets`) and the same two links exported as `- Setup\n\n## Setup\n\n[Jump](#setup) here and [back](#setup-1) here.` The bulleted heading renders as a list item, not a heading, but still takes a slug. `#setup` now names the wrong heading and `#setup-1` names none. Both Docs trashed.
2064
+
2065
+ **Result (2026-10-11, PR #988 round 2, `15d778d`) ✅ PASS** — Ran via `mcp-gee-sweet-kit` (OAuth). Links set by a scratch script (`updateTextStyle` with `link: {headingId}`). Calls 1 and 2 (revision 4) both returned `[Jump](#setup) here and [back](#setup-1) here.`, byte-identical, no `docs.google.com` URL. Call 3 (`qa-959-bullet`, revision 5) returned `# Top\n\n- Setup\n\nFirst.\n\n## Setup\n\nJump here and [back](#setup) here.`: the bulleted heading takes no slug and the link to it is dropped. **Extra probe:** a Doc with `## Setup`, then a table whose first cell is a HEADING_2 `Setup`, then a second body `## Setup`; "Jump" linked to the last body heading and "here" to the cell heading. The export was `[Jump](#setup-1) here.`: the cell heading is skipped when counting duplicates (it was `#setup-2` before the fix), and the link to it is dropped. All three Docs trashed.
2066
+
2067
+ ---
2068
+
2069
+ ### TC-D282: Doc revision `local_path`, and arguments that don't apply to the file's type (issue #959) ⚠️ local-filesystem ⚠️ requires-oauth
2070
+
2071
+ **Setup:** `mkdir -p /tmp/qa-959` (the directory must exist: a `local_path` naming a missing
2072
+ directory, even with a trailing `/`, is written as a file of that name).
2073
+ `create_doc(title="qa-959-args", content="<p>Saved to disk</p>")`, then
2074
+ `list_revisions` on it and on `TEST_SPREADSHEET_ID`, taking each one's last `revisionId`.
2075
+
2076
+ **Tool calls**
2077
+ 1. `export_revision(file_id="<doc-id>", revision_id="<doc rev>", local_path="/tmp/qa-959/")`
2078
+ 2. `export_revision(file_id="<doc-id>", revision_id="<doc rev>", range="A1:B2")`
2079
+ 3. `export_revision(file_id="<TEST_SPREADSHEET_ID>", revision_id="<sheet rev>", local_path="/tmp/qa-959/")`
2080
+ 4. `export_revision(file_id="<TEST_SPREADSHEET_ID>", revision_id="<sheet rev>", range="A1:B2")`
2081
+ 5. `export_revision(file_id="<doc-id>", revision_id="999999")`
2082
+ 6. `export_revision(file_id="<doc-id>_WRONG", revision_id="<doc rev>")`
2083
+ 7. `export_revision(file_id="<doc-id>", revision_id="<doc rev>", local_path="/etc/hosts/x.md")`,
2084
+ then `search_files(query="export_revision temp")`
2085
+
2086
+ **Checks**
2087
+ - Call 1 returns `local_path` ending `/tmp/qa-959/<doc-id>-r<doc rev>.md`, `bytes_written`,
2088
+ `revisionId`, `modifiedTime`, and no `markdown` or `images`. The file holds raw Markdown
2089
+ (`Saved to disk`), not JSON.
2090
+ - Call 2 raises "…is a Google Doc; range and sheet apply only to Google Sheets."
2091
+ - Call 3 raises "…is a Google Sheet; local_path applies only to Google Docs."
2092
+ - Call 4 returns `sheet`, `range`, and `values` exactly as before #959.
2093
+ - Call 5 raises "Revision 999999 of <doc-id> not found…call list_revisions again…".
2094
+ - Call 6 raises Drive's own 404 ("File not found: <doc-id>_WRONG"), not the merged-revision
2095
+ message (PR #988 round 1).
2096
+ - Call 7 raises "local_path '/etc/hosts/x.md' has a non-directory path component at
2097
+ '/etc/hosts'", and `search_files` finds no temporary Doc for `<doc-id>`: the destination is checked
2098
+ before the export (PR #988 round 1).
2099
+
2100
+ **Teardown:** remove `/tmp/qa-959/`; `delete_file(file_id="<doc-id>")`.
2101
+
2102
+ **Result (2026-10-11, PR #988 round 1, `8b223c5`) ✅ PASS** — Ran via `mcp-gee-sweet-kit` (OAuth). The first attempt at call 1 ran without `/tmp/qa-959` existing and wrote a 15-byte *file* named `/tmp/qa-959` (`local_path: "/tmp/qa-959"`); that comes from the shared `write_capped_result_to_disk`, which this PR doesn't touch, so it's ticketed separately and the setup now creates the directory. Rerun with the directory present: call 1 returned `local_path` `/tmp/qa-959/<doc-id>-r3.md`, `bytes_written: 15`, `revisionId`, `modifiedTime`, `images: []`, no `markdown`; the file held `Saved to disk\n\n`, raw Markdown. Calls 2, 3, and 5 raised the expected messages. Call 4 returned `sheet: "Sales"`, `range: "A1:B2"`, `values: [["Product", null], ["Widget", 100]]`. **Extra probe (sent back as a review finding):** `file_id="<doc-id>_WRONG"` raised the same "Revision 3 of … not found. Drive merges a recent revision…" message, misdiagnosing a bad file ID as a merged revision.
2103
+
2104
+ **Result (2026-10-11, PR #988 round 2, `15d778d`) ✅ PASS** — Ran via `mcp-gee-sweet-kit` (OAuth), `/tmp/qa-959` created first. Call 1 returned `local_path` `/tmp/qa-959/<doc-id>-r3.md`, `bytes_written: 15`, `revisionId`, `modifiedTime`, and no `markdown` or `images`; the file held `Saved to disk\n\n`. Calls 2, 3, and 5 raised the expected messages. Call 4 returned `sheet: "Sales"`, `range: "A1:B2"`, `values: [["Product", null], ["Widget", 100]]` (revision 342). Call 6 raised Drive's own `HttpError 404 … "File not found: <doc-id>_WRONG."`, not the merged-revision message. Call 7 raised `local_path '/etc/hosts/x.md' has a non-directory path component at '/etc/hosts'`, and `search_files` found no temporary Doc for `<doc-id>` (only the earlier trashed one noted in TC-D280). Doc trashed, `/tmp/qa-959` removed.
2105
+
2106
+ ---
2107
+
2108
+ ### TC-D283: A service account (Content manager) exports a Doc revision; the temporary Doc is trashed (issue #959)
2109
+
2110
+ **Background:** A Content manager on a Shared Drive can trash but not permanently delete (#876),
2111
+ so the temporary Doc is trashed instead. Use the `mcp-gee-sweet-sa` server, whose default folder
2112
+ is the Shared Drive.
2113
+
2114
+ **Setup:** On the SA server, `create_doc(title="qa-959-sa", content="<p>SA revision</p>")`, then
2115
+ `list_revisions(file_id="<id>")` and take the last `revisionId`.
2116
+
2117
+ **Tool calls**
2118
+ 1. `export_revision(file_id="<id>", revision_id="<last>")` on the SA server
2119
+ 2. `search_files(query="export_revision temp")` on the SA server
2120
+
2121
+ **Checks**
2122
+ - Call 1 returns `markdown` containing `SA revision` and no `warning`.
2123
+ - Call 2 finds no live file. If the client can show trashed files, the temporary Doc is in the
2124
+ Shared Drive's trash.
2125
+
2126
+ **Teardown:** `delete_file(file_id="<id>")` on the SA server.
2127
+
2128
+ **Result (2026-10-11, PR #988 round 1, `8b223c5`) ⏭️ SKIP** — Not run: a lane QA session may call only its own `mcp-gee-sweet-kit` server, and the case needs `mcp-gee-sweet-sa`. Pending for a release pass (Aziz may use the `-sa` slot). The trash branch is unit-tested only so far.
2129
+
2130
+ ---
2131
+
1941
2132
  ### TC-D256: `download_folder`'s `progress_unit='bytes'` feeds byte totals into the structured progress fields, with a file-count fallback when sizes aren't fully known (issue #741) ⚠️ local-filesystem
1942
2133
 
1943
2134
  **Background:** TC-D255 (#352) confirmed the message text carries bytes-transferred-so-far as supplementary context, but the structured `progress`/`total` fields passed to `ctx.report_progress()` stayed file-count-based unconditionally — a client rendering a progress bar from those fields alone (not parsing message text) sees the same one-tick-per-file granularity regardless of individual file size. #741's maintainer decision: keep file-count as the default (`progress_unit='files'`), but add an opt-in `progress_unit='bytes'` so a caller can request byte-based structured fields when every candidate's size is known upfront (same `bytes_total_known` gate TC-D255's message-fallback logic already uses) — falling back to file-count when it isn't, since there's no reliable byte total to report against then. `sync_folder` is explicitly out of scope for this decision (recursive descent never has a full upfront size list). Unit-tested deterministically against a mocked `ctx` in `tests/drive/test_transfer.py::TestDownloadFolder::test_progress_unit_bytes_reports_byte_totals_when_sizes_known`/`test_progress_unit_bytes_falls_back_to_files_when_sizes_unknown`; this live check confirms both branches against a real API call.
@@ -1594,12 +1594,12 @@ add_data_validation(sheet="NoSuchSheet") → {"error":"Sheet 'NoSuchSheet' not f
1594
1594
 
1595
1595
  ### TC-S122: add_data_validation — values count/shape mismatch per condition_type returns a local error (unit test)
1596
1596
 
1597
- **Background:** Issue #366 — before this, a mismatched `values` count reached the real Sheets API and got a clean 400 back (e.g. `NUMBER_BETWEEN` with 1 value: `"requires exactly two ConditionValues, but 1 value was supplied"`), which was already safe, just a round-trip later than necessary. Now caught locally via `_condition_value_count_error` before any API call, covering all five value-count shapes: zero-only (`NOT_BLANK`, `BLANK`, `DATE_IS_VALID`, `TEXT_IS_EMAIL`, `TEXT_IS_URL`), zero-or-two (`BOOLEAN`), exactly-one (`NUMBER_GREATER`, `CUSTOM_FORMULA`, `ONE_OF_RANGE`, and the other single-value TEXT_*/DATE_*/NUMBER_* types), exactly-two (`NUMBER_BETWEEN`, `NUMBER_NOT_BETWEEN`, `DATE_BETWEEN`, `DATE_NOT_BETWEEN`), and at-least-one (`ONE_OF_LIST`).
1597
+ **Background:** Issue #366 — before this, a mismatched `values` count reached the real Sheets API and got a clean 400 back (e.g. `NUMBER_BETWEEN` with 1 value: `"requires exactly two ConditionValues, but 1 value was supplied"`), which was already safe, just a round-trip later than necessary. Now caught locally via `_condition_value_count_error` before any API call, covering all five value-count shapes: zero-only (`DATE_IS_VALID`, `TEXT_IS_EMAIL`, `TEXT_IS_URL`; `BLANK`/`NOT_BLANK` were removed in PR #986 as unsupported by data validation, see TC-S134), zero-or-two (`BOOLEAN`), exactly-one (`NUMBER_GREATER`, `CUSTOM_FORMULA`, `ONE_OF_RANGE`, and the other single-value TEXT_*/DATE_*/NUMBER_* types), exactly-two (`NUMBER_BETWEEN`, `NUMBER_NOT_BETWEEN`, `DATE_BETWEEN`, `DATE_NOT_BETWEEN`), and at-least-one (`ONE_OF_LIST`).
1598
1598
 
1599
1599
  **Checks (unit test)**
1600
1600
  - `NUMBER_BETWEEN` with 1 value → `{"error": ...}`, before any API call (the issue's own example)
1601
1601
  - `BOOLEAN` accepts 0 or 2 values (a plain checkbox or custom checked/unchecked labels), rejects exactly 1
1602
- - A zero-value type (`NOT_BLANK`) rejects any supplied value
1602
+ - A zero-value type (`DATE_IS_VALID`) rejects any supplied value
1603
1603
  - `ONE_OF_LIST` rejects an empty/omitted `values`
1604
1604
  - Exactly-one types (`NUMBER_GREATER`, `CUSTOM_FORMULA`, `ONE_OF_RANGE`) reject 0 or 2 values
1605
1605
  - Exactly-two types (`NUMBER_BETWEEN`, `DATE_BETWEEN`) reject 1 or 3 values
@@ -1689,7 +1689,7 @@ Same as TC-S100 — re-confirms clean-error fix (duplicate coverage in test file
1689
1689
  ### TC-S126: add_data_validation — invalid A1 notation range returns a clean error (issue #747, unit test)
1690
1690
 
1691
1691
  **Checks (unit test)**
1692
- - `add_data_validation(spreadsheet_id, sheet="Sheet1", range="Sheet1!A1", condition_type="NOT_BLANK")` → `{"error": "Invalid A1 notation: Sheet1!A1"}`, not an uncaught `ValueError`
1692
+ - `add_data_validation(spreadsheet_id, sheet="Sheet1", range="Sheet1!A1", condition_type="DATE_IS_VALID")` → `{"error": "Invalid A1 notation: Sheet1!A1"}`, not an uncaught `ValueError`
1693
1693
  - No `batchUpdate` API call is made
1694
1694
  - Covered by `test_invalid_range_returns_error_without_api_call` in `TestAddDataValidation` — exercises the shared `_apply_data_validation` helper's `_grid_range` call
1695
1695
 
@@ -2083,3 +2083,94 @@ Separately, `update_cells`'s two `_parse_a1_notation` call sites (mixed-cell bra
2083
2083
  - All TC-S123–TC-S130/TC-W40 error-path unit tests still pass through the shared helpers
2084
2084
  - `uv run python -m pytest tests/sheets/` passes in full
2085
2085
 
2086
+
2087
+ ---
2088
+
2089
+ ### TC-S133: GridRange tools reject an out-of-bounds or malformed range before any API call (issue #955)
2090
+
2091
+ **Background:** `_parse_a1_notation` had no upper limit on column letters or row numbers, so `ZZZZZZZZ1` or `A99999999999` reached the Sheets API and came back as a raw `HttpError`, and a row string over 4300 digits made `int()` raise without the `Invalid A1 notation` prefix. Separately, `sort_range`, `merge_cells`, `unmerge_cells`, `format_cells`, `update_borders`, `add_data_validation` and `clear_data_validation` resolved the sheet ID, which is an API call on a cold cache, before parsing the range. #955 rejects columns past `ZZZ` and rows past 10,000,000 (Sheets' grid limits) locally, and parses the range before the sheet lookup. The nonexistent spreadsheet ID and sheet name below prove the ordering: a build that looks up the sheet first returns a 404 or `Sheet 'NoSuchSheet' not found`, not the A1 error.
2092
+
2093
+ **Fixture:** none. `spreadsheet_id="tc-s133-no-such-spreadsheet"`, `sheet="NoSuchSheet"`.
2094
+
2095
+ **Prompt**
2096
+ > "Call each of these with `spreadsheet_id=\"tc-s133-no-such-spreadsheet\"` and `sheet=\"NoSuchSheet\"`, and report each raw result:
2097
+ > 1. `sort_range(range=\"ZZZZZZZZ1\")`
2098
+ > 2. `merge_cells(range=\"A99999999999\")`
2099
+ > 3. `unmerge_cells(range=\"A1:AAAA2\")`
2100
+ > 4. `format_cells(range=\"A:5\", bold=True)`
2101
+ > 5. `update_borders(range=\"A:B5\", top={\"style\": \"SOLID\"})`
2102
+ > 6. `add_data_validation(range=\"\\u212a1\", condition_type=\"DATE_IS_VALID\")` (the Kelvin sign U+212A, then `1`)
2103
+ > 7. `clear_data_validation(range=\"A10000001\")`
2104
+ > 8. `sort_range(range=\"ZZZ10000000\")`"
2105
+
2106
+ **Checks**
2107
+ - 1 → `{"error": "Invalid A1 notation: column is beyond ZZZ: 'ZZZZZZZZ1'"}`
2108
+ - 2 → `{"error": "Invalid A1 notation: row is beyond 10,000,000: 'A99999999999'"}`
2109
+ - 3 → `{"error": "Invalid A1 notation: column is beyond ZZZ: 'A1:AAAA2'"}`
2110
+ - 4 → `{"error": "Invalid A1 notation: 'A:5'"}`
2111
+ - 5 → `{"error": "Invalid A1 notation: 'A:B5'"}`
2112
+ - 6 → `{"error": "Invalid A1 notation: 'K1'"}` (the error repr shows the Kelvin sign, not an ASCII `K`)
2113
+ - 7 → `{"error": "Invalid A1 notation: row is beyond 10,000,000: 'A10000001'"}`
2114
+ - 8 (control: the largest valid cell) → **not** an `Invalid A1 notation` error. It reaches the sheet lookup, so it fails on the fake spreadsheet ID instead (an `HttpError` 404 or a `Sheet ... not found` error)
2115
+ - None of calls 1–7 returns a 404 or a `Sheet 'NoSuchSheet' not found` error
2116
+
2117
+ **Unit coverage:** `TestGridRangeToolsRejectMalformedRange` (`tests/sheets/test_structure.py`) runs every malformed case against all seven tools and asserts neither `spreadsheets().get()` nor `batchUpdate()` is called. `test_column_beyond_zzz_raises`, `test_row_beyond_limit_raises`, `test_row_over_int_digit_limit_keeps_prefix` and `test_last_column_and_row_accepted` in `TestParseA1Notation` (`tests/sheets/test_helpers.py`) cover the bounds.
2118
+
2119
+ **Result (2026-10-11, PR #986 round 1, `6d180ec`) ✅ PASS.** Live via `mcp-gee-sweet-sky` after `/mcp reconnect`:
2120
+ - 1 → `{"error":"Invalid A1 notation: column is beyond ZZZ: 'ZZZZZZZZ1'"}`
2121
+ - 2 → `{"error":"Invalid A1 notation: row is beyond 10,000,000: 'A99999999999'"}`
2122
+ - 3 → `{"error":"Invalid A1 notation: column is beyond ZZZ: 'A1:AAAA2'"}`
2123
+ - 4 → `{"error":"Invalid A1 notation: 'A:5'"}`
2124
+ - 5 → `{"error":"Invalid A1 notation: 'A:B5'"}`
2125
+ - 6 → twice returned the `HttpError 404` from the sheet lookup. The Kelvin sign didn't survive the agent's own tool-call input: the call went out as ASCII `K1`, a valid cell. A fresh interpreter on this PR's code confirms `_parse_a1_notation("K1")` raises `Invalid A1 notation: 'K1'` while ASCII `K1` parses (and `"ff1"` raises). When an agent drives this case, verify step 6 that way, not through the tool call.
2126
+ - 7 → `{"error":"Invalid A1 notation: row is beyond 10,000,000: 'A10000001'"}`
2127
+ - 8 (control) → `HttpError 404` from the sheet lookup, as expected
2128
+ - None of calls 1–5 or 7 returned a 404 or `Sheet ... not found` error.
2129
+
2130
+ **#953 checks (folded into #955's pass), same round.**
2131
+ - **Bogus-ID preflight:** `format_cells(range="A1")` → `HttpError 404` (control). `A1:`, `1:B`, `ß1`, `A١` → `{"error":"Invalid A1 notation: '<range>'"}` each, no 404.
2132
+ - **No write on a real sheet** (temporary `sky-pr986-scratch` tab in `mcp-gee-sweet-qa-fixtures`, seeded A1:B6 with unsorted values, deleted afterward): `sort_range(range="A1:")` and `merge_cells(range="5:")` each returned the A1 error; a grid-data read afterward showed the original order and no merges.
2133
+ - **`format_cells` regression** (bold applied, bold cells read back with a fresh interpreter, reset between ranges, 1000×26 grid): `A1` → A1 only; `A1:B2` → rows 1–2, cols A–B (4); `A:A` → rows 1–1000, col A; `1:2` → rows 1–2, cols A–Z; `B2:D` → rows 2–1000, cols B–D; `B2:5` → rows 2–5, cols B–Z; `b2:d3` → rows 2–3, cols B–D (6). All exact.
2134
+ - **API contract:** `get_sheet_data(range="A:B5")` is accepted by the Sheets values API, which reads it as `A5:B` (returned rows 5–6 of the seeded A:B data). The GridRange tools' rejection of `A:B5` is therefore stricter than Sheets; filed as #987.
2135
+
2136
+ **Result (2026-10-11, PR #986 round 2, `da34415`) ✅ PASS (sent back for the BLANK/NOT_BLANK finding below).** Live via `mcp-gee-sweet-sky` after `/mcp reconnect`, verifying fix commit `da34415` (`_resolve_grid_range`):
2137
+ - TC-S133 calls 1–5, 7 → same `Invalid A1 notation` errors as round 1; `add_data_validation(range="A1:")` → `Invalid A1 notation: 'A1:'`; call 8 (control) → `HttpError 404` from the sheet lookup.
2138
+ - **sheetId lands on the named tab, not sheet 0:** on a temporary non-first tab (`sky-pr986-r2`, index 3, deleted afterward), `format_cells("B1:C2", bold)`, `merge_cells("E1:F2")`, `add_data_validation("H1:H3", ONE_OF_LIST)` and `update_borders("J1:J2", top SOLID)` each landed exactly there, and the `Sales` tab (sheet 0) showed no bold, merges, borders or validation. `unmerge_cells("E1:F2")` removed the merge; `clear_data_validation("H1:H2")` left only H3.
2139
+ - **`sort_range`'s rebuilt sort specs:** `sort_range("B1:C4", [{"column_index": 1, "order": "DESCENDING"}])` sorted by column C (`dimensionIndex` = start column B + 1), giving c/4, a/3, d/2, b/1.
2140
+ - Side note (pre-existing, sent back to the Dev in round 2): `add_data_validation` with `condition_type="BLANK"` or `"NOT_BLANK"` reaches the API and fails with `HttpError 400 … ConditionType 'NOT_BLANK' is not supported in data validation`. TC-S133 step 6 still works because the range is rejected before the request is built.
2141
+
2142
+ **Result (2026-10-11, PR #986 round 3, `44f9360`) ✅ PASS.** Live via `mcp-gee-sweet-sky` after `/mcp reconnect`:
2143
+ - 1–5, 7 → the same `Invalid A1 notation` errors as round 1, word for word.
2144
+ - 6 (now `DATE_IS_VALID`): the Kelvin sign again can't be sent through an agent's tool call, so it was checked in a fresh interpreter: `_parse_a1_notation("K1")` raises `Invalid A1 notation: 'K1'`. Live, `add_data_validation(range="A1:", condition_type="DATE_IS_VALID")` → `{"error":"Invalid A1 notation: 'A1:'"}`, so the new condition type passes the enum check and the range is still rejected before the sheet lookup.
2145
+ - 8 (control) → `HttpError 404` from the sheet lookup.
2146
+ - None of calls 1–5 or 7 returned a 404 or `Sheet ... not found` error.
2147
+
2148
+ ---
2149
+
2150
+ ### TC-S134: add_data_validation rejects condition types data validation doesn't support, before any API call (PR #986 QA round 2)
2151
+
2152
+ **Background:** `add_data_validation` accepted `BLANK`, `NOT_BLANK`, `TEXT_STARTS_WITH` and `TEXT_ENDS_WITH`, but the Sheets API supports these only for conditional formatting and filters (each one's `BooleanCondition.type` enum description in the discovery doc omits data validation). Every call came back as `HttpError 400 … ConditionType '<type>' is not supported in data validation`. A fresh-interpreter probe of `setDataValidation` confirmed all four are rejected and `ONE_OF_LIST` is accepted. All four are now removed from `_VALID_CONDITION_TYPES` and `_CONDITION_SPECS`, so `_enum_value_error` rejects them locally. The nonexistent spreadsheet ID below shows the rejection is local: a build that still accepts them fails at the sheet lookup with a 404.
2153
+
2154
+ **Fixture:** none. `spreadsheet_id="tc-s134-no-such-spreadsheet"`, `sheet="NoSuchSheet"`, `range="A1"`.
2155
+
2156
+ **Prompt**
2157
+ > "Call `add_data_validation(spreadsheet_id=\"tc-s134-no-such-spreadsheet\", sheet=\"NoSuchSheet\", range=\"A1\", condition_type=<type>)` for each `<type>` below, with `values=[\"ab\"]` for the two TEXT_ types and no `values` for the others, and report each raw result:
2158
+ > 1. `BLANK`
2159
+ > 2. `NOT_BLANK`
2160
+ > 3. `TEXT_STARTS_WITH`
2161
+ > 4. `TEXT_ENDS_WITH`
2162
+ > 5. `DATE_IS_VALID` (control)"
2163
+
2164
+ **Checks**
2165
+ - 1–4 → `{"error": "Invalid condition_type '<type>'. Must be one of: …"}`, and the listed valid types don't include any of the four
2166
+ - 5 (control: a supported zero-value type) → **not** an `Invalid condition_type` error. It reaches the sheet lookup and fails on the fake spreadsheet ID (an `HttpError` 404 or a `Sheet ... not found` error)
2167
+ - None of calls 1–4 returns a 404 or a `Sheet ... not found` error
2168
+ - The `add_data_validation` tool description served by the MCP server (its `condition_type` list) no longer names any of the four types
2169
+
2170
+ **Unit coverage:** `TestAddDataValidation::test_condition_type_unsupported_by_data_validation_rejected_locally` (`tests/sheets/test_structure.py`) asserts the local error and that neither `spreadsheets().get()` nor `batchUpdate()` is called.
2171
+
2172
+ **Result (2026-10-11, PR #986 round 3, `44f9360`) ✅ PASS.** Live via `mcp-gee-sweet-sky` after `/mcp reconnect`:
2173
+ - 1 `BLANK`, 2 `NOT_BLANK`, 3 `TEXT_STARTS_WITH` (`values=["ab"]`), 4 `TEXT_ENDS_WITH` (`values=["ab"]`) → `{"error":"Invalid condition_type '<type>'. Must be one of: BOOLEAN, TEXT_CONTAINS, TEXT_NOT_CONTAINS, TEXT_EQ, TEXT_IS_EMAIL, TEXT_IS_URL, DATE_EQ, … ONE_OF_RANGE, ONE_OF_LIST, CUSTOM_FORMULA"}` each. The valid-type list names none of the four.
2174
+ - 5 `DATE_IS_VALID` (control) → `HttpError 404` from the sheet lookup on the fake ID.
2175
+ - None of calls 1–4 returned a 404.
2176
+ - The served `add_data_validation` description (re-fetched after the reconnect) lists `DATE_IS_VALID`, `TEXT_CONTAINS`/`TEXT_NOT_CONTAINS`/`TEXT_EQ`, `TEXT_IS_EMAIL`/`TEXT_IS_URL` and the rest, and none of the four removed types.
@@ -220,10 +220,13 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
220
220
  - [x] SIGTERM with an open streamable-http `GET /mcp` stream truncates it ("ASGI callable returned without completing response"); sibling of #868 — lane A, after #894 (PR #975) ([#917](https://github.com/khuisman/mcp-gee-sweet/issues/917))
221
221
  - [x] `get_doc_as_markdown`: a paragraph exported as a fenced code block drops links on its code runs and images (`_render_code_block` keeps only run text). Found reviewing #940 — lane B, after #938 (same file), ahead of #958 (PR #976) ([#951](https://github.com/khuisman/mcp-gee-sweet/issues/951))
222
222
  - [x] `get_doc_as_markdown`: plain text ending in `!` right before a linked run exports as `![…](…)`, which renders as an image. Affects every link, not just code; fix belongs in `_escape`'s cross-run handling. Found in PR #968 — lane B, after #951 (same file), ahead of #958 (PR #981) ([#969](https://github.com/khuisman/mcp-gee-sweet/issues/969))
223
- - [ ] `get_doc_as_markdown(local_path=…)` writes the JSON result envelope, not the markdown: `markdown_export.py` routes through the generic JSON-to-disk helper — lane B, after #938 (same read-side area) ([#958](https://github.com/khuisman/mcp-gee-sweet/issues/958))
224
- - [ ] `add_chart`: a cell-to-row (`B2:5`) or row-only (`1:5`) range silently charts one column — `_per_column_ranges` defaults `endColumnIndex` to start+1. Found reviewing #931 — lane A, after #917 ([#954](https://github.com/khuisman/mcp-gee-sweet/issues/954))
225
- - [ ] `_parse_a1_notation` hardening: one compiled grammar, index bounds, validate before the sheet-ID fetch. #931 review follow-ups, no behavior change for valid input — lane A, after #954 (same A1 area) ([#955](https://github.com/khuisman/mcp-gee-sweet/issues/955))
223
+ - [x] `get_doc_as_markdown(local_path=…)` writes the JSON result envelope, not the markdown: `markdown_export.py` routes through the generic JSON-to-disk helper — lane B, after #938 (same read-side area) (PR #982) ([#958](https://github.com/khuisman/mcp-gee-sweet/issues/958))
224
+ - [x] `add_chart`: a cell-to-row (`B2:5`) or row-only (`1:5`) range silently charts one column — `_per_column_ranges` defaults `endColumnIndex` to start+1. Found reviewing #931 — lane A, after #917 (PR #983) ([#954](https://github.com/khuisman/mcp-gee-sweet/issues/954))
225
+ - [x] `_parse_a1_notation` hardening: one compiled grammar, index bounds, validate before the sheet-ID fetch. #931 review follow-ups, no behavior change for valid input — lane A, after #954 (same A1 area); its QA also runs #953's live checks for #931 (folded in) (PR #986) ([#955](https://github.com/khuisman/mcp-gee-sweet/issues/955))
226
226
  - [ ] Write side drops image links: `[![alt](src)](url)` (or `<a href><img></a>`) imports through `create_doc`/`create_doc_from_file`/`write_doc_content` without its URL, so it doesn't round-trip with #951's export. Found in PR #976 — lane A, after #955 ([#977](https://github.com/khuisman/mcp-gee-sweet/issues/977))
227
+ - [ ] `_parse_a1_notation` rejects `A:B5`, which the Sheets values API accepts as `A5:B` — decided 2026-10-11: keep rejecting (a destructive GridRange tool acting on "row 5 onward" is worse than an error); document in the docstring that this is deliberately stricter than Sheets. Found in PR #986 — lane A, after #977 ([#987](https://github.com/khuisman/mcp-gee-sweet/issues/987))
228
+ - [ ] Comment quoted text comes back HTML-escaped (`Caf&#233;`) from `add_doc_comment`, `list_doc_comments`, and `get_doc_as_markdown(include_comments=True)`: `comments.py` passes Drive's escaped `quotedFileContent.value` through unchanged. Found reviewing #958 — lane B, after #959 (defect, next pick) ([#984](https://github.com/khuisman/mcp-gee-sweet/issues/984))
229
+ - [ ] `write_capped_result_to_disk` writes `dir/` as a file when the directory doesn't exist: a trailing separator should mean "directory" (create it, append `default_filename`). Shared by every `local_path` writer. Found in PR #988 — lane B, after #984 (defect) ([#990](https://github.com/khuisman/mcp-gee-sweet/issues/990))
227
230
 
228
231
  **Sheets hardening** _(triaged out of `backlog` 2026-09-11 — see the "Backlog triage" note below)_
229
232
  - [x] `update_sheet_properties`/dimension-tool hardening + dedup follow-ups from PR #321's review ([#323](https://github.com/khuisman/mcp-gee-sweet/issues/323)) (PR #734)
@@ -247,12 +250,12 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
247
250
  - [x] `content.py`: duplicated image upload/share/revoke logic across `create_doc` and `insert_local_images`, plus an avoidable extra Drive round trip ([#511](https://github.com/khuisman/mcp-gee-sweet/issues/511)) (PR #842)
248
251
  - [x] `docs/images.py` oversized-image handling: duplicated logic + unneeded full pixel decode (#400 follow-up) ([#560](https://github.com/khuisman/mcp-gee-sweet/issues/560)) (PR #801)
249
252
  - [ ] **Bundle:** Docs read side (`doc_to_ast`/`ast_to_markdown`), one PR closing [#596](https://github.com/khuisman/mcp-gee-sweet/issues/596), [#600](https://github.com/khuisman/mcp-gee-sweet/issues/600), [#732](https://github.com/khuisman/mcp-gee-sweet/issues/732) — lane B ([#893](https://github.com/khuisman/mcp-gee-sweet/issues/893))
250
- - [ ] Docs image-sharing failure paths: unrevocable `anyone:reader` grant + missing folder-cache invalidation in `_replace_doc_content` ([#666](https://github.com/khuisman/mcp-gee-sweet/issues/666))
251
253
  - [ ] `write_doc_content`/`update_doc_from_file` leave the doc empty when the replacement fails after the clear — decided 2026-10-04: record the pre-clear revision ID and name it in the error so the caller can recover via `export_revision` ([#878](https://github.com/khuisman/mcp-gee-sweet/issues/878))
252
254
  - [ ] `update_doc_from_file`: opt-in incremental (`mode="patch"`) update that diffs block by block and rewrites only changed blocks, so comment anchors and revision history survive — lane A, after #894 (same write-path files) ([#957](https://github.com/khuisman/mcp-gee-sweet/issues/957))
253
255
  - [ ] `export_revision`: support Google Docs revisions (returned as markdown), and fix `list_revisions`' docstring claim that native-file revisions are pruned after 30 days. Also what #878's recovery path points callers at — lane B, after #958 ([#959](https://github.com/khuisman/mcp-gee-sweet/issues/959))
256
+ - [ ] `export_revision`'s temporary Doc exposes an old revision's content to the default folder's audience (a whole Shared Drive) — decided 2026-10-11: under OAuth create it in My Drive root; a service account keeps the default folder, and that remaining exposure goes in `docs/known-limitations.md`. Found in PR #988 — lane B, after #990 ([#989](https://github.com/khuisman/mcp-gee-sweet/issues/989))
254
257
  - [x] `insert_local_images`: temporary `anyone:reader` image shares aren't revoked when the call is cancelled — apply PR #877's shielded-cleanup pattern — lane B ([#883](https://github.com/khuisman/mcp-gee-sweet/issues/883)) (PR #903)
255
- - [ ] **Bundle:** Docs image-share cancellation follow-ups (from PR #903), one PR closing [#904](https://github.com/khuisman/mcp-gee-sweet/issues/904), [#907](https://github.com/khuisman/mcp-gee-sweet/issues/907), [#908](https://github.com/khuisman/mcp-gee-sweet/issues/908) ([#909](https://github.com/khuisman/mcp-gee-sweet/issues/909))
258
+ - [ ] **Bundle:** Docs image-share cancellation follow-ups (from PR #903), one PR closing [#904](https://github.com/khuisman/mcp-gee-sweet/issues/904), [#907](https://github.com/khuisman/mcp-gee-sweet/issues/907), [#908](https://github.com/khuisman/mcp-gee-sweet/issues/908), [#666](https://github.com/khuisman/mcp-gee-sweet/issues/666) — lane A ([#909](https://github.com/khuisman/mcp-gee-sweet/issues/909))
256
259
  - [x] `_apply_doc_content`: temporary `anyone:reader` image shares aren't revoked when the doc edit raises — revoke pass should run in a `finally`. Same share/revoke surface as #666 and #511 ([#789](https://github.com/khuisman/mcp-gee-sweet/issues/789)) (PR #877)
257
260
  - [ ] Docstrings: `create_paragraph_bullets` carries internal API-quirk mechanics plus residual bare issue refs in `style.py` tool docstrings ([#683](https://github.com/khuisman/mcp-gee-sweet/issues/683))
258
261
  - [ ] `style_doc_range` has no `font_family` parameter — can't apply monospace to an existing range ([#720](https://github.com/khuisman/mcp-gee-sweet/issues/720))
@@ -284,15 +287,13 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
284
287
  - [ ] Share one HttpError reason-check helper across the `storageQuotaExceeded` sites. Lane A, after #930; community PR #921 closed unmerged 2026-10-10 ([#916](https://github.com/khuisman/mcp-gee-sweet/issues/916))
285
288
 
286
289
  **Calendar hardening** _(triaged out of `backlog` 2026-09-11)_
287
- - [ ] `_get_cached_calendar_list` ignores `nextPageToken` (calendarList truncated past 100). Lane B, after #892; community PR #919 closed unmerged 2026-10-10 ([#915](https://github.com/khuisman/mcp-gee-sweet/issues/915))
288
- - [ ] `list_all_events`: fan-out semaphore permit released on cancel while the HTTP call is still in flight. Lane B, after #915 (same `calendar.py`); community PR #920 closed unmerged 2026-10-10 ([#918](https://github.com/khuisman/mcp-gee-sweet/issues/918))
290
+ - [ ] **Bundle:** Calendar follow-ups from PR #912, one PR closing [#915](https://github.com/khuisman/mcp-gee-sweet/issues/915), [#918](https://github.com/khuisman/mcp-gee-sweet/issues/918) — lane B, after #892 ([#985](https://github.com/khuisman/mcp-gee-sweet/issues/985))
289
291
 
290
292
  **Testing** _(triaged out of `backlog` 2026-09-11)_
291
293
  - [x] **Bundle:** test-suite tightening, one PR closing [#392](https://github.com/khuisman/mcp-gee-sweet/issues/392), [#526](https://github.com/khuisman/mcp-gee-sweet/issues/526), [#551](https://github.com/khuisman/mcp-gee-sweet/issues/551), [#561](https://github.com/khuisman/mcp-gee-sweet/issues/561), [#574](https://github.com/khuisman/mcp-gee-sweet/issues/574) — lane B (PR #924) ([#891](https://github.com/khuisman/mcp-gee-sweet/issues/891))
292
294
  - [ ] `qa`: `search_files` has no unit test coverage for the error-dict path ([#573](https://github.com/khuisman/mcp-gee-sweet/issues/573))
293
295
  - [x] Live verification for PR #922 (reject unbounded A1 ranges); coverage in PR #929 (PR #929) ([#927](https://github.com/khuisman/mcp-gee-sweet/issues/927))
294
296
  - [ ] Live verification for community PR #940 (literal fences in code-block exports) ([#952](https://github.com/khuisman/mcp-gee-sweet/issues/952))
295
- - [ ] Live verification for community PR #931 (reject missing and incompatible A1 bounds) ([#953](https://github.com/khuisman/mcp-gee-sweet/issues/953))
296
297
 
297
298
  **Infrastructure**
298
299
  - [ ] Interaction-log middleware for tool calls — structured, append-only JSONL log per call (inputs, duration, cache hit, error), opt-in ID redaction, swappable backend ([#646](https://github.com/khuisman/mcp-gee-sweet/issues/646))
@@ -94,7 +94,7 @@
94
94
  |---|---|---|
95
95
  | `export_file` | Export or download a file from Google Drive. | `file_id`, `export_format` |
96
96
  | `list_revisions` | List available revisions for a Google Drive file (Sheets, Docs, or any file). | `file_id` |
97
- | `export_revision` | Export a historical revision of a Google Sheets file and return its cell data. | `file_id`, `revision_id`, `range?`, `sheet?` |
97
+ | `export_revision` | Export a historical revision of a Google Sheets file (cell data) or a Google Doc (Markdown). Use list_revisions to find the revision_id. | `file_id`, `revision_id`, `range?`, `sheet?`, `local_path?` |
98
98
  | `upload_file` | Upload a text file to Google Drive, optionally converting it to a Google Doc. | `name`, `content`, `source_format?`, `folder_id?`, `convert_to_doc?` |
99
99
  | `upload_local_file` | Upload a file from the local filesystem to a Google Drive folder. Handles binary and text files (images, PDFs, DOCX, XLSX, scripts, etc.). | `local_path`, `parent_folder_id`, `name?`, `skip_if_exists?`, `convert?` |
100
100
  | `upload_local_folder` | Upload all files in a local directory (non-recursive) to a Google Drive folder. | `local_path`, `parent_folder_id`, `skip_if_exists?`, `skip_system_files?`, `convert?` |