mcp-gee-sweet 0.8.2.dev259__tar.gz → 0.8.2.dev263__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 (185) hide show
  1. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/CLAUDE.md +1 -1
  2. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/PKG-INFO +1 -1
  3. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/configuration.md +1 -1
  4. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/tests/docs.md +76 -0
  5. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/roadmap.md +2 -2
  6. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/tools.md +3 -1
  7. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/content.py +19 -2
  8. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/style.py +314 -0
  9. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/drive/test_transfer.py +5 -0
  10. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_docs_content.py +45 -0
  11. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_docs_style.py +434 -0
  12. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/cleanup-worktrees.md +0 -0
  13. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/merge-pr.md +0 -0
  14. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/orchestrator.md +0 -0
  15. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/prep-for-pr.md +0 -0
  16. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/qa-kickoff.md +0 -0
  17. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/release.md +0 -0
  18. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/retro.md +0 -0
  19. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/start-worktree.md +0 -0
  20. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/team-member.md +0 -0
  21. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/commands/verify-pr.md +0 -0
  22. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/settings.json +0 -0
  23. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/team-roles/amy.md +0 -0
  24. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/team-roles/aziz.md +0 -0
  25. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/team-roles/bob.md +0 -0
  26. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/team-roles/dev.md +0 -0
  27. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/team-roles/joy.md +0 -0
  28. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/team-roles/kai.md +0 -0
  29. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.claude/team-roles/qa.md +0 -0
  30. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.dockerignore +0 -0
  31. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  32. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  33. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  34. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.github/dependabot.yml +0 -0
  35. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.github/workflows/ci.yml +0 -0
  36. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.github/workflows/docs.yml +0 -0
  37. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.github/workflows/publish-dev.yml +0 -0
  38. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.github/workflows/release.yml +0 -0
  39. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.gitignore +0 -0
  40. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.pre-commit-config.yaml +0 -0
  41. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/.python-version +0 -0
  42. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/CHANGELOG.md +0 -0
  43. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/CODE_OF_CONDUCT.md +0 -0
  44. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/CONTRIBUTING.md +0 -0
  45. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/Dockerfile +0 -0
  46. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/LICENSE +0 -0
  47. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/Makefile +0 -0
  48. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/README.md +0 -0
  49. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/SECURITY.md +0 -0
  50. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docker-compose.yml +0 -0
  51. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/auth.md +0 -0
  52. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/client-setup.md +0 -0
  53. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-async-tool-execution.md +0 -0
  54. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-auth-status-limitation-categories.md +0 -0
  55. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-cache-invalidation.md +0 -0
  56. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-chart-theming.md +0 -0
  57. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-composite-tools.md +0 -0
  58. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-dev-team.md +0 -0
  59. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-docs-formatting.md +0 -0
  60. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-fork.md +0 -0
  61. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-grid-data-size-cap.md +0 -0
  62. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-prompt-qa-role.md +0 -0
  63. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-publishing.md +0 -0
  64. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-release-docs-roles.md +0 -0
  65. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-repositioning.md +0 -0
  66. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-response-size-cap-generalization.md +0 -0
  67. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-sync-folder-recursion.md +0 -0
  68. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/decision-testing.md +0 -0
  69. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/decisions/index.md +0 -0
  70. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/design/borderless-table-columns.md +0 -0
  71. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/design/docs-ast-pipeline.md +0 -0
  72. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/design/effectiveformat-spike.md +0 -0
  73. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/design/heading-anchor-resolution.md +0 -0
  74. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/design/image-conversion.md +0 -0
  75. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/design/index.md +0 -0
  76. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/design/markdown-support.md +0 -0
  77. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/design/phase3-theme-system.md +0 -0
  78. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/design.md +0 -0
  79. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/index.md +0 -0
  80. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/known-limitations.md +0 -0
  81. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/notes-read.md +0 -0
  82. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/README.md +0 -0
  83. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/qa-fixture-pixel.png +0 -0
  84. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  85. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  86. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-d213-dollar-escape.md +0 -0
  87. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  88. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc102-image-conversion.html +0 -0
  89. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc103-image-conversion.md +0 -0
  90. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc104-nested-lists.html +0 -0
  91. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc105-nested-lists-gfm.md +0 -0
  92. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc106-nested-lists-4space.md +0 -0
  93. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc135-paragraph-boundary.md +0 -0
  94. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc136-interrupted-block-boundary.md +0 -0
  95. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc137-inline-hr-no-block-ancestor.html +0 -0
  96. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc138-nbsp-spacer-paragraph.md +0 -0
  97. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc141-mismatched-list-tags.html +0 -0
  98. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc142-heading-anchors.md +0 -0
  99. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc144-anchor-in-table-cell.md +0 -0
  100. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures/tc-doc145-anchor-on-first-element.md +0 -0
  101. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/fixtures.template.md +0 -0
  102. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/operations.yaml +0 -0
  103. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/playwright_oauth.md +0 -0
  104. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/.gitkeep +0 -0
  105. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-06-02.md +0 -0
  106. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-06-14.md +0 -0
  107. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-06-17.md +0 -0
  108. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-06-18.md +0 -0
  109. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
  110. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
  111. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-06-28.md +0 -0
  112. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-07-04-docs.md +0 -0
  113. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-07-04-drive-smoke.md +0 -0
  114. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-07-04-infra.md +0 -0
  115. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-07-04-sheets_read.md +0 -0
  116. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/results/2026-07-04.md +0 -0
  117. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/retro-v0.8.0.md +0 -0
  118. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/run.md +0 -0
  119. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/runs/README.md +0 -0
  120. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/runs/v0.8.0.md +0 -0
  121. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/runs/v0.8.1.md +0 -0
  122. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/setup.md +0 -0
  123. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/tests/calendar.md +0 -0
  124. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/tests/drive.md +0 -0
  125. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/tests/infra.md +0 -0
  126. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/tests/sheets_charts.md +0 -0
  127. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/tests/sheets_mgmt.md +0 -0
  128. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/tests/sheets_read.md +0 -0
  129. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/qa/tests/sheets_write.md +0 -0
  130. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/docs/style-guide.md +0 -0
  131. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/mkdocs.yml +0 -0
  132. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/pyproject.toml +0 -0
  133. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/pyrightconfig.json +0 -0
  134. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/scripts/gen_tool_docs.py +0 -0
  135. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/scripts/oauth_setup.py +0 -0
  136. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/scripts/setup_team.sh +0 -0
  137. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/.env.template +0 -0
  138. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/__init__.py +0 -0
  139. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/auth.py +0 -0
  140. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/cache.py +0 -0
  141. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/http_transport.py +0 -0
  142. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/server.py +0 -0
  143. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  144. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/cache.py +0 -0
  145. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/calendar.py +0 -0
  146. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
  147. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/anchors.py +0 -0
  148. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/ast.py +0 -0
  149. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/comments.py +0 -0
  150. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/emitter.py +0 -0
  151. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/html_parser.py +0 -0
  152. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/indices.py +0 -0
  153. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
  154. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
  155. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  156. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
  157. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
  158. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  159. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/drive/transfer.py +0 -0
  160. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/response_limits.py +0 -0
  161. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  162. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
  163. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
  164. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
  165. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/__init__.py +0 -0
  166. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/drive/__init__.py +0 -0
  167. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/drive/test_activity.py +0 -0
  168. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/drive/test_files.py +0 -0
  169. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/drive/test_sharing.py +0 -0
  170. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/sheets/__init__.py +0 -0
  171. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/sheets/test_data.py +0 -0
  172. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/sheets/test_helpers.py +0 -0
  173. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/sheets/test_structure.py +0 -0
  174. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_auth.py +0 -0
  175. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_cache.py +0 -0
  176. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_calendar.py +0 -0
  177. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_docs_anchors.py +0 -0
  178. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_docs_comments.py +0 -0
  179. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_docs_core.py +0 -0
  180. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_docs_layout.py +0 -0
  181. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_docs_tables.py +0 -0
  182. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_gen_tool_docs.py +0 -0
  183. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_response_limits.py +0 -0
  184. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/tests/test_server.py +0 -0
  185. {mcp_gee_sweet-0.8.2.dev259 → mcp_gee_sweet-0.8.2.dev263}/uv.lock +0 -0
@@ -42,7 +42,7 @@ Logic is split across `src/mcp_gee_sweet/`: `server.py` (MCP setup, tool decorat
42
42
  - `docs/content.py` — helpers (`_html_to_text`, `_md_to_html`, `_to_doc_requests`, `_html_to_doc_requests`, `_collect_doc_paragraphs`) and tools (`create_doc`, `get_doc_content`, `write_doc_content`, `create_doc_from_file`, `insert_doc_text`, `delete_doc_range`, `get_doc_structure`, `find_in_doc`, `insert_inline_image`, `insert_page_break`, `insert_softbreak_paragraph`, `insert_local_images`, `create_named_range`, `create_bookmark`). The Docs API has no bookmark-creation endpoint — `create_bookmark` is a thin wrapper over `createNamedRange` spanning a single character; neither tool's output is a link target (only UI-created bookmarks/headings are). `find_in_doc` searches paragraph text (including table cells) and returns `start_index`/`end_index` offsets in UTF-16 code units usable directly with `style_doc_range` — `_collect_doc_paragraphs` is a generator (so a caller can stop early once `max_results` is hit) that walks the Docs API body, trusting each `ParagraphElement`'s own `startIndex` when present and otherwise carrying the running offset forward rather than dropping that element, since the API doesn't always populate it (seen on a document's first element). `insert_softbreak_paragraph` joins multiple lines with a literal `"\v"` (vertical tab) character in a single `insertText` call — confirmed live that the Docs API stores this as one paragraph element rather than splitting it, matching the Docs UI's own Shift+Enter soft-break convention — and sets `namedStyleType` explicitly over the inserted span rather than leaving it to whatever the insertion point would otherwise inherit. `insert_local_images` uploads local image files and swaps each into the doc at a plain-text marker in one call; confirmed live that `insertInlineImage` with a `drive_file_id` fails with "There was a problem retrieving the image" unless the file is first shared `anyone`/`reader` — the Docs backend fetches the image as an anonymous HTTP request, so being accessible to the authenticated user alone is not enough (this also applies to the existing `insert_inline_image` tool's `drive_file_id` path, not just the new one; its docstring now says so). `get_doc_structure` now also surfaces each paragraph's `headingId` (`paragraphStyle.headingId`) — confirmed live present on the default single-tab `documents.get()` response for both `TITLE`/`SUBTITLE` and `HEADING_1`..`6` paragraphs, no `includeTabsContent` needed. `create_doc`/`create_doc_from_file`/`write_doc_content` now run an automatic second pass (`_resolve_heading_anchors`, gated by the cheap `_has_pending_anchor_links` check so content with no `#`-links skips the extra API round trip entirely) after any conversion that left a GitHub/GitLab-style `#slug` heading-anchor link behind (issue #409): it resolves the anchor against the doc's own real headings via `anchors.resolve_heading_anchor` and rewrites it to a working `.../edit?tab=t.0#heading=<headingId>` jump link (an ordinary URL is sufficient — no `Link.headingId` API field needed, confirmed live), or strips the link (keeping the text plain) when no heading matches with reasonable confidence — a link silently pointing at the wrong section is worse than a plain, unlinked mention. This was a deliberate product decision to resolve anchors automatically rather than only exposing `headingId` and leaving matching to the calling agent — see `docs/design/heading-anchor-resolution.md` for the full rationale. PR #453's own QA round (`/code-review high` + live testing against a fixture with no table and no anchor on the doc's first element) caught two correctness bugs the first version's hand-rolled body walk had: it only inspected top-level `paragraph` elements (a heading or anchor link living inside a table cell was invisible to both `_has_pending_anchor_links` and `_resolve_heading_anchors`), and it required a run's own `pe.get("startIndex")`/`endIndex` to both be non-`None` (silently dropping an anchor that is, or is part of, the document's first body element — the Docs API omits `startIndex` there, the same quirk `_collect_doc_paragraphs` already handles). Fixed by `_walk_headings_and_anchor_runs`, a dedicated recursive walker mirroring `_collect_doc_paragraphs`'s own two correctness properties (table-cell recursion; carrying the running offset forward via character-by-character advance rather than trusting a run's own possibly-inconsistent `endIndex`) rather than reusing `_collect_doc_paragraphs` itself, since that generator's yielded shape (text + char-indices only) doesn't carry per-run link URLs or heading-paragraph style info and is used elsewhere (`find_in_doc`) in a way not worth risking a signature change for. `_has_pending_anchor_links` gained a second parameter (`tables: list[Table]`, walked via `_table_has_pending_anchor_link`, recursing into nested tables) since `fill_tables()` builds and executes table-cell requests separately from `content_requests`, invisible to the original single-argument guard. `create_doc`/`create_doc_from_file`/`write_doc_content` now support inline images in markdown (`![alt](src)`) and HTML (`<img>`) conversion (issue #333) via a shared `_apply_doc_content` helper: `src` may be a local filesystem path (uploaded to Drive), a `drive:<file_id>` reference to an already-uploaded file, or a public `http(s)://` URL (used directly) — resolved by `_resolve_image_source`, sharing local-path/`drive:` sources `anyone:reader` the same way `insert_local_images` already does, then (per explicit product decision, and now also `insert_local_images`'s own default — a behavior change from its original #332 shipped version) revoking that temporary share once the image is embedded, unless `revoke_sharing=False`. The Docs API rejects an entire `batchUpdate` atomically if any one `insertInlineImage` request in it can't be fetched (confirmed live via TC-DOC102's own deliberately-unreachable-URL case) — `_apply_doc_content` retries with the failing request stripped (its index is parsed directly out of Google's own error message) rather than losing every other request in the same call; see `docs/design/image-conversion.md` for the full design, including why `Image` is a zero-width AST node (not a marker) and the deliberate table-cell-image gap.
43
43
  - `docs/anchors.py` — `resolve_heading_anchor`, the pure slug-matching logic behind the anchor-resolution pass above. Tries GitHub's non-collapsing hyphen convention (hand-rolled — no known Python package reproduces it) and GitLab's collapsing convention (delegated to `python-markdown`'s own `toc` extension `slugify`, already a project dependency, confirmed live to collapse hyphens the same way) against the document's full heading list, with GitHub/GitLab-style numeric `-1`/`-2` duplicate-heading disambiguation applied before matching; falls back to normalized word-token comparison for a slug that doesn't exactly match either scheme. Deliberately does not try to detect which generator produced a given anchor up front — see the design doc for why. Has an inline `TODO(#409 follow-up)` flagging the cascading try-each-scheme approach as a first pass, not the most precise/efficient possible mechanism. The same QA round (PR #453) found the fuzzy fallback could silently resolve to the *wrong* heading: it unconditionally stripped a trailing `-<digits>` from the anchor before comparing, even when those digits were genuine heading wording rather than a dedup suffix, and its match loop returned the *first* heading with matching normalized tokens with no regard for which duplicate a stripped suffix actually pointed at — directly contradicting the feature's own "a wrong link is worse than no link" design goal. Fixed via `_fuzzy_match`/`_match_tokens_at_occurrence`: the anchor's literal tokens (with any trailing digits intact) are tried first at occurrence 0 — safe, since GitHub/GitLab give a duplicate heading's *first* occurrence the bare/unsuffixed slug, so a suffix-free anchor naming a duplicated heading still resolves correctly without ever needing to strip anything — and only if that fails, and the anchor ends in `-<digits>`, is the suffix stripped and its number used as the required occurrence index among headings sharing the same normalized tokens; a requested occurrence that doesn't exist (e.g. a claimed 3rd duplicate when only one heading matches) returns `None` rather than guessing the nearest candidate.
44
44
  - `docs/tables.py` — tools (`insert_doc_table`, `insert_table_row`, `delete_table_row`, `insert_table_column`, `delete_table_column`, `merge_table_cells`)
45
- - `docs/style.py` — helpers (`_NAMED_STYLE_TYPES`, `_read_body_styles`, `_read_named_styles`, `_build_named_style_requests`, `_text_style_and_fields`) and tools (`style_doc_range`, `style_doc_table_cells`, `get_doc_theme`, `get_doc_named_styles`, `apply_theme`). `_text_style_and_fields` (shared by `style_doc_range` and `content.py`'s `insert_softbreak_paragraph`) used to clear a hyperlink by setting `textStyle.link = {}` — the Docs API rejects an empty `Link` object outright ("must include at least one type"), confirmed live, so `link_url: null` always failed instead of clearing (#408). Fixed by omitting the `link` key from `textStyle` entirely while still naming `"link"` in the field mask, the correct way to reset a nested message field to its default (no link) per Docs API `fields`-mask semantics — confirmed live this actually clears an existing link. Both call sites' own `if text_style: requests.append(...)` guard had to change to `if fields:` too: a link-clear-only call now legitimately produces an empty `text_style` dict with a non-empty `fields` list (`["link"]`), and the old guard would have silently skipped sending the request at all.
45
+ - `docs/style.py` — helpers (`_NAMED_STYLE_TYPES`, `_read_body_styles`, `_read_named_styles`, `_build_named_style_requests`, `_text_style_and_fields`) and tools (`style_doc_range`, `style_doc_table_cells`, `create_paragraph_bullets`, `delete_paragraph_bullets`, `get_doc_theme`, `get_doc_named_styles`, `apply_theme`). `create_paragraph_bullets`/`delete_paragraph_bullets` (#334) wrap the Docs API's `createParagraphBullets`/`deleteParagraphBullets` requests, previously only used internally by `emitter.py`'s markdown-to-Doc conversion, as caller-facing tools for fixing list membership/nesting on paragraphs already in a doc — `get_doc_structure` had no way to even detect a flattened nested list (the AST emitter's own conversion bug from the same issue), and there was no way to fix one afterward short of delete-and-retype as plain text. `createParagraphBullets` has no `nestingLevel` field of its own — same constraint the emitter already works around (see that file's own long comment) — the API infers a paragraph's depth purely from its own leading tab characters at call time, consuming them once applied; `create_paragraph_bullets` reproduces that mechanism per range (`insertText`-ing `nesting_level` tab characters before the range, then extending the range's `endIndex` to cover them) rather than exposing a fake `nestingLevel` parameter that doesn't correspond to any real request field. `get_doc_structure` (`content.py`) now also surfaces each paragraph's `bullet` field (`{listId, nestingLevel}` or `null`), normalizing a top-level item's absent `nestingLevel` to `0` so every list paragraph reports a level — this is what makes the flattened-list defect (#334's original report) detectable at all, and is the read half `create_paragraph_bullets`/`delete_paragraph_bullets` fix on the write side. PR #524's QA round found `create_paragraph_bullets`'s first version didn't work at all on its own flagship use case (nesting paragraphs already in an existing list, #334's original repro): live-verified two compounding causes — `createParagraphBullets` is a no-op on a paragraph that's already part of a list (doesn't change its level, doesn't consume the signaling tab, which is left behind as literal text), and even after a `deleteParagraphBullets`+recreate, the API infers a paragraph's depth only relative to whatever else is included in that *same* call — a paragraph adjacent to an untouched, already-listed neighbor gets silently pulled back to that neighbor's level regardless of its own tabs (the same class of bug PR #432 fixed in `emitter.py`, confirmed live to also apply here). Fixed by always `deleteParagraphBullets`-ing a target paragraph first (harmless even if it wasn't previously listed), and — for a paragraph that's already part of a list — expanding the affected span to include its full contiguous run of same-`listId` neighbors (preserving each neighbor's own current `nestingLevel` unchanged) before issuing one `createParagraphBullets` call per contiguous same-preset run, mirroring `emitter.py`'s own established grouping pattern. Confirmed live against the real API for both the multi-paragraph-range case (TC-DOC156) and an isolated single-paragraph target whose only same-list neighbor sits outside the requested range. A second QA round on the same PR caught a follow-on regression from that fix, also live-verified (Playwright): merging context/neighbor paragraphs into the run and applying one `bulletPreset` to the whole call meant an already-numbered list, repaired without the caller re-passing `bullet_preset="NUMBERED_DECIMAL_ALPHA_ROMAN"` explicitly, silently converted its untouched siblings to plain bullets — the tool's own hardcoded default (`BULLET_DISC_CIRCLE_SQUARE`) always won. Fixed by reading the document's own `lists` map (`document["lists"][listId]["listProperties"]["nestingLevels"][level]`) to detect an already-listed paragraph's real style before choosing a run's preset — `glyphType` present means numbered, `glyphSymbol` present means bulleted, confirmed live for both — and preferring that inferred style over the hardcoded default whenever the caller didn't pass `bullet_preset` explicitly on any paragraph in the run; an explicit caller-supplied preset still always wins. Since context paragraphs no longer carry a fixed preset up front (it's resolved once per formed run, after grouping), a preset mismatch only forces a run split when it's between two paragraphs the caller *explicitly* gave conflicting presets to and they're immediately adjacent with no non-explicit paragraph bridging them; a conflict bridged by a shared context paragraph (both explicit ends expand into the same run via a same-`listId` neighbor between them) is instead reported as an `{"error": ...}` rather than silently picking one side. `_text_style_and_fields` (shared by `style_doc_range` and `content.py`'s `insert_softbreak_paragraph`) used to clear a hyperlink by setting `textStyle.link = {}` — the Docs API rejects an empty `Link` object outright ("must include at least one type"), confirmed live, so `link_url: null` always failed instead of clearing (#408). Fixed by omitting the `link` key from `textStyle` entirely while still naming `"link"` in the field mask, the correct way to reset a nested message field to its default (no link) per Docs API `fields`-mask semantics — confirmed live this actually clears an existing link. Both call sites' own `if text_style: requests.append(...)` guard had to change to `if fields:` too: a link-clear-only call now legitimately produces an empty `text_style` dict with a non-empty `fields` list (`["link"]`), and the old guard would have silently skipped sending the request at all.
46
46
  - `docs/layout.py` — tools (`create_header`, `create_footer`)
47
47
  - `docs/comments.py` — tools (`list_doc_comments`, `add_doc_comment`, `resolve_doc_comment`) via the Drive `comments`/`replies` resource, not the Docs API
48
48
  - `docs/ast.py` — dataclass schema
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-gee-sweet
3
- Version: 0.8.2.dev259
3
+ Version: 0.8.2.dev263
4
4
  Summary: MCP server for Google Workspace — Sheets, Drive, Docs (HTML or Markdown), 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 123 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 125 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
@@ -2794,3 +2794,79 @@ Tool call: `insert_local_images(doc_id=DOC_ID, images=[{"marker": "IMGMARKERONE"
2794
2794
  **Result (2026-08-04) ❌ FAIL — run live against PR #515 (issue #443).** Primary check and companion regression guard both PASS: `<pre>code<table>...</table> </pre>` produces exactly "code" paragraph → 1×1 table ("cell") → the mandatory structural trailing paragraph every Doc requires, with no spurious visible-space paragraph; a standalone `<pre> </pre>` (no interruption) still keeps its literal whitespace unconditionally. However, `/code-review high` on the same PR live-verified (via `html_to_ast` against the worktree's own code) and this session independently reproduced via the real Docs API a related, not-yet-fixed case the test prompt above doesn't cover: a resumed `<pre>` whose trailing whitespace-only flush follows genuinely *dropped* unsupported content (e.g. `<pre>code<table>...</table> <hr></pre>`) produces a doc structurally identical to the no-drop case — no boundary node at all, silently losing the paragraph break that `_emit_block_node`'s `preserve_if_empty` guarantees every other block type (`<p>`/`<li>`/headings) in an analogous situation. Sent back to Dev (PR #515 comment) rather than approved; not filed as a separate ticket since it's blocking on this same PR.
2795
2795
 
2796
2796
  **Result (2026-08-05) ✅ PASS — re-verified live against fix commit `a465ea3`.** Round 1's finding is fixed: `<pre>code<table>...</table> <hr></pre>` (space survives after dropped `<hr>`) now renders a boundary paragraph `" \n"` immediately after the table; the zero-whitespace variant `<pre>code<table>...</table><hr></pre>` renders an empty boundary paragraph, matching the fix's own two new unit tests. Both of round 1's original checks (primary + companion regression guard) re-confirmed unaffected by the rewritten condition. `TestPreBlock` (10/10) passes. Note for future rounds: the first live-verification attempt this round produced a false FAIL — the `/mcp reconnect` had been run *before* this worktree was reset to the fix commit, so the tool call exercised stale pre-fix code (same class of gotcha as the PR #385 retro entry in `.claude/team-roles/qa.md`); caught by cross-checking against a direct script invocation of the same code path, not by the tool output itself. `qa-approved` applied.
2797
+
2798
+ ---
2799
+
2800
+ ## Paragraph bullet inspection and repair — `get_doc_structure`'s `bullet` field, `create_paragraph_bullets`/`delete_paragraph_bullets` (issue #334)
2801
+
2802
+ ### TC-DOC155: `get_doc_structure` surfaces `bullet.listId`/`bullet.nestingLevel` for list paragraphs ⚠️ destructive
2803
+
2804
+ **Setup:** none — list created fresh by the call under test.
2805
+
2806
+ **Prompt**
2807
+ > "Write this Markdown to doc {DOC_ID}: '- Top level item\n - Nested item\n', then show me its structure."
2808
+
2809
+ Tool calls: `write_doc_content(doc_id={DOC_ID}, content="- Top level item\n - Nested item\n", content_format="markdown")`, then `get_doc_structure(doc_id={DOC_ID})`.
2810
+
2811
+ **Checks**
2812
+ - Both list-item elements have a non-null `bullet` field, and both share the SAME `listId`
2813
+ - "Top level item"'s `bullet.nestingLevel` is `0`
2814
+ - "Nested item"'s `bullet.nestingLevel` is `1`
2815
+
2816
+ **Cleanup:** write fixture content back
2817
+
2818
+ **Result (2026-08-06) ❌ FAIL as originally written, ✅ PASS after test-case fix — run live against PR #524 (issue #334).** The prompt originally used a 2-space indent (`"- Top level item\n - Nested item\n"`), which this codebase's `_md_to_html` (`sane_lists` extension, same as plain `python-markdown`) does not recognize as nested — both items render as a single flat `<ul>` with no nesting, so "Nested item" correctly reported `nestingLevel: 0` given that input; not a product bug, a test-case bug (`sane_lists`/`markdown` requires 4-space indent to nest a list). Fixed the prompt above to 4-space indent and re-ran: both items share one `listId`, "Top level item" is `nestingLevel: 0`, "Nested item" is `nestingLevel: 1` — PASS.
2819
+
2820
+ ---
2821
+
2822
+ ### TC-DOC156: `create_paragraph_bullets` fixes a markdown-flattened nested list by promoting specific paragraphs to a deeper nesting level (#334's original repro) ⚠️ destructive
2823
+
2824
+ **Background:** issue #334 — `create_doc`'s markdown-to-Doc conversion flattens an indented sub-list under a numbered item into the SAME single-level list (the six settings in the issue's own repro became items 3-8 of one flat list instead of a nested sub-list under item 2), and until now there was no way to fix this after the fact short of delete-and-retype as plain text. This test exercises the fix: `get_doc_structure`'s new `bullet` field to detect the flattening, then `create_paragraph_bullets` to repair it.
2825
+
2826
+ **Prompt**
2827
+ > "Write this Markdown to doc {DOC_ID}: '1. Select the snapshot\n2. Configure the instance:\n - Instance identifier\n - Instance class\n - Storage encryption\n3. Click Restore\n', then show me its structure."
2828
+
2829
+ Tool calls: `write_doc_content(doc_id={DOC_ID}, content="1. Select the snapshot\n2. Configure the instance:\n - Instance identifier\n - Instance class\n - Storage encryption\n3. Click Restore\n", content_format="markdown")`, then `get_doc_structure(doc_id={DOC_ID})` — note the `start_index`/`end_index` of the "Instance identifier" and "Storage encryption" paragraphs from this call's output.
2830
+
2831
+ Then: "Now nest the three settings paragraphs (Instance identifier through Storage encryption) one level deeper under item 2, and show me the structure again."
2832
+
2833
+ Tool calls: `create_paragraph_bullets(doc_id={DOC_ID}, ranges=[{"start_index": <Instance identifier para start_index>, "end_index": <Storage encryption para end_index>, "nesting_level": 1}])` (one range spanning all three contiguous settings paragraphs), then `get_doc_structure(doc_id={DOC_ID})` again.
2834
+
2835
+ **Checks**
2836
+ - First `get_doc_structure` call: all 6 items ("Select the snapshot" through "Click Restore") share one `listId`, all at `nestingLevel: 0` — confirms the flattening described in #334 (the three settings paragraphs are not visually distinguished from their numbered siblings)
2837
+ - `create_paragraph_bullets` call succeeds with no API error
2838
+ - Second `get_doc_structure` call: "Select the snapshot", "Configure the instance", and "Click Restore" are still `nestingLevel: 0`; "Instance identifier", "Instance class", "Storage encryption" are now `nestingLevel: 1`, still sharing the same `listId` as their siblings
2839
+ - The three promoted paragraphs' own text is unchanged — no visible tab character leaked into `text` (confirms the Docs API fully consumes the leading tab characters used to signal nesting depth)
2840
+ - 🔍 Visual check: the three settings render as a visually indented sub-list under "Configure the instance", and "Click Restore" still numbers as item 3 (not item 6)
2841
+
2842
+ **Cleanup:** write fixture content back
2843
+
2844
+ **Result (2026-08-06) ❌ FAIL — run live against PR #524 (issue #334).** First `get_doc_structure` call matches expectations (all 6 items share one `listId` at `nestingLevel: 0`, confirming the flattening). The repair step fails: after `create_paragraph_bullets(ranges=[{"start_index": 45, "end_index": 114, "nesting_level": 1}])` (single range spanning all three contiguous settings paragraphs — the tool's own documented "safe" pattern), a second `get_doc_structure` shows **all three settings paragraphs still at `nestingLevel: 0`** — the promotion had no effect. Worse, the leading tab character the tool inserts to signal depth was never consumed by the Docs API: "Instance identifier"'s own `text` field literally reads `"\t - Instance identifier\n"`, tab included — contradicting this test's own third check ("no visible tab character leaked into text") and the tool's docstring claim that the API "consumes (removing)" the tab once applied. This is the PR's own flagship, headline use case (#334's original repro) and it does not work at all as implemented. Sent back to Dev (PR #524 comment) rather than approved.
2845
+
2846
+ **Result (2026-08-06, round 2) ❌ FAIL — re-verified live against fix commit `a36f1c7`.** The `nestingLevel`/tab-leak failure from round 1 is fixed: the second `get_doc_structure` call now shows "Instance identifier"/"Instance class"/"Storage encryption" correctly at `nestingLevel: 1` sharing the sibling `listId`, with no leaked tab in `text`. However, this test's own 🔍 visual check now fails for a new reason: a Playwright screenshot of the result shows the **entire list rendered as unordered bullets (●/○) instead of numbers** — "Click Restore" is a bullet, not "3.". Confirmed via a control doc (identical markdown, no `create_paragraph_bullets` call) that the pre-repair list renders correctly numbered 1–6, so the repair call itself is what strips the numbering. Root cause: the fix's fixed algorithm applies one `bullet_preset` to the entire merged run (the requested paragraphs plus every already-listed neighbor it sweeps in as context) — since this test's own documented tool call doesn't pass `bullet_preset` (defaults to `BULLET_DISC_CIRCLE_SQUARE`), it silently overwrites the numbering of "Select the snapshot"/"Configure the instance"/"Click Restore" even though the caller never asked to touch those paragraphs' preset. The round 1 code-review's two non-blocking findings (uncaught `KeyError` on a malformed range, unvalidated negative `nesting_level`) are both fixed and confirmed live; the original multi-range-fragmentation finding is also fixed and confirmed live (3 single-paragraph ranges at depths 0/1/0 now correctly share one `listId`). Sent back to Dev (PR #524 comment) rather than approved.
2847
+
2848
+ **Result (2026-08-06, round 3) ✅ PASS — re-verified live against fix commit `9ae16ee`.** Round 2's numbering regression is fixed: `create_paragraph_bullets` now reads the existing list's own glyph info from the document's `lists` map (`infer_preset`) when the caller doesn't pass `bullet_preset` explicitly, instead of defaulting to unordered bullets. Re-ran this test's exact documented call (no `bullet_preset`) — a Playwright screenshot confirms the list renders correctly: "1./2./3." for the top-level items, "a./b./c." for the promoted settings paragraphs, "Click Restore" still "3." All of this test's own checks pass. Also spot-checked the fix's new conflicting-preset validation: two directly-adjacent ranges with different explicit `bullet_preset`s split cleanly into two separate lists (no error, reasonable default); a mediated 3-paragraph case (two explicit, conflicting presets bridged by an already-listed paragraph with no explicit preset) correctly returns `{"error": "conflicting bullet_preset values among contiguous paragraphs..."}` as the docstring describes — the docstring's phrasing ("two explicitly-requested contiguous paragraphs") is a little imprecise about requiring same-run membership rather than literal adjacency in the caller's `ranges` list, but the behavior itself is correct and safe; noted as a non-blocking documentation nit, not filed as a ticket. Re-confirmed no regression on the original multi-range-fragmentation repro (3 single-paragraph ranges at depths 0/1/0 still land in one shared `listId` with correct depths). `qa-approved` applied.
2849
+
2850
+ ---
2851
+
2852
+ ### TC-DOC157: `delete_paragraph_bullets` removes list membership from a range, leaving paragraph text untouched ⚠️ destructive
2853
+
2854
+ **Setup:** none — list created fresh by the call under test.
2855
+
2856
+ **Prompt**
2857
+ > "Write this Markdown to doc {DOC_ID}: '- First item\n- Second item\n- Third item\n', then show me its structure."
2858
+
2859
+ Tool calls: `write_doc_content(doc_id={DOC_ID}, content="- First item\n- Second item\n- Third item\n", content_format="markdown")`, then `get_doc_structure(doc_id={DOC_ID})` — note "Second item"'s `start_index`/`end_index`.
2860
+
2861
+ Then: "Remove the bullet from just the second item, and show me the structure again."
2862
+
2863
+ Tool calls: `delete_paragraph_bullets(doc_id={DOC_ID}, ranges=[{"start_index": <Second item start_index>, "end_index": <Second item end_index>}])`, then `get_doc_structure(doc_id={DOC_ID})` again.
2864
+
2865
+ **Checks**
2866
+ - After the call: "First item" and "Third item" still have non-null `bullet` fields (unaffected)
2867
+ - "Second item"'s `bullet` field is now `null`
2868
+ - "Second item"'s `text` is still exactly "Second item\n" (unchanged)
2869
+
2870
+ **Cleanup:** write fixture content back
2871
+
2872
+ **Result (2026-08-06) ✅ PASS — run live against PR #524 (issue #334).** All three checks confirmed exactly as specified: "First item"/"Third item" kept their `bullet` field and shared `listId`, "Second item"'s `bullet` became `null`, and its `text` was unchanged.
@@ -150,7 +150,7 @@ No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature wo
150
150
  - [x] `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 (PR #353) ([#262](https://github.com/khuisman/mcp-gee-sweet/issues/262))
151
151
  - [x] Markdown-to-Doc image support — `![alt](path)` silently dropped today; local file path, `drive:<id>`, or HTTPS URL should all resolve to an inline image at the right position — deprioritized behind formatting fixes (#401/#402/#399, #403/#404) per user request; no lane assignment yet (PR #502) ([#333](https://github.com/khuisman/mcp-gee-sweet/issues/333))
152
152
  - [ ] `insert_inline_image` fails with the raw Google Docs API error ("The provided image is too large") for images over ~25 megapixels — no upfront size check, no mention of the actual limit or the image's own megapixel count; diagnosing required external tooling (`sips`) and prior knowledge of Google's ceiling. Same image codepath as #333 — deprioritized alongside it, no lane assignment yet ([#400](https://github.com/khuisman/mcp-gee-sweet/issues/400))
153
- - [ ] Expose paragraph list/nesting info in `get_doc_structure`, plus a tool to set/change bullet membership and nesting level on an existing range (`createParagraphBullets`/`deleteParagraphBullets` equivalent) — markdown converter currently flattens indented sub-lists into the parent list with no way to detect or fix it after the fact ([#334](https://github.com/khuisman/mcp-gee-sweet/issues/334))
153
+ - [x] Expose paragraph list/nesting info in `get_doc_structure`, plus a tool to set/change bullet membership and nesting level on an existing range (`createParagraphBullets`/`deleteParagraphBullets` equivalent) — markdown converter currently flattens indented sub-lists into the parent list with no way to detect or fix it after the fact ([#334](https://github.com/khuisman/mcp-gee-sweet/issues/334)) (PR #524)
154
154
  - [x] Soft-break paragraph helper (single paragraph, multiple lines joined by soft breaks, explicit named style) + document `delete_doc_range`'s paragraph-merge-inherits-neighbor-style behavior ([#332](https://github.com/khuisman/mcp-gee-sweet/issues/332)) (PR #362)
155
155
  - [ ] `update_doc_from_file` — update an existing Doc in place from a local `.md`/`.html` file, reading server-side like `create_doc_from_file` instead of round-tripping full content through the caller's context; real friction confirmed live — reporter had to fake this via delete-then-recreate. Wants #401/#402 landed first so the same paragraph-collapse defects don't just resurface via the update path ([#341](https://github.com/khuisman/mcp-gee-sweet/issues/341))
156
156
  - [x] `style_doc_table_cells` applies `border_color`/`border_width`/`border_dash_style` uniformly to all four cell edges — no per-edge control (top/bottom/left/right independently), even though the Docs API's own `TableCellBorder` supports it; blocks the classic "signature line" (bottom-border-only) cell pattern. Queued for lane-b behind #409/#382's remaining defect chain (defect-before-enhancement precedent) ([#403](https://github.com/khuisman/mcp-gee-sweet/issues/403)) (PR #462)
@@ -169,7 +169,7 @@ No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature wo
169
169
  - [x] Expose `md5Checksum` in `list_files`/`get_file_metadata` for real content-diffing, and let `sync_folder` diff by checksum instead of/in addition to `modifiedTime` — mtime is unreliable across in-place overwrites, `upload_local_file`'s "now" stamp, and content-preserving regenerations; related to #239 (PR #472) ([#274](https://github.com/khuisman/mcp-gee-sweet/issues/274))
170
170
  - [x] Drive Activity API — file change history ([#72](https://github.com/khuisman/mcp-gee-sweet/issues/72))
171
171
  - [x] `upload_local_folder` doesn't route through `_upload_local_file`, so the `convert` capability (#188) is unreachable from bulk folder uploads — no way to bulk-import a folder of CSV/DOCX/PPTX into native Workspace formats. Same domain as #274, candidate for lane-a once #274 ships (PR #505) ([#411](https://github.com/khuisman/mcp-gee-sweet/issues/411))
172
- - [ ] Reconcile the two independent Drive-conversion mechanisms — `_upload_local_file`'s `_CONVERT_MIME` map and `upload_file`'s `convert_to_doc` param do the same thing (source mimetype upload + destination-metadata mimeType override) with no cross-reference; risk of drift as more conversion paths get added. Related to #411 ([#412](https://github.com/khuisman/mcp-gee-sweet/issues/412))
172
+ - [x] Reconcile the two independent Drive-conversion mechanisms — `_upload_local_file`'s `_CONVERT_MIME` map and `upload_file`'s `convert_to_doc` param do the same thing (source mimetype upload + destination-metadata mimeType override) with no cross-reference; risk of drift as more conversion paths get added. Related to #411 ([#412](https://github.com/khuisman/mcp-gee-sweet/issues/412), PR #543)
173
173
 
174
174
  **Infrastructure**
175
175
  - [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))
@@ -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
- 123 tools. All tool names are lowercase with underscores — use these exact strings with `ENABLED_TOOLS` or `--include-tools`.
5
+ 125 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
 
@@ -150,6 +150,8 @@ Requires the `drive.activity.readonly` scope; uses the Drive Activity API v2.
150
150
  | `delete_table_column` | Delete a column from an existing table in a Google Doc. | `doc_id`, `table_start_index`, `column_index` |
151
151
  | `style_doc_range` | Apply paragraph and/or text styles to one or more index ranges in a Google Doc. | `doc_id`, `ranges` |
152
152
  | `style_doc_table_cells` | Apply border, padding, and background styles to table cells in a Google Doc. | `doc_id`, `table_start_index`, `cells` |
153
+ | `create_paragraph_bullets` | Turn one or more paragraph ranges into a bulleted or numbered list, with explicit nesting depth (#334). | `doc_id`, `ranges` |
154
+ | `delete_paragraph_bullets` | Remove list membership (bullets/numbering) from one or more paragraph ranges, leaving each paragraph's text and other styling untouched (#334). | `doc_id`, `ranges` |
153
155
  | `get_doc_theme` | Derive a theme dict from a Google Doc's actual paragraph styles. | `doc_id` |
154
156
  | `get_doc_named_styles` | Read a Google Doc's named style defaults and return them as a theme dict. | `doc_id` |
155
157
  | `apply_theme` | Apply a theme dict to a Google Doc by updating its named style definitions. | `doc_id`, `theme`, `overwrite?` |
@@ -1122,8 +1122,15 @@ def register(tool):
1122
1122
  - startIndex, endIndex
1123
1123
  Paragraphs also include namedStyleType, text, headingId (Google's internal
1124
1124
  ID for this heading, present only when namedStyleType is HEADING_1..6 —
1125
- e.g. "h.abc123"; null otherwise), and a runs list (each run has text,
1126
- bold, italic, underline, strikethrough, font_size, link_url).
1125
+ e.g. "h.abc123"; null otherwise), a runs list (each run has text,
1126
+ bold, italic, underline, strikethrough, font_size, link_url), and
1127
+ bullet (null for a non-list paragraph; otherwise {listId,
1128
+ nestingLevel} — nestingLevel is 0 for a top-level list item, absent
1129
+ from the raw API response in that case, normalized to 0 here so
1130
+ every list paragraph reports a level). Use this to detect a
1131
+ markdown-to-Doc conversion that flattened an intended nested list
1132
+ (#334) — sibling paragraphs that should differ in depth but share
1133
+ the same nestingLevel — then fix it with create_paragraph_bullets.
1127
1134
  Tables include rows, columns, and a cells list (each cell has row, col,
1128
1135
  startIndex, endIndex, paragraphStartIndex, text).
1129
1136
 
@@ -1153,6 +1160,15 @@ def register(tool):
1153
1160
  # would offer as a "Headings & bookmarks" link target (HEADING_1..6,
1154
1161
  # and TITLE/SUBTITLE) — no client-side filtering needed here.
1155
1162
  heading_id = pstyle.get("headingId")
1163
+ bullet = para.get("bullet")
1164
+ bullet_info = (
1165
+ {
1166
+ "listId": bullet.get("listId"),
1167
+ "nestingLevel": bullet.get("nestingLevel", 0),
1168
+ }
1169
+ if bullet
1170
+ else None
1171
+ )
1156
1172
  runs = []
1157
1173
  for pe in para.get("elements", []):
1158
1174
  tr = pe.get("textRun")
@@ -1179,6 +1195,7 @@ def register(tool):
1179
1195
  "endIndex": elem.get("endIndex", 0),
1180
1196
  "namedStyleType": named_style,
1181
1197
  "headingId": heading_id,
1198
+ "bullet": bullet_info,
1182
1199
  "text": "".join(r["text"] for r in runs),
1183
1200
  "runs": runs,
1184
1201
  }
@@ -460,6 +460,320 @@ def register(tool):
460
460
  logger.debug("style_doc_table_cells: %d requests in doc %s", len(requests), doc_id)
461
461
  return {"docId": doc_id, "requests": len(requests)}
462
462
 
463
+ @tool(annotations=ToolAnnotations(title="Create Paragraph Bullets", destructiveHint=True))
464
+ async def create_paragraph_bullets(
465
+ doc_id: str,
466
+ ranges: list[dict],
467
+ ctx: Context = None,
468
+ ) -> dict[str, Any]:
469
+ """
470
+ Turn one or more paragraph ranges into a bulleted or numbered list, with
471
+ explicit nesting depth (#334).
472
+
473
+ Wraps the Docs API's createParagraphBullets/deleteParagraphBullets
474
+ requests, working around two confirmed-live API quirks:
475
+
476
+ - createParagraphBullets is a no-op on a paragraph that's already
477
+ part of a list — it neither changes the existing nesting level nor
478
+ consumes any leading tab characters meant to signal a new one
479
+ (they're left behind as literal text). Every paragraph this tool
480
+ touches is deleteParagraphBullets'd first (harmless even if it
481
+ wasn't previously a list item) so createParagraphBullets always
482
+ applies to a clean, non-list paragraph.
483
+ - createParagraphBullets has no nestingLevel field of its own — the
484
+ API infers each paragraph's depth from its own leading tab
485
+ characters, but only relative to whatever ELSE is included in that
486
+ *same* call. A paragraph immediately adjacent to an existing list
487
+ item that call doesn't also cover gets pulled back to that
488
+ neighbor's level regardless of its own tabs (the same class of bug
489
+ PR #432 fixed in this project's own markdown-to-Doc converter).
490
+ To keep every requested paragraph's relative depth correct, this
491
+ tool fetches the document, resolves each requested range to the
492
+ paragraph(s) it covers, and — for any that are already part of a
493
+ list — expands the affected span to include their full contiguous
494
+ run of same-list neighbors (preserving each neighbor's own current
495
+ nesting level unchanged). Contiguous spans sharing one bullet
496
+ preset are then submitted as a single createParagraphBullets call
497
+ each, applied in descending document-position order so no
498
+ not-yet-processed span's indices are invalidated by an earlier
499
+ span's tab insertions.
500
+
501
+ Use get_doc_structure to obtain start_index/end_index for each target
502
+ range — typically one paragraph's own startIndex/endIndex, or a
503
+ contiguous span covering several (all promoted to the same
504
+ nesting_level/bullet_preset). For independent depths per paragraph,
505
+ pass one range per paragraph.
506
+
507
+ Args:
508
+ doc_id: The Google Doc file ID.
509
+ ranges: List of range dicts, each with:
510
+ start_index (int), end_index (int): from get_doc_structure —
511
+ must overlap at least one existing paragraph.
512
+ bullet_preset (str, optional): "BULLET_DISC_CIRCLE_SQUARE"
513
+ (unordered) or "NUMBERED_DECIMAL_ALPHA_ROMAN" (ordered) —
514
+ the only two presets this codebase has confirmed live
515
+ (see emitter.py). The Docs API defines additional
516
+ glyph/numbering presets not exercised here; pass the
517
+ literal API enum string to use one anyway. If omitted,
518
+ an already-listed target paragraph keeps its existing
519
+ list's own style (read from the document's own `lists`
520
+ map) rather than silently being converted to a
521
+ different one; a paragraph with no existing list falls
522
+ back to "BULLET_DISC_CIRCLE_SQUARE".
523
+ nesting_level (int, optional): 0 = top-level (default), 1 =
524
+ one level indented, etc. Must be >= 0.
525
+
526
+ Returns:
527
+ Confirmation with docId and count of batchUpdate requests sent.
528
+ {"error": ...} if ranges is empty, a nesting_level is negative, a
529
+ range doesn't overlap any paragraph, two explicitly-requested
530
+ contiguous paragraphs specify conflicting presets, or the Docs
531
+ API call fails.
532
+ """
533
+ lc = ctx.request_context.lifespan_context
534
+ if not ranges:
535
+ return {"error": "ranges list is empty"}
536
+ for r in ranges:
537
+ if r.get("nesting_level", 0) < 0:
538
+ return {"error": f"nesting_level must be >= 0, got {r['nesting_level']}"}
539
+
540
+ try:
541
+ doc = await execute_in_thread(
542
+ lc.docs_service.documents().get(documentId=doc_id).execute,
543
+ lc.docs_service,
544
+ )
545
+
546
+ doc_lists = doc.get("lists", {})
547
+
548
+ def infer_preset(bullet: dict | None) -> str | None:
549
+ # Reads the existing list's own glyph info (not just its
550
+ # listId/nestingLevel) so an already-listed paragraph's
551
+ # style is preserved rather than silently overridden by
552
+ # this tool's own default preset (#334 round 2).
553
+ if not bullet:
554
+ return None
555
+ levels = (
556
+ doc_lists.get(bullet.get("listId"), {})
557
+ .get("listProperties", {})
558
+ .get("nestingLevels", [])
559
+ )
560
+ level = bullet.get("nestingLevel", 0)
561
+ if level >= len(levels):
562
+ return None
563
+ info = levels[level]
564
+ if "glyphType" in info:
565
+ return "NUMBERED_DECIMAL_ALPHA_ROMAN"
566
+ if "glyphSymbol" in info:
567
+ return "BULLET_DISC_CIRCLE_SQUARE"
568
+ return None
569
+
570
+ body_paragraphs = [
571
+ (
572
+ elem.get("startIndex", 0),
573
+ elem.get("endIndex", 0),
574
+ elem["paragraph"].get("bullet"),
575
+ )
576
+ for elem in doc.get("body", {}).get("content", [])
577
+ if "paragraph" in elem
578
+ ]
579
+ by_start = {ps: (pe, b) for ps, pe, b in body_paragraphs}
580
+ by_end = {pe: (ps, b) for ps, pe, b in body_paragraphs}
581
+
582
+ requested: dict[tuple[int, int], dict] = {}
583
+ for r in ranges:
584
+ r_start, r_end = r["start_index"], r["end_index"]
585
+ preset_explicit = "bullet_preset" in r
586
+ nesting_level = r.get("nesting_level", 0)
587
+ covered = [
588
+ (ps, pe, b) for ps, pe, b in body_paragraphs if ps < r_end and pe > r_start
589
+ ]
590
+ if not covered:
591
+ return {"error": f"no paragraph overlaps range {r_start}-{r_end}"}
592
+ for ps, pe, bullet in covered:
593
+ requested[(ps, pe)] = {
594
+ "start": ps,
595
+ "end": pe,
596
+ "nesting_level": nesting_level,
597
+ "preset": r.get("bullet_preset"),
598
+ "preset_explicit": preset_explicit,
599
+ "existing_bullet": bullet,
600
+ }
601
+
602
+ # Expand each already-listed requested paragraph to include its
603
+ # full contiguous same-listId neighbor run (see docstring) —
604
+ # context paragraphs keep their own current nesting level and
605
+ # carry no explicit preset of their own (resolved per-run below).
606
+ expanded: dict[tuple[int, int], dict] = dict(requested)
607
+ for ps, pe, bullet in body_paragraphs:
608
+ key = (ps, pe)
609
+ if key not in requested or not bullet:
610
+ continue
611
+ list_id = bullet.get("listId")
612
+
613
+ cursor = ps
614
+ while cursor in by_end:
615
+ prev_start, prev_bullet = by_end[cursor]
616
+ if not prev_bullet or prev_bullet.get("listId") != list_id:
617
+ break
618
+ pkey = (prev_start, cursor)
619
+ if pkey not in expanded:
620
+ expanded[pkey] = {
621
+ "start": prev_start,
622
+ "end": cursor,
623
+ "nesting_level": prev_bullet.get("nestingLevel", 0),
624
+ "preset": None,
625
+ "preset_explicit": False,
626
+ "existing_bullet": prev_bullet,
627
+ }
628
+ cursor = prev_start
629
+
630
+ cursor = pe
631
+ while cursor in by_start:
632
+ next_end, next_bullet = by_start[cursor]
633
+ if not next_bullet or next_bullet.get("listId") != list_id:
634
+ break
635
+ nkey = (cursor, next_end)
636
+ if nkey not in expanded:
637
+ expanded[nkey] = {
638
+ "start": cursor,
639
+ "end": next_end,
640
+ "nesting_level": next_bullet.get("nestingLevel", 0),
641
+ "preset": None,
642
+ "preset_explicit": False,
643
+ "existing_bullet": next_bullet,
644
+ }
645
+ cursor = next_end
646
+
647
+ units = sorted(expanded.values(), key=lambda u: u["start"])
648
+
649
+ # Group touching paragraphs into runs — each run gets exactly one
650
+ # createParagraphBullets call, since the API infers nesting level
651
+ # relative to siblings within that one call. Only an explicit,
652
+ # conflicting caller-specified preset breaks an otherwise-
653
+ # touching run; an unspecified (context or defaulted) preset
654
+ # never does, since it's resolved per-run below.
655
+ runs: list[list[dict]] = [[units[0]]]
656
+ for u in units[1:]:
657
+ last = runs[-1][-1]
658
+ conflicts = (
659
+ u["preset_explicit"]
660
+ and last["preset_explicit"]
661
+ and u["preset"] != last["preset"]
662
+ )
663
+ if u["start"] == last["end"] and not conflicts:
664
+ runs[-1].append(u)
665
+ else:
666
+ runs.append([u])
667
+
668
+ requests: list[dict] = []
669
+ # Runs applied in descending document-position order so one
670
+ # run's tab insertions never invalidate a not-yet-processed
671
+ # run's own indices — same convention as insert_doc_text.
672
+ for run in sorted(runs, key=lambda run: run[0]["start"], reverse=True):
673
+ explicit_presets = {u["preset"] for u in run if u["preset_explicit"]}
674
+ if len(explicit_presets) > 1:
675
+ return {
676
+ "error": "conflicting bullet_preset values among contiguous "
677
+ f"paragraphs at index {run[0]['start']}"
678
+ }
679
+ if explicit_presets:
680
+ resolved_preset = next(iter(explicit_presets))
681
+ else:
682
+ resolved_preset = next(
683
+ (p for u in run if (p := infer_preset(u["existing_bullet"])) is not None),
684
+ "BULLET_DISC_CIRCLE_SQUARE",
685
+ )
686
+
687
+ run_start = run[0]["start"]
688
+ run_end = run[-1]["end"]
689
+ requests.append(
690
+ {
691
+ "deleteParagraphBullets": {
692
+ "range": {"startIndex": run_start, "endIndex": run_end}
693
+ }
694
+ }
695
+ )
696
+ for unit in sorted(run, key=lambda u: u["start"], reverse=True):
697
+ if unit["nesting_level"]:
698
+ requests.append(
699
+ {
700
+ "insertText": {
701
+ "location": {"index": unit["start"]},
702
+ "text": "\t" * unit["nesting_level"],
703
+ }
704
+ }
705
+ )
706
+ total_tabs = sum(u["nesting_level"] for u in run)
707
+ requests.append(
708
+ {
709
+ "createParagraphBullets": {
710
+ "range": {
711
+ "startIndex": run_start,
712
+ "endIndex": run_end + total_tabs,
713
+ },
714
+ "bulletPreset": resolved_preset,
715
+ }
716
+ }
717
+ )
718
+
719
+ await execute_in_thread(
720
+ lc.docs_service.documents()
721
+ .batchUpdate(documentId=doc_id, body={"requests": requests})
722
+ .execute,
723
+ lc.docs_service,
724
+ )
725
+ except Exception as e:
726
+ return {"error": str(e)}
727
+
728
+ lc.doc_cache.mark_dirty(doc_id)
729
+ logger.debug("create_paragraph_bullets: %d requests in doc %s", len(requests), doc_id)
730
+ return {"docId": doc_id, "requests": len(requests)}
731
+
732
+ @tool(annotations=ToolAnnotations(title="Delete Paragraph Bullets", destructiveHint=True))
733
+ async def delete_paragraph_bullets(
734
+ doc_id: str,
735
+ ranges: list[dict],
736
+ ctx: Context = None,
737
+ ) -> dict[str, Any]:
738
+ """
739
+ Remove list membership (bullets/numbering) from one or more paragraph
740
+ ranges, leaving each paragraph's text and other styling untouched (#334).
741
+
742
+ Args:
743
+ doc_id: The Google Doc file ID.
744
+ ranges: List of range dicts, each with start_index and end_index
745
+ (from get_doc_structure).
746
+
747
+ Returns:
748
+ Confirmation with docId and count of batchUpdate requests sent.
749
+ """
750
+ lc = ctx.request_context.lifespan_context
751
+ if not ranges:
752
+ return {"error": "ranges list is empty"}
753
+
754
+ requests = [
755
+ {
756
+ "deleteParagraphBullets": {
757
+ "range": {"startIndex": r["start_index"], "endIndex": r["end_index"]}
758
+ }
759
+ }
760
+ for r in ranges
761
+ ]
762
+
763
+ try:
764
+ await execute_in_thread(
765
+ lc.docs_service.documents()
766
+ .batchUpdate(documentId=doc_id, body={"requests": requests})
767
+ .execute,
768
+ lc.docs_service,
769
+ )
770
+ except Exception as e:
771
+ return {"error": str(e)}
772
+
773
+ lc.doc_cache.mark_dirty(doc_id)
774
+ logger.debug("delete_paragraph_bullets: %d requests in doc %s", len(requests), doc_id)
775
+ return {"docId": doc_id, "requests": len(requests)}
776
+
463
777
  @tool(annotations=ToolAnnotations(title="Get Document Theme"))
464
778
  async def get_doc_theme(
465
779
  doc_id: str,
@@ -746,6 +746,11 @@ class TestUploadLocalFolder:
746
746
  assert result["uploaded"] == []
747
747
  ctx.request_context.lifespan_context.drive_folder_cache.mark_dirty.assert_not_called()
748
748
 
749
+ async def test_missing_local_directory_raises(self, tmp_path):
750
+ drive_svc = MagicMock()
751
+ with pytest.raises(ValueError, match="No directory found"):
752
+ await self._tool()(str(tmp_path / "missing"), "folder1", ctx=self._ctx(drive_svc))
753
+
749
754
 
750
755
  class TestXlsxRangeValues:
751
756
  async def test_no_range_returns_all_rows(self):
@@ -1433,6 +1433,51 @@ class TestGetDocStructureHeadingId:
1433
1433
  assert result["elements"][0]["headingId"] is None
1434
1434
 
1435
1435
 
1436
+ class TestGetDocStructureBullet:
1437
+ """#334: get_doc_structure surfaces each paragraph's list membership/nesting."""
1438
+
1439
+ def _ctx(self, docs_svc):
1440
+ return _make_ctx(docs_service=docs_svc)
1441
+
1442
+ def _doc(self, bullet=None):
1443
+ para = {
1444
+ "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
1445
+ "elements": [{"textRun": {"content": "Item\n", "textStyle": {}}}],
1446
+ }
1447
+ if bullet is not None:
1448
+ para["bullet"] = bullet
1449
+ return {
1450
+ "documentId": "doc1",
1451
+ "title": "Doc",
1452
+ "body": {"content": [{"startIndex": 1, "endIndex": 10, "paragraph": para}]},
1453
+ }
1454
+
1455
+ async def test_non_list_paragraph_has_null_bullet(self):
1456
+ docs_svc = MagicMock()
1457
+ docs_svc.documents.return_value.get.return_value.execute.return_value = self._doc()
1458
+ result = await _docs_tools["get_doc_structure"](doc_id="doc1", ctx=self._ctx(docs_svc))
1459
+ assert result["elements"][0]["bullet"] is None
1460
+
1461
+ async def test_list_paragraph_reports_bullet_and_nesting_level(self):
1462
+ docs_svc = MagicMock()
1463
+ docs_svc.documents.return_value.get.return_value.execute.return_value = self._doc(
1464
+ bullet={"listId": "list1", "nestingLevel": 2}
1465
+ )
1466
+ result = await _docs_tools["get_doc_structure"](doc_id="doc1", ctx=self._ctx(docs_svc))
1467
+ assert result["elements"][0]["bullet"] == {"listId": "list1", "nestingLevel": 2}
1468
+
1469
+ async def test_top_level_bullet_normalizes_missing_nesting_level_to_zero(self):
1470
+ # The Docs API omits nestingLevel entirely for a top-level (depth 0)
1471
+ # list item rather than sending an explicit 0 — get_doc_structure
1472
+ # normalizes so every list paragraph reports a level.
1473
+ docs_svc = MagicMock()
1474
+ docs_svc.documents.return_value.get.return_value.execute.return_value = self._doc(
1475
+ bullet={"listId": "list1"}
1476
+ )
1477
+ result = await _docs_tools["get_doc_structure"](doc_id="doc1", ctx=self._ctx(docs_svc))
1478
+ assert result["elements"][0]["bullet"] == {"listId": "list1", "nestingLevel": 0}
1479
+
1480
+
1436
1481
  # ---------------------------------------------------------------------------
1437
1482
  # Heading-anchor resolution (#409): _has_pending_anchor_links,
1438
1483
  # _resolve_heading_anchors, and end-to-end via create_doc_from_file.