mcp-gee-sweet 0.7.0.dev82__tar.gz → 0.7.0.dev87__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 (143) hide show
  1. mcp_gee_sweet-0.7.0.dev87/.claude/commands/merge-pr.md +15 -0
  2. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.claude/commands/verify-pr.md +16 -2
  3. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/CLAUDE.md +1 -1
  4. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/PKG-INFO +1 -1
  5. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/configuration.md +1 -1
  6. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/design/docs-ast-pipeline.md +14 -0
  7. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/tests/docs.md +19 -0
  8. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/tests/sheets_mgmt.md +88 -0
  9. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/roadmap.md +9 -8
  10. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/tools.md +2 -1
  11. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/docs/emitter.py +57 -23
  12. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/sheets/helpers.py +16 -0
  13. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/sheets/structure.py +61 -1
  14. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/sheets/test_structure.py +152 -0
  15. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_docs_tables.py +120 -0
  16. mcp_gee_sweet-0.7.0.dev82/.claude/commands/merge-pr.md +0 -13
  17. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.claude/commands/cleanup-worktrees.md +0 -0
  18. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.claude/commands/orchestrator.md +0 -0
  19. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.claude/commands/prep-for-pr.md +0 -0
  20. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.claude/commands/start-worktree.md +0 -0
  21. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.dockerignore +0 -0
  22. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  23. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  24. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  25. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.github/dependabot.yml +0 -0
  26. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.github/workflows/ci.yml +0 -0
  27. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.github/workflows/docs.yml +0 -0
  28. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.github/workflows/publish-dev.yml +0 -0
  29. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.github/workflows/release.yml +0 -0
  30. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.gitignore +0 -0
  31. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.pre-commit-config.yaml +0 -0
  32. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/.python-version +0 -0
  33. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/CHANGELOG.md +0 -0
  34. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/CODE_OF_CONDUCT.md +0 -0
  35. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/CONTRIBUTING.md +0 -0
  36. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/Dockerfile +0 -0
  37. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/LICENSE +0 -0
  38. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/Makefile +0 -0
  39. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/README.md +0 -0
  40. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/SECURITY.md +0 -0
  41. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docker-compose.yml +0 -0
  42. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/auth.md +0 -0
  43. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/client-setup.md +0 -0
  44. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/decision-cache-invalidation.md +0 -0
  45. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/decision-chart-theming.md +0 -0
  46. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/decision-composite-tools.md +0 -0
  47. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/decision-docs-formatting.md +0 -0
  48. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/decision-fork.md +0 -0
  49. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/decision-grid-data-size-cap.md +0 -0
  50. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/decision-publishing.md +0 -0
  51. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/decision-response-size-cap-generalization.md +0 -0
  52. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/decision-testing.md +0 -0
  53. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/decisions/index.md +0 -0
  54. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/design/effectiveformat-spike.md +0 -0
  55. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/design/index.md +0 -0
  56. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/design/markdown-support.md +0 -0
  57. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/design/phase3-theme-system.md +0 -0
  58. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/design.md +0 -0
  59. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/index.md +0 -0
  60. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/known-limitations.md +0 -0
  61. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/notes-read.md +0 -0
  62. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/README.md +0 -0
  63. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  64. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  65. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/fixtures/tc-d213-dollar-escape.md +0 -0
  66. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  67. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/fixtures.template.md +0 -0
  68. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/operations.yaml +0 -0
  69. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/playwright_oauth.md +0 -0
  70. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/.gitkeep +0 -0
  71. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-06-02.md +0 -0
  72. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-06-14.md +0 -0
  73. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-06-17.md +0 -0
  74. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-06-18.md +0 -0
  75. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
  76. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
  77. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-06-28.md +0 -0
  78. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-07-04-docs.md +0 -0
  79. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-07-04-drive-smoke.md +0 -0
  80. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-07-04-infra.md +0 -0
  81. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-07-04-sheets_read.md +0 -0
  82. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/results/2026-07-04.md +0 -0
  83. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/retro-v0.8.0.md +0 -0
  84. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/run.md +0 -0
  85. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/runs/README.md +0 -0
  86. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/runs/v0.8.0.md +0 -0
  87. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/runs/v0.8.1.md +0 -0
  88. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/setup.md +0 -0
  89. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/tests/calendar.md +0 -0
  90. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/tests/drive.md +0 -0
  91. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/tests/infra.md +0 -0
  92. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/tests/sheets_charts.md +0 -0
  93. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/tests/sheets_read.md +0 -0
  94. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa/tests/sheets_write.md +0 -0
  95. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/docs/qa-checklist.md +0 -0
  96. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/mkdocs.yml +0 -0
  97. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/pyproject.toml +0 -0
  98. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/pyrightconfig.json +0 -0
  99. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/scripts/gen_tool_docs.py +0 -0
  100. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/scripts/oauth_setup.py +0 -0
  101. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/.env.template +0 -0
  102. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/__init__.py +0 -0
  103. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/auth.py +0 -0
  104. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/cache.py +0 -0
  105. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/server.py +0 -0
  106. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  107. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/cache.py +0 -0
  108. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/calendar.py +0 -0
  109. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
  110. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/docs/ast.py +0 -0
  111. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/docs/content.py +0 -0
  112. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/docs/html_parser.py +0 -0
  113. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
  114. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/docs/style.py +0 -0
  115. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
  116. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  117. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
  118. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
  119. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  120. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/drive/transfer.py +0 -0
  121. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/response_limits.py +0 -0
  122. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  123. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
  124. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/__init__.py +0 -0
  125. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/drive/__init__.py +0 -0
  126. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/drive/test_activity.py +0 -0
  127. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/drive/test_files.py +0 -0
  128. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/drive/test_sharing.py +0 -0
  129. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/drive/test_transfer.py +0 -0
  130. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/sheets/__init__.py +0 -0
  131. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/sheets/test_data.py +0 -0
  132. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/sheets/test_helpers.py +0 -0
  133. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_auth.py +0 -0
  134. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_cache.py +0 -0
  135. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_calendar.py +0 -0
  136. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_docs_content.py +0 -0
  137. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_docs_core.py +0 -0
  138. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_docs_layout.py +0 -0
  139. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_docs_style.py +0 -0
  140. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_gen_tool_docs.py +0 -0
  141. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_response_limits.py +0 -0
  142. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/tests/test_server.py +0 -0
  143. {mcp_gee_sweet-0.7.0.dev82 → mcp_gee_sweet-0.7.0.dev87}/uv.lock +0 -0
@@ -0,0 +1,15 @@
1
+ Merge the current branch's PR and clean up its worktree. Uses `--admin` only to bypass the required-review gate (this is a solo-maintainer repo, so a second approver is never coming) — it must never be used to bypass a failing or pending status check.
2
+
3
+ 1. Get the PR: if given a PR number (e.g. following on from `/verify-pr <number>`), use `gh pr view <number> --json ...` explicitly rather than relying on current-branch detection — `/verify-pr` may have checked out a locally-named `review/<headRefName>` branch (see its step 3) instead of the PR's real branch name, which current-branch auto-detection can't always resolve. Otherwise, get the PR for the current branch: `gh pr view --json number,title,url,state,mergeable,statusCheckRollup,reviewDecision`. If there's no open PR for this branch/number, stop and tell the user.
4
+ 2. Verify it's actually safe to merge:
5
+ - `state` must be `OPEN`.
6
+ - Every check in `statusCheckRollup` (in particular "Lint and test") must show success. If anything is failing or still pending, stop and report which — do not merge, and do not wait/poll.
7
+ - `mergeable` must not be `CONFLICTING`.
8
+ 3. Show the user a summary: PR number, title, URL, check status, and review status (expected to be blocked only on the missing-approval requirement). State plainly that merging will use `--admin` to bypass that review gate. Ask for explicit confirmation before proceeding.
9
+ 4. On confirmation, squash-merge (matching this repo's existing history — commits land on `develop` as a single `title (#number)` commit): `gh pr merge <number> --admin --squash`. The remote branch is auto-deleted on merge (repo setting), so don't pass `--delete-branch`.
10
+ 5. Clean up:
11
+ - If the current branch is a `review/<headRefName>` branch created by `/verify-pr` (not a `.claude/worktrees/*` worktree — just a local branch in the main checkout), there's no worktree to remove: switch to `develop` and `git branch -D review/<headRefName>`.
12
+ - If this session created the current worktree (i.e. it got here via `/start-worktree` or `/next-issue` earlier in this same session), use `ExitWorktree` with `action: "remove"`.
13
+ - Otherwise (worktree predates this session), fall back to manual cleanup from the main checkout: `git worktree remove <path>`, then `git branch -d <branch>`, then `git worktree prune`.
14
+ 6. Update the roadmap: from the main checkout on `develop`, `git pull` then check `docs/roadmap.md` for a checkbox line referencing the issue(s) this PR closes (search by issue number). If found, check it off (`- [ ]` → `- [x]`) and append `(PR <number>)` in the existing style (e.g. `(PR #280)`) — see recent `- [x]` entries for the exact convention. If the roadmap has no line item for this issue (not every ticket is roadmap-tracked — chores, follow-up defects, etc.), skip this step silently. Commit directly to `develop` (`docs(roadmap): check off #<issue> (PR <number>)`) and push — `develop`'s branch protection has `enforce_admins: false`, so a direct push by the repo owner is allowed for this kind of small, reversible, docs-only change; don't open a separate PR for it.
15
+ 7. Report a summary: the merge commit, the now-merged PR URL, confirmation the worktree was removed, and whether the roadmap was updated.
@@ -6,7 +6,13 @@ CI (the "Lint and test" check `/merge-pr` already gates on) covers unit tests. I
6
6
 
7
7
  2. **Identify the PR.** Take a PR number as an argument if given, otherwise use `gh pr list --state open` and ask the user which one if there's more than one. Get its details: `gh pr view <number> --json number,title,url,headRefName,baseRefName,statusCheckRollup,mergeable,mergeStateStatus`. If `mergeable` is `CONFLICTING`, stop here — report it and say the branch needs a rebase onto `<baseRefName>` before verification is worth doing. Don't spend code-review or live-QA effort on a branch that can't merge as-is; that cost is only worth paying once it's rebased.
8
8
 
9
- 3. **Check out the branch.** `gh pr checkout <number>`. This fetches and switches the main checkout to the PR's branch directly (no worktree needed — the orchestrator owns the main checkout).
9
+ 3. **Check out the branch.** Don't use `gh pr checkout <number>` directly if the worker's own worktree still has `<headRefName>` checked out (the normal case while that worker session is active), it fails with "already checked out," and the only recovery `gh` offers is unlocking/removing that worktree, which disrupts the worker for no reason. Git only blocks two worktrees from sharing the same branch *name*, not the same commit so fetch the PR's branch into a locally-named review branch instead:
10
+ ```
11
+ git fetch origin <headRefName>:review/<headRefName> --force
12
+ git checkout review/<headRefName>
13
+ git branch --set-upstream-to=origin/<headRefName> review/<headRefName>
14
+ ```
15
+ This points the main checkout at the exact same commit without touching the worker's worktree at all. The `--set-upstream-to` matters: without it, `gh pr view`/`gh pr merge` with no PR number can't map the local `review/<headRefName>` branch back to the PR later (their current-branch auto-detection relies on tracked-branch info, not name matching) — `/merge-pr` should also be run with the PR number as an explicit argument here rather than relying on that auto-detection. When pushing QA-result commits later (step 9), push to the PR's real branch name via a refspec: `git push origin review/<headRefName>:<headRefName>`. Delete the local `review/<headRefName>` branch once you're done with this PR (switch away first, then `git branch -D review/<headRefName>`) so it doesn't accumulate across sessions.
10
16
 
11
17
  4. **Reconnect the MCP server.** The server hot-reloads on file change, but this Claude session's MCP connection stays on the old process. Tell the user to run:
12
18
  ```
@@ -26,7 +32,15 @@ CI (the "Lint and test" check `/merge-pr` already gates on) covers unit tests. I
26
32
 
27
33
  8. **Record results honestly.** For each test case actually executed, add `**Result (<date>) ✅ PASS**` or `**Result (<date>) ❌ FAIL — <what happened>**` directly below its `Checks` block. Never write a Result for a case you didn't actually invoke — if something blocks a case (fixture missing, tool errors before completing), write `**Result (<date>)** pending — <reason>` and say so plainly rather than guessing the outcome.
28
34
 
29
- 9. **Commit and push the results.** If any Result entries were added, commit just those QA doc changes onto the PR branch (`qa: record live verification for PR #<number>`) and push, so the evidence lands in history before merge. Ask before pushing, per this repo's normal commit/push confirmation rule.
35
+ 9. **Commit and push the results.** If any Result entries were added, they need to land in a real file edit. If this session runs under an isolation policy that blocks edits outside `.claude/worktrees/*` (background jobs do regardless of which branch is checked out in the main checkout), don't spin up a fresh throwaway worktree per PR (churn create, edit, commit, push, remove, repeat). Instead keep **one persistent scratch worktree** for the whole orchestrator session, e.g. `.claude/worktrees/qa-scratch`, and reuse it every time this step comes up:
36
+ ```
37
+ git worktree add .claude/worktrees/qa-scratch -b orchestrator-qa-scratch develop # first time only
38
+ ```
39
+ Its own branch name (`orchestrator-qa-scratch`) is arbitrary and never touches any PR branch, so it can never conflict with a worker's worktree. Each time this step comes up: enter it (`EnterWorktree` with `path`), `git fetch origin <headRefName> && git reset --hard origin/<headRefName>` to line it up with the PR's latest commit, make the edits, commit (`qa: record live verification for PR #<number>`), then push straight to the PR's real branch via refspec — same trick as step 3:
40
+ ```
41
+ git push origin HEAD:<headRefName>
42
+ ```
43
+ Ask before pushing, per this repo's normal commit/push confirmation rule. Leave the scratch worktree in place afterward (`ExitWorktree` with `action: "keep"`) for next time — don't remove it between PRs, only if the user asks for a cleanup pass.
30
44
 
31
45
  10. **Report.** Summarize: code review findings (if any), which test cases passed/failed/pending, any coverage gaps found in step 6, and whether the branch looks ready to merge. If everything passed, say the next step is `/merge-pr`. If something failed, say the PR needs to go back to its worker session — don't attempt the fix yourself here; this session's job is verification, not implementation.
32
46
 
@@ -32,7 +32,7 @@ Tests live in `tests/` and can be run with `uv run pytest`.
32
32
  Logic is split across `src/mcp_gee_sweet/`: `server.py` (MCP setup, tool decorator, resources), `auth.py` (lifespan, `SpreadsheetContext`), and `tools/` (domain-based layout):
33
33
 
34
34
  - `tools/sheets/data.py` — read/write cell data (`get_sheet_data`, `get_sheet_formulas`, `get_multiple_*`, `find_in_spreadsheet`, `clear_values`, `update_cells`, `batch_update_cells`, `batch_update`)
35
- - `tools/sheets/structure.py` — sheet structure (`list_sheets`, `copy_sheet`, `rename_sheet`, `create_sheet`, `delete_sheet`, `add_rows`, `add_columns`, `delete_rows`, `delete_columns`, `format_cells`, `merge_cells`, `unmerge_cells`, `freeze`, `sort_range`, `add_chart`)
35
+ - `tools/sheets/structure.py` — sheet structure (`list_sheets`, `copy_sheet`, `duplicate_sheet`, `rename_sheet`, `create_sheet`, `delete_sheet`, `add_rows`, `add_columns`, `delete_rows`, `delete_columns`, `format_cells`, `merge_cells`, `unmerge_cells`, `freeze`, `sort_range`, `add_chart`)
36
36
  - `tools/sheets/helpers.py` — A1 notation helpers (`_parse_a1_notation`, `_column_index_to_letter`, etc.)
37
37
  - `tools/drive/files.py` — file/folder operations (`create_spreadsheet`, `import_csv_to_sheet`, `list_spreadsheets`, `list_files`, `search_files`, `create_folder`, `copy_file`, `move_file`, `rename_file`, `delete_file`, `list_shared_with_me`, `list_recent_files`, `get_storage_quota`, etc.)
38
38
  - `tools/drive/sharing.py` — permissions (`share_spreadsheet`, `share_file`, `list_permissions`, `update_permission`, `remove_permission`)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-gee-sweet
3
- Version: 0.7.0.dev82
3
+ Version: 0.7.0.dev87
4
4
  Summary: MCP server for Google Workspace — Sheets, Drive, Docs, and Calendar. 60+ tools for AI clients.
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
@@ -70,7 +70,7 @@ Access logs are emitted at `INFO` level — they appear when `DEBUG_LEVEL` is `D
70
70
 
71
71
  <!-- Auto-generated by `scripts/gen_tool_docs.py` — do not edit by hand, your changes will be overwritten on the next commit. -->
72
72
 
73
- By default all 92 tools are registered. Use `ENABLED_TOOLS` (or `--include-tools` on the CLI) to restrict the server to exactly the tools you need. This reduces the AI's context window cost — each registered tool is a name the model must reason about on every call.
73
+ By default all 93 tools are registered. Use `ENABLED_TOOLS` (or `--include-tools` on the CLI) to restrict the server to exactly the tools you need. This reduces the AI's context window cost — each registered tool is a name the model must reason about on every call.
74
74
 
75
75
  ```bash
76
76
  # Environment variable
@@ -338,3 +338,17 @@ Cells with no `Table` in `children` are still filled in one bulk batch by `_buil
338
338
  - Text before, between, and after any number of nested tables in one cell renders in true source order, at any nesting depth.
339
339
  - The "one level of nesting only" and "no colspan/rowspan/col_widths inside nested tables" limitations from the original #92 design are otherwise unchanged — this rewrite only touches content ordering, not those structural features.
340
340
  - Tested with `tests/test_docs_tables.py`'s `TestFillNestedCellContent`, which drives the real algorithm against a small in-memory Docs-API simulator (`FakeDoc`/`Para`/`TableNode` in that file) that actually applies `insertText`/`insertTable` mutations and re-derives indices — chosen over hand-computed index fixtures because this much re-fetch/recursion logic is easy to get subtly wrong in a way a fixture can't catch (and did, twice, during development).
341
+
342
+ ---
343
+
344
+ ## Nested table colspan/rowspan (#109)
345
+
346
+ **Date:** 2026-07-08
347
+
348
+ Nested tables produced a correctly-sized shell but never merged cells with `colspan`/`rowspan` > 1 — `_fill_table_fully` (the per-nested-table fill helper introduced in the rewrite above) only ran the plain-cell bulk fill, with no equivalent of the outer-table merge phase (`fill_tables` phase 2).
349
+
350
+ Fix: `_fill_table_fully` now checks the nested `ast_table` for any cell with `colspan > 1 or rowspan > 1` and, if found, builds and executes `mergeTableCells` requests via the existing `_build_merge_requests` (unchanged — it already operated generically on any `doc_table`/`ast_table` pair, nested or not) before re-fetching and running the bulk fill. This exactly mirrors `fill_tables`'s own phase 1→2→3 order, just scoped to one nested table.
351
+
352
+ `col_widths` on nested tables remains unsupported — out of scope for #109, not addressed here.
353
+
354
+ Tested in `tests/test_docs_tables.py`'s `TestNestedTableMerges`, extending the same `FakeDoc` simulator with a no-op `mergeTableCells` handler (the real Docs API doesn't shift indices or delete content on merge — covered cells stay physical, just skipped by the existing phantom-cell mapping — so the simulator has nothing to mutate; the tests instead assert the merge request's shape and its position in batch order relative to the fill).
@@ -1038,6 +1038,25 @@ These test the HTML→AST→Docs API pipeline introduced in Phase 2 (#87). All u
1038
1038
 
1039
1039
  ---
1040
1040
 
1041
+ ### TC-DOC90: `colspan`/`rowspan` inside a nested table now merges correctly (issue #109) ⚠️ destructive
1042
+
1043
+ **Background:** Nested tables produced a correctly-sized shell but silently ignored `colspan`/`rowspan` on their own cells — no `mergeTableCells` request was ever emitted for them, unlike outer-table cells (TC-DOC34/36/37). Fixed by having `_fill_table_fully` run the same merge phase for a nested table's own cells that `fill_tables` already runs for the outer table.
1044
+
1045
+ **Prompt**
1046
+ > "Write this HTML to doc {DOC_ID}: `<table><tr><td><table><tr><td colspan=\"2\">Header</td></tr><tr><td>A</td><td>B</td></tr></table></td></tr></table>`"
1047
+
1048
+ **Checks**
1049
+ - Call succeeds with no API error
1050
+ - `get_doc_structure` shows the outer table (1 row × 1 col) with cell [0,0] text empty (holds the nested table, not text)
1051
+ - 🔍 Visual check: nested table renders with "Header" spanning both columns of the top row, and "A"/"B" as two separate cells in the second row — not four ungrouped cells
1052
+
1053
+ **Cleanup:** write fixture content back
1054
+
1055
+ **Result (2026-07-09) ✅ PASS**
1056
+ `write_doc_content` succeeded. `get_doc_structure` showed the outer table (1 row × 1 col) with cell [0,0] text empty. Playwright screenshot confirmed the nested table rendered with "Header" spanning both columns of the top row and "A"/"B" as two separate cells below — the merge applied correctly. (Unrelated observation: the fixture doc had leftover header/footer text visible in the render and in `get_doc_content`'s plain-text export but not in `get_doc_structure` — headers/footers aren't part of the body map that tool returns; pre-existing fixture-doc state from an earlier header/footer test, untouched by `write_doc_content`, not a regression from this PR.) Fixture content restored per cleanup step.
1057
+
1058
+ ---
1059
+
1041
1060
  ### TC-DOC52: `get_doc_theme` scans body paragraph styles
1042
1061
  **Note:** `get_doc_theme` reads explicit per-paragraph and per-run styles from the document body. It returns data for AI-generated docs (where styles are set explicitly on runs); for standard docs whose styles are fully inherited from named style defaults it returns an empty dict.
1043
1062
 
@@ -125,6 +125,94 @@ Tests marked **⚠️ destructive** rename or delete sheets — reset fixtures a
125
125
 
126
126
  ---
127
127
 
128
+ ## `duplicate_sheet`
129
+
130
+ ### TC-S51: Duplicate within the same spreadsheet, default name ⚠️ destructive
131
+
132
+ **Prompt**
133
+ > "Duplicate the Sales sheet in {SPREADSHEET_ID}"
134
+
135
+ **Checks**
136
+ - New tab appears, named by Google's default (e.g. "Copy of Sales")
137
+ - Data in the new tab matches Sales (6 rows, 4 columns)
138
+ - Original Sales sheet unchanged
139
+ - Response includes `sheetId`, `title`, `index`, `spreadsheetId`
140
+
141
+ **Result (2026-07-10) ✅ PASS**
142
+ `duplicate_sheet(spreadsheet_id, sheet="Sales")` → `{"sheetId":1766233601,"title":"Copy of Sales","index":1,...}`. `get_sheet_data` on "Copy of Sales" returned matching headers/rows. Deleted after verification.
143
+
144
+ ---
145
+
146
+ ### TC-S52: Duplicate with a custom name ⚠️ destructive
147
+
148
+ **Prompt**
149
+ > "Duplicate the Sales sheet in {SPREADSHEET_ID} and name the copy 'Sales Duplicate'"
150
+
151
+ **Checks**
152
+ - New tab is named 'Sales Duplicate', not Google's default
153
+ - `newSheetName` was passed on the same `duplicateSheet` request (single API call, no follow-up rename)
154
+
155
+ **Result (2026-07-10) ✅ PASS**
156
+ `duplicate_sheet(spreadsheet_id, sheet="Sales", new_name="Sales Duplicate")` → title `"Sales Duplicate"`, confirmed via code that `newSheetName` is set on the same request body as `sourceSheetId`. Deleted after verification.
157
+
158
+ ---
159
+
160
+ ### TC-S53: Default position lands immediately after the source tab ⚠️ destructive
161
+
162
+ **Prompt**
163
+ > "Duplicate the Sales sheet in {SPREADSHEET_ID} without specifying a position, then list all sheets"
164
+
165
+ **Checks**
166
+ - The new tab appears immediately after 'Sales' in the `list_sheets` response order
167
+ - 🔍 **Regression:** the Sheets API's own default for `duplicateSheet` places the copy at tab position 0 regardless of source position (confirmed via live QA on this tool) — the tool must compute and pass `insertSheetIndex` explicitly to land the copy after the source, matching Sheets UI's native "Duplicate" behavior
168
+
169
+ **Result (2026-07-10) ✅ PASS**
170
+ `duplicate_sheet(spreadsheet_id, sheet="Sales")` → index 1. `list_sheets` → `["Sales","Copy of Sales","Empty","Notes & Misc","BrandNew"]` — lands immediately after Sales, confirming the regression fix (`_get_sheet_index` + explicit `insertSheetIndex`). Deleted after verification.
171
+
172
+ ---
173
+
174
+ ### TC-S54: Explicit insert_index is honored ⚠️ destructive
175
+
176
+ **Prompt**
177
+ > "Duplicate the Sales sheet in {SPREADSHEET_ID}, placing the copy at tab position 0"
178
+
179
+ **Checks**
180
+ - The new tab is the first tab in the `list_sheets` response order
181
+ - Confirms an explicit `insert_index` overrides the "after source" default
182
+
183
+ **Result (2026-07-10) ✅ PASS**
184
+ `duplicate_sheet(spreadsheet_id, sheet="Sales", insert_index=0)` → index 0. `list_sheets` → `["Copy of Sales","Sales",...]` — explicit index correctly overrides the default. Deleted after verification.
185
+
186
+ ---
187
+
188
+ ### TC-S55: Source sheet not found
189
+
190
+ **Prompt**
191
+ > "Duplicate a sheet called 'DoesNotExist' in {SPREADSHEET_ID}"
192
+
193
+ **Checks**
194
+ - Returns `{"error": ...}` before calling the duplicate API
195
+ - Error references the missing sheet name
196
+
197
+ **Result (2026-07-10) ✅ PASS**
198
+ `duplicate_sheet(spreadsheet_id, sheet="DoesNotExist")` → `{"error": "Sheet 'DoesNotExist' not found"}`. No mutation, non-destructive.
199
+
200
+ ---
201
+
202
+ ### TC-S56: Cache invalidated after duplicate ⚠️ destructive
203
+
204
+ **Prompt**
205
+ > "Duplicate the Sales sheet in {SPREADSHEET_ID} as 'PostDuplicateCache', then immediately list all sheets"
206
+
207
+ **Checks**
208
+ - `list_sheets` includes 'PostDuplicateCache'
209
+ - Confirms `cache.mark_dirty(spreadsheet_id)` fires after duplicate
210
+
211
+ **Result (2026-07-10) ✅ PASS**
212
+ `duplicate_sheet(spreadsheet_id, sheet="Sales", new_name="PostDuplicateCache")` succeeded; immediate `list_sheets` (no explicit `refresh_cache`) included `"PostDuplicateCache"`, confirming `mark_dirty` fired. Deleted after verification; fixture restored to `["Sales","Empty","Notes & Misc","BrandNew"]`.
213
+
214
+ ---
215
+
128
216
  ## `rename_sheet`
129
217
 
130
218
  ### TC-S11: Rename to a new name ⚠️ destructive
@@ -95,8 +95,8 @@ No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature wo
95
95
  ### Tier 2 — Useful for structured work, plus defects surfaced since v0.8.1 _(target: [v0.9.0](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av0.9))_
96
96
 
97
97
  **Defects** _(found after v0.8.1 shipped; too late for that release, don't warrant a standalone patch)_
98
- - [ ] Bare URLs in markdown content aren't autolinked — Python-Markdown's built-in autolink only fires on `<https://...>` or `[text](url)`, not a bare URL ([#248](https://github.com/khuisman/mcp-gee-sweet/issues/248))
99
- - [ ] `zip(doc_tables, ast_tables)` in `emitter.py` silently cross-pairs tables when one is skipped (zero-row/zero-col table), misapplying fill/merge/style requests to the wrong table — surfaced during #276's review ([#277](https://github.com/khuisman/mcp-gee-sweet/issues/277))
98
+ - [x] Bare URLs in markdown content aren't autolinked — Python-Markdown's built-in autolink only fires on `<https://...>` or `[text](url)`, not a bare URL (PR #265) ([#248](https://github.com/khuisman/mcp-gee-sweet/issues/248))
99
+ - [x] `zip(doc_tables, ast_tables)` in `emitter.py` silently cross-pairs tables when one is skipped (zero-row/zero-col table), misapplying fill/merge/style requests to the wrong table — surfaced during #276's review (PR #282) ([#277](https://github.com/khuisman/mcp-gee-sweet/issues/277))
100
100
 
101
101
  **Sheets**
102
102
  - [ ] `update_borders` — border style, width, color on a range ([#122](https://github.com/khuisman/mcp-gee-sweet/issues/122)) _(freema/mcp-gsheets)_
@@ -106,11 +106,11 @@ No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature wo
106
106
  - [ ] `update_sheet_properties` — tab color, hide/show gridlines ([#126](https://github.com/khuisman/mcp-gee-sweet/issues/126)) _(freema/mcp-gsheets)_
107
107
  - [ ] `duplicate_sheet` — copy a sheet within the same spreadsheet ([#127](https://github.com/khuisman/mcp-gee-sweet/issues/127))
108
108
  - [ ] Partial (rich-text) hyperlinks in `update_cells` ([#89](https://github.com/khuisman/mcp-gee-sweet/issues/89))
109
- - [ ] `import_csv_to_sheet` — populate a spreadsheet from a local CSV file ([#187](https://github.com/khuisman/mcp-gee-sweet/issues/187))
109
+ - [x] `import_csv_to_sheet` — populate a spreadsheet from a local CSV file (PR #272) ([#187](https://github.com/khuisman/mcp-gee-sweet/issues/187))
110
110
 
111
111
  **Calendar**
112
- - [ ] `create_calendar` / `update_calendar` / `delete_calendar` — calendar lifecycle ([#156](https://github.com/khuisman/mcp-gee-sweet/issues/156))
113
- - [ ] `add_calendar_to_list` / `remove_calendar_from_list` — subscribe/unsubscribe ([#157](https://github.com/khuisman/mcp-gee-sweet/issues/157))
112
+ - [x] `create_calendar` / `update_calendar` / `delete_calendar` — calendar lifecycle (PR #266) ([#156](https://github.com/khuisman/mcp-gee-sweet/issues/156))
113
+ - [x] `add_calendar_to_list` / `remove_calendar_from_list` — subscribe/unsubscribe (PR #269) ([#157](https://github.com/khuisman/mcp-gee-sweet/issues/157))
114
114
  - [ ] Calendar ACL — share a calendar with users or groups ([#158](https://github.com/khuisman/mcp-gee-sweet/issues/158))
115
115
  - [ ] `list_all_events` — query all subscribed calendars in parallel _(decision needed — also tracked in Decisions Needed)_ ([#194](https://github.com/khuisman/mcp-gee-sweet/issues/194))
116
116
 
@@ -120,8 +120,8 @@ No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature wo
120
120
  - [ ] Comments API — list, add, resolve doc comments ([#151](https://github.com/khuisman/mcp-gee-sweet/issues/151))
121
121
  - [ ] `create_named_range` / `create_bookmark` — anchor points for internal links ([#152](https://github.com/khuisman/mcp-gee-sweet/issues/152))
122
122
  - [ ] Rowspan emitter — AST `Cell.rowspan` field exists but emitter does not yet merge rows ([#195](https://github.com/khuisman/mcp-gee-sweet/issues/195))
123
- - [ ] Warn/error on mixed text + nested table in same cell ([#108](https://github.com/khuisman/mcp-gee-sweet/issues/108))
124
- - [ ] colspan/rowspan inside nested tables ([#109](https://github.com/khuisman/mcp-gee-sweet/issues/109))
123
+ - [x] Warn/error on mixed text + nested table in same cell — shipped as the fuller `Cell.children: list[Run | Table]` ordered model, also fixing trailing-text-after-nested-table ordering ([#275](https://github.com/khuisman/mcp-gee-sweet/issues/275)) in the same PR (PR #276) ([#108](https://github.com/khuisman/mcp-gee-sweet/issues/108))
124
+ - [x] colspan/rowspan inside nested tables (PR #287) ([#109](https://github.com/khuisman/mcp-gee-sweet/issues/109))
125
125
  - [ ] `find_in_doc` — search doc text and return match locations, parallel to `find_in_spreadsheet`; removes the manual index math currently needed to retrofit links (or any style) onto text already in a doc, raised while fixing #248 ([#262](https://github.com/khuisman/mcp-gee-sweet/issues/262))
126
126
 
127
127
  **Drive**
@@ -135,7 +135,8 @@ No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature wo
135
135
  - [x] Drive Activity API — file change history ([#72](https://github.com/khuisman/mcp-gee-sweet/issues/72))
136
136
 
137
137
  **Infrastructure**
138
- - [ ] Cache reliability & configurability — runtime-configurable TTL, smarter invalidation for shared files ([#99](https://github.com/khuisman/mcp-gee-sweet/issues/99)); harden concurrent-session access — fail-open on cache read/write errors, `busy_timeout` ([#234](https://github.com/khuisman/mcp-gee-sweet/issues/234))
138
+ - [x] Harden concurrent-session access — fail-open on cache read/write errors, `busy_timeout` (PR #280) ([#234](https://github.com/khuisman/mcp-gee-sweet/issues/234))
139
+ - [x] Cache reliability & configurability — runtime-configurable TTL, smarter invalidation for shared files (PR #284) ([#99](https://github.com/khuisman/mcp-gee-sweet/issues/99))
139
140
  - [ ] Async tool execution — `asyncio.gather()` for parallel Google API calls; establishes the project's parallel-call pattern, which [#194](https://github.com/khuisman/mcp-gee-sweet/issues/194) should reuse rather than introducing its own `ThreadPoolExecutor` ([#183](https://github.com/khuisman/mcp-gee-sweet/issues/183))
140
141
 
141
142
  **Documentation**
@@ -2,7 +2,7 @@
2
2
 
3
3
  <!-- Auto-generated by `scripts/gen_tool_docs.py` — do not edit by hand, your changes will be overwritten on the next commit. -->
4
4
 
5
- 92 tools. All tool names are lowercase with underscores — use these exact strings with `ENABLED_TOOLS` or `--include-tools`.
5
+ 93 tools. All tool names are lowercase with underscores — use these exact strings with `ENABLED_TOOLS` or `--include-tools`.
6
6
 
7
7
  ## Sheets — data
8
8
 
@@ -26,6 +26,7 @@
26
26
  |---|---|---|
27
27
  | `list_sheets` | List all sheets in a Google Spreadsheet. | `spreadsheet_id` |
28
28
  | `copy_sheet` | Copy a sheet from one spreadsheet to another. | `src_spreadsheet`, `src_sheet`, `dst_spreadsheet`, `dst_sheet` |
29
+ | `duplicate_sheet` | Duplicate a sheet tab within the same spreadsheet. | `spreadsheet_id`, `sheet`, `new_name?`, `insert_index?` |
29
30
  | `rename_sheet` | Rename a sheet in a Google Spreadsheet. | `spreadsheet`, `sheet`, `new_name` |
30
31
  | `create_sheet` | Create a new sheet tab in an existing Google Spreadsheet. | `spreadsheet_id`, `title` |
31
32
  | `add_rows` | Add rows to a sheet in a Google Spreadsheet. | `spreadsheet_id`, `sheet`, `count`, `start_row?` |
@@ -199,24 +199,20 @@ def fill_tables(docs_service, doc_id: str, tables: list[Table]) -> None:
199
199
  3. Fill plain cells (no nested table) — insertText + updateTextStyle, high→low
200
200
  4. Fill cells with a nested table, segment by segment (see
201
201
  _fill_nested_cell_content) — text and tables in true source order,
202
- recursing into any depth of further nesting
202
+ recursing into any depth of further nesting. Each nested table's own
203
+ merges are handled by _fill_table_fully, mirroring phase 2 above (#109).
203
204
  5. Emit updateTableColumnProperties for tables with col_widths
204
205
  6. If any cell has Phase 3 style fields: emit updateTableCellStyle
205
206
 
206
- Nested table limitations: no colspan/rowspan or col_widths inside nested
207
- tables. Text sharing a cell with one or more nested tables (#108, #275) is
208
- fully supported each run of text and each nested table renders in the
209
- same order it appeared in the source, at any nesting depth.
207
+ Nested table limitations: no col_widths inside nested tables. colspan/
208
+ rowspan (#109) and text sharing a cell with one or more nested tables
209
+ (#108, #275) are both fully supported, at any nesting depth each run of
210
+ text, merge, and nested table renders in the same order and shape it had
211
+ in the source.
210
212
  """
211
213
  if not tables:
212
214
  return
213
215
 
214
- has_merges = any(
215
- cell.colspan > 1 or cell.rowspan > 1
216
- for table in tables
217
- for row in table.rows
218
- for cell in row.cells
219
- )
220
216
  has_nested = any(
221
217
  any(isinstance(child, Table) for child in cell.children)
222
218
  for table in tables
@@ -238,14 +234,7 @@ def fill_tables(docs_service, doc_id: str, tables: list[Table]) -> None:
238
234
  doc_tables = _top_level_tables(live_doc)
239
235
 
240
236
  # Step 2: outer merges
241
- if has_merges:
242
- merge_requests = _build_merge_requests(doc_tables, tables)
243
- if merge_requests:
244
- docs_service.documents().batchUpdate(
245
- documentId=doc_id, body={"requests": merge_requests}
246
- ).execute()
247
- live_doc = docs_service.documents().get(documentId=doc_id).execute()
248
- doc_tables = _top_level_tables(live_doc)
237
+ doc_tables = _apply_merges(docs_service, doc_id, doc_tables, tables, _top_level_tables)
249
238
 
250
239
  # Step 3: fill plain cells (no nested table) in one bulk batch
251
240
  fill_requests = _build_fill_requests(doc_tables, tables)
@@ -552,12 +541,13 @@ def _fill_children_recursive(
552
541
 
553
542
 
554
543
  def _fill_table_fully(docs_service, doc_id: str, resolve, ast_table: Table) -> None:
555
- """Fill one (typically just-inserted) table's cells: plain cells in bulk,
556
- cells with a further nested table via the recursive round algorithm.
544
+ """Fill one (typically just-inserted) table's cells: merges first, then
545
+ plain cells in bulk, then cells with a further nested table via the
546
+ recursive round algorithm.
557
547
 
558
548
  Every nested table needs this — not just ones with further nesting — since
559
- a nested table's own plain-text cells are never touched by the top-level
560
- _build_fill_requests bulk pass (that only sees the outer tables list).
549
+ a nested table's own plain-text cells (and merges) are never touched by the
550
+ top-level fill_tables passes (those only see the outer tables list).
561
551
  """
562
552
  live_doc = docs_service.documents().get(documentId=doc_id).execute()
563
553
  doc_tables = resolve(live_doc)
@@ -569,6 +559,8 @@ def _fill_table_fully(docs_service, doc_id: str, resolve, ast_table: Table) -> N
569
559
  )
570
560
  return
571
561
 
562
+ doc_tables = _apply_merges(docs_service, doc_id, doc_tables, [ast_table], resolve)
563
+
572
564
  fill_requests = _build_fill_requests(doc_tables, [ast_table])
573
565
  if fill_requests:
574
566
  docs_service.documents().batchUpdate(
@@ -582,6 +574,48 @@ def _fill_table_fully(docs_service, doc_id: str, resolve, ast_table: Table) -> N
582
574
  _fill_children_recursive(docs_service, doc_id, resolve, [ast_table], _doc_tables=doc_tables)
583
575
 
584
576
 
577
+ def _apply_merges(
578
+ docs_service, doc_id: str, doc_tables: list[dict], ast_tables: list[Table], resolve
579
+ ) -> list[dict]:
580
+ """Emit mergeTableCells for any ast_tables cell with colspan/rowspan > 1,
581
+ re-fetch, and return the updated doc_tables — or doc_tables unchanged if
582
+ nothing needed merging.
583
+
584
+ mergeTableCells doesn't shift character indices or delete content (covered
585
+ cells remain physical entries in the doc tree), so doc_tables — already
586
+ successfully resolved by the caller — is expected to resolve again right
587
+ after. If it doesn't, the live doc no longer matches the model used to
588
+ build the merge requests just executed; silently returning here would mean
589
+ every already-merged table renders with zero content and no operator-
590
+ visible signal, so raise instead of skipping the fill.
591
+ """
592
+ has_merges = any(
593
+ cell.colspan > 1 or cell.rowspan > 1
594
+ for table in ast_tables
595
+ for row in table.rows
596
+ for cell in row.cells
597
+ )
598
+ if not has_merges:
599
+ return doc_tables
600
+
601
+ merge_requests = _build_merge_requests(doc_tables, ast_tables)
602
+ if not merge_requests:
603
+ return doc_tables
604
+
605
+ docs_service.documents().batchUpdate(
606
+ documentId=doc_id, body={"requests": merge_requests}
607
+ ).execute()
608
+ live_doc = docs_service.documents().get(documentId=doc_id).execute()
609
+ updated = resolve(live_doc)
610
+ if len(updated) < len(ast_tables) or any(t is None for t in updated):
611
+ raise RuntimeError(
612
+ f"_apply_merges: table(s) vanished from doc {doc_id} immediately after "
613
+ "mergeTableCells, which does not itself shift indices or delete content — "
614
+ "refusing to silently skip the fill and leave the merged table empty."
615
+ )
616
+ return updated
617
+
618
+
585
619
  def _build_phantom_set(ast_table: Table) -> set[tuple[int, int]]:
586
620
  """Return (row, col) logical positions covered by rowspan from a cell in an earlier row."""
587
621
  phantom: set[tuple[int, int]] = set()
@@ -106,3 +106,19 @@ def _get_sheet_id(
106
106
  return None
107
107
  except Exception:
108
108
  return None
109
+
110
+
111
+ def _get_sheet_index(sheets_service: Any, spreadsheet_id: str, sheet_id: int) -> int | None:
112
+ """Return the current 0-based tab position of sheet_id, or None if not found."""
113
+ try:
114
+ spreadsheet = (
115
+ sheets_service.spreadsheets()
116
+ .get(spreadsheetId=spreadsheet_id, fields="sheets.properties(sheetId,index)")
117
+ .execute()
118
+ )
119
+ for sheet in spreadsheet.get("sheets", []):
120
+ if sheet["properties"]["sheetId"] == sheet_id:
121
+ return sheet["properties"]["index"]
122
+ return None
123
+ except Exception:
124
+ return None
@@ -4,7 +4,7 @@ from mcp.server.fastmcp import Context
4
4
  from mcp.types import ToolAnnotations
5
5
 
6
6
  from ...cache import fetch_sheets
7
- from .helpers import _get_sheet_id, _parse_a1_notation
7
+ from .helpers import _get_sheet_id, _get_sheet_index, _parse_a1_notation
8
8
 
9
9
  _VALID_CHART_TYPES = ["COLUMN", "BAR", "LINE", "AREA", "PIE", "SCATTER", "COMBO", "HISTOGRAM"]
10
10
 
@@ -124,6 +124,66 @@ def register(tool):
124
124
  lc.cache.mark_dirty(dst_spreadsheet)
125
125
  return result
126
126
 
127
+ @tool(annotations=ToolAnnotations(title="Duplicate Sheet", destructiveHint=True))
128
+ def duplicate_sheet(
129
+ spreadsheet_id: str,
130
+ sheet: str,
131
+ new_name: str | None = None,
132
+ insert_index: int | None = None,
133
+ ctx: Context = None,
134
+ ) -> dict[str, Any]:
135
+ """
136
+ Duplicate a sheet tab within the same spreadsheet.
137
+
138
+ Distinct from copy_sheet, which copies a sheet across spreadsheets.
139
+
140
+ Args:
141
+ spreadsheet_id: The ID of the spreadsheet
142
+ sheet: The name of the sheet to duplicate
143
+ new_name: Optional title for the new sheet. Defaults to Google's
144
+ auto-generated name (e.g. "Copy of Sheet1") if omitted.
145
+ insert_index: Optional 0-based tab position for the new sheet.
146
+ Defaults to immediately after the source sheet if omitted.
147
+
148
+ Returns:
149
+ Information about the newly created sheet
150
+ """
151
+ lc = ctx.request_context.lifespan_context
152
+ sheets_service = lc.sheets_service
153
+
154
+ sheet_id = _get_sheet_id(sheets_service, spreadsheet_id, sheet, lc.cache, lc.drive_service)
155
+ if sheet_id is None:
156
+ return {"error": f"Sheet '{sheet}' not found"}
157
+
158
+ duplicate_request: dict[str, Any] = {"sourceSheetId": sheet_id}
159
+ if insert_index is not None:
160
+ duplicate_request["insertSheetIndex"] = insert_index
161
+ else:
162
+ source_index = _get_sheet_index(sheets_service, spreadsheet_id, sheet_id)
163
+ if source_index is not None:
164
+ duplicate_request["insertSheetIndex"] = source_index + 1
165
+ if new_name is not None:
166
+ duplicate_request["newSheetName"] = new_name
167
+
168
+ result = (
169
+ sheets_service.spreadsheets()
170
+ .batchUpdate(
171
+ spreadsheetId=spreadsheet_id,
172
+ body={"requests": [{"duplicateSheet": duplicate_request}]},
173
+ )
174
+ .execute()
175
+ )
176
+
177
+ lc.cache.mark_dirty(spreadsheet_id)
178
+
179
+ new_sheet_props = result["replies"][0]["duplicateSheet"]["properties"]
180
+ return {
181
+ "sheetId": new_sheet_props["sheetId"],
182
+ "title": new_sheet_props["title"],
183
+ "index": new_sheet_props.get("index"),
184
+ "spreadsheetId": spreadsheet_id,
185
+ }
186
+
127
187
  @tool(annotations=ToolAnnotations(title="Rename Sheet", destructiveHint=True))
128
188
  def rename_sheet(
129
189
  spreadsheet: str, sheet: str, new_name: str, ctx: Context = None