mcp-gee-sweet 0.9.2.dev313__tar.gz → 0.9.2.dev319__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 (258) hide show
  1. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/PKG-INFO +1 -1
  2. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/doc-to-markdown.md +3 -2
  3. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/image-conversion.md +33 -0
  4. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/markdown-support.md +1 -0
  5. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/docs_content.md +111 -0
  6. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/roadmap.md +4 -3
  7. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/ast.py +2 -2
  8. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/ast_to_markdown.py +63 -10
  9. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/comments.py +14 -4
  10. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/content.py +34 -7
  11. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/emitter.py +66 -18
  12. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/html_parser.py +51 -13
  13. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_comments.py +41 -0
  14. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_content.py +173 -0
  15. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_core.py +153 -0
  16. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_markdown_export.py +139 -0
  17. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/cleanup-worktrees.md +0 -0
  18. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/merge-pr.md +0 -0
  19. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/notify-kai.md +0 -0
  20. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/notify-partner.md +0 -0
  21. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/orchestrator.md +0 -0
  22. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/prep-for-pr.md +0 -0
  23. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/qa-kickoff.md +0 -0
  24. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/release.md +0 -0
  25. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/retro.md +0 -0
  26. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/roadmap-status.md +0 -0
  27. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/start-worktree.md +0 -0
  28. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/team-member.md +0 -0
  29. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/commands/verify-pr.md +0 -0
  30. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/settings.json +0 -0
  31. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/team-roles/amy.md +0 -0
  32. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/team-roles/aziz.md +0 -0
  33. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/team-roles/bob.md +0 -0
  34. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/team-roles/dev.md +0 -0
  35. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/team-roles/joy.md +0 -0
  36. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/team-roles/kai.md +0 -0
  37. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.claude/team-roles/qa.md +0 -0
  38. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.dockerignore +0 -0
  39. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  40. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  41. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  42. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.github/dependabot.yml +0 -0
  43. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.github/workflows/ci.yml +0 -0
  44. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.github/workflows/docs.yml +0 -0
  45. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.github/workflows/publish-dev.yml +0 -0
  46. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.github/workflows/release.yml +0 -0
  47. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.gitignore +0 -0
  48. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.pre-commit-config.yaml +0 -0
  49. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/.python-version +0 -0
  50. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/CHANGELOG.md +0 -0
  51. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/CLAUDE.md +0 -0
  52. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/CODE_OF_CONDUCT.md +0 -0
  53. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/CONTRIBUTING.md +0 -0
  54. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/Dockerfile +0 -0
  55. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/LICENSE +0 -0
  56. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/Makefile +0 -0
  57. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/README.md +0 -0
  58. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/SECURITY.md +0 -0
  59. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docker-compose.yml +0 -0
  60. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/assets/logo.png +0 -0
  61. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/auth.md +0 -0
  62. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/client-setup.md +0 -0
  63. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/configuration.md +0 -0
  64. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-async-tool-execution.md +0 -0
  65. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-auth-status-limitation-categories.md +0 -0
  66. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-cache-invalidation.md +0 -0
  67. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-chart-theming.md +0 -0
  68. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-comments-first-class.md +0 -0
  69. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-composite-tools.md +0 -0
  70. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-condition-type-docstring-generation.md +0 -0
  71. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-converted-doc-change-detection.md +0 -0
  72. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-dev-team.md +0 -0
  73. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-docs-formatting.md +0 -0
  74. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-fork.md +0 -0
  75. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-gmail-domain.md +0 -0
  76. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-grid-data-size-cap.md +0 -0
  77. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-local-fs-test-harness.md +0 -0
  78. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-mcp-v2-resource-context-access.md +0 -0
  79. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-pillow-image-dependency.md +0 -0
  80. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-prompt-qa-role.md +0 -0
  81. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-publishing.md +0 -0
  82. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-release-docs-roles.md +0 -0
  83. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-repositioning.md +0 -0
  84. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-response-size-cap-generalization.md +0 -0
  85. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-response-size-cap-reevaluation-519.md +0 -0
  86. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-review-finding-ticket-bar.md +0 -0
  87. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-supports-all-drives-lint-check.md +0 -0
  88. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-sync-folder-recursion.md +0 -0
  89. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-testing.md +0 -0
  90. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/decision-tool-error-visibility.md +0 -0
  91. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/index.md +0 -0
  92. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/decisions/role-file-history.md +0 -0
  93. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/blockquote-representation.md +0 -0
  94. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/borderless-table-columns.md +0 -0
  95. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/doc-revision-export.md +0 -0
  96. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/docs-ast-pipeline.md +0 -0
  97. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/effectiveformat-spike.md +0 -0
  98. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/heading-anchor-resolution.md +0 -0
  99. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/index.md +0 -0
  100. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/local-file-access-policy.md +0 -0
  101. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/module-history.md +0 -0
  102. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/oauth-reauthorize-from-tool-call.md +0 -0
  103. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/phase3-theme-system.md +0 -0
  104. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design/sse-consent-off-event-loop.md +0 -0
  105. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/design.md +0 -0
  106. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/index.md +0 -0
  107. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/known-limitations.md +0 -0
  108. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/notes-read.md +0 -0
  109. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/README.md +0 -0
  110. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/qa-fixture-pixel.png +0 -0
  111. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  112. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  113. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-d213-dollar-escape.md +0 -0
  114. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  115. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc102-image-conversion.html +0 -0
  116. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc103-image-conversion.md +0 -0
  117. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc104-nested-lists.html +0 -0
  118. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc105-nested-lists-gfm.md +0 -0
  119. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc106-nested-lists-4space.md +0 -0
  120. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc135-paragraph-boundary.md +0 -0
  121. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc136-interrupted-block-boundary.md +0 -0
  122. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc137-inline-hr-no-block-ancestor.html +0 -0
  123. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc138-nbsp-spacer-paragraph.md +0 -0
  124. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc141-mismatched-list-tags.html +0 -0
  125. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc142-heading-anchors.md +0 -0
  126. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc144-anchor-in-table-cell.md +0 -0
  127. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures/tc-doc145-anchor-on-first-element.md +0 -0
  128. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/fixtures.template.md +0 -0
  129. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/gmail-test-plan.md +0 -0
  130. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/operations.yaml +0 -0
  131. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/playwright_oauth.md +0 -0
  132. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/.gitkeep +0 -0
  133. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-06-02.md +0 -0
  134. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-06-14.md +0 -0
  135. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-06-17.md +0 -0
  136. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-06-18.md +0 -0
  137. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
  138. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
  139. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-06-28.md +0 -0
  140. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-07-04-docs.md +0 -0
  141. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-07-04-drive-smoke.md +0 -0
  142. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-07-04-infra.md +0 -0
  143. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-07-04-sheets_read.md +0 -0
  144. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-07-04.md +0 -0
  145. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/results/2026-09-04.md +0 -0
  146. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/retro-v0.8.0.md +0 -0
  147. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/retro-v0.9.0.md +0 -0
  148. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/run.md +0 -0
  149. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/runs/README.md +0 -0
  150. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/runs/v0.8.0.md +0 -0
  151. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/runs/v0.8.1.md +0 -0
  152. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/runs/v0.9.0.md +0 -0
  153. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/setup.md +0 -0
  154. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/calendar.md +0 -0
  155. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/docs_layout.md +0 -0
  156. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/docs_style.md +0 -0
  157. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/docs_tables.md +0 -0
  158. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/drive_activity.md +0 -0
  159. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/drive_files.md +0 -0
  160. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/drive_sharing.md +0 -0
  161. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/drive_transfer.md +0 -0
  162. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/gmail.md +0 -0
  163. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/infra.md +0 -0
  164. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/sheets_charts.md +0 -0
  165. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/sheets_mgmt.md +0 -0
  166. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/sheets_read.md +0 -0
  167. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/qa/tests/sheets_write.md +0 -0
  168. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/style-guide.md +0 -0
  169. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/docs/tools.md +0 -0
  170. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/mkdocs.yml +0 -0
  171. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/pyproject.toml +0 -0
  172. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/pyrightconfig.json +0 -0
  173. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/scripts/check_supports_all_drives.py +0 -0
  174. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/scripts/gen_tool_docs.py +0 -0
  175. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/scripts/lane_context_hook.py +0 -0
  176. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/scripts/oauth_setup.py +0 -0
  177. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/scripts/qa_cancel_insert_local_images.py +0 -0
  178. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/scripts/qa_gmail_fixtures.py +0 -0
  179. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/scripts/setup_team.sh +0 -0
  180. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/scripts/write_team_mcp_configs.sh +0 -0
  181. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/server.json +0 -0
  182. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/.env.template +0 -0
  183. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/__init__.py +0 -0
  184. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/auth.py +0 -0
  185. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/cache.py +0 -0
  186. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/http_transport.py +0 -0
  187. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/reauth.py +0 -0
  188. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/server.py +0 -0
  189. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/sse_shutdown.py +0 -0
  190. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  191. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/atomic_write.py +0 -0
  192. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/cache.py +0 -0
  193. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/calendar.py +0 -0
  194. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/concurrency.py +0 -0
  195. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
  196. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/anchors.py +0 -0
  197. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/doc_to_ast.py +0 -0
  198. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/editing.py +0 -0
  199. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/images.py +0 -0
  200. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/indices.py +0 -0
  201. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
  202. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/markdown_export.py +0 -0
  203. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/named_ranges.py +0 -0
  204. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/style.py +0 -0
  205. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
  206. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  207. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
  208. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
  209. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  210. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/drive/transfer.py +0 -0
  211. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/gmail.py +0 -0
  212. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/pagination.py +0 -0
  213. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/response_limits.py +0 -0
  214. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  215. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
  216. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
  217. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
  218. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/__init__.py +0 -0
  219. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/conftest.py +0 -0
  220. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/drive/__init__.py +0 -0
  221. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/drive/test_activity.py +0 -0
  222. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/drive/test_files.py +0 -0
  223. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/drive/test_sharing.py +0 -0
  224. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/drive/test_transfer.py +0 -0
  225. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/fakes.py +0 -0
  226. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/integration/__init__.py +0 -0
  227. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/integration/conftest.py +0 -0
  228. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/integration/test_local_fs_harness.py +0 -0
  229. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/sheets/__init__.py +0 -0
  230. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/sheets/test_data.py +0 -0
  231. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/sheets/test_helpers.py +0 -0
  232. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/sheets/test_structure.py +0 -0
  233. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_atomic_write.py +0 -0
  234. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_auth.py +0 -0
  235. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_cache.py +0 -0
  236. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_calendar.py +0 -0
  237. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_check_supports_all_drives.py +0 -0
  238. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_concurrency.py +0 -0
  239. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_anchors.py +0 -0
  240. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_editing.py +0 -0
  241. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_images.py +0 -0
  242. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_indices.py +0 -0
  243. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_layout.py +0 -0
  244. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_named_ranges.py +0 -0
  245. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_revision_export.py +0 -0
  246. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_style.py +0 -0
  247. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_docs_tables.py +0 -0
  248. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_gen_tool_docs.py +0 -0
  249. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_gmail.py +0 -0
  250. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_lane_context_hook.py +0 -0
  251. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_qa_gmail_fixtures.py +0 -0
  252. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_reauth.py +0 -0
  253. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_response_limits.py +0 -0
  254. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_server.py +0 -0
  255. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_server_json.py +0 -0
  256. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_sse_shutdown.py +0 -0
  257. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/tests/test_team_mcp_configs.py +0 -0
  258. {mcp_gee_sweet-0.9.2.dev313 → mcp_gee_sweet-0.9.2.dev319}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-gee-sweet
3
- Version: 0.9.2.dev313
3
+ Version: 0.9.2.dev319
4
4
  Summary: MCP server for Google Workspace — Sheets, Drive, Docs (HTML or Markdown), and Calendar.
5
5
  Project-URL: Homepage, https://github.com/khuisman/mcp-gee-sweet
6
6
  Project-URL: Bug Tracker, https://github.com/khuisman/mcp-gee-sweet/issues
@@ -128,8 +128,9 @@ Three gaps were kept **as documented limitations, not implementation shortcuts**
128
128
  - **Linked images** (#951): Docs puts an image's link on its `inlineObjectElement`'s own
129
129
  `textStyle.link`, the same as a text run (confirmed live). `doc_to_ast` reads it into
130
130
  `Image.link_url`, and every image renders through `_render_image` as `[![alt](src)](url)`, in
131
- ordinary text, a code paragraph, and a table cell alike. The write side doesn't set
132
- `Image.link_url` yet (#977).
131
+ ordinary text, a code paragraph, and a table cell alike. The write side sets it too (#977;
132
+ see [image-conversion.md](image-conversion.md#linked-images-977)), so a linked image
133
+ round-trips.
133
134
  - **Same-document links** (PR #976 QA round 1): Docs reports a link to a heading or bookmark by
134
135
  ID with no `url` (confirmed live for a heading: `{"headingId": "h.x"}`, on a text run and an
135
136
  image alike). `doc_to_ast._link_url`, shared by both, rebuilds it as
@@ -103,6 +103,39 @@ need K retries. Resending the whole document each time would be K+1 full-size ro
103
103
  Confirmed live that the split produces a document structurally identical to the old
104
104
  whole-list retry (text, bullets, table, and the surviving image all in place).
105
105
 
106
+ ## Linked images (#977)
107
+
108
+ An `<img>` inside `<a href>` (markdown `[![alt](src)](url)`) keeps its link: `html_parser.py`
109
+ copies the open `<a>`'s href onto `Image.link_url`. That includes raw HTML with no enclosing
110
+ block: a top-level `<img>` gets an implicit paragraph even when it's wrapped in inline tags (unlike
111
+ bare text or `<hr>` in that position), and reads its link from `_bare_link_url`, a stack separate
112
+ from `_link_url` so text handling and an unclosed top-level `<a>` are unaffected. Each open `<a>`
113
+ records which of the two stacks it pushed onto, or that it pushed nothing (`_anchor_pushes`), and
114
+ its `</a>` pops only that: an href-less `<a name>` nested in a linked anchor used to pop the outer
115
+ link (#994, PR #992 QA round 2). Docs stores an image's link on its
116
+ `inlineObjectElement`'s own `textStyle.link`, so `emitter.py` follows the image's
117
+ `insertInlineImage` with an `updateTextStyle` over its one-character range
118
+ `[position, position + 1)`. The link has to come right after its own insert, inside the
119
+ descending-position tail: at that point nothing before `position` has shifted yet. An inserted
120
+ image never inherits a neighboring run's link (confirmed live, both directions), so an unlinked
121
+ image gets no clearing request.
122
+
123
+ Inserts that share a position, such as adjacent images with nothing between them, each land in
124
+ front of whatever is already there, so the later one in the document has to be inserted first.
125
+ The descending pass sorts on `(position, node order, item order)`, all descending; sorting by
126
+ position alone kept source order within a tie and wrote adjacent images reversed (PR #992 QA
127
+ round 1). A table and the image right after it share a position the same way, and still come out
128
+ table first.
129
+
130
+ The retry treats the pair as one unit. `ast_to_requests(image_links=...)` records each
131
+ `id(insert request) -> link request` as it builds them, and `_apply_doc_content` uses that record
132
+ rather than matching request shapes. `_drop_failed_image` pops the link request with its failed
133
+ insert, since a stranded link would land on whatever character now sits at that position.
134
+ `_positional_insert_tail_start` counts a recorded link request as part of the tail, so the K≥2
135
+ split still applies. A `#slug` image link goes
136
+ through the heading-anchor pass like a text link: `_walk_headings_and_anchor_runs` reads the
137
+ `inlineObjectElement`'s link too, so it's resolved or stripped.
138
+
106
139
  ## Known, deliberate gap: table-cell images
107
140
 
108
141
  `html_parser.py`'s img-handling only fires when a block context is open at the body level
@@ -173,6 +173,7 @@ another registered tool), passing `content_format` through to `_to_doc_requests`
173
173
  | `~~strikethrough~~` | `<del>` | `Run(strikethrough=True)` | strikethrough |
174
174
  | `[text](url)` | `<a href=...>` | `Run(link_url=...)` | hyperlink |
175
175
  | bare `https://url` | `<a href=...>` (autolink extension) | `Run(link_url=...)` | hyperlink — disable via `autolink_urls=False` |
176
+ | `[![alt](src)](url)` | `<a href=...><img ...></a>` | `Image(link_url=...)` | linked inline image (#977) |
176
177
  | `- item` | `<ul><li>` | `BulletItem(ordered=False)` | disc bullet list |
177
178
  | `1. item` | `<ol><li>` | `BulletItem(ordered=True)` | numbered list |
178
179
  | `- [x] item` | `<input type="checkbox" checked>` | `BulletItem(checked=True)` | bullet with a `☑`/`☐` glyph prefix |
@@ -3716,6 +3716,64 @@ Not covered live: a soft break (Shift+Enter) inside an inline code run in a para
3716
3716
 
3717
3717
  **Result (2026-10-10) ✅ PASS — Kit, PR #981 round 2 (`b2bfafe`).** Ran on a scratch doc (created with `create_doc` from the step 1 HTML, trashed afterwards) instead of `{DOC_ID}`. Step 2 used one `find_in_doc(regex=True)` call for all three queries: matches at 4–11, 15–20 and 26–39. After step 3, `get_doc_structure` showed each of the first three paragraphs as two runs split at the `<`/`&` (`a <` | `b> text`, `AT&` | `amp;T`, `see <` | `https://x.io>`). `get_doc_as_markdown` returned `a \<b> text`, `AT\&amp;T`, `see \<https://x.io>`, and `a < b and AT&T` unchanged. markdown-it-py `commonmark` rendered `a &lt;b&gt; text`, `AT&amp;amp;T`, `see &lt;https://x.io&gt;`, and `a &lt; b and AT&amp;T`, with no `<b>` and no `<a>`. Round 1 got `a <b> text` and `AT&amp;T` unescaped from the same kind of split, on pre-fix code (`307e47c`).
3718
3718
 
3719
+
3720
+ ### TC-DOC216: A linked image written as Markdown or HTML keeps its link and round-trips (#977) ⚠️ requires-oauth
3721
+ **Background:** the write side built a linked image with no link, so `[![alt](src)](url)` and `<a href><img></a>` imported as a plain image and didn't round-trip with TC-DOC209's export. The image now gets its link through an `updateTextStyle` over its one-character range, right after its insert. That request has to stay paired with the image when an unreachable image is dropped and retried, and a `#slug` image link goes through the heading-anchor pass.
3722
+
3723
+ **Setup:** none. Each step uses a scratch doc.
3724
+
3725
+ **Prompt**
3726
+ **Playwright: required** (each linked image is clickable to its URL in the rendered doc; the unlinked one isn't)
3727
+ 1. `create_doc(title="TC-DOC216 md", folder_id={FOLDER_ID}, content_format='markdown', content=...)` with:
3728
+ ```markdown
3729
+ # Overview
3730
+
3731
+ Before [![Logo](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)](https://example.com/one) and [linked text](https://example.com/t)![Plain](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)
3732
+
3733
+ Jump: [![A](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)](#overview) and [![B](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)](#missing)
3734
+
3735
+ | a | b |
3736
+ |---|---|
3737
+ | 1 | 2 |
3738
+
3739
+ [![C](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)](https://example.com/after-table)
3740
+ ```
3741
+ 2. `get_doc_as_markdown(doc_id=<step 1 docId>)`
3742
+ 3. `create_doc(title="TC-DOC216 retry", folder_id={FOLDER_ID}, content_format='html', content=...)` with:
3743
+ ```html
3744
+ <p><a href="https://example.com/b1"><img src="https://example.invalid/nope.png?1"></a></p><p>x <a href="https://example.com/good"><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png"></a> y</p><p><a href="https://example.com/b2"><img src="https://example.invalid/nope.png?2"></a></p>
3745
+ ```
3746
+ 4. `get_doc_as_markdown(doc_id=<step 3 docId>)`
3747
+ 5. `create_doc(title="TC-DOC216 adjacent", folder_id={FOLDER_ID}, content_format='html', content=...)` with (raw HTML, the first image wrapped in a top-level `<a>` with no `<p>`):
3748
+ ```html
3749
+ <a href="https://example.com/top"><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" width="100"></a><p>x <a href="https://example.com/first"><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" width="40"></a><a href="https://example.com/second"><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" width="80"></a><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" width="120"> y</p>
3750
+ ```
3751
+ 6. `get_doc_as_markdown(doc_id=<step 5 docId>)`
3752
+ 7. `create_doc(title="TC-DOC216 nested anchor", folder_id={FOLDER_ID}, content_format='html', content=...)` with an href-less `<a name>` nested inside each linked anchor (#994):
3753
+ ```html
3754
+ <a href="https://example.com/bare"><a name="n1"></a><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png"></a><p><a href="https://example.com/block"><a name="n2"></a>tail<img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png"></a> after</p>
3755
+ ```
3756
+ 8. `get_doc_as_markdown(doc_id=<step 7 docId>)`
3757
+
3758
+ **Checks**
3759
+ - Step 2: `[![](<contentUri>)](https://example.com/one)`
3760
+ - Step 2: `[linked text](https://example.com/t)` is followed by an unlinked `![](<contentUri>)`, not one inside the link
3761
+ - Step 2: image `A` links to `https://docs.google.com/document/d/<docId>/edit?tab=t.0#heading=<headingId>`, the `Overview` heading. Image `B` is unlinked, its `#missing` link stripped
3762
+ - Step 2: the image after the table is `[![](<contentUri>)](https://example.com/after-table)`
3763
+ - Step 3: `images` reports an `error` for both `example.invalid` sources and none for the Google logo
3764
+ - Step 4: the only image is `[![](<contentUri>)](https://example.com/good)`, between `x` and `y`. No text character carries `b1`'s or `b2`'s link
3765
+ - Step 6: the first paragraph is the top-level image alone, as `[![](<contentUri>)](https://example.com/top)`
3766
+ - Step 6: the second paragraph is `x `, `[![](<contentUri>)](https://example.com/first)`, `[![](<contentUri>)](https://example.com/second)`, an unlinked `![](<contentUri>)`, then ` y`, in that order. Playwright: in the step 5 doc's editor the three inline images get wider left to right (40, 80, 120px source widths), which confirms the order independently of the links
3767
+ - Step 8: the first paragraph is `[![](<contentUri>)](https://example.com/bare)`. The second is `[tail](https://example.com/block)`, then `[![](<contentUri>)](https://example.com/block)`, then an unlinked ` after`: the nested `<a name>` doesn't end the outer link
3768
+
3769
+ **Cleanup:** `delete_file` all four scratch docs
3770
+
3771
+ **Result (2026-10-11) ✅ PASS — Sky, PR #992 round 1 (`6aa4dac`).** Step 2: `Logo` came back as `[![](<contentUri>)](https://example.com/one)`. `[linked text](https://example.com/t)` was followed by an unlinked `![](<contentUri>)`. `A` linked to `.../edit?tab=t.0#heading=h.…` (the `Overview` heading), and `B` was unlinked. The image after the table kept `https://example.com/after-table`. Step 3: `images` reported an `error` for both `example.invalid` sources and none for the logo. Step 4: the only image was `[![](<contentUri>)](https://example.com/good)` between `x` and `y`, and no text carried `b1`'s or `b2`'s link. Playwright, clicking each image in the step 1 doc's editor: `Logo` and the after-table image each showed a link chip for `https://example.com/…`, and `A`'s chip showed `Overview`. The unlinked image and `B` showed no link chip. The `/mobilebasic` HTML view isn't a substitute: it renders the text link but never wraps an image in `<a>`. A separate probe, `[![first](…)](https://example.com/first)[![second](…)](https://example.com/second)`, exported as `second` then `first`: adjacent images are written in reverse order. Each kept its own link.
3772
+
3773
+ **Result (2026-10-11) ✅ PASS — Sky, PR #992 round 2 (`3a506f4`).** Re-ran step 3 (the retry, now pairing each image with its link by identity) and the new step 5. Step 4: the only image was `[![](<contentUri>)](https://example.com/good)` between `x` and `y`, and no text carried `b1`'s or `b2`'s link. Step 6: the first paragraph was the top-level image alone, as `[![](<contentUri>)](https://example.com/top)`. The second paragraph was `x `, `…(https://example.com/first)`, `…(https://example.com/second)`, an unlinked image, then ` y`. Playwright: in the step 5 doc's editor the three inline images got wider left to right, and the top image's link chip showed `https://example.com/…`. Steps 1–2 weren't re-run: the fix commit doesn't change that path beyond the sort, which step 5 covers.
3774
+
3775
+ **Result (2026-10-11) ✅ PASS — Sky, PR #992 round 3 (`9271dfe`).** Ran the new step 7. Step 8: the first paragraph was `[![](<contentUri>)](https://example.com/bare)`, and the second was `[tail](https://example.com/block)`, then `[![](<contentUri>)](https://example.com/block)`, then an unlinked ` after`. The fix commit only touches `html_parser.py`'s anchor handling, so steps 1–6 weren't re-run.
3776
+
3719
3777
  ---
3720
3778
 
3721
3779
  ## Orphan fileId on inline-image sharing failure (#649)
@@ -4167,3 +4225,56 @@ Both tools read the local file through the shared `_read_doc_source_file` helper
4167
4225
  **Cleanup:** `rm -rf /tmp/tc-doc213`
4168
4226
 
4169
4227
  **Result (2026-10-10) ✅ PASS — Sky, PR #961 round 2 (`849f0bb`).** Ran with the fixtures under this job's scratch dir instead of `/tmp/tc-doc213`. Steps 1 and 3 returned `{"error": "Not a regular file: <path>/dir.md"}`; steps 2 and 4 returned `{"error": "File is not valid UTF-8: <path>/latin1.html (invalid continuation byte at byte 6)"}`. No call surfaced as `Error executing tool`. `list_files({FOLDER_ID})` showed only the three standing fixtures. After `refresh_cache`, `get_doc_content({DOC_ID})` matched the Setup snapshot. Round 1 reproduced both raises on the pre-fix code (`075ce99`).
4228
+
4229
+ ## Comment quoted text is plain text, not HTML-escaped (#984)
4230
+
4231
+ Drive returns a comment's `quotedFileContent.value` HTML-escaped (`Caf&#233;`, `&amp;`, `&lt;`, `&quot;`, `&#39;`), and a literal `&amp;` in the anchor as `&amp;amp;`. `add_doc_comment`, `list_doc_comments`, and `get_doc_as_markdown(include_comments=True)` unescape it once. The Markdown export then escapes the quote only as Markdown needs, with `"> "` on every line, and escapes comment authors, text, and replies the same way.
4232
+
4233
+ ### TC-DOC215: Non-ASCII, `&`/`<`/`"`, literal-entity, and multi-line anchors round-trip as plain text ⚠️ destructive ⚠️ requires-oauth
4234
+ **Setup:** `create_doc(title="QA TC-DOC215", folder_id={FOLDER_ID}, content="<p>Café and Tom &amp; Jerry, a &lt; b, say \"hi\" it's &amp;amp; literal</p><p>line one</p><p>line *two* &lt;x&gt;</p>")`, note the returned `docId` as `{SCRATCH_DOC_ID}`
4235
+
4236
+ **Steps**
4237
+ 1. `add_doc_comment(doc_id={SCRATCH_DOC_ID}, content="QA TC-DOC215 a <b> *x*", quoted_text="Café and Tom & Jerry, a < b, say \"hi\" it's")`
4238
+ 2. `add_doc_comment(doc_id={SCRATCH_DOC_ID}, content="QA TC-DOC215 b", quoted_text="&amp; literal")`
4239
+ 3. `add_doc_comment(doc_id={SCRATCH_DOC_ID}, content="QA TC-DOC215 c", quoted_text="line one\nline *two* <x>")`
4240
+ 4. `list_doc_comments(doc_id={SCRATCH_DOC_ID})`
4241
+ 5. `get_doc_as_markdown(doc_id={SCRATCH_DOC_ID}, include_comments=True)`
4242
+
4243
+ **Checks**
4244
+ - Steps 1–3 each return `quoted_text` equal to the `quoted_text` passed in, character for character: `Café`, not `Caf&#233;`; `&`, `<`, `"`, `'` unescaped; step 2 returns `&amp; literal`, not `&amp;amp; literal` or `& literal`
4245
+ - Step 4's three comments carry the same `quoted_text` values as steps 1–3
4246
+ - Step 5's `## Comments` section contains these quote blocks, each followed by a blank line before the comment text:
4247
+ - `> Café and Tom & Jerry, a < b, say "hi" it's`
4248
+ - `> \&amp; literal`
4249
+ - `> line one\` (ending in a hard-break backslash, since PR #991 round 2) then `> line \*two\* \<x>` on the next line
4250
+ - Step 5 renders step 1's comment text as `QA TC-DOC215 a \<b> \*x\*` (plain text, escaped only as Markdown needs), and step 1 itself returns `content` unchanged
4251
+ - No `&#233;`, `&quot;`, `&#39;`, or `&lt;` appears anywhere in step 5's comments section
4252
+
4253
+ **Cleanup:** `delete_file(file_id={SCRATCH_DOC_ID})`
4254
+
4255
+ **Result (2026-10-11) ✅ PASS — Kit, PR #991 round 1 (`9953a83`).** Steps 1–3 echoed each `quoted_text` exactly (`Café and Tom & Jerry, a < b, say "hi" it's`, `&amp; literal`, `line one\nline *two* <x>`); step 4 returned the same three values. Step 5's comments section had `> Café and Tom & Jerry, a < b, say "hi" it's`, `> \&amp; literal`, and `> line one` / `> line \*two\* \<x>`, each followed by a blank line, and comment text `QA TC-DOC215 a \<b> \*x\*`; no `&#233;`/`&quot;`/`&#39;`/`&lt;` in the section. Extra probe: a comment created in the Docs UI (Playwright) on the doc's literal `&amp; literal` text also came back from `list_doc_comments` as `&amp; literal`, so Drive escapes UI-created anchors too and a single unescape is correct for them. The case passes, but the round was sent back for Markdown-escaping gaps in comment text that this case doesn't exercise (see the PR comment).
4256
+
4257
+ ### TC-DOC217: Multi-line comment text and indented/rule body paragraphs stay plain text in the Markdown export ⚠️ destructive ⚠️ requires-oauth
4258
+ Comment text is plain text, so no line of it may render as Markdown structure, and each line keeps its own line (a hard break, not a soft wrap). Body paragraphs that start with spaces or a tab before a marker, or that are a `---` rule line, stay plain paragraphs too (#993). Replies are covered by unit tests only: the one tool that posts a reply, `resolve_doc_comment`, also resolves the comment, and `include_comments` exports only open ones.
4259
+
4260
+ **Setup:**
4261
+ - `create_doc(title="QA TC-DOC217", folder_id={FOLDER_ID}, content="<p>anchor</p>")`, note the returned `docId` as `{SCRATCH_DOC_ID}`
4262
+ - `insert_doc_text(doc_id={SCRATCH_DOC_ID}, insertions=[{"index": 1, "text": " - a\n # h\n---\n\ttab indent\n## Notes\n"}])`
4263
+
4264
+ **Steps**
4265
+ 1. `add_doc_comment(doc_id={SCRATCH_DOC_ID}, quoted_text="anchor", content="Summary\n---\nPoints:\n - a\n - b\na | b\n-|-\nc | d\n## Notes\n\n indented *x*")`
4266
+ 1a. `add_doc_comment(doc_id={SCRATCH_DOC_ID}, quoted_text="Summary\n=== ", content="QA TC-DOC217 setext")` (the trailing spaces after `===` are part of the anchor; Drive keeps them in `quoted_text`)
4267
+ 2. `get_doc_as_markdown(doc_id={SCRATCH_DOC_ID}, include_comments=True, local_path="/tmp/tc-doc217.md")`
4268
+ 3. `uv run python -c "from markdown_it import MarkdownIt; print(MarkdownIt('commonmark').enable('table').render(open('/tmp/tc-doc217.md').read()))"`
4269
+
4270
+ **Checks**
4271
+ - Step 3's HTML has no `<ul>`, `<ol>`, `<h1>`, `<table>`, or `<pre>`. It has exactly one `<h2>` (`Comments`) and one `<hr />`: the comments section's own heading and separator
4272
+ - The body renders `<p>- a</p>`, `<p># h</p>`, `<p>---</p>`, `<p>tab indent</p>`, and `<p>## Notes</p>`
4273
+ - Step 1's comment renders `Summary`, `---`, `Points:`, `- a`, `- b`, `a | b`, `-|-`, `c | d`, and `## Notes` as separate lines joined by `<br />` in one paragraph, with no literal `\` at any line end, then `<p>indented *x*</p>` as its own paragraph
4274
+ - Step 1a's quote renders as `<blockquote>` holding `<p>Summary<br />` and `===</p>`, with no `<h1>`
4275
+
4276
+ **Cleanup:** `delete_file(file_id={SCRATCH_DOC_ID})`, `rm /tmp/tc-doc217.md`
4277
+
4278
+ **Result (2026-10-11) ✅ PASS (case as written), ❌ sent back on probes — Kit, PR #991 round 2 (`de8143e`).** Ran with the export under this job's scratch dir instead of `/tmp`. Every check passed for the case's own content: body `<p>- a</p>`, `<p># h</p>`, `<p>---</p>`, `<p>tab indent</p>`; the comment's eight lines joined by `<br />` in one paragraph, then `<p>indented *x*</p>`; no `<ul>`/`<ol>`/`<table>`/`<pre>`. Extra probes on the same doc failed: (1) a body paragraph `## Notes` and a comment line `## Notes` each rendered as `<h2>`, and the comment line before it kept a literal `\`, because `_LEADING_SIMPLE_MARKER` matches only a single `#`; (2) `add_doc_comment(quoted_text="Summary\n=== ")` exported `> Summary\` / `> === `, which renders as `<h1>Summary\</h1>` inside the blockquote, because `_LEADING_RULE_LINE` doesn't allow trailing whitespace after `=`. Drive trims trailing whitespace from the end of comment `content` (not from `quoted_text`), and a mid-content `=== ` line is safe because the hard break before it blocks the setext.
4279
+
4280
+ **Result (2026-10-11) ✅ PASS — Kit, PR #991 round 3 (`95ba882`).** Ran with the export under this job's scratch dir instead of `/tmp`. Step 3's HTML had no `<ul>`/`<ol>`/`<h1>`/`<table>`/`<pre>`, exactly one `<h2>` (`Comments`) and one `<hr />`. Body: `<p>- a</p>`, `<p># h</p>`, `<p>---</p>`, `<p>tab indent</p>`, `<p>## Notes</p>`. Step 1's comment rendered its nine lines through `## Notes` joined by `<br />` with no literal `\`, then `<p>indented *x*</p>`. Step 1a's `quoted_text` came back as `Summary\n=== ` (trailing spaces kept) and rendered as `<blockquote><p>Summary<br />===</p></blockquote>`, no `<h1>`.
@@ -224,8 +224,7 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
224
224
  - [x] `add_chart`: a cell-to-row (`B2:5`) or row-only (`1:5`) range silently charts one column — `_per_column_ranges` defaults `endColumnIndex` to start+1. Found reviewing #931 — lane A, after #917 (PR #983) ([#954](https://github.com/khuisman/mcp-gee-sweet/issues/954))
225
225
  - [x] `_parse_a1_notation` hardening: one compiled grammar, index bounds, validate before the sheet-ID fetch. #931 review follow-ups, no behavior change for valid input — lane A, after #954 (same A1 area); its QA also runs #953's live checks for #931 (folded in) (PR #986) ([#955](https://github.com/khuisman/mcp-gee-sweet/issues/955))
226
226
  - [ ] Write side drops image links: `[![alt](src)](url)` (or `<a href><img></a>`) imports through `create_doc`/`create_doc_from_file`/`write_doc_content` without its URL, so it doesn't round-trip with #951's export. Found in PR #976 — lane A, after #955 ([#977](https://github.com/khuisman/mcp-gee-sweet/issues/977))
227
- - [ ] `_parse_a1_notation` rejects `A:B5`, which the Sheets values API accepts as `A5:B` — decided 2026-10-11: keep rejecting (a destructive GridRange tool acting on "row 5 onward" is worse than an error); document in the docstring that this is deliberately stricter than Sheets. Found in PR #986 — lane A, after #977 ([#987](https://github.com/khuisman/mcp-gee-sweet/issues/987))
228
- - [ ] Comment quoted text comes back HTML-escaped (`Caf&#233;`) from `add_doc_comment`, `list_doc_comments`, and `get_doc_as_markdown(include_comments=True)`: `comments.py` passes Drive's escaped `quotedFileContent.value` through unchanged. Found reviewing #958 — lane B, after #959 (defect, next pick) ([#984](https://github.com/khuisman/mcp-gee-sweet/issues/984))
227
+ - [x] Comment quoted text comes back HTML-escaped (`Caf&#233;`) from `add_doc_comment`, `list_doc_comments`, and `get_doc_as_markdown(include_comments=True)`: `comments.py` passes Drive's escaped `quotedFileContent.value` through unchanged. Found reviewing #958 — lane B, after #959 (defect, next pick) (PR #991) ([#984](https://github.com/khuisman/mcp-gee-sweet/issues/984))
229
228
  - [ ] `write_capped_result_to_disk` writes `dir/` as a file when the directory doesn't exist: a trailing separator should mean "directory" (create it, append `default_filename`). Shared by every `local_path` writer. Found in PR #988 — lane B, after #984 (defect) ([#990](https://github.com/khuisman/mcp-gee-sweet/issues/990))
230
229
 
231
230
  **Sheets hardening** _(triaged out of `backlog` 2026-09-11 — see the "Backlog triage" note below)_
@@ -252,7 +251,7 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
252
251
  - [ ] **Bundle:** Docs read side (`doc_to_ast`/`ast_to_markdown`), one PR closing [#596](https://github.com/khuisman/mcp-gee-sweet/issues/596), [#600](https://github.com/khuisman/mcp-gee-sweet/issues/600), [#732](https://github.com/khuisman/mcp-gee-sweet/issues/732) — lane B ([#893](https://github.com/khuisman/mcp-gee-sweet/issues/893))
253
252
  - [ ] `write_doc_content`/`update_doc_from_file` leave the doc empty when the replacement fails after the clear — decided 2026-10-04: record the pre-clear revision ID and name it in the error so the caller can recover via `export_revision` ([#878](https://github.com/khuisman/mcp-gee-sweet/issues/878))
254
253
  - [ ] `update_doc_from_file`: opt-in incremental (`mode="patch"`) update that diffs block by block and rewrites only changed blocks, so comment anchors and revision history survive — lane A, after #894 (same write-path files) ([#957](https://github.com/khuisman/mcp-gee-sweet/issues/957))
255
- - [ ] `export_revision`: support Google Docs revisions (returned as markdown), and fix `list_revisions`' docstring claim that native-file revisions are pruned after 30 days. Also what #878's recovery path points callers at — lane B, after #958 ([#959](https://github.com/khuisman/mcp-gee-sweet/issues/959))
254
+ - [x] `export_revision`: support Google Docs revisions (returned as markdown), and fix `list_revisions`' docstring claim that native-file revisions are pruned after 30 days. Also what #878's recovery path points callers at — lane B, after #958 (PR #988) ([#959](https://github.com/khuisman/mcp-gee-sweet/issues/959))
256
255
  - [ ] `export_revision`'s temporary Doc exposes an old revision's content to the default folder's audience (a whole Shared Drive) — decided 2026-10-11: under OAuth create it in My Drive root; a service account keeps the default folder, and that remaining exposure goes in `docs/known-limitations.md`. Found in PR #988 — lane B, after #990 ([#989](https://github.com/khuisman/mcp-gee-sweet/issues/989))
257
256
  - [x] `insert_local_images`: temporary `anyone:reader` image shares aren't revoked when the call is cancelled — apply PR #877's shielded-cleanup pattern — lane B ([#883](https://github.com/khuisman/mcp-gee-sweet/issues/883)) (PR #903)
258
257
  - [ ] **Bundle:** Docs image-share cancellation follow-ups (from PR #903), one PR closing [#904](https://github.com/khuisman/mcp-gee-sweet/issues/904), [#907](https://github.com/khuisman/mcp-gee-sweet/issues/907), [#908](https://github.com/khuisman/mcp-gee-sweet/issues/908), [#666](https://github.com/khuisman/mcp-gee-sweet/issues/666) — lane A ([#909](https://github.com/khuisman/mcp-gee-sweet/issues/909))
@@ -380,6 +379,8 @@ Follows the design in [`docs/decisions/decision-comments-first-class.md`](decisi
380
379
 
381
380
  - [ ] `sync_folder(export_format=...)`: converted Sheets/Slides probably have #814's `modifiedTime` lag and restamp race too. Suspected, not yet observed ([#818](https://github.com/khuisman/mcp-gee-sweet/issues/818))
382
381
  - [ ] Add a tool to set (and read back) Google Docs table column widths — `tableColumnProperties` isn't reachable through any tool today ([#809](https://github.com/khuisman/mcp-gee-sweet/issues/809))
382
+ - [ ] `_parse_a1_notation` rejects `A:B5`, which the Sheets values API accepts as `A5:B` — decided 2026-10-11: keep rejecting (a destructive GridRange tool acting on "row 5 onward" is worse than an error); document in the docstring that this is deliberately stricter than Sheets. Found in PR #986 — lane A; moved from v0.9.2 2026-10-11 (docstring only, current behavior is the safe one) ([#987](https://github.com/khuisman/mcp-gee-sweet/issues/987))
383
+ - [ ] `html_parser`: the `</a>` of an href-less anchor nested in a linked one pops the outer link, so text or an image after it is written unlinked. Found in PR #992's QA, which sent back the same bug in its new `_bare_link_url` stack; may close with PR #992 — lane A; moved from v0.9.2 2026-10-11 (pre-existing, invalid nested HTML only) ([#994](https://github.com/khuisman/mcp-gee-sweet/issues/994))
383
384
 
384
385
  **Code structure & typing (Joy)** _(slotted into v0.9.2 (now v0.9.3) 2026-09-09 so Joy's architecture initiative carries release deadlines instead of staying open-ended)_
385
386
 
@@ -49,8 +49,8 @@ class Image:
49
49
  alt: str | None = None
50
50
  width: float | None = None # pt
51
51
  height: float | None = None # pt
52
- # Read side only (doc_to_ast.py): the image element's own textStyle.link.
53
- # The write side doesn't set it yet.
52
+ # The image element's own textStyle.link: read by doc_to_ast.py, and written
53
+ # by emitter.py as an updateTextStyle over the image's one-character range (#977).
54
54
  link_url: str | None = None
55
55
 
56
56
 
@@ -57,8 +57,12 @@ _BACKTICK_RUN = re.compile(r"`+")
57
57
  # Paragraph rendering only: Heading/NamedBlock/BulletItem already have their
58
58
  # own legitimate prefix on the same line, so content following it is never
59
59
  # re-parsed as a new block starting mid-line.
60
- _LEADING_SIMPLE_MARKER = re.compile(r"^([#>]|[-*+])(?=[ \t]|$)")
60
+ _LEADING_SIMPLE_MARKER = re.compile(r"^(#{1,6}|[>*+-])(?=[ \t]|$)")
61
61
  _LEADING_ORDERED_MARKER = re.compile(r"^(\d{1,9})([.)])(?=[ \t]|$)")
62
+ # A line of only "-" (spaces allowed) is a thematic break, or a setext
63
+ # underline under a preceding line, and so is a line of only "=". "*" and "_"
64
+ # rules can't occur: _escape has already escaped both (#993).
65
+ _LEADING_RULE_LINE = re.compile(r"^(?:=+[ \t]*|-[- \t]*)$")
62
66
 
63
67
  _NESTED_TABLE_PLACEHOLDER = (
64
68
  "*(nested table omitted — Markdown tables can't contain a table; "
@@ -162,14 +166,24 @@ def _render_block(node: Heading | Paragraph | BulletItem | NamedBlock) -> str:
162
166
 
163
167
  depth = getattr(node, "blockquote_depth", 0)
164
168
  if depth:
165
- prefix = "> " * depth
166
- body = "\n".join(
167
- f"{prefix}{line}" if line else prefix.rstrip() for line in body.split("\n")
168
- )
169
+ body = _blockquote(body, depth)
169
170
  return body
170
171
 
171
172
 
173
+ def _blockquote(body: str, depth: int) -> str:
174
+ prefix = "> " * depth
175
+ return "\n".join(f"{prefix}{line}" if line else prefix.rstrip() for line in body.split("\n"))
176
+
177
+
172
178
  def _escape_leading_block_marker(text: str) -> str:
179
+ """Make one line that starts a block read as paragraph text. Leading
180
+ spaces and tabs are dropped first: CommonMark strips a paragraph's
181
+ leading whitespace anyway, so the rendered text is unchanged, while
182
+ keeping them lets " - a" parse as a list and four spaces or a tab as an
183
+ indented code block (#993)."""
184
+ text = text.lstrip(" \t")
185
+ if _LEADING_RULE_LINE.match(text):
186
+ return "\\" + text
173
187
  m = _LEADING_SIMPLE_MARKER.match(text)
174
188
  if m:
175
189
  return "\\" + text
@@ -463,12 +477,51 @@ def _table_safe(text: str) -> str:
463
477
 
464
478
 
465
479
  def _render_comment(comment: dict) -> str:
466
- author = (comment.get("author") or {}).get("display_name") or "Unknown"
480
+ """Comment authors, anchors, and text are all plain text (Drive's
481
+ `content`, not `htmlContent`), so none of it may render as Markdown
482
+ structure (#984). See _comment_text for how each line is kept literal.
483
+
484
+ The quote is a blockquote ended by a blank line, since the comment text
485
+ after it would otherwise be a lazy continuation of the quote. Without a
486
+ quote, the author line ends in a hard break so the text starts on its own
487
+ line. A reply's continuation lines are indented to the list item's
488
+ content column, so they stay in the item instead of ending the list."""
489
+ author = _comment_inline((comment.get("author") or {}).get("display_name") or "Unknown")
490
+ content = _comment_text(comment.get("content") or "")
467
491
  lines = [f"**{author}**"]
468
492
  if comment.get("quoted_text"):
469
- lines.append(f"> {comment['quoted_text']}")
470
- lines.append(comment.get("content") or "")
493
+ lines.append(_blockquote(_comment_text(comment["quoted_text"]), 1) + "\n")
494
+ elif content.split("\n", 1)[0]:
495
+ lines[0] += "\\"
496
+ lines.append(content)
471
497
  for reply in comment.get("replies", []):
472
- r_author = (reply.get("author") or {}).get("display_name") or "Unknown"
473
- lines.append(f" - **{r_author}**: {reply.get('content') or ''}")
498
+ r_author = _comment_inline((reply.get("author") or {}).get("display_name") or "Unknown")
499
+ first, *rest = _comment_text(reply.get("content") or "", starts_block=False).split("\n")
500
+ lines.append(
501
+ "\n".join(
502
+ [f" - **{r_author}**: {first}"] + [f" {line}" if line else "" for line in rest]
503
+ )
504
+ )
474
505
  return "\n".join(lines) + "\n"
506
+
507
+
508
+ def _comment_inline(text: str) -> str:
509
+ # "|" too: consecutive comment lines like "a | b" and "-|-" form a table.
510
+ return _escape(text).replace("|", "\\|")
511
+
512
+
513
+ def _comment_text(text: str, starts_block: bool = True) -> str:
514
+ """Multi-line plain text as Markdown that renders as the same lines. Each
515
+ line is escaped inline, and block markers are escaped on every line that
516
+ can start a block: all of them, except a first line that follows other
517
+ text on its own line (a reply's, after "**author**: "). Every line
518
+ followed by a non-empty one ends in a hard break ("\\"); a soft break
519
+ would join them into one line. An empty line stays a paragraph break."""
520
+ lines = [_comment_inline(line) for line in text.split("\n")]
521
+ for i in range(len(lines)):
522
+ if i or starts_block:
523
+ lines[i] = _escape_leading_block_marker(lines[i])
524
+ for i in range(len(lines) - 1):
525
+ if lines[i] and lines[i + 1]:
526
+ lines[i] += "\\"
527
+ return "\n".join(lines)
@@ -1,3 +1,4 @@
1
+ import html
1
2
  import logging
2
3
  from typing import Any
3
4
 
@@ -38,8 +39,18 @@ def _map_reply(reply: dict[str, Any]) -> dict[str, Any]:
38
39
  }
39
40
 
40
41
 
42
+ def _quoted_text(resource: dict[str, Any]) -> str | None:
43
+ """The plain text a comment is anchored to. Drive returns
44
+ quotedFileContent.value HTML-escaped ("Caf&#233;", "&amp;") even though
45
+ it was written as plain text, and escapes a literal "&amp;" in the
46
+ anchor as "&amp;amp;", so unescaping once restores it exactly (#984)."""
47
+ quoted = resource.get("quotedFileContent")
48
+ if not quoted or quoted.get("value") is None:
49
+ return None
50
+ return html.unescape(quoted["value"])
51
+
52
+
41
53
  def _map_comment(comment: dict[str, Any]) -> dict[str, Any]:
42
- quoted = comment.get("quotedFileContent")
43
54
  return {
44
55
  "id": comment.get("id"),
45
56
  "content": comment.get("content"),
@@ -48,7 +59,7 @@ def _map_comment(comment: dict[str, Any]) -> dict[str, Any]:
48
59
  "modified_time": comment.get("modifiedTime"),
49
60
  "resolved": comment.get("resolved", False),
50
61
  "deleted": comment.get("deleted", False),
51
- "quoted_text": quoted.get("value") if quoted else None,
62
+ "quoted_text": _quoted_text(comment),
52
63
  "replies": [_map_reply(r) for r in comment.get("replies", [])],
53
64
  }
54
65
 
@@ -155,14 +166,13 @@ def register(tool):
155
166
  drive_service,
156
167
  )
157
168
 
158
- quoted = result.get("quotedFileContent")
159
169
  logger.debug("Added comment %s to doc %s", result.get("id"), doc_id)
160
170
  return {
161
171
  "id": result.get("id"),
162
172
  "content": result.get("content"),
163
173
  "author": _map_author(result.get("author")),
164
174
  "created_time": result.get("createdTime"),
165
- "quoted_text": quoted.get("value") if quoted else None,
175
+ "quoted_text": _quoted_text(result),
166
176
  }
167
177
 
168
178
  @tool(annotations=ToolAnnotations(title="Resolve Document Comment", destructiveHint=True))
@@ -162,12 +162,20 @@ def _failed_insert_image_message(error: HttpError) -> str:
162
162
  _POSITIONAL_INSERT_KEYS = ("insertTable", "insertInlineImage")
163
163
 
164
164
 
165
- def _positional_insert_tail_start(requests: list[dict]) -> int:
165
+ def _positional_insert_tail_start(
166
+ requests: list[dict], image_links: dict[int, dict] | None = None
167
+ ) -> int:
166
168
  """Index where ast_to_requests's trailing stage-3 pass (the descending-position
167
169
  insertTable + insertInlineImage requests — see its docstring) begins: the start
168
- of the maximal suffix made up only of those two request kinds."""
170
+ of the maximal suffix made up only of those two request kinds, plus each linked
171
+ image's own link request (#977). `image_links` is the id(insert) -> link
172
+ request map ast_to_requests filled; a link request counts by identity."""
173
+ link_ids = {id(link) for link in (image_links or {}).values()}
169
174
  start = len(requests)
170
- while start > 0 and any(key in requests[start - 1] for key in _POSITIONAL_INSERT_KEYS):
175
+ while start > 0 and (
176
+ id(requests[start - 1]) in link_ids
177
+ or any(key in requests[start - 1] for key in _POSITIONAL_INSERT_KEYS)
178
+ ):
171
179
  start -= 1
172
180
  return start
173
181
 
@@ -553,8 +561,9 @@ async def _apply_doc_content(
553
561
  # (#789): revoking after a failed edit is harmless, since a doc that did embed
554
562
  # an image holds its own copy and one that didn't never needed the share.
555
563
  try:
564
+ image_links: dict[int, dict] = {}
556
565
  content_requests, tables = ast_to_requests(
557
- nodes, start_index=1, image_uris=placeholder_uris
566
+ nodes, start_index=1, image_uris=placeholder_uris, image_links=image_links
558
567
  )
559
568
 
560
569
  # Swap each request's placeholder URI for the real one, recording which
@@ -626,13 +635,21 @@ async def _apply_doc_content(
626
635
 
627
636
  def _drop_failed_image(requests: list[dict], e: HttpError) -> bool:
628
637
  """Pop the insertInlineImage request `e` names from `requests` (the
629
- batch that raised `e`), recording the failure on its image's outcome
638
+ batch that raised `e`), plus its link request if the image was
639
+ linked (#977), recording the failure on its image's outcome
630
640
  entry. False if `e` isn't an image-fetch failure, so the caller
631
641
  re-raises."""
632
642
  bad_index = _failed_insert_image_request_index(e)
633
643
  if bad_index is None or bad_index >= len(requests):
634
644
  return False
635
645
  bad_request = requests.pop(bad_index)
646
+ # Left in place, the link would land on whatever character now
647
+ # sits at the image's position. Matched by identity: the list
648
+ # holds the same dict objects ast_to_requests recorded.
649
+ link = image_links.get(id(bad_request))
650
+ if link is not None:
651
+ link_index = next(i for i, r in enumerate(requests) if r is link)
652
+ requests.pop(link_index)
636
653
  img_id = request_id_to_img_id.get(id(bad_request))
637
654
  entry = entry_by_id.get(img_id) if img_id is not None else None
638
655
  if entry is not None:
@@ -660,7 +677,7 @@ async def _apply_doc_content(
660
677
  image_failures += 1
661
678
  if image_failures != 2:
662
679
  continue
663
- tail_start = _positional_insert_tail_start(remaining)
680
+ tail_start = _positional_insert_tail_start(remaining, image_links)
664
681
  # Fail-safe: split only if every image request really is in the
665
682
  # tail. If ast_to_requests ever emits something after its
666
683
  # positional-insert pass, the boundary scan comes up short —
@@ -728,7 +745,7 @@ def _walk_headings_and_anchor_runs(
728
745
  ) -> tuple[list[tuple[str, str]], list[tuple[str, int, int]]]:
729
746
  """Recursively walk document body content, including table cells,
730
747
  collecting (heading_text, heading_id) pairs and (anchor_url, start, end)
731
- runs for any run whose link_url starts with '#'.
748
+ runs for any run or inline image whose link_url starts with '#'.
732
749
 
733
750
  Mirrors _collect_doc_paragraphs's two correctness properties: it recurses
734
751
  into table cells (a heading or an anchor link living inside a table cell
@@ -767,6 +784,16 @@ def _walk_headings_and_anchor_runs(
767
784
  end = pe.get("endIndex")
768
785
  if end is not None:
769
786
  offset = end
787
+ # A linked image carries its link on the inlineObjectElement's
788
+ # own textStyle (#977).
789
+ link_url = (
790
+ pe.get("inlineObjectElement", {})
791
+ .get("textStyle", {})
792
+ .get("link", {})
793
+ .get("url", "")
794
+ )
795
+ if link_url.startswith("#") and end is not None:
796
+ anchor_runs.append((link_url, run_start, end))
770
797
 
771
798
  if is_heading and heading_id:
772
799
  headings.append(("".join(text_parts).strip(), heading_id))