mcp-gee-sweet 0.7.0.dev48__tar.gz → 0.7.0.dev49__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 (123) hide show
  1. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/CLAUDE.md +8 -1
  2. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/PKG-INFO +1 -1
  3. mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/__init__.py +11 -0
  4. mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/content.py +640 -0
  5. mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/layout.py +214 -0
  6. mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/style.py +666 -0
  7. mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/tables.py +324 -0
  8. mcp_gee_sweet-0.7.0.dev49/tests/test_docs_content.py +604 -0
  9. mcp_gee_sweet-0.7.0.dev49/tests/test_docs_core.py +777 -0
  10. mcp_gee_sweet-0.7.0.dev49/tests/test_docs_layout.py +269 -0
  11. mcp_gee_sweet-0.7.0.dev49/tests/test_docs_style.py +566 -0
  12. mcp_gee_sweet-0.7.0.dev49/tests/test_docs_tables.py +433 -0
  13. mcp_gee_sweet-0.7.0.dev48/src/mcp_gee_sweet/tools/docs/__init__.py +0 -1816
  14. mcp_gee_sweet-0.7.0.dev48/tests/test_docs.py +0 -2502
  15. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.claude/commands/prep-for-pr.md +0 -0
  16. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.dockerignore +0 -0
  17. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/dependabot.yml +0 -0
  20. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/workflows/ci.yml +0 -0
  21. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/workflows/docs.yml +0 -0
  22. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/workflows/publish-dev.yml +0 -0
  23. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/workflows/release.yml +0 -0
  24. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.gitignore +0 -0
  25. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.pre-commit-config.yaml +0 -0
  26. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.python-version +0 -0
  27. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/CHANGELOG.md +0 -0
  28. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/CONTRIBUTING.md +0 -0
  29. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/Dockerfile +0 -0
  30. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/LICENSE +0 -0
  31. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/Makefile +0 -0
  32. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/README.md +0 -0
  33. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/SECURITY.md +0 -0
  34. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/TODO.md +0 -0
  35. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docker-compose.yml +0 -0
  36. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/auth.md +0 -0
  37. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/client-setup.md +0 -0
  38. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/configuration.md +0 -0
  39. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-chart-theming.md +0 -0
  40. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-composite-tools.md +0 -0
  41. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-docs-formatting.md +0 -0
  42. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-fork.md +0 -0
  43. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-publishing.md +0 -0
  44. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-testing.md +0 -0
  45. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/index.md +0 -0
  46. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/docs-ast-pipeline.md +0 -0
  47. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/effectiveformat-spike.md +0 -0
  48. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/index.md +0 -0
  49. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/markdown-support.md +0 -0
  50. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/phase3-theme-system.md +0 -0
  51. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design.md +0 -0
  52. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/index.md +0 -0
  53. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/known-limitations.md +0 -0
  54. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/notes-read.md +0 -0
  55. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/README.md +0 -0
  56. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  57. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  58. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  59. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/fixtures.template.md +0 -0
  60. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/operations.yaml +0 -0
  61. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/playwright_oauth.md +0 -0
  62. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/.gitkeep +0 -0
  63. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-02.md +0 -0
  64. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-14.md +0 -0
  65. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-17.md +0 -0
  66. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-18.md +0 -0
  67. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
  68. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
  69. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-28.md +0 -0
  70. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/retro-v0.8.0.md +0 -0
  71. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/run.md +0 -0
  72. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/runs/README.md +0 -0
  73. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/runs/v0.8.0.md +0 -0
  74. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/setup.md +0 -0
  75. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/calendar.md +0 -0
  76. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/docs.md +0 -0
  77. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/drive.md +0 -0
  78. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/infra.md +0 -0
  79. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/sheets_charts.md +0 -0
  80. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/sheets_mgmt.md +0 -0
  81. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/sheets_read.md +0 -0
  82. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/sheets_write.md +0 -0
  83. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa-checklist.md +0 -0
  84. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/roadmap.md +0 -0
  85. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/tools.md +0 -0
  86. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/mkdocs.yml +0 -0
  87. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/pyproject.toml +0 -0
  88. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/scripts/oauth_setup.py +0 -0
  89. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/.env.template +0 -0
  90. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/__init__.py +0 -0
  91. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/auth.py +0 -0
  92. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/cache.py +0 -0
  93. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/server.py +0 -0
  94. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  95. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/cache.py +0 -0
  96. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/calendar.py +0 -0
  97. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/docs/ast.py +0 -0
  98. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/docs/emitter.py +0 -0
  99. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/docs/html_parser.py +0 -0
  100. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  101. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
  102. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
  103. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  104. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/transfer.py +0 -0
  105. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  106. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
  107. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
  108. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
  109. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/__init__.py +0 -0
  110. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/__init__.py +0 -0
  111. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/test_activity.py +0 -0
  112. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/test_files.py +0 -0
  113. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/test_sharing.py +0 -0
  114. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/test_transfer.py +0 -0
  115. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/sheets/__init__.py +0 -0
  116. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/sheets/test_data.py +0 -0
  117. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/sheets/test_helpers.py +0 -0
  118. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/sheets/test_structure.py +0 -0
  119. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/test_auth.py +0 -0
  120. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/test_cache.py +0 -0
  121. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/test_calendar.py +0 -0
  122. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/test_server.py +0 -0
  123. {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/uv.lock +0 -0
@@ -38,7 +38,14 @@ Logic is split across `src/mcp_gee_sweet/`: `server.py` (MCP setup, tool decorat
38
38
  - `tools/drive/sharing.py` — permissions (`share_spreadsheet`, `share_file`, `list_permissions`, `update_permission`, `remove_permission`)
39
39
  - `tools/drive/transfer.py` — upload/download/sync/export/revisions
40
40
  - `tools/drive/activity.py` — `list_file_activity` (Drive Activity API v2; requires `drive.activity.readonly` scope)
41
- - `tools/docs/` — Google Docs package (`create_doc`, `get_doc_content`, `write_doc_content`, `create_doc_from_file`, `insert_doc_text`, `delete_doc_range`, `get_doc_structure`, `style_doc_range`, `style_doc_table_cells`, `get_doc_theme`, `get_doc_named_styles`, `apply_theme`, `insert_inline_image`, `insert_table_row`, `delete_table_row`, `insert_table_column`, `delete_table_column`, `create_header`, `create_footer`); sub-modules: `ast.py` (dataclass schema), `html_parser.py` (HTML→AST), `emitter.py` (AST→Docs API requests + multi-phase table fill including nested table and cell styling support)
41
+ - `tools/docs/` — Google Docs package; `__init__.py` is a thin dispatcher that delegates to four submodules:
42
+ - `docs/content.py` — helpers (`_html_to_text`, `_md_to_html`, `_to_doc_requests`, `_html_to_doc_requests`) and tools (`create_doc`, `get_doc_content`, `write_doc_content`, `create_doc_from_file`, `insert_doc_text`, `delete_doc_range`, `get_doc_structure`, `insert_inline_image`)
43
+ - `docs/tables.py` — tools (`insert_doc_table`, `insert_table_row`, `delete_table_row`, `insert_table_column`, `delete_table_column`)
44
+ - `docs/style.py` — helpers (`_NAMED_STYLE_TYPES`, `_read_body_styles`, `_read_named_styles`, `_build_named_style_requests`) and tools (`style_doc_range`, `style_doc_table_cells`, `get_doc_theme`, `get_doc_named_styles`, `apply_theme`)
45
+ - `docs/layout.py` — tools (`create_header`, `create_footer`)
46
+ - `docs/ast.py` — dataclass schema
47
+ - `docs/html_parser.py` — HTML→AST
48
+ - `docs/emitter.py` — AST→Docs API requests + multi-phase table fill including nested table and cell styling support
42
49
  - `tools/cache.py` — `refresh_cache`
43
50
  - `tools/calendar.py` — Calendar API tools
44
51
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-gee-sweet
3
- Version: 0.7.0.dev48
3
+ Version: 0.7.0.dev49
4
4
  Summary: MCP server for Google Workspace — Sheets, Drive, Docs, and Calendar. 60+ tools for AI clients.
5
5
  Project-URL: Homepage, https://github.com/khuisman/mcp-gee-sweet
6
6
  Project-URL: Bug Tracker, https://github.com/khuisman/mcp-gee-sweet/issues
@@ -0,0 +1,11 @@
1
+ from .content import register as _register_content
2
+ from .layout import register as _register_layout
3
+ from .style import register as _register_style
4
+ from .tables import register as _register_tables
5
+
6
+
7
+ def register(tool):
8
+ _register_content(tool)
9
+ _register_tables(tool)
10
+ _register_style(tool)
11
+ _register_layout(tool)
@@ -0,0 +1,640 @@
1
+ import html as html_module
2
+ import logging
3
+ from html.parser import HTMLParser
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import markdown as _md
8
+ from googleapiclient.errors import HttpError
9
+ from mcp.server.fastmcp import Context
10
+ from mcp.types import ToolAnnotations
11
+
12
+ from ..drive import _SA_QUOTA_ERROR
13
+ from .ast import Table
14
+ from .emitter import ast_to_requests, fill_tables
15
+ from .html_parser import html_to_ast
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _html_to_text(html_content: str) -> str:
21
+ """Convert HTML to plain text, preserving block-level line breaks."""
22
+ _BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "div", "tr", "blockquote"}
23
+
24
+ class _Extractor(HTMLParser):
25
+ def __init__(self):
26
+ super().__init__()
27
+ self.parts = []
28
+
29
+ def handle_starttag(self, tag, attrs):
30
+ if tag == "br" or (tag in _BLOCK and self.parts and not self.parts[-1].endswith("\n")):
31
+ self.parts.append("\n")
32
+
33
+ def handle_endtag(self, tag):
34
+ if tag in _BLOCK:
35
+ self.parts.append("\n")
36
+
37
+ def handle_data(self, data):
38
+ self.parts.append(data)
39
+
40
+ def handle_entityref(self, name):
41
+ self.parts.append(html_module.unescape(f"&{name};"))
42
+
43
+ def handle_charref(self, name):
44
+ self.parts.append(html_module.unescape(f"&#{name};"))
45
+
46
+ extractor = _Extractor()
47
+ extractor.feed(html_content)
48
+ return "".join(extractor.parts).strip()
49
+
50
+
51
+ def _md_to_html(md_text: str) -> str:
52
+ """Convert Markdown to HTML using the Python markdown library (tables, fenced_code, sane_lists extensions)."""
53
+ return _md.markdown(md_text, extensions=["tables", "fenced_code", "sane_lists"])
54
+
55
+
56
+ def _to_doc_requests(
57
+ content: str, content_format: str = "html", start_index: int = 1
58
+ ) -> tuple[list[dict], list[Table]]:
59
+ """Convert HTML or Markdown to Docs API batchUpdate requests via the AST pipeline.
60
+
61
+ Returns (requests, tables) where tables is a list of Table AST nodes. Table cells
62
+ are NOT filled here — call fill_tables() after executing the returned requests.
63
+ """
64
+ if content_format == "markdown":
65
+ content = _md_to_html(content)
66
+ nodes = html_to_ast(content)
67
+ return ast_to_requests(nodes, start_index)
68
+
69
+
70
+ def _html_to_doc_requests(
71
+ html_content: str, start_index: int = 1
72
+ ) -> tuple[list[dict], list[Table]]:
73
+ return _to_doc_requests(html_content, "html", start_index)
74
+
75
+
76
+ def register(tool):
77
+ @tool(annotations=ToolAnnotations(title="Create Document", destructiveHint=True))
78
+ def create_doc(
79
+ title: str,
80
+ content: str | None = None,
81
+ folder_id: str | None = None,
82
+ content_format: str = "html",
83
+ ctx: Context = None,
84
+ ) -> dict[str, Any]:
85
+ """
86
+ Create a new Google Doc, optionally with initial content.
87
+
88
+ Content is interpreted as HTML by default. Pass content_format='markdown' to supply
89
+ Markdown instead (headings, bold, italic, lists, links, tables, fenced code blocks,
90
+ and task list items are all supported). Tables are appended after all paragraph
91
+ content. Nested tables are not supported.
92
+
93
+ Args:
94
+ title: The title of the new document
95
+ content: Optional content for the document body
96
+ folder_id: Optional Google Drive folder ID where the document should be created.
97
+ If not provided, creates in the root of My Drive.
98
+ content_format: 'html' (default) or 'markdown'
99
+
100
+ Returns:
101
+ Information about the newly created document including its ID and web link
102
+
103
+ Note:
104
+ Requires OAuth or ADC auth. Service accounts cannot create files in personal
105
+ Drive (no storage quota). Works on Shared Drives regardless of auth method.
106
+ Workaround for service accounts: create the file manually in Drive, then use
107
+ write_doc_content to populate it. Check server://auth-status for your current
108
+ auth method.
109
+ """
110
+ lc = ctx.request_context.lifespan_context
111
+ drive_service = lc.drive_service
112
+ docs_service = lc.docs_service
113
+ target_folder_id = folder_id or lc.folder_id
114
+
115
+ file_body: dict[str, Any] = {
116
+ "name": title,
117
+ "mimeType": "application/vnd.google-apps.document",
118
+ }
119
+ if target_folder_id:
120
+ file_body["parents"] = [target_folder_id]
121
+
122
+ try:
123
+ doc = (
124
+ drive_service.files()
125
+ .create(
126
+ supportsAllDrives=True,
127
+ body=file_body,
128
+ fields="id, name, parents, webViewLink",
129
+ )
130
+ .execute()
131
+ )
132
+ except HttpError as e:
133
+ if e.resp.status == 403 and b"storageQuotaExceeded" in (e.content or b""):
134
+ return {"error": _SA_QUOTA_ERROR}
135
+ raise
136
+
137
+ doc_id = doc.get("id")
138
+ parents = doc.get("parents")
139
+ logger.debug(
140
+ "Doc created with ID: %s%s",
141
+ doc_id,
142
+ f" in folder {target_folder_id}" if target_folder_id else " in root",
143
+ )
144
+
145
+ if content:
146
+ content_requests, tables = _to_doc_requests(content, content_format, start_index=1)
147
+ if content_requests:
148
+ docs_service.documents().batchUpdate(
149
+ documentId=doc_id, body={"requests": content_requests}
150
+ ).execute()
151
+ fill_tables(docs_service, doc_id, tables)
152
+
153
+ if target_folder_id:
154
+ lc.drive_folder_cache.mark_dirty(target_folder_id)
155
+
156
+ return {
157
+ "docId": doc_id,
158
+ "title": doc.get("name", title),
159
+ "folder": parents[0] if parents else "root",
160
+ "web_link": doc.get("webViewLink"),
161
+ }
162
+
163
+ @tool(annotations=ToolAnnotations(title="Create Document from File", destructiveHint=True))
164
+ def create_doc_from_file(
165
+ local_path: str,
166
+ title: str | None = None,
167
+ folder_id: str | None = None,
168
+ ctx: Context = None,
169
+ ) -> dict[str, Any]:
170
+ """
171
+ Create a Google Doc from a local .md or .html file.
172
+
173
+ The file format is inferred from the extension: .md files are parsed as
174
+ Markdown, .html / .htm files as HTML. The document title defaults to the
175
+ filename without extension if not supplied.
176
+
177
+ Args:
178
+ local_path: Absolute or relative path to the local file.
179
+ title: Document title. Defaults to the filename stem.
180
+ folder_id: Optional Google Drive folder ID. Defaults to server default folder.
181
+
182
+ Returns:
183
+ Information about the newly created document including its ID and web link.
184
+
185
+ Note:
186
+ Requires OAuth or ADC auth. Service accounts cannot create files in personal
187
+ Drive (no storage quota). Check server://auth-status for your current auth method.
188
+ """
189
+ path = Path(local_path)
190
+ if not path.exists():
191
+ return {"error": f"File not found: {local_path}"}
192
+
193
+ ext = path.suffix.lower()
194
+ if ext == ".md":
195
+ content_format = "markdown"
196
+ elif ext in (".html", ".htm"):
197
+ content_format = "html"
198
+ else:
199
+ return {"error": f"Unsupported file extension '{ext}'. Use .md or .html/.htm"}
200
+
201
+ content = path.read_text(encoding="utf-8")
202
+ doc_title = title or path.stem
203
+
204
+ lc = ctx.request_context.lifespan_context
205
+ drive_service = lc.drive_service
206
+ docs_service = lc.docs_service
207
+ target_folder_id = folder_id or lc.folder_id
208
+
209
+ file_body: dict[str, Any] = {
210
+ "name": doc_title,
211
+ "mimeType": "application/vnd.google-apps.document",
212
+ }
213
+ if target_folder_id:
214
+ file_body["parents"] = [target_folder_id]
215
+
216
+ try:
217
+ doc = (
218
+ drive_service.files()
219
+ .create(
220
+ supportsAllDrives=True,
221
+ body=file_body,
222
+ fields="id, name, parents, webViewLink",
223
+ )
224
+ .execute()
225
+ )
226
+ except HttpError as e:
227
+ if e.resp.status == 403 and b"storageQuotaExceeded" in (e.content or b""):
228
+ return {"error": _SA_QUOTA_ERROR}
229
+ raise
230
+
231
+ doc_id = doc.get("id")
232
+ parents = doc.get("parents")
233
+ logger.debug("Doc created from file %s with ID: %s", local_path, doc_id)
234
+
235
+ if content:
236
+ content_requests, tables = _to_doc_requests(content, content_format, start_index=1)
237
+ if content_requests:
238
+ docs_service.documents().batchUpdate(
239
+ documentId=doc_id, body={"requests": content_requests}
240
+ ).execute()
241
+ fill_tables(docs_service, doc_id, tables)
242
+
243
+ if target_folder_id:
244
+ lc.drive_folder_cache.mark_dirty(target_folder_id)
245
+
246
+ return {
247
+ "docId": doc_id,
248
+ "title": doc.get("name", doc_title),
249
+ "folder": parents[0] if parents else "root",
250
+ "web_link": doc.get("webViewLink"),
251
+ }
252
+
253
+ @tool(annotations=ToolAnnotations(title="Get Document Content", readOnlyHint=True))
254
+ def get_doc_content(file_id: str, ctx: Context = None) -> dict[str, Any]:
255
+ """
256
+ Get the plain text content of a Google Doc.
257
+
258
+ Args:
259
+ file_id: The Google Drive file ID of the document.
260
+
261
+ Returns:
262
+ Dictionary with the document's text content and metadata. Results are
263
+ cached; call refresh_cache(doc_id=file_id) to invalidate, or
264
+ refresh_cache() to clear all caches.
265
+ """
266
+ lc = ctx.request_context.lifespan_context
267
+ drive_service = lc.drive_service
268
+ doc_cache = lc.doc_cache
269
+
270
+ cached = doc_cache.get(file_id)
271
+ if cached is not None:
272
+ return cached
273
+
274
+ metadata = (
275
+ drive_service.files()
276
+ .get(
277
+ fileId=file_id, fields="id, name, modifiedTime, webViewLink", supportsAllDrives=True
278
+ )
279
+ .execute()
280
+ )
281
+
282
+ content = drive_service.files().export(fileId=file_id, mimeType="text/plain").execute()
283
+
284
+ result = {
285
+ "id": metadata["id"],
286
+ "name": metadata["name"],
287
+ "modified_time": metadata.get("modifiedTime"),
288
+ "web_link": metadata.get("webViewLink"),
289
+ "content": content.decode("utf-8") if isinstance(content, bytes) else content,
290
+ }
291
+ doc_cache.store(file_id, result)
292
+ return result
293
+
294
+ @tool(annotations=ToolAnnotations(title="Write Document Content", destructiveHint=True))
295
+ def write_doc_content(
296
+ doc_id: str,
297
+ content: str,
298
+ content_format: str = "html",
299
+ ctx: Context = None,
300
+ ) -> dict[str, Any]:
301
+ """
302
+ Replace the full content of an existing Google Doc.
303
+
304
+ Content is interpreted as HTML by default. Pass content_format='markdown' to supply
305
+ Markdown instead. Headings, paragraphs, lists, links, tables, fenced code blocks,
306
+ and task list items are all supported. Tables are appended after all
307
+ paragraph content. Use this to populate a doc created manually in Drive (bypassing
308
+ service account storage quota limits).
309
+
310
+ Args:
311
+ doc_id: The Google Doc file ID.
312
+ content: Content to write into the document body.
313
+ content_format: 'html' (default) or 'markdown'
314
+
315
+ Returns:
316
+ Confirmation with the document ID and web link.
317
+ """
318
+ lc = ctx.request_context.lifespan_context
319
+ docs_service = lc.docs_service
320
+ drive_service = lc.drive_service
321
+
322
+ doc = docs_service.documents().get(documentId=doc_id).execute()
323
+ body_content = doc.get("body", {}).get("content", [])
324
+ end_index = body_content[-1].get("endIndex", 2) if body_content else 2
325
+
326
+ clear_requests = []
327
+ if end_index > 2:
328
+ clear_requests.append(
329
+ {"deleteContentRange": {"range": {"startIndex": 1, "endIndex": end_index - 1}}}
330
+ )
331
+
332
+ content_requests, tables = _to_doc_requests(content, content_format, start_index=1)
333
+ all_requests = clear_requests + content_requests
334
+ if all_requests:
335
+ docs_service.documents().batchUpdate(
336
+ documentId=doc_id, body={"requests": all_requests}
337
+ ).execute()
338
+ fill_tables(docs_service, doc_id, tables)
339
+
340
+ metadata = (
341
+ drive_service.files()
342
+ .get(fileId=doc_id, fields="webViewLink", supportsAllDrives=True)
343
+ .execute()
344
+ )
345
+
346
+ lc.doc_cache.mark_dirty(doc_id)
347
+ logger.debug("Wrote content to doc %s", doc_id)
348
+ return {"docId": doc_id, "web_link": metadata.get("webViewLink")}
349
+
350
+ @tool(annotations=ToolAnnotations(title="Get Document Structure", readOnlyHint=True))
351
+ def get_doc_structure(doc_id: str, ctx: Context = None) -> dict[str, Any]:
352
+ """
353
+ Return the full structural map of a Google Doc body with element indices.
354
+
355
+ Every batchUpdate operation (insert, delete, style, table) requires knowing
356
+ the startIndex and endIndex of the target element. This tool exposes those
357
+ indices alongside paragraph styles, text run formatting, and table cell positions.
358
+
359
+ Args:
360
+ doc_id: The Google Doc file ID.
361
+
362
+ Returns:
363
+ Dictionary with docId, title, and an elements list. Each element has:
364
+ - type: "paragraph" | "table" | "sectionBreak" | "tableOfContents"
365
+ - startIndex, endIndex
366
+ Paragraphs also include namedStyleType, text, and a runs list (each run
367
+ has text, bold, italic, underline, strikethrough, font_size, link_url).
368
+ Tables include rows, columns, and a cells list (each cell has row, col,
369
+ startIndex, endIndex, paragraphStartIndex, text).
370
+ """
371
+ lc = ctx.request_context.lifespan_context
372
+ try:
373
+ doc = lc.docs_service.documents().get(documentId=doc_id).execute()
374
+ except Exception as e:
375
+ return {"error": str(e)}
376
+
377
+ elements = []
378
+ for elem in doc.get("body", {}).get("content", []):
379
+ if "paragraph" in elem:
380
+ para = elem["paragraph"]
381
+ named_style = para.get("paragraphStyle", {}).get("namedStyleType", "NORMAL_TEXT")
382
+ runs = []
383
+ for pe in para.get("elements", []):
384
+ tr = pe.get("textRun")
385
+ if not tr:
386
+ continue
387
+ ts = tr.get("textStyle", {})
388
+ runs.append(
389
+ {
390
+ "text": tr.get("content", ""),
391
+ "bold": ts.get("bold"),
392
+ "italic": ts.get("italic"),
393
+ "underline": ts.get("underline"),
394
+ "strikethrough": ts.get("strikethrough"),
395
+ "font_size": ts["fontSize"].get("magnitude")
396
+ if "fontSize" in ts
397
+ else None,
398
+ "link_url": ts["link"].get("url") if "link" in ts else None,
399
+ }
400
+ )
401
+ elements.append(
402
+ {
403
+ "type": "paragraph",
404
+ "startIndex": elem.get("startIndex", 0),
405
+ "endIndex": elem.get("endIndex", 0),
406
+ "namedStyleType": named_style,
407
+ "text": "".join(r["text"] for r in runs),
408
+ "runs": runs,
409
+ }
410
+ )
411
+ elif "table" in elem:
412
+ table = elem["table"]
413
+ cells = []
414
+ for r, row in enumerate(table.get("tableRows", [])):
415
+ for c, cell in enumerate(row.get("tableCells", [])):
416
+ content = cell.get("content", [])
417
+ para_start = content[0].get("startIndex") if content else None
418
+ cell_text = "".join(
419
+ pe.get("textRun", {}).get("content", "")
420
+ for ce in content
421
+ if "paragraph" in ce
422
+ for pe in ce["paragraph"].get("elements", [])
423
+ )
424
+ cells.append(
425
+ {
426
+ "row": r,
427
+ "col": c,
428
+ "startIndex": cell.get("startIndex"),
429
+ "endIndex": cell.get("endIndex"),
430
+ "paragraphStartIndex": para_start,
431
+ "text": cell_text.strip(),
432
+ }
433
+ )
434
+ elements.append(
435
+ {
436
+ "type": "table",
437
+ "startIndex": elem.get("startIndex", 0),
438
+ "endIndex": elem.get("endIndex", 0),
439
+ "rows": table.get("rows", 0),
440
+ "columns": table.get("columns", 0),
441
+ "cells": cells,
442
+ }
443
+ )
444
+ elif "sectionBreak" in elem:
445
+ elements.append(
446
+ {
447
+ "type": "sectionBreak",
448
+ "startIndex": elem.get("startIndex", 0),
449
+ "endIndex": elem.get("endIndex", 0),
450
+ }
451
+ )
452
+ elif "tableOfContents" in elem:
453
+ elements.append(
454
+ {
455
+ "type": "tableOfContents",
456
+ "startIndex": elem.get("startIndex", 0),
457
+ "endIndex": elem.get("endIndex", 0),
458
+ }
459
+ )
460
+
461
+ logger.debug("get_doc_structure: %d elements in doc %s", len(elements), doc_id)
462
+ return {
463
+ "docId": doc.get("documentId"),
464
+ "title": doc.get("title"),
465
+ "elements": elements,
466
+ }
467
+
468
+ @tool(annotations=ToolAnnotations(title="Insert Document Text", destructiveHint=True))
469
+ def insert_doc_text(
470
+ doc_id: str,
471
+ insertions: list[dict],
472
+ ctx: Context = None,
473
+ ) -> dict[str, Any]:
474
+ """
475
+ Insert text at one or more positions in a Google Doc.
476
+
477
+ All indices are interpreted as positions in the document before any of
478
+ the insertions in this call. Insertions are applied high-index-first so
479
+ earlier indices are not shifted by later ones.
480
+
481
+ Use get_doc_structure to obtain the correct target indices.
482
+
483
+ Args:
484
+ doc_id: The Google Doc file ID.
485
+ insertions: List of insertion dicts. Required keys:
486
+ index (int): document position to insert at.
487
+ text (str): text to insert. "\\n" creates a new paragraph.
488
+ Optional keys:
489
+ segment_id (str): segmentId of the header or footer to insert into
490
+ (the headerId / footerId returned by create_header / create_footer).
491
+ Omit to insert into the document body.
492
+
493
+ Returns:
494
+ Confirmation with docId and count of insertions applied.
495
+ """
496
+ lc = ctx.request_context.lifespan_context
497
+ if not insertions:
498
+ return {"error": "insertions list is empty"}
499
+ try:
500
+ sorted_ops = sorted(insertions, key=lambda x: x["index"], reverse=True)
501
+ requests = []
502
+ for op in sorted_ops:
503
+ location: dict[str, Any] = {"index": op["index"]}
504
+ if "segment_id" in op:
505
+ location["segmentId"] = op["segment_id"]
506
+ requests.append({"insertText": {"location": location, "text": op["text"]}})
507
+ lc.docs_service.documents().batchUpdate(
508
+ documentId=doc_id, body={"requests": requests}
509
+ ).execute()
510
+ except Exception as e:
511
+ return {"error": str(e)}
512
+ lc.doc_cache.mark_dirty(doc_id)
513
+ logger.debug("insert_doc_text: %d insertions in doc %s", len(requests), doc_id)
514
+ return {"docId": doc_id, "insertions": len(requests)}
515
+
516
+ @tool(annotations=ToolAnnotations(title="Delete Document Range", destructiveHint=True))
517
+ def delete_doc_range(
518
+ doc_id: str,
519
+ deletions: list[dict],
520
+ ctx: Context = None,
521
+ ) -> dict[str, Any]:
522
+ """
523
+ Delete one or more content ranges from a Google Doc.
524
+
525
+ All indices are interpreted as positions in the document before any of
526
+ the deletions in this call. Deletions are applied high-index-first so
527
+ earlier indices are not shifted by later ones.
528
+
529
+ Use get_doc_structure to obtain the correct start/end indices.
530
+
531
+ Args:
532
+ doc_id: The Google Doc file ID.
533
+ deletions: List of {"start_index": int, "end_index": int}.
534
+ The range [start_index, end_index) is deleted (end_index is exclusive).
535
+ Do not include the document's final newline (last endIndex - 1).
536
+
537
+ Returns:
538
+ Confirmation with docId and count of deletions applied.
539
+ """
540
+ lc = ctx.request_context.lifespan_context
541
+ if not deletions:
542
+ return {"error": "deletions list is empty"}
543
+ try:
544
+ sorted_ops = sorted(deletions, key=lambda x: x["start_index"], reverse=True)
545
+ requests = [
546
+ {
547
+ "deleteContentRange": {
548
+ "range": {
549
+ "startIndex": op["start_index"],
550
+ "endIndex": op["end_index"],
551
+ }
552
+ }
553
+ }
554
+ for op in sorted_ops
555
+ ]
556
+ lc.docs_service.documents().batchUpdate(
557
+ documentId=doc_id, body={"requests": requests}
558
+ ).execute()
559
+ except Exception as e:
560
+ return {"error": str(e)}
561
+ lc.doc_cache.mark_dirty(doc_id)
562
+ logger.debug("delete_doc_range: %d deletions in doc %s", len(requests), doc_id)
563
+ return {"docId": doc_id, "deletions": len(requests)}
564
+
565
+ @tool(annotations=ToolAnnotations(title="Insert Inline Image", destructiveHint=True))
566
+ def insert_inline_image(
567
+ doc_id: str,
568
+ index: int,
569
+ uri: str | None = None,
570
+ drive_file_id: str | None = None,
571
+ width: float | None = None,
572
+ height: float | None = None,
573
+ ctx: Context = None,
574
+ ) -> dict[str, Any]:
575
+ """
576
+ Insert an inline image at a specific index in a Google Doc.
577
+
578
+ Provide either uri (a publicly accessible HTTPS image URL) or drive_file_id
579
+ (a Drive file ID for an image stored in Drive). The image is inserted at the
580
+ given document index.
581
+
582
+ Use get_doc_structure to find a suitable insertion index.
583
+
584
+ Args:
585
+ doc_id: The Google Doc file ID.
586
+ index: Document body index where the image should be inserted.
587
+ uri: A publicly accessible image URI (HTTPS). Mutually exclusive with drive_file_id.
588
+ drive_file_id: A Google Drive file ID for an image stored in Drive.
589
+ The file must be accessible by the authenticated user.
590
+ Mutually exclusive with uri.
591
+ width: Optional image width in points.
592
+ height: Optional image height in points.
593
+
594
+ Returns:
595
+ Confirmation with docId and the insertion index.
596
+ """
597
+ if not uri and not drive_file_id:
598
+ return {"error": "Provide either uri or drive_file_id"}
599
+ if uri and drive_file_id:
600
+ return {"error": "Provide only one of uri or drive_file_id, not both"}
601
+
602
+ lc = ctx.request_context.lifespan_context
603
+
604
+ if drive_file_id:
605
+ try:
606
+ metadata = (
607
+ lc.drive_service.files()
608
+ .get(fileId=drive_file_id, fields="webContentLink", supportsAllDrives=True)
609
+ .execute()
610
+ )
611
+ uri = metadata.get("webContentLink")
612
+ if not uri:
613
+ return {"error": f"Could not get download link for Drive file {drive_file_id}"}
614
+ except Exception as e:
615
+ return {"error": f"Failed to get Drive file metadata: {e}"}
616
+
617
+ image_request: dict[str, Any] = {
618
+ "insertInlineImage": {
619
+ "location": {"index": index},
620
+ "uri": uri,
621
+ }
622
+ }
623
+ if width is not None or height is not None:
624
+ object_size: dict[str, Any] = {}
625
+ if width is not None:
626
+ object_size["width"] = {"magnitude": width, "unit": "PT"}
627
+ if height is not None:
628
+ object_size["height"] = {"magnitude": height, "unit": "PT"}
629
+ image_request["insertInlineImage"]["objectSize"] = object_size
630
+
631
+ try:
632
+ lc.docs_service.documents().batchUpdate(
633
+ documentId=doc_id, body={"requests": [image_request]}
634
+ ).execute()
635
+ except Exception as e:
636
+ return {"error": str(e)}
637
+
638
+ lc.doc_cache.mark_dirty(doc_id)
639
+ logger.debug("insert_inline_image: at index %d in doc %s", index, doc_id)
640
+ return {"docId": doc_id, "index": index}