mcp-gee-sweet 0.9.2.dev215__tar.gz → 0.9.2.dev221__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 (242) hide show
  1. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/PKG-INFO +1 -1
  2. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/drive_files.md +26 -0
  3. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/drive_transfer.md +50 -0
  4. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/roadmap.md +10 -33
  5. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/drive/files.py +30 -2
  6. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/drive/transfer.py +97 -17
  7. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/drive/test_files.py +82 -0
  8. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/drive/test_transfer.py +372 -0
  9. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/cleanup-worktrees.md +0 -0
  10. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/merge-pr.md +0 -0
  11. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/notify-kai.md +0 -0
  12. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/notify-partner.md +0 -0
  13. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/orchestrator.md +0 -0
  14. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/prep-for-pr.md +0 -0
  15. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/qa-kickoff.md +0 -0
  16. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/release.md +0 -0
  17. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/retro.md +0 -0
  18. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/roadmap-status.md +0 -0
  19. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/start-worktree.md +0 -0
  20. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/team-member.md +0 -0
  21. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/commands/verify-pr.md +0 -0
  22. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/settings.json +0 -0
  23. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/team-roles/amy.md +0 -0
  24. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/team-roles/aziz.md +0 -0
  25. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/team-roles/bob.md +0 -0
  26. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/team-roles/dev.md +0 -0
  27. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/team-roles/joy.md +0 -0
  28. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/team-roles/kai.md +0 -0
  29. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.claude/team-roles/qa.md +0 -0
  30. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.dockerignore +0 -0
  31. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  32. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  33. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  34. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.github/dependabot.yml +0 -0
  35. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.github/workflows/ci.yml +0 -0
  36. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.github/workflows/docs.yml +0 -0
  37. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.github/workflows/publish-dev.yml +0 -0
  38. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.github/workflows/release.yml +0 -0
  39. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.gitignore +0 -0
  40. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.pre-commit-config.yaml +0 -0
  41. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/.python-version +0 -0
  42. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/CHANGELOG.md +0 -0
  43. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/CLAUDE.md +0 -0
  44. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/CODE_OF_CONDUCT.md +0 -0
  45. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/CONTRIBUTING.md +0 -0
  46. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/Dockerfile +0 -0
  47. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/LICENSE +0 -0
  48. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/Makefile +0 -0
  49. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/README.md +0 -0
  50. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/SECURITY.md +0 -0
  51. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docker-compose.yml +0 -0
  52. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/assets/logo.png +0 -0
  53. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/auth.md +0 -0
  54. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/client-setup.md +0 -0
  55. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/configuration.md +0 -0
  56. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-async-tool-execution.md +0 -0
  57. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-auth-status-limitation-categories.md +0 -0
  58. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-cache-invalidation.md +0 -0
  59. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-chart-theming.md +0 -0
  60. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-comments-first-class.md +0 -0
  61. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-composite-tools.md +0 -0
  62. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-condition-type-docstring-generation.md +0 -0
  63. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-converted-doc-change-detection.md +0 -0
  64. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-dev-team.md +0 -0
  65. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-docs-formatting.md +0 -0
  66. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-fork.md +0 -0
  67. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-gmail-domain.md +0 -0
  68. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-grid-data-size-cap.md +0 -0
  69. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-local-fs-test-harness.md +0 -0
  70. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-mcp-v2-resource-context-access.md +0 -0
  71. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-pillow-image-dependency.md +0 -0
  72. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-prompt-qa-role.md +0 -0
  73. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-publishing.md +0 -0
  74. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-release-docs-roles.md +0 -0
  75. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-repositioning.md +0 -0
  76. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-response-size-cap-generalization.md +0 -0
  77. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-response-size-cap-reevaluation-519.md +0 -0
  78. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-supports-all-drives-lint-check.md +0 -0
  79. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-sync-folder-recursion.md +0 -0
  80. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/decision-testing.md +0 -0
  81. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/decisions/index.md +0 -0
  82. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/blockquote-representation.md +0 -0
  83. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/borderless-table-columns.md +0 -0
  84. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/doc-to-markdown.md +0 -0
  85. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/docs-ast-pipeline.md +0 -0
  86. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/effectiveformat-spike.md +0 -0
  87. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/heading-anchor-resolution.md +0 -0
  88. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/image-conversion.md +0 -0
  89. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/index.md +0 -0
  90. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/markdown-support.md +0 -0
  91. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/module-history.md +0 -0
  92. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/phase3-theme-system.md +0 -0
  93. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design/sse-consent-off-event-loop.md +0 -0
  94. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/design.md +0 -0
  95. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/index.md +0 -0
  96. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/known-limitations.md +0 -0
  97. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/notes-read.md +0 -0
  98. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/README.md +0 -0
  99. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/qa-fixture-pixel.png +0 -0
  100. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  101. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  102. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-d213-dollar-escape.md +0 -0
  103. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  104. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc102-image-conversion.html +0 -0
  105. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc103-image-conversion.md +0 -0
  106. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc104-nested-lists.html +0 -0
  107. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc105-nested-lists-gfm.md +0 -0
  108. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc106-nested-lists-4space.md +0 -0
  109. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc135-paragraph-boundary.md +0 -0
  110. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc136-interrupted-block-boundary.md +0 -0
  111. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc137-inline-hr-no-block-ancestor.html +0 -0
  112. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc138-nbsp-spacer-paragraph.md +0 -0
  113. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc141-mismatched-list-tags.html +0 -0
  114. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc142-heading-anchors.md +0 -0
  115. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc144-anchor-in-table-cell.md +0 -0
  116. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures/tc-doc145-anchor-on-first-element.md +0 -0
  117. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/fixtures.template.md +0 -0
  118. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/gmail-test-plan.md +0 -0
  119. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/operations.yaml +0 -0
  120. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/playwright_oauth.md +0 -0
  121. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/.gitkeep +0 -0
  122. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-06-02.md +0 -0
  123. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-06-14.md +0 -0
  124. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-06-17.md +0 -0
  125. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-06-18.md +0 -0
  126. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
  127. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
  128. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-06-28.md +0 -0
  129. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-07-04-docs.md +0 -0
  130. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-07-04-drive-smoke.md +0 -0
  131. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-07-04-infra.md +0 -0
  132. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-07-04-sheets_read.md +0 -0
  133. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-07-04.md +0 -0
  134. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/results/2026-09-04.md +0 -0
  135. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/retro-v0.8.0.md +0 -0
  136. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/retro-v0.9.0.md +0 -0
  137. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/run.md +0 -0
  138. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/runs/README.md +0 -0
  139. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/runs/v0.8.0.md +0 -0
  140. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/runs/v0.8.1.md +0 -0
  141. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/runs/v0.9.0.md +0 -0
  142. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/setup.md +0 -0
  143. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/calendar.md +0 -0
  144. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/docs_content.md +0 -0
  145. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/docs_layout.md +0 -0
  146. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/docs_style.md +0 -0
  147. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/docs_tables.md +0 -0
  148. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/drive_activity.md +0 -0
  149. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/drive_sharing.md +0 -0
  150. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/gmail.md +0 -0
  151. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/infra.md +0 -0
  152. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/sheets_charts.md +0 -0
  153. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/sheets_mgmt.md +0 -0
  154. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/sheets_read.md +0 -0
  155. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/qa/tests/sheets_write.md +0 -0
  156. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/style-guide.md +0 -0
  157. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/docs/tools.md +0 -0
  158. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/mkdocs.yml +0 -0
  159. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/pyproject.toml +0 -0
  160. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/pyrightconfig.json +0 -0
  161. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/scripts/check_supports_all_drives.py +0 -0
  162. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/scripts/gen_tool_docs.py +0 -0
  163. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/scripts/lane_context_hook.py +0 -0
  164. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/scripts/oauth_setup.py +0 -0
  165. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/scripts/qa_gmail_fixtures.py +0 -0
  166. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/scripts/setup_team.sh +0 -0
  167. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/scripts/write_team_mcp_configs.sh +0 -0
  168. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/server.json +0 -0
  169. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/.env.template +0 -0
  170. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/__init__.py +0 -0
  171. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/auth.py +0 -0
  172. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/cache.py +0 -0
  173. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/http_transport.py +0 -0
  174. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/server.py +0 -0
  175. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  176. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/cache.py +0 -0
  177. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/calendar.py +0 -0
  178. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/concurrency.py +0 -0
  179. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
  180. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/anchors.py +0 -0
  181. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/ast.py +0 -0
  182. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/ast_to_markdown.py +0 -0
  183. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/comments.py +0 -0
  184. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/content.py +0 -0
  185. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/doc_to_ast.py +0 -0
  186. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/editing.py +0 -0
  187. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/emitter.py +0 -0
  188. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/html_parser.py +0 -0
  189. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/images.py +0 -0
  190. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/indices.py +0 -0
  191. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
  192. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/markdown_export.py +0 -0
  193. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/named_ranges.py +0 -0
  194. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/style.py +0 -0
  195. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
  196. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  197. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
  198. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  199. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/gmail.py +0 -0
  200. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/response_limits.py +0 -0
  201. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  202. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
  203. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
  204. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
  205. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/__init__.py +0 -0
  206. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/conftest.py +0 -0
  207. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/drive/__init__.py +0 -0
  208. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/drive/test_activity.py +0 -0
  209. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/drive/test_sharing.py +0 -0
  210. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/integration/__init__.py +0 -0
  211. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/integration/conftest.py +0 -0
  212. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/integration/test_local_fs_harness.py +0 -0
  213. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/sheets/__init__.py +0 -0
  214. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/sheets/test_data.py +0 -0
  215. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/sheets/test_helpers.py +0 -0
  216. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/sheets/test_structure.py +0 -0
  217. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_auth.py +0 -0
  218. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_cache.py +0 -0
  219. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_calendar.py +0 -0
  220. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_check_supports_all_drives.py +0 -0
  221. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_concurrency.py +0 -0
  222. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_anchors.py +0 -0
  223. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_comments.py +0 -0
  224. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_content.py +0 -0
  225. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_core.py +0 -0
  226. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_editing.py +0 -0
  227. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_images.py +0 -0
  228. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_indices.py +0 -0
  229. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_layout.py +0 -0
  230. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_markdown_export.py +0 -0
  231. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_named_ranges.py +0 -0
  232. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_style.py +0 -0
  233. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_docs_tables.py +0 -0
  234. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_gen_tool_docs.py +0 -0
  235. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_gmail.py +0 -0
  236. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_lane_context_hook.py +0 -0
  237. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_qa_gmail_fixtures.py +0 -0
  238. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_response_limits.py +0 -0
  239. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_server.py +0 -0
  240. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_server_json.py +0 -0
  241. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/tests/test_team_mcp_configs.py +0 -0
  242. {mcp_gee_sweet-0.9.2.dev215 → mcp_gee_sweet-0.9.2.dev221}/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.dev215
3
+ Version: 0.9.2.dev221
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
@@ -887,6 +887,32 @@ delete_file('invalidid123xyz') → HttpError 404 propagates cleanly, no crash. N
887
887
 
888
888
  ---
889
889
 
890
+ ### TC-D280: Permanent delete without permanent-delete permission — clear error, file kept (issue #876) ⚠️ destructive
891
+
892
+ **Requires:** an identity with the **Content manager** role (not Manager) on the `mcp-gee-sweet-shared` Shared Drive. The QA service account is added at exactly that role (`setup.md` Step 1), so run this on a service-account server (`mcp-gee-sweet-sa`, or the lane's own server relaunched with `AUTH_METHOD=service_account`). An OAuth identity that manages the Shared Drive gets `canDelete: true` and can't reproduce this.
893
+
894
+ **Setup:** `upload_file(name="QA-876-Delete-Denied.txt", content="x", folder_id={FOLDER_ID})` as the Content-manager identity. Note the returned file ID.
895
+
896
+ **Steps**
897
+ 1. `delete_file(file_id=<id>, permanent=True)`
898
+ 2. `get_file_metadata(file_id=<id>)`
899
+ 3. `delete_file(file_id=<id>, permanent=False)`
900
+
901
+ **Checks**
902
+ - Step 1 fails with a `ValueError` saying the identity lacks permanent-delete permission, noting that on a Shared Drive this usually requires the Manager role, and suggesting `permanent=False`. It is **not** Drive's `HttpError 404 "File not found"`.
903
+ - Step 2 returns the file with `trashed: false`: nothing was deleted.
904
+ - Step 3 returns `{"fileId": <id>, "action": "trashed"}`.
905
+
906
+ **Cleanup:** step 3 leaves the file in the Shared Drive's trash. Purge it with `delete_file(file_id=<id>, permanent=True)` from an identity with the Manager role (e.g. the lane's OAuth server), or leave it for a later `empty_trash(drive_id={SHARED_DRIVE_ID})` run.
907
+
908
+ **Result (2026-10-04) ✅ PASS**
909
+ Run as the QA service account (Content manager) from a fresh interpreter in the kit worktree driving PR #888's `delete_file` (kit's server is OAuth/Manager). Pre-check: `capabilities={canDelete: false, canTrash: true}`, file on the Shared Drive. Step 1 raised `ValueError` naming missing permanent-delete permission + `permanent=False` hint; no `files().delete` sent. Step 2 `trashed: false`. Baseline: a raw `files().delete` as the same SA still returns Drive's `404 "File not found"`, so the pre-flight check is what fixes it. Step 3 `{"action": "trashed"}`, then `trashed: true`. Cleanup: `delete_file(permanent=True)` via kit's OAuth (Manager) server returned `{"action": "deleted"}`, which also covers the `canDelete: true` path live.
910
+
911
+ **Result (2026-10-04, round 2) ✅ PASS**
912
+ Re-run the same way against `255512b` (Shared Drive note scoped to `driveId`). Step 1's `ValueError` now reads "...lacks permanent-delete permission on it. On a Shared Drive this usually requires the Manager role. Use permanent=False to move it to the trash instead." Steps 2–3 and the raw-delete 404 baseline as in round 1. Cleanup via kit's reconnected OAuth server: `{"action": "deleted"}`. The My Drive (no `driveId`) wording isn't live-tested: it needs a file owned by someone else, where a wrong result would be a real permanent delete. Unit test `test_delete_file_permanent_without_can_delete_on_my_drive_omits_shared_drive_note` covers it.
913
+
914
+ ---
915
+
890
916
  ## `restore_file`
891
917
 
892
918
  ### TC-D269: Restore a trashed file ⚠️ destructive
@@ -1787,6 +1787,56 @@ Unit tests: full suite 1883 passed / 3 skipped; ruff clean. Fast-path re-verific
1787
1787
 
1788
1788
  ---
1789
1789
 
1790
+ ### TC-D279: downloads write to a temp file and rename it into place; a leftover temp file is never uploaded (issue #844) ⚠️ local-filesystem ⚠️ destructive
1791
+
1792
+ **Background:** `sync_folder`, `download_file`, and `download_folder` used to stream straight into the destination. A download that failed partway left a truncated file with a current mtime, so the next bidirectional sync read it as locally newer and uploaded it over the intact Drive copy. Every download now goes through `_write_atomically`: it writes a sibling `.gee-sweet-partial-<16 hex>` file, stamps `sync_folder`'s Drive mtime on it, and renames it onto the destination only when complete. A temp file survives only a hard kill mid-write, and `sync_folder` and `upload_local_folder` skip one. The failure path itself (a dropped connection mid-download) can't be triggered through the tool surface; it's unit-tested in `tests/drive/test_transfer.py::TestSyncFolderFailedDownloadLeavesNoPartial`, `TestDownloadToolsFailedDownloadLeavesNoPartial`, and `TestWriteAtomically`. This case checks the success path through the new write, the mode and mtime it leaves, and the leftover-temp-file skip.
1793
+
1794
+ **Setup**
1795
+ > 1. Call `create_folder(name="qa-279", parent_folder_id="{FOLDER_ID}")`; call the returned ID `{F}`.
1796
+ > 2. Run `rm -rf /tmp/qa-279 && mkdir -p /tmp/qa-279/src /tmp/qa-279/sync && head -c 3000000 /dev/urandom > /tmp/qa-279/src/big.bin`.
1797
+ > 3. Call `upload_local_file(local_path="/tmp/qa-279/src/big.bin", parent_folder_id="{F}")`; call the returned ID `{BIG}`. Call `get_file_metadata(file_id="{BIG}")` and note `modified_time` and `md5_checksum`.
1798
+ > 4. Plant a stale local copy and a leftover temp file: `printf 'stale' > /tmp/qa-279/sync/big.bin && chmod 640 /tmp/qa-279/sync/big.bin && touch -t 202001010000 /tmp/qa-279/sync/big.bin && printf 'half a file' > /tmp/qa-279/sync/.gee-sweet-partial-0123456789abcdef`.
1799
+
1800
+ **Steps**
1801
+ > 1. `sync_folder(folder_id="{F}", local_path="/tmp/qa-279/sync/")` (real run, default `bidirectional`).
1802
+ > 2. Run `ls -la /tmp/qa-279/sync/ && md5 -q /tmp/qa-279/sync/big.bin && stat -f '%Sp %Sm' -t '%Y-%m-%dT%H:%M:%S' /tmp/qa-279/sync/big.bin` (Linux: `md5sum`, `stat -c '%A %y'`).
1803
+ > 3. `list_files(folder_id="{F}")`.
1804
+ > 4. `sync_folder(folder_id="{F}", local_path="/tmp/qa-279/sync/")` again.
1805
+ > 5. `upload_local_folder(local_path="/tmp/qa-279/sync/", parent_folder_id="{F}", skip_if_exists=false)`, then `list_files(folder_id="{F}")`.
1806
+ > 6. `download_file(file_id="{BIG}", local_path="/tmp/qa-279/sync/big.bin")`, then `ls -la /tmp/qa-279/sync/`.
1807
+ > 7. `download_folder(folder_id="{F}", local_path="/tmp/qa-279/dl/", skip_if_exists=false)`, then `ls -la /tmp/qa-279/dl/`.
1808
+ > 8. Make a read-only local copy: `printf 'protected' > /tmp/qa-279/ro.bin && chmod 444 /tmp/qa-279/ro.bin && chmod 4644 /tmp/qa-279/dl/big.bin`. Call `download_file(file_id="{BIG}", local_path="/tmp/qa-279/ro.bin")`. Then call `download_file(file_id="{BIG}", local_path="/tmp/qa-279/dl/big.bin")` and run `ls -la /tmp/qa-279/ /tmp/qa-279/dl/ && cat /tmp/qa-279/ro.bin`. (Not as root: root can write a `0444` file.)
1809
+
1810
+ **Checks**
1811
+ - Step 1: `downloaded` is `["big.bin"]`. `uploaded` is empty, and `.gee-sweet-partial-0123456789abcdef` appears in no result list. `failed` is empty.
1812
+ - Step 2: `big.bin` is 3000000 bytes, its md5 matches setup step 3's `md5_checksum`, its mode is still `-rw-r-----` (the replaced file's mode is kept), and its mtime matches setup step 3's `modified_time` to the second (local time vs UTC aside). The only `.gee-sweet-partial-*` file in the directory is the planted `…0123456789abcdef`; no new one was left behind.
1813
+ - Step 3: `{F}` holds exactly one file, `big.bin`. No `.gee-sweet-partial-*` file was uploaded.
1814
+ - Step 4: `big.bin` in `skipped`; `uploaded` and `downloaded` empty (the restamped mtime round-trips, as in #346).
1815
+ - Step 5: `uploaded` lists only `big.bin` (a second copy, since `skip_if_exists=false`); the planted temp file is not uploaded and not in `failed`. `list_files` shows two `big.bin` and no `.gee-sweet-partial-*`.
1816
+ - Step 6: succeeds with `size_bytes` 3000000. The directory still holds only `big.bin` and the planted temp file.
1817
+ - Step 7: `downloaded` is `["big.bin"]` and `failed` has one duplicate-filename entry for the second `big.bin` (PR #351). `/tmp/qa-279/dl/` holds only `big.bin`, 3000000 bytes, and no `.gee-sweet-partial-*` file.
1818
+ - Step 8 (PR #884 QA round 1): the first `download_file` fails with a permission error. `ro.bin` still reads `protected`, still `-r--r--r--`, and no `.gee-sweet-partial-*` file is in `/tmp/qa-279/`. The second call succeeds, and `dl/big.bin` is `-rw-r--r--`: the setuid bit is not carried onto the new content.
1819
+
1820
+ **Teardown**
1821
+ `delete_file` on `{F}` (takes its contents with it). `rm -rf /tmp/qa-279`.
1822
+
1823
+ **Result (2026-10-04) ✅ PASS** — Sky, PR #884 round 1 (`b74ec2b`), OAuth, throwaway `qa-279` folder under `TEST_FOLDER_ID`. Drive `big.bin`: 3000000 B, md5 `f606776b…2c1a`, `modified_time` `17:42:20Z`.
1824
+ - **Step 1:** `downloaded: ["big.bin"]`; `uploaded`, `conflicts`, `failed` empty; the planted temp file appears in no list.
1825
+ - **Step 2:** `big.bin` 3000000 B, md5 matches, mode `-rw-r-----` kept, mtime `10:42:20-0700` (= `17:42:20Z`). Only the planted `…0123456789abcdef` temp file present.
1826
+ - **Step 3:** one file, `big.bin`.
1827
+ - **Step 4:** `skipped: ["big.bin"]`, nothing transferred.
1828
+ - **Step 5:** `uploaded: ["big.bin"]` only; `list_files` shows two `big.bin`, no temp file.
1829
+ - **Step 6:** `size_bytes` 3000000; directory still holds only `big.bin` + the planted temp file.
1830
+ - **Step 7:** `downloaded: ["big.bin"]`, one duplicate-filename `failed` entry; `dl/` holds only `big.bin`, 3000000 B.
1831
+
1832
+ The case passes, but the PR goes back for a regression this case doesn't cover: a read-only (`0444`) local destination is now silently replaced. Live, after this case: `printf protected > big.bin && chmod 444 big.bin`, then `download_file(file_id="{BIG}", local_path=".../big.bin")` returned success and left a 3000000 B `-r--r--r--` file. Before this PR the `open("wb")` raised `PermissionError` and left the file alone. See the PR comment.
1833
+
1834
+ **Result (2026-10-04) ✅ PASS** — Sky, PR #884 round 2 (`b4e7287`), step 8 only, after reconnecting to the fix. Steps 1–7 code paths are unchanged apart from the new up-front `os.access` check and the `0o777` mask. Fresh `qa-279` folder; setup plus step 7 (`download_folder` → `dl/big.bin`, 3000000 B) re-run first.
1835
+ - **Step 8, `ro.bin`:** `download_file` failed with `[Errno 13] Permission denied: '/tmp/qa-279/ro.bin'`. `ro.bin` still reads `protected`, still `-r--r--r--`, and there's no `.gee-sweet-partial-*` in `/tmp/qa-279/`.
1836
+ - **Step 8, `dl/big.bin` (`chmod 4644` first):** succeeded, `size_bytes` 3000000, and the file is now `-rw-r--r--`: setuid dropped.
1837
+
1838
+ ---
1839
+
1790
1840
  ## `list_revisions`
1791
1841
 
1792
1842
  ### TC-D146: List revisions for a spreadsheet
@@ -192,10 +192,8 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
192
192
  - [x] `create_paragraph_bullets` (`style.py`) has the same isolated-run collapse-to-`nestingLevel 0` bug #439 fixed in `emitter.py` only — port PR #711's anchor-paragraph approach to the sibling path (PR #723) ([#713](https://github.com/khuisman/mcp-gee-sweet/issues/713))
193
193
  - [x] Lock in the isolated-run rendering rule — an isolated bullet run renders at its true depth (`depth N` → `nestingLevel N`) at *every* depth, never clamped (decided 2026-09-09: consistency over avoiding orphaned indent; the markup generator owns the depth it emits). Extend TC-DOC190 with a `min_depth >= 2` case; state the rule in `emitter.py`'s bullet-pass comment and the `docs/emitter.py` paragraph of `CLAUDE.md` (PR #731) ([#714](https://github.com/khuisman/mcp-gee-sweet/issues/714))
194
194
  - [x] `create_doc` markdown: a fenced code block whose body is raw HTML tags renders as loose plain paragraphs (fence ignored, indentation flattened) instead of a monospaced code block — python-markdown's raw-HTML-block handling appears to pre-empt `fenced_code`. Surfaced during #714 triage (PR #730) ([#719](https://github.com/khuisman/mcp-gee-sweet/issues/719))
195
- - [ ] `_auth_status_json` silently overrides a caller passing an inconsistent `is_service_account_identity` instead of erroring or reconciling ([#614](https://github.com/khuisman/mcp-gee-sweet/issues/614))
196
- - [ ] `_timed` logs a `200` status for tool calls that catch their own exception and return `{error: ...}` — observability reports success for a call that actually failed ([#579](https://github.com/khuisman/mcp-gee-sweet/issues/579))
197
- - [ ] `emitter.py` table-cell images have no cursor-advance handling — latent infinite loop if that code path is ever reached ([#509](https://github.com/khuisman/mcp-gee-sweet/issues/509))
198
- - [ ] Calendar ACL tools use a bare `except Exception` instead of the `HttpError` friendly-error pattern used elsewhere in `calendar.py` ([#459](https://github.com/khuisman/mcp-gee-sweet/issues/459))
195
+ - [ ] **Bundle:** Docs write side (emitter, html_parser, images, content), one PR closing [#509](https://github.com/khuisman/mcp-gee-sweet/issues/509), [#567](https://github.com/khuisman/mcp-gee-sweet/issues/567), [#581](https://github.com/khuisman/mcp-gee-sweet/issues/581), [#638](https://github.com/khuisman/mcp-gee-sweet/issues/638), [#715](https://github.com/khuisman/mcp-gee-sweet/issues/715) — lane A ([#894](https://github.com/khuisman/mcp-gee-sweet/issues/894))
196
+ - [ ] **Bundle:** Calendar hardening, one PR closing [#459](https://github.com/khuisman/mcp-gee-sweet/issues/459), [#615](https://github.com/khuisman/mcp-gee-sweet/issues/615), [#626](https://github.com/khuisman/mcp-gee-sweet/issues/626), [#627](https://github.com/khuisman/mcp-gee-sweet/issues/627), [#726](https://github.com/khuisman/mcp-gee-sweet/issues/726) — lane B ([#890](https://github.com/khuisman/mcp-gee-sweet/issues/890))
199
197
  - [ ] `delete_file(permanent=True)` surfaces a misleading 404 when the caller can trash but not permanently delete (Shared Drive Content manager) — an agent can read it as "already gone" and skip cleanup; check `capabilities.canDelete` first — lane B ([#876](https://github.com/khuisman/mcp-gee-sweet/issues/876))
200
198
  - [ ] SSE: SIGTERM with an SSE stream open logs an "Exception in ASGI application" traceback; the server still exits cleanly, pre-existing before #833 — lane A ([#868](https://github.com/khuisman/mcp-gee-sweet/issues/868))
201
199
  - [x] `sync_folder`'s `convert_markdown` restamp-failure handling: quota-message gap, duplicated create()+update() try/except, test fixture isolation — companion to #420 (PR #669) ([#650](https://github.com/khuisman/mcp-gee-sweet/issues/650))
@@ -238,20 +236,14 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
238
236
  - [x] `content.py`: image-fetch-failure retry mechanism is fragile, resends the whole document per bad image ([#510](https://github.com/khuisman/mcp-gee-sweet/issues/510)) (PR #787)
239
237
  - [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)
240
238
  - [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)
241
- - [ ] `content.py`: `update_doc_from_file` duplicates `create_doc_from_file`'s file-reading logic; `_replace_doc_content` has a wide positional-bool signature ([#567](https://github.com/khuisman/mcp-gee-sweet/issues/567))
242
- - [ ] Inline-image size validation: shrink-loop postcondition, check-order inconsistency, unguarded size parsing (#562 follow-up) ([#581](https://github.com/khuisman/mcp-gee-sweet/issues/581))
243
- - [ ] `doc_to_ast.py` duplicates `style.py`'s glyph-inference and color-extraction logic ([#596](https://github.com/khuisman/mcp-gee-sweet/issues/596))
244
- - [ ] `ast_to_markdown.py`: image-render duplication + falsy-based code-block buffer flush ([#600](https://github.com/khuisman/mcp-gee-sweet/issues/600))
245
- - [ ] `html_parser.py` `handle_data`: a SIM114 lint merge conflates two structurally distinct buffering triggers ([#638](https://github.com/khuisman/mcp-gee-sweet/issues/638))
239
+ - [ ] **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))
246
240
  - [ ] 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))
247
241
  - [ ] `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))
248
242
  - [ ] `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))
249
243
  - [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)
250
244
  - [ ] 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))
251
- - [ ] `TestIsolatedDepthRunGlyph` asserts request shape, not rendered nesting outcome (PR #711) ([#715](https://github.com/khuisman/mcp-gee-sweet/issues/715))
252
245
  - [ ] `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))
253
246
  - [x] Extract a shared helper for the isolated-bullet-run zero-tab anchor mechanic (`emitter.py` ↔ `style.py`); harden its regression test (`lane-a`) ([#727](https://github.com/khuisman/mcp-gee-sweet/issues/727)) (PR #735)
254
- - [ ] `doc_to_ast.py`'s Courier-New soft-break reversal is per-run, not per-paragraph — mismatched-style edge case can emit malformed inline code with an embedded newline (PR #730 follow-up) ([#732](https://github.com/khuisman/mcp-gee-sweet/issues/732))
255
247
 
256
248
  **Drive hardening & dedup** _(triaged out of `backlog` 2026-09-11)_
257
249
  - [x] `download_folder`/`sync_folder` progress notifications: report bytes transferred, not just file count ([#352](https://github.com/khuisman/mcp-gee-sweet/issues/352)) (PR #739)
@@ -273,37 +265,24 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
273
265
  - [x] `sync_folder(convert_markdown=True)`: a `.md` name over ~95 bytes overflows Drive's 124-byte property cap — upload fails with `propertyLengthLimitExceeded` but Drive still creates an unmarked orphan Doc, repeated on every sync. Needs a design call on converted-Doc recognition (PR #800 follow-up) ([#805](https://github.com/khuisman/mcp-gee-sweet/issues/805)) (PR #812)
274
266
  - [x] Deduplicate the `max_results` clamp expression, hand-copied 8x across `drive/files.py` with inconsistent caps (PR #736 follow-up) ([#737](https://github.com/khuisman/mcp-gee-sweet/issues/737)) (PR #781)
275
267
  - [x] `download_file`: one pre-flight `local_path` check before any Drive call, for both trailing-slash and plain branches, naming the blocking path (#724/#827 follow-up; on-deck, `good first issue`) (PR #855) ([#836](https://github.com/khuisman/mcp-gee-sweet/issues/836))
276
- - [ ] `sync_folder`: a failed mid-stream download leaves a truncated local file with a fresh mtime, which the next sync uploads over the intact Drive copy — download to a temp file and `os.replace` on success; check `download_file`/`download_folder` too. Found by code review, not yet observed live (lane-a, after #814 since both touch `_run_one`) ([#844](https://github.com/khuisman/mcp-gee-sweet/issues/844))
277
- - [ ] `download_file` local-path preflight follow-ups (#836/PR #855): mkdir-race fallback, trailing `/.` treated as a file destination, dangling-symlink inconsistency — lane A ([#874](https://github.com/khuisman/mcp-gee-sweet/issues/874))
268
+ - [x] `sync_folder`: a failed mid-stream download leaves a truncated local file with a fresh mtime, which the next sync uploads over the intact Drive copy — download to a temp file and `os.replace` on success; check `download_file`/`download_folder` too. Found by code review, not yet observed live (lane-a, after #814 since both touch `_run_one`) ([#844](https://github.com/khuisman/mcp-gee-sweet/issues/844)) (PR #884)
269
+ - [ ] **Bundle:** Drive transfer follow-ups (`download_file` preflight + `_write_atomically`), one PR closing [#874](https://github.com/khuisman/mcp-gee-sweet/issues/874), [#887](https://github.com/khuisman/mcp-gee-sweet/issues/887) — lane A ([#895](https://github.com/khuisman/mcp-gee-sweet/issues/895))
278
270
 
279
271
  **Calendar hardening** _(triaged out of `backlog` 2026-09-11)_
280
- - [ ] `list_calendar_acl` pagination fix (#460) needs a response-size cap, loop safety bound, and partial-failure handling ([#615](https://github.com/khuisman/mcp-gee-sweet/issues/615))
281
- - [ ] `list_all_events` concurrency cap: per-invocation only, no env override ([#626](https://github.com/khuisman/mcp-gee-sweet/issues/626))
282
- - [ ] `_shape_event`: `get_event` still duplicates it, no null-guard, no kwarg-collision guard ([#627](https://github.com/khuisman/mcp-gee-sweet/issues/627))
283
- - [ ] `find_free_slots`/`list_all_events`: unify duplicated id-to-summary lookup, align empty-string fallback (PR #725 follow-up) ([#726](https://github.com/khuisman/mcp-gee-sweet/issues/726))
284
272
 
285
273
  **Testing** _(triaged out of `backlog` 2026-09-11)_
286
- - [ ] Duplicate fake-Sheets-service test scaffolding in `test_helpers.py` ([#392](https://github.com/khuisman/mcp-gee-sweet/issues/392))
287
- - [ ] Add unit test coverage for `sync_folder`'s SA-limitations caveat text ([#526](https://github.com/khuisman/mcp-gee-sweet/issues/526))
288
- - [ ] `test`: strengthen `download_file` unit test assertions (metadata call signature, export str-fallback) ([#551](https://github.com/khuisman/mcp-gee-sweet/issues/551))
289
- - [ ] `test(drive)`: dedupe `skip_if_exists` companion-test boilerplate in `test_transfer.py` ([#561](https://github.com/khuisman/mcp-gee-sweet/issues/561))
274
+ - [ ] **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 ([#891](https://github.com/khuisman/mcp-gee-sweet/issues/891))
290
275
  - [ ] `qa`: `search_files` has no unit test coverage for the error-dict path ([#573](https://github.com/khuisman/mcp-gee-sweet/issues/573))
291
- - [ ] `test`: brittle exact-equality assertion in `test_api_error_returns_error_dict_not_raised` (`test_files.py`) ([#574](https://github.com/khuisman/mcp-gee-sweet/issues/574))
292
276
 
293
277
  **Infrastructure**
294
278
  - [ ] 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))
295
279
  - [ ] Automated tooling for dependency-bump security review — today's manual skim can miss a well-disguised supply-chain compromise ([#520](https://github.com/khuisman/mcp-gee-sweet/issues/520))
296
- - [ ] CI: dedupe lint/format-check and lockfile-check across the Python version matrix ([#619](https://github.com/khuisman/mcp-gee-sweet/issues/619))
297
- - [ ] Consolidate `uv lock --check` + `uv sync --frozen` into a single `uv sync --locked` step ([#536](https://github.com/khuisman/mcp-gee-sweet/issues/536))
298
- - [ ] Catch stale hardcoded tool counts in prose docs at commit time, instead of by hand each time drift is caught ([#308](https://github.com/khuisman/mcp-gee-sweet/issues/308))
280
+ - [ ] **Bundle:** CI tidy (on deck, no lane), one PR closing [#536](https://github.com/khuisman/mcp-gee-sweet/issues/536), [#619](https://github.com/khuisman/mcp-gee-sweet/issues/619), [#308](https://github.com/khuisman/mcp-gee-sweet/issues/308), [#708](https://github.com/khuisman/mcp-gee-sweet/issues/708) ([#897](https://github.com/khuisman/mcp-gee-sweet/issues/897))
299
281
  - [ ] Automate the mechanical `release.md` steps as GitHub Actions ([#660](https://github.com/khuisman/mcp-gee-sweet/issues/660))
300
- - [ ] Guard `README.md` against non-absolute image URLs — PyPI's `readme_renderer` silently strips them (shipped a logo-less v0.9.0 page, #706). CI guard test; release/prep-for-pr backstop routed to Bob (#709) ([#708](https://github.com/khuisman/mcp-gee-sweet/issues/708))
301
282
  - [ ] `/release` + `prep-for-pr` backstop checks for README render breakage on PyPI — the human-facing companion to #708's CI guard, routed to Bob ([#709](https://github.com/khuisman/mcp-gee-sweet/issues/709))
302
283
  - [x] `supportsAllDrives=True` is hand-inlined at ~58 Drive call sites — retrofitted twice now (#687 plus its own original miss); wants a shared wrapper or lint guard so a new call site can't omit it ([#696](https://github.com/khuisman/mcp-gee-sweet/issues/696)) (PR #784)
303
284
  - [x] Extract a shared `_BaseCache` helper for the `rows_fetched` sufficiency check + epoch-guarded store — #688's fix copy-pasted the pattern into `DriveFolderCache` instead, and the epoch guard is still missing there ([#698](https://github.com/khuisman/mcp-gee-sweet/issues/698)) (PR #788)
304
- - [ ] `_SA_LIMITATIONS` (`server.py`) hygiene: no registry tying restricted tools to entries, redundant per-call flatten, duplicated alternatives text — triaged out of `backlog` 2026-09-11 ([#517](https://github.com/khuisman/mcp-gee-sweet/issues/517))
305
- - [ ] `server.json`/`test_server_json.py`: minor hardening findings from PR #603's review — triaged out of `backlog` 2026-09-11 ([#607](https://github.com/khuisman/mcp-gee-sweet/issues/607))
306
- - [ ] Restore the explanatory comment for the `annotations=None` stub param in `gen_tool_docs.py` — triaged out of `backlog` 2026-09-11 ([#637](https://github.com/khuisman/mcp-gee-sweet/issues/637))
285
+ - [ ] **Bundle:** server/infra hygiene (start after #872), one PR closing [#517](https://github.com/khuisman/mcp-gee-sweet/issues/517), [#607](https://github.com/khuisman/mcp-gee-sweet/issues/607), [#614](https://github.com/khuisman/mcp-gee-sweet/issues/614), [#579](https://github.com/khuisman/mcp-gee-sweet/issues/579), [#637](https://github.com/khuisman/mcp-gee-sweet/issues/637) — lane B ([#892](https://github.com/khuisman/mcp-gee-sweet/issues/892))
307
286
  - [x] Team setup: give each worktree only its own MCP server — other roles' tool names are an estimated ~15–20k of the ~80k lane bootstrap; measure first. Prioritized 2026-09-29, lane-b ahead of #789 ([#850](https://github.com/khuisman/mcp-gee-sweet/issues/850)) (PR #856)
308
287
  - [x] Context-size backstop hook for lane sessions, plus a warning before a cold `--resume` — code half of #843; lane-b after #850 ([#847](https://github.com/khuisman/mcp-gee-sweet/issues/847)) (PR #866)
309
288
  - [ ] Cut `CLAUDE.md` and the role files down to lasting rules + pointers, moving per-PR history to `docs/design/`/`docs/decisions/` — Amy writes it (user-assigned), Bob reviews each PR under the prompt-QA gate; can land as one PR per file ([#849](https://github.com/khuisman/mcp-gee-sweet/issues/849))
@@ -315,13 +294,11 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
315
294
  - [x] `reply_to_message` should honor Reply-To and handle replies to your own sent mail ([#791](https://github.com/khuisman/mcp-gee-sweet/issues/791)) (PR #815)
316
295
  - [x] Decode message bodies using the part's charset; tolerate missing base64 padding ([#792](https://github.com/khuisman/mcp-gee-sweet/issues/792)) (PR #816)
317
296
  - [x] `get_message`/`get_thread` return no body when Gmail delivers a large (~MB) `text/plain`/`text/html` part by `attachmentId`. Found in #803's live QA ([#825](https://github.com/khuisman/mcp-gee-sweet/issues/825)) (PR #829)
318
- - [ ] `get_thread`/`get_message`: add a body-less option — a long thread trips the response-size cap and there's no other way to get its message IDs, so it can't be read at all ([#793](https://github.com/khuisman/mcp-gee-sweet/issues/793))
319
- - [ ] Reply-all self-exclusion (silent `getProfile` failure, send-as aliases), attachment reads blocking the event loop, attachment MIME guessing ([#802](https://github.com/khuisman/mcp-gee-sweet/issues/802))
320
- - [ ] Deduplicate `send_message`/`create_draft` compose and `list_messages`/`list_threads` kwargs/pagination helpers ([#795](https://github.com/khuisman/mcp-gee-sweet/issues/795))
297
+ - [x] `get_thread`/`get_message`: add a body-less option — a long thread trips the response-size cap and there's no other way to get its message IDs, so it can't be read at all ([#793](https://github.com/khuisman/mcp-gee-sweet/issues/793)) (PR #885)
298
+ - [ ] **Bundle:** Gmail cleanup, one PR closing [#795](https://github.com/khuisman/mcp-gee-sweet/issues/795), [#802](https://github.com/khuisman/mcp-gee-sweet/issues/802), [#823](https://github.com/khuisman/mcp-gee-sweet/issues/823) — lane B ([#896](https://github.com/khuisman/mcp-gee-sweet/issues/896))
321
299
  - [ ] Local-file read policy across local-path tools — in a remote/SSE deployment these read the server's own filesystem (token, SA key), and Gmail attachments can now mail them out. One consistent policy (e.g. a config switch), not per-tool patches. Decision-needed; Joy scopes ([#796](https://github.com/khuisman/mcp-gee-sweet/issues/796))
322
300
  - [ ] Release-pass Gmail QA (`docs/qa/tests/gmail.md`, added by #786). Many cases are destructive (send/draft/trash), so they need a dedicated mailbox fixture plan — Aziz ([#803](https://github.com/khuisman/mcp-gee-sweet/issues/803))
323
301
  - [ ] Rewrite TC-GM01–22 prescriptively, add TC-GM26–44 and the Smoke rows, per `docs/qa/gmail-test-plan.md` §4. Part of #803, Aziz ([#822](https://github.com/khuisman/mcp-gee-sweet/issues/822))
324
- - [ ] Gmail unit-test hardening: MIME build branches, label validation, subject normalization, `max_results` clamping (plan §5 P4–P9; nothing observed failing) ([#823](https://github.com/khuisman/mcp-gee-sweet/issues/823))
325
302
 
326
303
  Tool surface (from the original v1.1+ plan):
327
304
 
@@ -1260,6 +1260,9 @@ def register(tool):
1260
1260
  file_id: The ID of the file or folder to remove.
1261
1261
  permanent: If False (default), moves to trash (recoverable).
1262
1262
  If True, permanently deletes — this cannot be undone.
1263
+ Raises ValueError, without deleting anything, if the
1264
+ caller can't permanently delete the file (e.g. a
1265
+ Content manager on a Shared Drive, who can only trash).
1263
1266
 
1264
1267
  Returns:
1265
1268
  Confirmation with fileId and action taken ('trashed' or 'deleted').
@@ -1267,13 +1270,38 @@ def register(tool):
1267
1270
  lc = ctx.request_context.lifespan_context
1268
1271
  drive_service = lc.drive_service
1269
1272
 
1270
- # Fetch parents before deletion so we can invalidate the cache
1273
+ # Fetch parents before deletion so we can invalidate the cache, plus
1274
+ # capabilities: on a Shared Drive a Content manager can trash but not
1275
+ # permanently delete, and Drive reports that missing permission as a
1276
+ # misleading 404 "File not found" (#876).
1271
1277
  existing = await execute_in_thread(
1272
1278
  drive_service.files()
1273
- .get(fileId=file_id, fields="parents", supportsAllDrives=True)
1279
+ .get(
1280
+ fileId=file_id,
1281
+ fields="parents,driveId,capabilities(canDelete,canTrash)",
1282
+ supportsAllDrives=True,
1283
+ )
1274
1284
  .execute,
1275
1285
  drive_service,
1276
1286
  )
1287
+ capabilities = existing.get("capabilities", {})
1288
+ if permanent and capabilities.get("canDelete") is False:
1289
+ # canDelete is also false for a non-owner editor of a My Drive
1290
+ # file, so only mention Shared Drive roles when the file is on one.
1291
+ shared_drive_note = (
1292
+ " On a Shared Drive this usually requires the Manager role."
1293
+ if existing.get("driveId")
1294
+ else ""
1295
+ )
1296
+ trash_hint = (
1297
+ " Use permanent=False to move it to the trash instead."
1298
+ if capabilities.get("canTrash")
1299
+ else ""
1300
+ )
1301
+ raise ValueError(
1302
+ f"Cannot permanently delete file {file_id}: the authenticated identity "
1303
+ f"lacks permanent-delete permission on it.{shared_drive_note}{trash_hint}"
1304
+ )
1277
1305
  for parent in existing.get("parents", []):
1278
1306
  lc.drive_folder_cache.mark_dirty(parent)
1279
1307
 
@@ -1,15 +1,19 @@
1
1
  import asyncio
2
2
  import base64
3
+ import contextlib
4
+ import errno
3
5
  import hashlib
4
6
  import io
5
7
  import logging
6
8
  import mimetypes
7
9
  import os
8
- from collections.abc import Iterable
10
+ import re
11
+ import secrets
12
+ from collections.abc import Callable, Iterable
9
13
  from dataclasses import dataclass
10
14
  from datetime import datetime, timezone
11
15
  from pathlib import Path
12
- from typing import Any, Literal
16
+ from typing import Any, BinaryIO, Literal
13
17
 
14
18
  import markdown as _md
15
19
  from googleapiclient.errors import HttpError
@@ -39,6 +43,15 @@ _EXPORT_MIME: dict[str, tuple[str, str]] = {
39
43
 
40
44
  _SYSTEM_FILES = {".DS_Store", "Thumbs.db", "desktop.ini", ".localized"}
41
45
 
46
+ # Every download writes to a sibling temp file with this name and renames it
47
+ # onto the destination only once complete (_write_atomically, #844). A temp
48
+ # file only outlives its download when the process is killed mid-write;
49
+ # sync_folder and upload_local_folder skip one so a leftover partial is never
50
+ # uploaded. No part of the destination's name is embedded, so the temp name
51
+ # can't exceed the filesystem's name-length limit.
52
+ _PARTIAL_DOWNLOAD_PREFIX = ".gee-sweet-partial-"
53
+ _PARTIAL_DOWNLOAD_RE = re.compile(r"\.gee-sweet-partial-[0-9a-f]{16}")
54
+
42
55
  _SYNC_MTIME_TOLERANCE = 5 # seconds — absorbs clock skew and upload-time drift
43
56
 
44
57
  # use_checksum hashes every both-sides pair with a Drive md5Checksum (#716), so
@@ -375,6 +388,55 @@ def _drive_time_str(dt: datetime) -> str:
375
388
  return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
376
389
 
377
390
 
391
+ def _is_partial_download(name: str) -> bool:
392
+ """Whether `name` is a temp file _write_atomically left behind."""
393
+ return _PARTIAL_DOWNLOAD_RE.fullmatch(name) is not None
394
+
395
+
396
+ def _write_atomically(
397
+ dest: Path, write: Callable[[BinaryIO], object], mtime: float | None = None
398
+ ) -> None:
399
+ """Run `write` against a sibling temp file, then rename it onto `dest`.
400
+
401
+ `dest` only ever holds its previous content or the complete new content.
402
+ Writing straight into `dest` left a truncated file behind when a download
403
+ failed partway, with a current mtime that made sync_folder treat it as the
404
+ newer copy and upload it over the intact Drive file (#844). `mtime`, when
405
+ given, is stamped on the temp file before the rename, so a failed restamp
406
+ also leaves `dest` untouched. The temp file is removed on any failure.
407
+
408
+ A symlink at `dest` is written through, as the old `open("wb")` did: the
409
+ temp file goes next to the link's target and replaces the target. The new
410
+ file gets the mode a fresh `open("wb")` would (0o666 less the umask), or the
411
+ replaced file's permission bits when there was one (never its
412
+ setuid/setgid/sticky bits, which an in-place write would have cleared). A
413
+ hard link to the old file keeps the old content, since the rename swaps in
414
+ a new inode.
415
+
416
+ A rename needs write permission on the directory, not the file, so an
417
+ existing file this process couldn't open for writing is refused up front
418
+ with the same PermissionError `open("wb")` raised; otherwise a file the
419
+ user made read-only would be silently replaced (PR #884 QA round 1).
420
+ """
421
+ target = Path(os.path.realpath(dest)) if dest.is_symlink() else dest
422
+ if target.exists() and not os.access(target, os.W_OK):
423
+ raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(target))
424
+ tmp = target.with_name(_PARTIAL_DOWNLOAD_PREFIX + secrets.token_hex(8))
425
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o666)
426
+ try:
427
+ with os.fdopen(fd, "wb") as fh:
428
+ write(fh)
429
+ with contextlib.suppress(FileNotFoundError):
430
+ os.chmod(tmp, target.stat().st_mode & 0o777)
431
+ if mtime is not None:
432
+ os.utime(tmp, (mtime, mtime))
433
+ os.replace(tmp, target)
434
+ except BaseException:
435
+ with contextlib.suppress(OSError):
436
+ os.unlink(tmp)
437
+ raise
438
+
439
+
378
440
  def _local_mtime_str(path: Path) -> str:
379
441
  """_local_mtime_dt in the RFC 3339 form Drive's modifiedTime field takes."""
380
442
  return _drive_time_str(_local_mtime_dt(path))
@@ -1328,6 +1390,8 @@ async def _sync_level(
1328
1390
  continue
1329
1391
  if skip_system_files and p.name in _SYSTEM_FILES:
1330
1392
  continue
1393
+ if _is_partial_download(p.name):
1394
+ continue
1331
1395
  # Only a backslash (a separator on Windows, legal in a POSIX name) can
1332
1396
  # fail this for a name the OS listed. Such a file would upload under
1333
1397
  # a name this check then refuses on the Drive side, so it'd never
@@ -1899,6 +1963,15 @@ async def _sync_level(
1899
1963
  # The plan already routed unsafe names to 'unsafe_name'; this is
1900
1964
  # the shared backstop, and a refusal lands as download_fail.
1901
1965
  dest_file = _safe_local_dest(dest_dir, name, resolved_dest_dir)
1966
+ # Mirror what the upload branch above does in reverse: set the
1967
+ # local file's mtime to Drive's modifiedTime so the round trip
1968
+ # stays within _SYNC_MTIME_TOLERANCE on the next sync. Without
1969
+ # this the local mtime is "now" (write time), which is always
1970
+ # later than Drive's original timestamp — the next sync sees the
1971
+ # file as locally newer and re-uploads it, indefinitely (#346).
1972
+ # _write_atomically stamps it before the rename, so a failed
1973
+ # download never leaves a "newer" partial in place (#844).
1974
+ drive_ts = _drive_mtime(entry).timestamp()
1902
1975
  if is_workspace:
1903
1976
  target_mime = _EXPORT_MIME[export_format][0]
1904
1977
  content = await execute_in_thread(
@@ -1907,7 +1980,9 @@ async def _sync_level(
1907
1980
  )
1908
1981
  if not isinstance(content, bytes):
1909
1982
  content = content.encode("utf-8")
1910
- await asyncio.to_thread(dest_file.write_bytes, content)
1983
+ await asyncio.to_thread(
1984
+ _write_atomically, dest_file, lambda fh, c=content: fh.write(c), drive_ts
1985
+ )
1911
1986
  else:
1912
1987
 
1913
1988
  def _download_to_completion(fid=fid, dest_file=dest_file) -> None:
@@ -1915,21 +1990,16 @@ async def _sync_level(
1915
1990
  fileId=fid, supportsAllDrives=True
1916
1991
  )
1917
1992
  request.http = thread_http(drive_service)
1918
- with dest_file.open("wb") as fh:
1993
+
1994
+ def _stream(fh: BinaryIO) -> None:
1919
1995
  downloader = MediaIoBaseDownload(fh, request)
1920
1996
  done = False
1921
1997
  while not done:
1922
1998
  _, done = downloader.next_chunk()
1923
1999
 
2000
+ _write_atomically(dest_file, _stream, drive_ts)
2001
+
1924
2002
  await asyncio.to_thread(_download_to_completion)
1925
- # Mirror what the upload branch above does in reverse: set the
1926
- # local file's mtime to Drive's modifiedTime so the round trip
1927
- # stays within _SYNC_MTIME_TOLERANCE on the next sync. Without
1928
- # this the local mtime is "now" (write time), which is always
1929
- # later than Drive's original timestamp — the next sync sees the
1930
- # file as locally newer and re-uploads it, indefinitely (#346).
1931
- dtime = _drive_mtime(entry)
1932
- os.utime(dest_file, (dtime.timestamp(), dtime.timestamp()))
1933
2003
  size = dest_file.stat().st_size
1934
2004
  logger.debug("Synced (download) Drive → %s%s (%d bytes)", rel_prefix, name, size)
1935
2005
  return {"kind": "download_ok", "name": name, "bytes": size}
@@ -2628,7 +2698,9 @@ def register(tool):
2628
2698
  if not folder.is_dir():
2629
2699
  raise ValueError(f"No directory found at {local_path!r}")
2630
2700
 
2631
- candidates = [p for p in folder.iterdir() if p.is_file()]
2701
+ candidates = [
2702
+ p for p in folder.iterdir() if p.is_file() and not _is_partial_download(p.name)
2703
+ ]
2632
2704
  if skip_system_files:
2633
2705
  candidates = [p for p in candidates if p.name not in _SYSTEM_FILES]
2634
2706
 
@@ -2867,18 +2939,21 @@ def register(tool):
2867
2939
  )
2868
2940
  if not isinstance(content, bytes):
2869
2941
  content = content.encode("utf-8")
2870
- await asyncio.to_thread(dest.write_bytes, content)
2942
+ await asyncio.to_thread(_write_atomically, dest, lambda fh: fh.write(content))
2871
2943
  else:
2872
2944
 
2873
2945
  def _download_to_completion() -> None:
2874
2946
  request = drive_service.files().get_media(fileId=file_id, supportsAllDrives=True)
2875
2947
  request.http = thread_http(drive_service)
2876
- with dest.open("wb") as fh:
2948
+
2949
+ def _stream(fh: BinaryIO) -> None:
2877
2950
  downloader = MediaIoBaseDownload(fh, request)
2878
2951
  done = False
2879
2952
  while not done:
2880
2953
  _, done = downloader.next_chunk()
2881
2954
 
2955
+ _write_atomically(dest, _stream)
2956
+
2882
2957
  await asyncio.to_thread(_download_to_completion)
2883
2958
 
2884
2959
  size = dest.stat().st_size
@@ -3086,7 +3161,9 @@ def register(tool):
3086
3161
  )
3087
3162
  if not isinstance(content, bytes):
3088
3163
  content = content.encode("utf-8")
3089
- await asyncio.to_thread(candidate.dest_file.write_bytes, content)
3164
+ await asyncio.to_thread(
3165
+ _write_atomically, candidate.dest_file, lambda fh: fh.write(content)
3166
+ )
3090
3167
  else:
3091
3168
 
3092
3169
  def _download_to_completion(
@@ -3096,12 +3173,15 @@ def register(tool):
3096
3173
  fileId=fid, supportsAllDrives=True
3097
3174
  )
3098
3175
  request.http = thread_http(drive_service)
3099
- with dest_file.open("wb") as fh:
3176
+
3177
+ def _stream(fh: BinaryIO) -> None:
3100
3178
  downloader = MediaIoBaseDownload(fh, request)
3101
3179
  done = False
3102
3180
  while not done:
3103
3181
  _, done = downloader.next_chunk()
3104
3182
 
3183
+ _write_atomically(dest_file, _stream)
3184
+
3105
3185
  await asyncio.to_thread(_download_to_completion)
3106
3186
 
3107
3187
  size = candidate.dest_file.stat().st_size
@@ -255,6 +255,88 @@ class TestFileMutations:
255
255
  await _drive_tools["delete_file"](file_id="fid1", permanent=True, ctx=ctx)
256
256
  folder_cache.mark_dirty.assert_called_once_with("par1")
257
257
 
258
+ async def test_delete_file_requests_capabilities_in_parents_lookup(self):
259
+ """#876: the capability check rides on the existing get, no extra request."""
260
+ mock = MagicMock()
261
+ mock.files.return_value.get.return_value.execute.return_value = {"parents": ["par1"]}
262
+ ctx = _make_ctx(drive_service=mock, drive_folder_cache=MagicMock())
263
+ await _drive_tools["delete_file"](file_id="fid1", permanent=True, ctx=ctx)
264
+ mock.files.return_value.get.assert_called_once()
265
+ fields = mock.files.return_value.get.call_args.kwargs["fields"]
266
+ assert "canDelete" in fields
267
+ assert "canTrash" in fields
268
+ assert "driveId" in fields
269
+
270
+ async def test_delete_file_permanent_without_can_delete_raises_before_delete(self):
271
+ """#876: Content manager on a Shared Drive gets a clear error, not Drive's 404."""
272
+ mock = MagicMock()
273
+ mock.files.return_value.get.return_value.execute.return_value = {
274
+ "parents": ["par1"],
275
+ "driveId": "drive1",
276
+ "capabilities": {"canDelete": False, "canTrash": True},
277
+ }
278
+ folder_cache = MagicMock()
279
+ ctx = _make_ctx(drive_service=mock, drive_folder_cache=folder_cache)
280
+ with pytest.raises(ValueError, match="permanent=False") as exc_info:
281
+ await _drive_tools["delete_file"](file_id="fid1", permanent=True, ctx=ctx)
282
+ assert "Shared Drive" in str(exc_info.value)
283
+ mock.files.return_value.delete.assert_not_called()
284
+ folder_cache.mark_dirty.assert_not_called()
285
+
286
+ async def test_delete_file_permanent_without_can_delete_on_my_drive_omits_shared_drive_note(
287
+ self,
288
+ ):
289
+ """A non-owner editor of a My Drive file also lacks canDelete; no Shared Drive aside."""
290
+ mock = MagicMock()
291
+ mock.files.return_value.get.return_value.execute.return_value = {
292
+ "parents": ["par1"],
293
+ "capabilities": {"canDelete": False, "canTrash": True},
294
+ }
295
+ ctx = _make_ctx(drive_service=mock, drive_folder_cache=MagicMock())
296
+ with pytest.raises(ValueError) as exc_info:
297
+ await _drive_tools["delete_file"](file_id="fid1", permanent=True, ctx=ctx)
298
+ assert "Shared Drive" not in str(exc_info.value)
299
+ assert "permanent=False" in str(exc_info.value)
300
+ mock.files.return_value.delete.assert_not_called()
301
+
302
+ async def test_delete_file_permanent_without_delete_or_trash_omits_trash_hint(self):
303
+ mock = MagicMock()
304
+ mock.files.return_value.get.return_value.execute.return_value = {
305
+ "parents": ["par1"],
306
+ "capabilities": {"canDelete": False, "canTrash": False},
307
+ }
308
+ ctx = _make_ctx(drive_service=mock, drive_folder_cache=MagicMock())
309
+ with pytest.raises(ValueError) as exc_info:
310
+ await _drive_tools["delete_file"](file_id="fid1", permanent=True, ctx=ctx)
311
+ assert "permanent=False" not in str(exc_info.value)
312
+ mock.files.return_value.delete.assert_not_called()
313
+
314
+ async def test_delete_file_permanent_with_can_delete_calls_delete(self):
315
+ mock = MagicMock()
316
+ mock.files.return_value.get.return_value.execute.return_value = {
317
+ "parents": ["par1"],
318
+ "capabilities": {"canDelete": True, "canTrash": True},
319
+ }
320
+ mock.files.return_value.delete.return_value.execute.return_value = None
321
+ ctx = _make_ctx(drive_service=mock, drive_folder_cache=MagicMock())
322
+ result = await _drive_tools["delete_file"](file_id="fid1", permanent=True, ctx=ctx)
323
+ mock.files.return_value.delete.assert_called_once_with(
324
+ fileId="fid1", supportsAllDrives=True
325
+ )
326
+ assert result == {"fileId": "fid1", "action": "deleted"}
327
+
328
+ async def test_delete_file_trash_ignores_can_delete_false(self):
329
+ """Trashing only needs canTrash; a missing canDelete must not block it."""
330
+ mock = MagicMock()
331
+ mock.files.return_value.get.return_value.execute.return_value = {
332
+ "parents": ["par1"],
333
+ "capabilities": {"canDelete": False, "canTrash": True},
334
+ }
335
+ mock.files.return_value.update.return_value.execute.return_value = {"id": "fid1"}
336
+ ctx = _make_ctx(drive_service=mock, drive_folder_cache=MagicMock())
337
+ result = await _drive_tools["delete_file"](file_id="fid1", permanent=False, ctx=ctx)
338
+ assert result == {"fileId": "fid1", "action": "trashed"}
339
+
258
340
  async def test_restore_file_marks_parent_dirty(self):
259
341
  mock = MagicMock()
260
342
  mock.files.return_value.update.return_value.execute.return_value = {