mcp-gee-sweet 0.7.0.dev69__tar.gz → 0.7.0.dev72__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 (136) hide show
  1. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/CLAUDE.md +1 -1
  2. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/PKG-INFO +1 -1
  3. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/configuration.md +3 -3
  4. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/tests/calendar.md +107 -0
  5. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/tests/drive.md +83 -0
  6. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/tools.md +4 -1
  7. mcp_gee_sweet-0.7.0.dev72/pyrightconfig.json +4 -0
  8. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/scripts/gen_tool_docs.py +17 -2
  9. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/calendar.py +82 -0
  10. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/drive/files.py +154 -0
  11. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/drive/test_files.py +224 -0
  12. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_calendar.py +141 -0
  13. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.claude/commands/prep-for-pr.md +0 -0
  14. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.dockerignore +0 -0
  15. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.github/dependabot.yml +0 -0
  19. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.github/workflows/ci.yml +0 -0
  20. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.github/workflows/docs.yml +0 -0
  21. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.github/workflows/publish-dev.yml +0 -0
  22. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.github/workflows/release.yml +0 -0
  23. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.gitignore +0 -0
  24. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.pre-commit-config.yaml +0 -0
  25. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/.python-version +0 -0
  26. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/CHANGELOG.md +0 -0
  27. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/CODE_OF_CONDUCT.md +0 -0
  28. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/CONTRIBUTING.md +0 -0
  29. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/Dockerfile +0 -0
  30. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/LICENSE +0 -0
  31. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/Makefile +0 -0
  32. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/README.md +0 -0
  33. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/SECURITY.md +0 -0
  34. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docker-compose.yml +0 -0
  35. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/auth.md +0 -0
  36. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/client-setup.md +0 -0
  37. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/decisions/decision-chart-theming.md +0 -0
  38. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/decisions/decision-composite-tools.md +0 -0
  39. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/decisions/decision-docs-formatting.md +0 -0
  40. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/decisions/decision-fork.md +0 -0
  41. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/decisions/decision-grid-data-size-cap.md +0 -0
  42. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/decisions/decision-publishing.md +0 -0
  43. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/decisions/decision-response-size-cap-generalization.md +0 -0
  44. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/decisions/decision-testing.md +0 -0
  45. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/decisions/index.md +0 -0
  46. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/design/docs-ast-pipeline.md +0 -0
  47. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/design/effectiveformat-spike.md +0 -0
  48. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/design/index.md +0 -0
  49. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/design/markdown-support.md +0 -0
  50. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/design/phase3-theme-system.md +0 -0
  51. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/design.md +0 -0
  52. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/index.md +0 -0
  53. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/known-limitations.md +0 -0
  54. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/notes-read.md +0 -0
  55. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/README.md +0 -0
  56. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  57. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  58. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/fixtures/tc-d213-dollar-escape.md +0 -0
  59. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  60. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/fixtures.template.md +0 -0
  61. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/operations.yaml +0 -0
  62. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/playwright_oauth.md +0 -0
  63. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/.gitkeep +0 -0
  64. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-06-02.md +0 -0
  65. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-06-14.md +0 -0
  66. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-06-17.md +0 -0
  67. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-06-18.md +0 -0
  68. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
  69. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
  70. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-06-28.md +0 -0
  71. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-07-04-docs.md +0 -0
  72. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-07-04-drive-smoke.md +0 -0
  73. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-07-04-infra.md +0 -0
  74. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-07-04-sheets_read.md +0 -0
  75. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/results/2026-07-04.md +0 -0
  76. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/retro-v0.8.0.md +0 -0
  77. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/run.md +0 -0
  78. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/runs/README.md +0 -0
  79. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/runs/v0.8.0.md +0 -0
  80. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/runs/v0.8.1.md +0 -0
  81. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/setup.md +0 -0
  82. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/tests/docs.md +0 -0
  83. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/tests/infra.md +0 -0
  84. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/tests/sheets_charts.md +0 -0
  85. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/tests/sheets_mgmt.md +0 -0
  86. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/tests/sheets_read.md +0 -0
  87. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa/tests/sheets_write.md +0 -0
  88. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/qa-checklist.md +0 -0
  89. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/docs/roadmap.md +0 -0
  90. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/mkdocs.yml +0 -0
  91. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/pyproject.toml +0 -0
  92. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/scripts/oauth_setup.py +0 -0
  93. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/.env.template +0 -0
  94. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/__init__.py +0 -0
  95. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/auth.py +0 -0
  96. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/cache.py +0 -0
  97. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/server.py +0 -0
  98. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  99. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/cache.py +0 -0
  100. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
  101. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/docs/ast.py +0 -0
  102. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/docs/content.py +0 -0
  103. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/docs/emitter.py +0 -0
  104. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/docs/html_parser.py +0 -0
  105. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
  106. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/docs/style.py +0 -0
  107. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
  108. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  109. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
  110. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  111. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/drive/transfer.py +0 -0
  112. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/response_limits.py +0 -0
  113. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  114. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
  115. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
  116. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
  117. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/__init__.py +0 -0
  118. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/drive/__init__.py +0 -0
  119. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/drive/test_activity.py +0 -0
  120. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/drive/test_sharing.py +0 -0
  121. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/drive/test_transfer.py +0 -0
  122. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/sheets/__init__.py +0 -0
  123. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/sheets/test_data.py +0 -0
  124. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/sheets/test_helpers.py +0 -0
  125. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/sheets/test_structure.py +0 -0
  126. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_auth.py +0 -0
  127. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_cache.py +0 -0
  128. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_docs_content.py +0 -0
  129. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_docs_core.py +0 -0
  130. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_docs_layout.py +0 -0
  131. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_docs_style.py +0 -0
  132. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_docs_tables.py +0 -0
  133. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_gen_tool_docs.py +0 -0
  134. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_response_limits.py +0 -0
  135. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/tests/test_server.py +0 -0
  136. {mcp_gee_sweet-0.7.0.dev69 → mcp_gee_sweet-0.7.0.dev72}/uv.lock +0 -0
@@ -34,7 +34,7 @@ Logic is split across `src/mcp_gee_sweet/`: `server.py` (MCP setup, tool decorat
34
34
  - `tools/sheets/data.py` — read/write cell data (`get_sheet_data`, `get_sheet_formulas`, `get_multiple_*`, `find_in_spreadsheet`, `clear_values`, `update_cells`, `batch_update_cells`, `batch_update`)
35
35
  - `tools/sheets/structure.py` — sheet structure (`list_sheets`, `copy_sheet`, `rename_sheet`, `create_sheet`, `delete_sheet`, `add_rows`, `add_columns`, `delete_rows`, `delete_columns`, `format_cells`, `merge_cells`, `unmerge_cells`, `freeze`, `sort_range`, `add_chart`)
36
36
  - `tools/sheets/helpers.py` — A1 notation helpers (`_parse_a1_notation`, `_column_index_to_letter`, etc.)
37
- - `tools/drive/files.py` — file/folder operations (`create_spreadsheet`, `list_spreadsheets`, `list_files`, `search_files`, `create_folder`, `copy_file`, `move_file`, `rename_file`, `delete_file`, `list_shared_with_me`, `list_recent_files`, `get_storage_quota`, etc.)
37
+ - `tools/drive/files.py` — file/folder operations (`create_spreadsheet`, `import_csv_to_sheet`, `list_spreadsheets`, `list_files`, `search_files`, `create_folder`, `copy_file`, `move_file`, `rename_file`, `delete_file`, `list_shared_with_me`, `list_recent_files`, `get_storage_quota`, etc.)
38
38
  - `tools/drive/sharing.py` — permissions (`share_spreadsheet`, `share_file`, `list_permissions`, `update_permission`, `remove_permission`)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-gee-sweet
3
- Version: 0.7.0.dev69
3
+ Version: 0.7.0.dev72
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
@@ -69,7 +69,7 @@ Access logs are emitted at `INFO` level — they appear when `DEBUG_LEVEL` is `D
69
69
 
70
70
  <!-- Auto-generated by `scripts/gen_tool_docs.py` — do not edit by hand, your changes will be overwritten on the next commit. -->
71
71
 
72
- By default all 87 tools are registered. Use `ENABLED_TOOLS` (or `--include-tools` on the CLI) to restrict the server to exactly the tools you need. This reduces the AI's context window cost — each registered tool is a name the model must reason about on every call.
72
+ By default all 90 tools are registered. Use `ENABLED_TOOLS` (or `--include-tools` on the CLI) to restrict the server to exactly the tools you need. This reduces the AI's context window cost — each registered tool is a name the model must reason about on every call.
73
73
 
74
74
  ```bash
75
75
  # Environment variable
@@ -90,7 +90,7 @@ get_sheet_data,get_sheet_formulas,get_multiple_sheet_data,get_multiple_spreadshe
90
90
 
91
91
  **Sheets read + write:**
92
92
  ```
93
- get_sheet_data,get_sheet_formulas,get_multiple_sheet_data,get_multiple_spreadsheet_summary,find_in_spreadsheet,list_sheets,list_spreadsheets,update_cells,batch_update_cells,create_sheet,rename_sheet,add_rows,add_columns
93
+ get_sheet_data,get_sheet_formulas,get_multiple_sheet_data,get_multiple_spreadsheet_summary,find_in_spreadsheet,list_sheets,list_spreadsheets,update_cells,batch_update_cells,create_sheet,rename_sheet,add_rows,add_columns,import_csv_to_sheet
94
94
  ```
95
95
 
96
96
  **Docs only:**
@@ -100,7 +100,7 @@ create_doc,get_doc_content,get_doc_structure,write_doc_content,insert_doc_text,i
100
100
 
101
101
  **Calendar only:**
102
102
  ```
103
- list_calendars,get_calendar,create_calendar,update_calendar,delete_calendar,list_events,get_event,create_event,update_event,delete_event,find_free_slots
103
+ list_calendars,get_calendar,create_calendar,update_calendar,delete_calendar,add_calendar_to_list,remove_calendar_from_list,list_events,get_event,create_event,update_event,delete_event,find_free_slots
104
104
  ```
105
105
 
106
106
  See [Tools](tools.md) for the full list of tool names.
@@ -243,6 +243,113 @@ Fixtures: see [`docs/qa/setup.md`](../setup.md). Substitute your `{CALENDAR_ID}`
243
243
 
244
244
  ---
245
245
 
246
+ ## `add_calendar_to_list`
247
+
248
+ > **Note on test targets:** subscribing/unsubscribing only makes sense for a calendar you don't own — Google's API rejects unsubscribing from a calendar you're the owner of (see TC-CAL59). Public holiday calendars (e.g. `en.japanese#holiday@group.v.calendar.google.com`, `en.canadian#holiday@group.v.calendar.google.com`) are convenient, safe, reversible targets: not owned by any test account, freely subscribable, and already used elsewhere in this account for `en.usa#holiday@group.v.calendar.google.com`.
249
+
250
+ ### TC-CAL53: Subscribe to an existing calendar ⚠️ destructive
251
+
252
+ **Setup:** a public calendar not currently in your list, e.g. `en.japanese#holiday@group.v.calendar.google.com` (Holidays in Japan)
253
+
254
+ **Prompt**
255
+ > "Subscribe me to calendar 'en.japanese#holiday@group.v.calendar.google.com'"
256
+
257
+ **Checks**
258
+ - Returns `id`, `summary`, `time_zone`, `access_role`, `primary`, `color_id`
259
+ - Calendar now appears in a follow-up `list_calendars` call (cache invalidated)
260
+
261
+ **Result (2026-07-05) ✅** — Response had `summary: "Holidays in Japan"`, `access_role: "reader"`, `color_id: "8"` (Google's default color for this public calendar). Confirmed present in a follow-up `list_calendars` call.
262
+
263
+ ---
264
+
265
+ ### TC-CAL54: Subscribe with a color ⚠️ destructive
266
+
267
+ **Setup:** another public calendar not yet in your list, e.g. `en.canadian#holiday@group.v.calendar.google.com` (Holidays in Canada)
268
+
269
+ **Prompt**
270
+ > "Subscribe me to calendar 'en.canadian#holiday@group.v.calendar.google.com' with color ID '7'"
271
+
272
+ **Checks**
273
+ - `color_id` in the response is `7`
274
+ - Calendar appears in `list_calendars`
275
+
276
+ **Result (2026-07-05) ✅** — Response had `color_id: "7"` and `summary: "Holidays in Canada"`. Confirmed present in `list_calendars`.
277
+
278
+ ---
279
+
280
+ ### TC-CAL55: Non-existent calendar ID
281
+
282
+ **Prompt**
283
+ > "Subscribe me to calendar 'totally-invalid-cal-id@example.com'"
284
+
285
+ **Checks**
286
+ - Returns `{"error": "..."}` — not a top-level exception
287
+ - No entry added to the calendar list
288
+
289
+ **Result (2026-07-05) ✅** — Returned `{"error": "<HttpError 404 ... 'reason': 'notFound' ...>"}`.
290
+
291
+ ---
292
+
293
+ ## `remove_calendar_from_list`
294
+
295
+ ### TC-CAL56: Unsubscribe from a calendar ⚠️ destructive
296
+
297
+ **Setup:** use the calendar subscribed to in TC-CAL53
298
+
299
+ **Prompt**
300
+ > "Remove calendar 'en.japanese#holiday@group.v.calendar.google.com' from my calendar list"
301
+
302
+ **Checks**
303
+ - Returns `{"calendar_id": "...", "action": "removed_from_list"}`
304
+ - Calendar no longer appears in a follow-up `list_calendars` call
305
+
306
+ **Result (2026-07-05) ✅** — Returned `{"calendar_id": "en.japanese#holiday@group.v.calendar.google.com", "action": "removed_from_list"}`. Confirmed absent from a follow-up `list_calendars` call (the still-subscribed "Holidays in Canada" from TC-CAL54 remained, confirming only the targeted subscription was removed).
307
+
308
+ ---
309
+
310
+ ### TC-CAL57: Unsubscribing does not delete the calendar ⚠️ destructive
311
+
312
+ **Setup:** the calendar removed from the list in TC-CAL56
313
+
314
+ **Prompt**
315
+ > "Subscribe me again to calendar 'en.japanese#holiday@group.v.calendar.google.com'"
316
+
317
+ **Checks**
318
+ - `add_calendar_to_list` succeeds and returns the same `summary` as TC-CAL53
319
+ - Confirms `remove_calendar_from_list` only removed the subscription — the underlying calendar was never affected and is still fully subscribable
320
+
321
+ **Result (2026-07-05) ✅** — Re-subscribed successfully; response had the identical `summary: "Holidays in Japan"` as TC-CAL53. Confirms `remove_calendar_from_list` does not touch the calendar resource itself.
322
+
323
+ ---
324
+
325
+ ### TC-CAL58: Non-existent calendar ID
326
+
327
+ **Prompt**
328
+ > "Remove calendar 'totally-invalid-cal-id@example.com' from my calendar list"
329
+
330
+ **Checks**
331
+ - Returns `{"error": "..."}` — not a top-level exception
332
+ - No side effects
333
+
334
+ **Result (2026-07-05) ✅** — Returned `{"error": "<HttpError 404 ... 'reason': 'notFound' ...>"}`.
335
+
336
+ ---
337
+
338
+ ### TC-CAL59: Cannot unsubscribe from a calendar you own
339
+
340
+ **Setup:** a calendar owned by the authenticated user (e.g. a fresh calendar from `create_calendar`)
341
+
342
+ **Prompt**
343
+ > "Remove calendar {OWNED_CALENDAR_ID} from my calendar list"
344
+
345
+ **Checks**
346
+ - Returns `{"error": "..."}` — not a top-level exception
347
+ - The error message is actionable and names `delete_calendar` as the correct tool — `remove_calendar_from_list` detects the `cannotUnsubscribeFromOwnedCalendar` reason specifically and substitutes a friendly message instead of passing through the raw `HttpError` repr
348
+
349
+ **Result (2026-07-05) ✅** — First pass (before the friendly-message special-case was added) returned the raw passthrough: `{"error": "<HttpError 403 ... 'reason': 'cannotUnsubscribeFromOwnedCalendar', 'message': 'The data owner of a calendar cannot remove such a calendar from their calendar list.' ...>"}`. After adding the special-case (reviewer feedback on #269), re-ran against a freshly created owned calendar and got `{"error": "Google does not allow removing a calendar you own from your own calendar list (reason: cannotUnsubscribeFromOwnedCalendar). Use delete_calendar instead to permanently delete it."}` — confirms the friendlier message is live and correctly names the fix.
350
+
351
+ ---
352
+
246
353
  ## `list_events`
247
354
 
248
355
  ### TC-CAL09: No time filters — upcoming events
@@ -2015,3 +2015,86 @@ Exporting even the small QA fixture spreadsheet as `xlsx` immediately exceeded t
2015
2015
  **Background:** `list_file_activity` is already Drive-API-paginated (`page_size` clamped 1–100) and low per-item size — the only realistic exposure is a single activity's `actors` list ballooning on a file with many collaborators. Per the #242 decision doc, this tool intentionally did NOT get a dedicated live-fixture verification (reproducing hundreds of real Drive Activity events isn't cheaply reproducible) — the cap was added for defense-in-depth only, verified by unit tests (`tests/drive/test_activity.py::TestListFileActivity::test_oversized_result_raises`, `test_error_points_to_page_size_not_local_path`).
2016
2016
 
2017
2017
  **Result (2026-07-03) ✅ N/A (by design)** — sanity-checked live that the tool still functions normally post-change (`list_file_activity(file_id={SPREADSHEET_ID}, page_size=5)` returned a normal activity list, no regression). Cap-triggering behavior covered by unit tests only, not live-verified — documented scoping decision, not an oversight.
2018
+
2019
+ ---
2020
+
2021
+ ## `import_csv_to_sheet` (issue #187)
2022
+
2023
+ ### TC-D169: Basic import creates a populated spreadsheet ⚠️ requires-oauth ⚠️ local-filesystem
2024
+ **Prompt**
2025
+ > "Import the CSV at `/tmp/qa-import.csv` into a new spreadsheet called 'QA-CSV-Import' in {FOLDER_ID}" *(create `/tmp/qa-import.csv` first with a header row and 3-4 data rows, e.g. `name,age\nAlice,30\nBob,25`)*
2026
+
2027
+ **Checks**
2028
+ - Response includes `spreadsheetId`, `title` exactly 'QA-CSV-Import', `web_link`, and `rows_written` matching the CSV's row count (including header)
2029
+ - `get_sheet_data` on the new spreadsheet's default sheet returns the same rows, in order
2030
+ - `drive_folder_cache.mark_dirty` fired — `list_files` on {FOLDER_ID} shows the new spreadsheet without a manual refresh
2031
+
2032
+ **Result (2026-07-05) ✅ PASS** Created spreadsheet with `rows_written: 4`, exact title, and a `web_link`. `get_sheet_data` returned `[["name","age"],["Alice","30"],["Bob","25"],["Carol","42"]]` — matches the source CSV exactly. `list_files` on `{FOLDER_ID}` showed the new spreadsheet immediately, confirming the folder cache invalidation.
2033
+
2034
+ ---
2035
+
2036
+ ### TC-D170: Custom sheet_name renames the default sheet ⚠️ requires-oauth ⚠️ local-filesystem
2037
+ **Prompt**
2038
+ > "Import `/tmp/qa-import.csv` into a new spreadsheet called 'QA-CSV-SheetName', writing to a sheet named 'Imported Data'"
2039
+
2040
+ **Checks**
2041
+ - `list_sheets` on the new spreadsheet shows exactly one sheet, named 'Imported Data' (not the default 'Sheet1')
2042
+ - Data is present on that sheet via `get_sheet_data`
2043
+
2044
+ **Result (2026-07-05) ✅ PASS** `list_sheets` returned exactly `["Imported Data"]` — the default 'Sheet1' was renamed, not left as a second sheet. `get_sheet_data(sheet="Imported Data")` returned all 4 rows intact.
2045
+
2046
+ ---
2047
+
2048
+ ### TC-D171: Grid auto-expands beyond the default 1000-row limit ⚠️ requires-oauth ⚠️ local-filesystem
2049
+ **Prompt**
2050
+ > "Import `/tmp/qa-import-large.csv` into a new spreadsheet called 'QA-CSV-Large'" *(generate a CSV with 1500+ rows first, e.g. a header plus 1500 numbered rows)*
2051
+
2052
+ **Checks**
2053
+ - Call succeeds without a grid-limit error (the documented pre-fix failure mode from the issue)
2054
+ - `rows_written` matches the CSV's row count
2055
+ - `get_sheet_data` confirms the last row of data is present and readable (not silently truncated at row 1000)
2056
+
2057
+ **Result (2026-07-05) ✅ PASS** Imported a 1501-row CSV (header + 1500 numbered rows) with no grid-limit error — `rows_written: 1501`. Fetched `A999:B1501` and confirmed rows 998–1500 are all present and correctly ordered, including the final row `["1500","row-1500"]` — no truncation at the default 1000-row boundary.
2058
+
2059
+ ---
2060
+
2061
+ ### TC-D172: Ragged rows padded to a common width ⚠️ requires-oauth ⚠️ local-filesystem
2062
+ **Prompt**
2063
+ > "Import `/tmp/qa-import-ragged.csv` into a new spreadsheet called 'QA-CSV-Ragged'" *(create a CSV where some rows have fewer columns than others, e.g. `a,b,c\n1,2\n`)*
2064
+
2065
+ **Checks**
2066
+ - No dropped or misaligned rows in `get_sheet_data` — short rows are padded with empty cells rather than shifted
2067
+ - `rows_written` counts every row, including the short one
2068
+
2069
+ **Result (2026-07-05) ✅ PASS** CSV `a,b,c / 1,2 / 4,5,6` (middle row missing column `c`) imported with `rows_written: 3`. `get_sheet_data` returned `[["a","b","c"],["1","2"],["4","5","6"]]` — the short row landed under columns a/b with no shift, and the third row wasn't dropped. (Sheets' values.get omits the trailing empty string we pad with, which is expected — the important signal is correct alignment, not a literal empty-string round-trip.)
2070
+
2071
+ ---
2072
+
2073
+ ### TC-D173: Non-existent local path (unit test)
2074
+
2075
+ **Checks (unit test)**
2076
+ - Missing `local_path` returns `{"error": "..."}` mentioning the path — no Drive API call made
2077
+
2078
+ **Result:** ✅ Unit test `test_file_not_found_returns_error` confirms this — no live Drive call needed since the function returns before touching `ctx`.
2079
+
2080
+ ---
2081
+
2082
+ ### TC-D174: Non-.csv extension rejected (unit test)
2083
+
2084
+ **Checks (unit test)**
2085
+ - A `.txt` (or other non-.csv) file returns `{"error": "..."}` mentioning `.csv`, without calling the Drive API
2086
+
2087
+ **Result:** ✅ Unit test `test_unsupported_extension_returns_error` confirms this.
2088
+
2089
+ ---
2090
+
2091
+ ### TC-D175: Service account Drive limitation
2092
+
2093
+ **Prompt**
2094
+ > "Import `/tmp/qa-import.csv` into a new spreadsheet called 'QA-CSV-SA-Limit' — I want to verify whether the service account can create in personal Drive"
2095
+
2096
+ **Checks**
2097
+ - 🔍 **Known limitation:** same as `create_spreadsheet` (TC-D04) — service account cannot create in personal Drive, only shared folders it has access to
2098
+ - Unit-level equivalent already covered: `test_storage_quota_error_returns_helpful_message`
2099
+
2100
+ **Result (2026-07-05) ✅ PASS** Against the service-account server (`mcp-gee-sweet-sa`), the call returned `{"error": "Service accounts cannot create or copy files in personal Drive (no storage quota). Use OAuth or ADC auth for full Drive write access, or use a Shared Drive destination. Check server://auth-status for your current auth method and affected tools."}` — the same `_SA_QUOTA_ERROR` message `create_spreadsheet` returns, confirming the shared error path works for this tool too.
@@ -2,7 +2,7 @@
2
2
 
3
3
  <!-- Auto-generated by `scripts/gen_tool_docs.py` — do not edit by hand, your changes will be overwritten on the next commit. -->
4
4
 
5
- 87 tools. All tool names are lowercase with underscores — use these exact strings with `ENABLED_TOOLS` or `--include-tools`.
5
+ 90 tools. All tool names are lowercase with underscores — use these exact strings with `ENABLED_TOOLS` or `--include-tools`.
6
6
 
7
7
  ## Sheets — data
8
8
 
@@ -47,6 +47,7 @@
47
47
  | Tool | Description | Key parameters |
48
48
  |---|---|---|
49
49
  | `create_spreadsheet` | Create a new Google Spreadsheet. | `title`, `folder_id?` |
50
+ | `import_csv_to_sheet` | Create a new Google Spreadsheet populated from a local CSV file. | `local_path`, `title`, `folder_id?`, `sheet_name?` |
50
51
  | `list_spreadsheets` | List all spreadsheets in the specified Google Drive folder. If no folder is specified, uses the configured default folder or lists from 'My Drive'. | `folder_id?` |
51
52
  | `search_spreadsheets` | Search for spreadsheets in Google Drive by name or content. | `query`, `max_results?` |
52
53
 
@@ -144,6 +145,8 @@ Requires the `drive.activity.readonly` scope; uses the Drive Activity API v2.
144
145
  | `create_calendar` | Create a new secondary calendar owned by the authenticated user. | `summary`, `description?`, `timezone?` |
145
146
  | `update_calendar` | Update fields on an existing calendar using patch semantics. Only the fields you provide are changed; omitted fields are left as-is. | `calendar_id`, `summary?`, `description?`, `timezone?`, `color_id?` |
146
147
  | `delete_calendar` | Permanently delete a calendar. For calendars you own, this deletes the calendar entirely for all users it is shared with — use with caution. To only stop seeing a calendar in your own list, use remove_calendar_from_list instead. | `calendar_id` |
148
+ | `add_calendar_to_list` | Subscribe the authenticated user (or service account) to a calendar, adding it to their calendar list so it appears in list_calendars. | `calendar_id`, `color_id?` |
149
+ | `remove_calendar_from_list` | Unsubscribe from a calendar, removing it from the authenticated user's calendar list. The calendar itself is not deleted and is unaffected for any other user it's shared with — use delete_calendar to permanently delete a calendar you own. | `calendar_id` |
147
150
  | `list_events` | List events in a calendar. | `calendar_id`, `time_min?`, `time_max?`, `query?`, `max_results?`, `expand_recurring?` |
148
151
  | `get_event` | Fetch a single event by calendar ID and event ID. | `calendar_id`, `event_id` |
149
152
  | `create_event` | Create a new event in a calendar. | `calendar_id`, `summary`, `start`, `end`, `description?`, `location?`, `attendees?`, `timezone?`, `recurrence?` |
@@ -0,0 +1,4 @@
1
+ {
2
+ "venvPath": ".",
3
+ "venv": ".venv"
4
+ }
@@ -97,7 +97,13 @@ SECTIONS = [
97
97
  "Spreadsheets (Drive-level)",
98
98
  lambda f: (
99
99
  f.__module__ == "mcp_gee_sweet.tools.drive.files"
100
- and f.__name__ in {"create_spreadsheet", "list_spreadsheets", "search_spreadsheets"}
100
+ and f.__name__
101
+ in {
102
+ "create_spreadsheet",
103
+ "list_spreadsheets",
104
+ "search_spreadsheets",
105
+ "import_csv_to_sheet",
106
+ }
101
107
  ),
102
108
  "`create_spreadsheet` cannot create files in a personal Drive when using "
103
109
  "service account auth — use OAuth or a Shared Drive.",
@@ -106,7 +112,13 @@ SECTIONS = [
106
112
  "Drive — files",
107
113
  lambda f: (
108
114
  f.__module__ == "mcp_gee_sweet.tools.drive.files"
109
- and f.__name__ not in {"create_spreadsheet", "list_spreadsheets", "search_spreadsheets"}
115
+ and f.__name__
116
+ not in {
117
+ "create_spreadsheet",
118
+ "list_spreadsheets",
119
+ "search_spreadsheets",
120
+ "import_csv_to_sheet",
121
+ }
110
122
  ),
111
123
  None,
112
124
  ),
@@ -194,6 +206,7 @@ SUBSETS = [
194
206
  "rename_sheet",
195
207
  "add_rows",
196
208
  "add_columns",
209
+ "import_csv_to_sheet",
197
210
  ],
198
211
  ),
199
212
  (
@@ -218,6 +231,8 @@ SUBSETS = [
218
231
  "create_calendar",
219
232
  "update_calendar",
220
233
  "delete_calendar",
234
+ "add_calendar_to_list",
235
+ "remove_calendar_from_list",
221
236
  "list_events",
222
237
  "get_event",
223
238
  "create_event",
@@ -2,11 +2,18 @@ import logging
2
2
  from datetime import datetime, timezone
3
3
  from typing import Any
4
4
 
5
+ from googleapiclient.errors import HttpError
5
6
  from mcp.server.fastmcp import Context
6
7
  from mcp.types import ToolAnnotations
7
8
 
8
9
  logger = logging.getLogger(__name__)
9
10
 
11
+ _CANNOT_UNSUBSCRIBE_OWNED_ERROR = (
12
+ "Google does not allow removing a calendar you own from your own calendar list "
13
+ "(reason: cannotUnsubscribeFromOwnedCalendar). Use delete_calendar instead to "
14
+ "permanently delete it."
15
+ )
16
+
10
17
 
11
18
  def register(tool):
12
19
  @tool(annotations=ToolAnnotations(title="List Calendars", readOnlyHint=True))
@@ -207,6 +214,81 @@ def register(tool):
207
214
  logger.debug("Deleted calendar %s", calendar_id)
208
215
  return {"calendar_id": calendar_id, "action": "deleted"}
209
216
 
217
+ @tool(annotations=ToolAnnotations(title="Add Calendar To List", destructiveHint=True))
218
+ def add_calendar_to_list(
219
+ calendar_id: str,
220
+ color_id: str | None = None,
221
+ ctx: Context = None,
222
+ ) -> dict[str, Any]:
223
+ """
224
+ Subscribe the authenticated user (or service account) to a calendar,
225
+ adding it to their calendar list so it appears in list_calendars.
226
+
227
+ This does not create or share a calendar — it only adds an existing,
228
+ already-accessible calendar to your own list. For service accounts,
229
+ this is the required step before a calendar shared with the service
230
+ account's email will show up in list_calendars.
231
+
232
+ Args:
233
+ calendar_id: The calendar ID to subscribe to (e.g. the owner's email
234
+ for a personal calendar, or a `...@group.calendar.google.com` ID).
235
+ color_id: Optional color ID from the palette returned by the Calendar
236
+ API's colors().get() endpoint (predefined IDs, typically '1'-'24').
237
+
238
+ Returns:
239
+ The new calendar list entry: id, summary, time_zone, access_role,
240
+ primary, color_id.
241
+ """
242
+ lc = ctx.request_context.lifespan_context
243
+
244
+ body: dict[str, Any] = {"id": calendar_id}
245
+ if color_id is not None:
246
+ body["colorId"] = color_id
247
+
248
+ try:
249
+ c = lc.calendar_service.calendarList().insert(body=body).execute()
250
+ except Exception as e:
251
+ return {"error": str(e)}
252
+
253
+ lc.calendar_cache.mark_dirty(c["id"])
254
+ logger.debug("Added calendar %s to calendar list", c["id"])
255
+ return {
256
+ "id": c["id"],
257
+ "summary": c.get("summary", ""),
258
+ "time_zone": c.get("timeZone"),
259
+ "access_role": c.get("accessRole"),
260
+ "primary": c.get("primary", False),
261
+ "color_id": c.get("colorId"),
262
+ }
263
+
264
+ @tool(annotations=ToolAnnotations(title="Remove Calendar From List", destructiveHint=True))
265
+ def remove_calendar_from_list(calendar_id: str, ctx: Context = None) -> dict[str, Any]:
266
+ """
267
+ Unsubscribe from a calendar, removing it from the authenticated user's
268
+ calendar list. The calendar itself is not deleted and is unaffected for
269
+ any other user it's shared with — use delete_calendar to permanently
270
+ delete a calendar you own.
271
+
272
+ Args:
273
+ calendar_id: The calendar ID to remove from the list.
274
+
275
+ Returns:
276
+ Confirmation with calendar_id and action 'removed_from_list'.
277
+ """
278
+ lc = ctx.request_context.lifespan_context
279
+ try:
280
+ lc.calendar_service.calendarList().delete(calendarId=calendar_id).execute()
281
+ except HttpError as e:
282
+ if e.resp.status == 403 and b"cannotUnsubscribeFromOwnedCalendar" in (e.content or b""):
283
+ return {"error": _CANNOT_UNSUBSCRIBE_OWNED_ERROR}
284
+ return {"error": str(e)}
285
+ except Exception as e:
286
+ return {"error": str(e)}
287
+
288
+ lc.calendar_cache.mark_dirty(calendar_id)
289
+ logger.debug("Removed calendar %s from calendar list", calendar_id)
290
+ return {"calendar_id": calendar_id, "action": "removed_from_list"}
291
+
210
292
  @tool(annotations=ToolAnnotations(title="List Events", readOnlyHint=True))
211
293
  def list_events(
212
294
  calendar_id: str,
@@ -1,15 +1,20 @@
1
+ import csv
1
2
  import logging
2
3
  from datetime import datetime, timedelta, timezone
4
+ from pathlib import Path
3
5
  from typing import Any
4
6
 
5
7
  from googleapiclient.errors import HttpError
6
8
  from mcp.server.fastmcp import Context
7
9
  from mcp.types import ToolAnnotations
8
10
 
11
+ from ..sheets.helpers import _quote_sheet_name
9
12
  from . import _SA_QUOTA_ERROR
10
13
 
11
14
  logger = logging.getLogger(__name__)
12
15
 
16
+ _CSV_IMPORT_CHUNK_ROWS = 5000
17
+
13
18
 
14
19
  def register(tool):
15
20
  @tool(annotations=ToolAnnotations(title="Create Spreadsheet", destructiveHint=True))
@@ -71,6 +76,155 @@ def register(tool):
71
76
  "folder": parents[0] if parents else "root",
72
77
  }
73
78
 
79
+ @tool(annotations=ToolAnnotations(title="Import CSV to Sheet", destructiveHint=True))
80
+ def import_csv_to_sheet(
81
+ local_path: str,
82
+ title: str,
83
+ folder_id: str | None = None,
84
+ sheet_name: str = "Sheet1",
85
+ ctx: Context = None,
86
+ ) -> dict[str, Any]:
87
+ """
88
+ Create a new Google Spreadsheet populated from a local CSV file.
89
+
90
+ Reads the CSV, creates the spreadsheet, expands the sheet's grid to fit
91
+ the data (avoiding the default 1000-row/26-column limit), and writes
92
+ every row in one or more batched value updates.
93
+
94
+ Args:
95
+ local_path: Absolute path to the local .csv file.
96
+ title: Title for the new spreadsheet.
97
+ folder_id: Optional Google Drive folder ID where the spreadsheet should
98
+ be created. If not provided, uses the configured default
99
+ folder or creates in root.
100
+ sheet_name: Name of the sheet the data is written to (default "Sheet1").
101
+
102
+ Returns:
103
+ spreadsheetId, title, web_link, and rows_written.
104
+
105
+ Note:
106
+ Requires OAuth or ADC auth. Service accounts cannot create files in personal
107
+ Drive (no storage quota). Works on Shared Drives regardless of auth method.
108
+ Check server://auth-status for your current auth method.
109
+ """
110
+ path = Path(local_path)
111
+ if not path.exists():
112
+ return {"error": f"File not found: {local_path}"}
113
+ if path.suffix.lower() != ".csv":
114
+ return {"error": f"Unsupported file extension '{path.suffix}'. Use .csv"}
115
+
116
+ with path.open(newline="", encoding="utf-8") as f:
117
+ rows = list(csv.reader(f))
118
+
119
+ if not rows:
120
+ return {"error": f"CSV file is empty: {local_path}"}
121
+
122
+ width = max(len(row) for row in rows)
123
+ rows = [row + [""] * (width - len(row)) for row in rows]
124
+
125
+ lc = ctx.request_context.lifespan_context
126
+ drive_service = lc.drive_service
127
+ sheets_service = lc.sheets_service
128
+ target_folder_id = folder_id or lc.folder_id
129
+
130
+ file_body = {"name": title, "mimeType": "application/vnd.google-apps.spreadsheet"}
131
+ if target_folder_id:
132
+ file_body["parents"] = [target_folder_id]
133
+
134
+ try:
135
+ spreadsheet = (
136
+ drive_service.files()
137
+ .create(
138
+ supportsAllDrives=True,
139
+ body=file_body,
140
+ fields="id, name, parents, webViewLink",
141
+ )
142
+ .execute()
143
+ )
144
+ except HttpError as e:
145
+ if e.resp.status == 403 and b"storageQuotaExceeded" in (e.content or b""):
146
+ return {"error": _SA_QUOTA_ERROR}
147
+ raise
148
+
149
+ spreadsheet_id = spreadsheet.get("id")
150
+ target_folder_id = target_folder_id or (spreadsheet.get("parents", [None])[0])
151
+
152
+ default_sheet = (
153
+ sheets_service.spreadsheets()
154
+ .get(
155
+ spreadsheetId=spreadsheet_id,
156
+ fields="sheets(properties(sheetId,title,gridProperties))",
157
+ )
158
+ .execute()
159
+ .get("sheets", [{}])[0]
160
+ )
161
+ default_props = default_sheet.get("properties", {})
162
+ sheet_id = default_props.get("sheetId", 0)
163
+ default_title = default_props.get("title")
164
+ grid = default_props.get("gridProperties", {})
165
+
166
+ requests: list[dict[str, Any]] = []
167
+ if default_title != sheet_name:
168
+ requests.append(
169
+ {
170
+ "updateSheetProperties": {
171
+ "properties": {"sheetId": sheet_id, "title": sheet_name},
172
+ "fields": "title",
173
+ }
174
+ }
175
+ )
176
+ needed_rows, needed_cols = len(rows), width
177
+ if needed_rows > grid.get("rowCount", 1000) or needed_cols > grid.get("columnCount", 26):
178
+ requests.append(
179
+ {
180
+ "updateSheetProperties": {
181
+ "properties": {
182
+ "sheetId": sheet_id,
183
+ "gridProperties": {
184
+ "rowCount": max(needed_rows, grid.get("rowCount", 1000)),
185
+ "columnCount": max(needed_cols, grid.get("columnCount", 26)),
186
+ },
187
+ },
188
+ "fields": "gridProperties.rowCount,gridProperties.columnCount",
189
+ }
190
+ }
191
+ )
192
+
193
+ if requests:
194
+ sheets_service.spreadsheets().batchUpdate(
195
+ spreadsheetId=spreadsheet_id, body={"requests": requests}
196
+ ).execute()
197
+ lc.cache.mark_dirty(spreadsheet_id)
198
+
199
+ quoted_sheet = _quote_sheet_name(sheet_name)
200
+ for start in range(0, len(rows), _CSV_IMPORT_CHUNK_ROWS):
201
+ chunk = rows[start : start + _CSV_IMPORT_CHUNK_ROWS]
202
+ sheets_service.spreadsheets().values().update(
203
+ spreadsheetId=spreadsheet_id,
204
+ range=f"{quoted_sheet}!A{start + 1}",
205
+ valueInputOption="USER_ENTERED",
206
+ body={"values": chunk},
207
+ ).execute()
208
+
209
+ if target_folder_id:
210
+ lc.drive_folder_cache.mark_dirty(target_folder_id)
211
+ lc.sheet_data_cache.mark_dirty(spreadsheet_id)
212
+
213
+ logger.debug(
214
+ "Imported CSV %s into spreadsheet %s (%d rows, %d cols)",
215
+ local_path,
216
+ spreadsheet_id,
217
+ len(rows),
218
+ width,
219
+ )
220
+
221
+ return {
222
+ "spreadsheetId": spreadsheet_id,
223
+ "title": spreadsheet.get("name", title),
224
+ "web_link": spreadsheet.get("webViewLink"),
225
+ "rows_written": len(rows),
226
+ }
227
+
74
228
  @tool(annotations=ToolAnnotations(title="List Spreadsheets", readOnlyHint=True))
75
229
  def list_spreadsheets(
76
230
  folder_id: str | None = None, ctx: Context = None