mcp-gee-sweet 0.7.0.dev53__tar.gz → 0.7.0.dev55__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 (127) hide show
  1. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/CLAUDE.md +1 -0
  2. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/PKG-INFO +1 -1
  3. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/configuration.md +1 -1
  4. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/decisions/decision-grid-data-size-cap.md +2 -0
  5. mcp_gee_sweet-0.7.0.dev55/docs/decisions/decision-response-size-cap-generalization.md +66 -0
  6. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/tests/docs.md +16 -0
  7. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/tests/drive.md +23 -0
  8. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/tests/sheets_read.md +41 -6
  9. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/roadmap.md +11 -10
  10. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/tools.md +3 -3
  11. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/.env.template +6 -4
  12. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/docs/content.py +41 -21
  13. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/drive/activity.py +13 -0
  14. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/drive/transfer.py +33 -40
  15. mcp_gee_sweet-0.7.0.dev55/src/mcp_gee_sweet/tools/response_limits.py +45 -0
  16. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/sheets/data.py +97 -86
  17. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/drive/test_activity.py +40 -0
  18. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/drive/test_transfer.py +46 -0
  19. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/sheets/test_data.py +78 -12
  20. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_docs_content.py +53 -0
  21. mcp_gee_sweet-0.7.0.dev55/tests/test_response_limits.py +104 -0
  22. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.claude/commands/prep-for-pr.md +0 -0
  23. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.dockerignore +0 -0
  24. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  25. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  26. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.github/dependabot.yml +0 -0
  27. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.github/workflows/ci.yml +0 -0
  28. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.github/workflows/docs.yml +0 -0
  29. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.github/workflows/publish-dev.yml +0 -0
  30. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.github/workflows/release.yml +0 -0
  31. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.gitignore +0 -0
  32. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.pre-commit-config.yaml +0 -0
  33. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/.python-version +0 -0
  34. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/CHANGELOG.md +0 -0
  35. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/CONTRIBUTING.md +0 -0
  36. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/Dockerfile +0 -0
  37. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/LICENSE +0 -0
  38. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/Makefile +0 -0
  39. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/README.md +0 -0
  40. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/SECURITY.md +0 -0
  41. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/TODO.md +0 -0
  42. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docker-compose.yml +0 -0
  43. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/auth.md +0 -0
  44. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/client-setup.md +0 -0
  45. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/decisions/decision-chart-theming.md +0 -0
  46. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/decisions/decision-composite-tools.md +0 -0
  47. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/decisions/decision-docs-formatting.md +0 -0
  48. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/decisions/decision-fork.md +0 -0
  49. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/decisions/decision-publishing.md +0 -0
  50. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/decisions/decision-testing.md +0 -0
  51. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/decisions/index.md +0 -0
  52. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/design/docs-ast-pipeline.md +0 -0
  53. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/design/effectiveformat-spike.md +0 -0
  54. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/design/index.md +0 -0
  55. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/design/markdown-support.md +0 -0
  56. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/design/phase3-theme-system.md +0 -0
  57. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/design.md +0 -0
  58. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/index.md +0 -0
  59. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/known-limitations.md +0 -0
  60. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/notes-read.md +0 -0
  61. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/README.md +0 -0
  62. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  63. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  64. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  65. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/fixtures.template.md +0 -0
  66. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/operations.yaml +0 -0
  67. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/playwright_oauth.md +0 -0
  68. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/results/.gitkeep +0 -0
  69. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/results/2026-06-02.md +0 -0
  70. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/results/2026-06-14.md +0 -0
  71. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/results/2026-06-17.md +0 -0
  72. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/results/2026-06-18.md +0 -0
  73. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
  74. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
  75. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/results/2026-06-28.md +0 -0
  76. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/retro-v0.8.0.md +0 -0
  77. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/run.md +0 -0
  78. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/runs/README.md +0 -0
  79. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/runs/v0.8.0.md +0 -0
  80. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/setup.md +0 -0
  81. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/tests/calendar.md +0 -0
  82. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/tests/infra.md +0 -0
  83. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/tests/sheets_charts.md +0 -0
  84. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/tests/sheets_mgmt.md +0 -0
  85. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa/tests/sheets_write.md +0 -0
  86. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/docs/qa-checklist.md +0 -0
  87. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/mkdocs.yml +0 -0
  88. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/pyproject.toml +0 -0
  89. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/scripts/gen_tool_docs.py +0 -0
  90. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/scripts/oauth_setup.py +0 -0
  91. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/__init__.py +0 -0
  92. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/auth.py +0 -0
  93. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/cache.py +0 -0
  94. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/server.py +0 -0
  95. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  96. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/cache.py +0 -0
  97. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/calendar.py +0 -0
  98. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
  99. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/docs/ast.py +0 -0
  100. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/docs/emitter.py +0 -0
  101. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/docs/html_parser.py +0 -0
  102. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
  103. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/docs/style.py +0 -0
  104. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
  105. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  106. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
  107. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  108. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  109. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
  110. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
  111. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/__init__.py +0 -0
  112. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/drive/__init__.py +0 -0
  113. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/drive/test_files.py +0 -0
  114. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/drive/test_sharing.py +0 -0
  115. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/sheets/__init__.py +0 -0
  116. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/sheets/test_helpers.py +0 -0
  117. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/sheets/test_structure.py +0 -0
  118. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_auth.py +0 -0
  119. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_cache.py +0 -0
  120. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_calendar.py +0 -0
  121. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_docs_core.py +0 -0
  122. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_docs_layout.py +0 -0
  123. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_docs_style.py +0 -0
  124. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_docs_tables.py +0 -0
  125. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_gen_tool_docs.py +0 -0
  126. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/tests/test_server.py +0 -0
  127. {mcp_gee_sweet-0.7.0.dev53 → mcp_gee_sweet-0.7.0.dev55}/uv.lock +0 -0
@@ -48,6 +48,7 @@ Logic is split across `src/mcp_gee_sweet/`: `server.py` (MCP setup, tool decorat
48
48
  - `docs/emitter.py` — AST→Docs API requests + multi-phase table fill including nested table and cell styling support
49
49
  - `tools/cache.py` — `refresh_cache`
50
50
  - `tools/calendar.py` — Calendar API tools
51
+ - `tools/response_limits.py` — shared response-size safety net (`enforce_response_size_cap`, `write_capped_result_to_disk`); imported cross-package the same way `tools/drive/__init__.py`'s `_SA_QUOTA_ERROR` is. Cap configured via `MAX_TOOL_RESPONSE_CHARS` (default 40000); see `docs/decisions/decision-response-size-cap-generalization.md`
51
52
 
52
53
  `__init__.py` loads `src/mcp_gee_sweet/.env` via `python-dotenv` before importing `server`, so env vars from that file are in `os.environ` by the time any module-level `os.environ.get()` runs. Priority: real env var > `.env` > default.
53
54
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-gee-sweet
3
- Version: 0.7.0.dev53
3
+ Version: 0.7.0.dev55
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
@@ -14,7 +14,7 @@
14
14
  | `ENABLED_TOOLS` | Comma-separated list of tool names to register | all tools |
15
15
  | `CACHE_DB_PATH` | Path to the SQLite cache database | `/tmp/mcp_gee_sweet.db` |
16
16
  | `CACHE_TTL` | Cache time-to-live in seconds | `1800` (30 min) |
17
- | `MAX_GRID_DATA_RESPONSE_CHARS` | Safety cap on `get_sheet_data(include_grid_data=True)` response size, in characters. Raise to match a raised `MAX_MCP_OUTPUT_TOKENS` in your MCP client (roughly 2 characters per token observed for this densely-formatted JSON) | `40000` |
17
+ | `MAX_TOOL_RESPONSE_CHARS` | Safety cap on response size, in characters, for tools that can return large inline payloads (`get_sheet_data`, `get_multiple_sheet_data`, `find_in_spreadsheet`, `get_doc_content`, `export_file`, `list_file_activity`). Raise to match a raised `MAX_MCP_OUTPUT_TOKENS` in your MCP client (roughly 2 characters per token observed for densely-formatted JSON) | `40000` |
18
18
  | `HOST` | Bind address for SSE transport | `0.0.0.0` |
19
19
  | `PORT` | Port for SSE transport | `8000` |
20
20
  | `DEBUG_LEVEL` | Logging level: `DEBUG`, `INFO`, `WARNING`, `ERROR` — controls package and access logs | — |
@@ -3,6 +3,8 @@
3
3
  **Date:** 2026-07-03
4
4
  **Snapshot commit:** branch `feat/issue-235-grid-data-range` — see `src/mcp_gee_sweet/tools/sheets/data.py`
5
5
 
6
+ **Update (2026-07-03, issue #242):** `MAX_GRID_DATA_RESPONSE_CHARS` referenced below was renamed to `MAX_TOOL_RESPONSE_CHARS` and generalized to 5 more tools — see `docs/decisions/decision-response-size-cap-generalization.md`. This doc is left as-is below as the historical record of the original decision.
7
+
6
8
  ## Background
7
9
 
8
10
  Issue #235: `get_sheet_data(include_grid_data=True)` without a `range` fetches the sheet's full padded grid (often 1000x26 by default, regardless of actual content). In the reported case, the server-side Sheets API call succeeded (`200` in under a second), but the MCP client reported "Connection closed" — the failure was downstream of this server, in the client/transport layer, with no exception for the server to catch.
@@ -0,0 +1,66 @@
1
+ # Decision: Generalizing the Response-Size Safety Net (issue #242)
2
+
3
+ **Date:** 2026-07-03
4
+ **Snapshot commit:** branch `feat/issue-242-response-size-safety-net` — see `src/mcp_gee_sweet/tools/response_limits.py`
5
+
6
+ ## Background
7
+
8
+ Issue #235 (see `docs/decisions/decision-grid-data-size-cap.md`) added a response-size safety net to exactly one tool, `get_sheet_data(include_grid_data=True)`: an MCP client (e.g. Claude Code, via `MAX_MCP_OUTPUT_TOKENS`) enforces a hard cap on tool response size, and exceeding it doesn't degrade gracefully — it silently kills the MCP session, requiring a full server restart to recover. That decision doc explicitly flagged #242 as the follow-up to generalize the pattern to other tools, and warned that `MAX_GRID_DATA_RESPONSE_CHARS` was scoped to one tool and wouldn't generalize as named.
9
+
10
+ This is a confirmed defect, not a hypothetical hardening gap: the silent-session-death failure mode has been observed live. What was unmapped was *which other tools* can trigger it and at what real thresholds — verifying that requires generating fixtures above the ceiling per tool, which gets most of the way to the fix anyway, so investigation and fix were scoped as one unit (`docs/roadmap.md`, v0.8.1).
11
+
12
+ ## Scope
13
+
14
+ Five more tools, in priority order by how easily they can produce an oversized response:
15
+
16
+ 1. `export_file` — base64-encodes binary content inline, unbounded, no existing size control
17
+ 2. `get_doc_content` — full unbounded plain-text export of a doc
18
+ 3. `get_multiple_sheet_data` — unbounded query list, each returning a full value range
19
+ 4. `find_in_spreadsheet` — bounds match *count* (`max_results`) but not match *size*
20
+ 5. `list_file_activity` — already Drive-API-paginated and lowest risk of the five
21
+
22
+ ## Decisions
23
+
24
+ ### 1. Single global env var, not per-tool
25
+
26
+ Considered three options: (A) one global `MAX_TOOL_RESPONSE_CHARS` for all 6 capped tools, (B) six separate per-tool vars, (C) a hybrid — global default with a per-tool override reserved for `export_file` (whose base64 encoding inflates raw file size ~33%, making it structurally different from the others).
27
+
28
+ **Chose (A), confirmed with the user.** One knob is simpler to discover and configure than six, and the marginal precision a per-tool override buys wasn't judged worth the added config surface — an operator who needs a materially higher ceiling for `export_file` specifically can raise the shared var, or use `download_file` instead (see below).
29
+
30
+ `MAX_GRID_DATA_RESPONSE_CHARS` (from #235) was renamed to `MAX_TOOL_RESPONSE_CHARS` as part of this change — free to do since #235 hadn't shipped in a released package version yet (still pre-release on `develop`). The cap constant and check logic now live in one shared module, `src/mcp_gee_sweet/tools/response_limits.py` (`enforce_response_size_cap`, `write_capped_result_to_disk`), imported cross-package by each tool module the same way `tools/drive/__init__.py`'s `_SA_QUOTA_ERROR` already is.
31
+
32
+ ### 2. `export_file` gets the cap but not `local_path`
33
+
34
+ `local_path` on the other four tools writes `json.dumps(result)` to disk as an escape hatch. For `export_file`, that would mean writing a JSON file *containing a base64 string* — a worse artifact than what already exists: `download_file` (pre-existing tool) writes the raw file bytes straight to disk with no base64 or JSON wrapping overhead. Adding a redundant, structurally worse bypass just for API symmetry with the other tools wasn't worth it. `export_file`'s cap error message points to `download_file` instead.
35
+
36
+ ### 3. `list_file_activity` gets the cap, no `local_path`, no dedicated live-fixture verification
37
+
38
+ Already 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. Included the cap for defense-in-depth (cheap — one shared-helper call), but:
39
+ - No `local_path`: the natural remedy for "too much" here is `page_size`/pagination, not writing timeline fragments to disk.
40
+ - No dedicated live-fixture verification (see below): reproducing hundreds of real Drive Activity events cheaply isn't practical, and the risk is real but rare.
41
+
42
+ ### 4. `get_doc_content` cache-ordering bug fixed while touched
43
+
44
+ The `doc_cache` early-return previously ran *before* any cap logic could — a cached oversized doc would bypass the cap on repeat calls. Fixed so the cap check runs on both the cache-hit and cache-miss paths (see `tests/test_docs_content.py::TestGetDocContent::test_cached_oversized_result_also_raises`).
45
+
46
+ ## Live-tested thresholds
47
+
48
+ Live-verified against the real QA fixture spreadsheet/docs on 2026-07-03, after reconnecting the MCP session to this branch. Full detail in `docs/qa/tests/sheets_read.md` (TC-R34, TC-R35), `docs/qa/tests/docs.md` (TC-DOC80), `docs/qa/tests/drive.md` (TC-D167, TC-D168).
49
+
50
+ | Tool | Real fixture | Measured size | Cap tripped? |
51
+ |---|---|---|---|
52
+ | `get_doc_content` | `TEST_LARGE_DOC_ID` grown to ~49,700 chars (seeded via `insert_doc_text`) | 49,700 chars | ✅ (fetch path and cached path both) |
53
+ | `export_file` (xlsx) | Small QA fixture spreadsheet (3 sheets, mostly tiny) | 54,280 chars | ✅ — even a small workbook's base64-encoded xlsx export exceeds the cap |
54
+ | `get_multiple_sheet_data` | 200 queries against a 6x4 range (`Sales` sheet) | 150,106 chars | ✅ — query *count* alone is a real vector, independent of per-query range size |
55
+ | `find_in_spreadsheet` | 10 cells x ~4,785 chars each, all matching (well under `max_results=50`) | 42,491 chars | ✅ — confirms `max_results` bounds count, not size |
56
+ | `list_file_activity` | N/A — no dedicated fixture, per the scoping decision above | N/A | Not live-tested; unit-tested only (defense-in-depth) |
57
+
58
+ Two findings worth calling out:
59
+
60
+ 1. **`export_file` trips the cap far more readily than the others.** A workbook small enough that its *values* fit trivially under the cap still blows past it once base64-encoded — this validates the concern raised during design (item 2 above) and confirms `download_file` is the practical default, not `export_file`, for anything but the smallest files.
61
+ 2. **`get_multiple_sheet_data`'s query-count axis is real and independent of range size** — 200 queries against a *tiny* 6x4 range (not a large one) already produced a 150K-character response. This means the cap protects against "many small things" just as much as "one big thing," which the original #235 cap (single range, single tool) never had to consider.
62
+
63
+ ## When to Re-evaluate
64
+
65
+ - If a materially different MCP client (different `MAX_MCP_OUTPUT_TOKENS` or equivalent) becomes a primary consumer, the shared `40000`-character default should be re-measured, not assumed to still be well-calibrated — same caveat #235 already carries.
66
+ - If `export_file` usage in practice shows the shared cap is routinely too restrictive for legitimate small-to-medium binary exports, revisit the per-tool-override hybrid (option C) that was considered and set aside here.
@@ -1352,3 +1352,19 @@ These test the HTML→AST→Docs API pipeline introduced in Phase 2 (#87). All u
1352
1352
 
1353
1353
  **Result (2026-06-25) ✅ PASS**
1354
1354
  - Tool completed without error. Structure: sectionBreak → HEADING_2 "HIGH\n" (1-6) → blank para "\n" (6-7, `font_size: 1` on its run confirming collapse applied) → table (7-70, cells filled correctly: Finding/Severity/Ticket header, Some finding/HIGH/KINDLY-123 data) → trailing para (70-71). Visual check: no visible gap between heading and table in the rendered doc.
1355
+
1356
+ ---
1357
+
1358
+ ### TC-DOC80: get_doc_content trips the response-size cap; cached path re-checks it too (issue #242)
1359
+
1360
+ **Background:** #242 generalized #235's response-size safety net to `get_doc_content`. `doc_cache` previously returned a cached result *before* any cap check ran, so a cached oversized doc would bypass the cap on repeat calls — fixed so the check runs on both the cache-hit and cache-miss paths.
1361
+
1362
+ **Setup:** `TEST_LARGE_DOC_ID` (`mcp-gee-sweet-qa-large-doc`), grown from its original ~5,300-character seed content to ~49,700 characters by inserting repeated padding text (permanent fixture growth — this doc's whole purpose is being a large-content fixture, and it was never previously large enough to exceed any cap since none existed for this tool before now).
1363
+
1364
+ **Checks**
1365
+ - First call (fetch path) raises `ValueError` mentioning the actual response size, the cap, and `MAX_TOOL_RESPONSE_CHARS`
1366
+ - Second call (cache-hit path, no `refresh_cache` in between) raises the *same* error — proves the cache-hit path re-checks the cap rather than returning the stale oversized cached result
1367
+ - Same call with `local_path` set succeeds, returns `{local_path, id, bytes_written}`, and the file on disk contains the full content
1368
+
1369
+ **Result (2026-07-03) ✅ PASS**
1370
+ Fetch-path call raised: `get_doc_content: the response is 49700 characters, over the 40000-character safety cap. Pass local_path to write the result to disk instead of returning it inline (bypasses this cap), or set MAX_TOOL_RESPONSE_CHARS if your MCP client can handle larger responses (e.g. a raised MAX_MCP_OUTPUT_TOKENS).` Repeat call (served from `doc_cache`, confirmed via no additional Drive API round-trip) raised the identical error — confirms the cache-ordering fix. `local_path` call succeeded: `{"local_path":"/tmp/qa_doc_content_242.json","bytes_written":49700,"id":"{TEST_LARGE_DOC_ID}"}`; file verified then cleaned up.
@@ -1992,3 +1992,26 @@ Fixtures: see [`docs/qa/setup.md`](../setup.md). Substitute your `{SPREADSHEET_I
1992
1992
  - When the Drive Activity API returns an HTTP 403 or 404, the tool returns `{"error": "..."}` rather than raising an exception
1993
1993
 
1994
1994
  **Result (2026-06-24) ✅** Unit test `test_http_error_returns_error_dict` confirms HTTP errors are caught and returned as `{"error": str(e)}`.
1995
+
1996
+ ---
1997
+
1998
+ ### TC-D167: export_file trips the response-size cap on base64-inflated content (issue #242)
1999
+
2000
+ **Background:** #242 generalized #235's response-size safety net to `export_file`. Unlike the other capped tools, `export_file` has no `local_path` param — its base64 output written to a JSON file would be a worse artifact than `download_file` (pre-existing tool) already produces, so the error points there instead.
2001
+
2002
+ **Prompt**
2003
+ > "Export {SPREADSHEET_ID} as xlsx"
2004
+
2005
+ **Checks**
2006
+ - Call raises `ValueError` mentioning the actual response size, the 40,000-character cap, base64's ~33% inflation, and `download_file` as the recommended alternative — must NOT mention `local_path` (this tool doesn't have that param)
2007
+
2008
+ **Result (2026-07-03) ✅ PASS**
2009
+ Exporting even the small QA fixture spreadsheet as `xlsx` immediately exceeded the cap: `export_file: the response is 54280 characters, over the 40000-character safety cap. Base64 encoding inflates raw file size by ~33%. Call download_file instead to write the file straight to disk without this overhead, or set MAX_TOOL_RESPONSE_CHARS if your MCP client can handle larger responses (e.g. a raised MAX_MCP_OUTPUT_TOKENS).` Confirms `export_file`'s cap trips far more readily than the other capped tools given base64 inflation — `download_file` is the practical default for anything but tiny files.
2010
+
2011
+ ---
2012
+
2013
+ ### TC-D168: list_file_activity response-size cap — code path only, no dedicated live fixture (issue #242)
2014
+
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
+
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.
@@ -67,11 +67,11 @@ Called live against the actual Sales fixture (`gridProperties: rowCount=3016, co
67
67
  **Setup**
68
68
  Apply formatting across a range with `format_cells(spreadsheet_id={SPREADSHEET_ID}, sheet="Sales", range="A1:Z200", bold=True, background_color={"red": 0.9, "green": 0.95, "blue": 1}, number_format_type="NUMBER")`, then call `get_sheet_data(spreadsheet_id={SPREADSHEET_ID}, sheet="Sales", range="A1:Z200", include_grid_data=True)`.
69
69
 
70
- Note: cell *count* alone doesn't predict this — a live test found a 26,000-cell blank range costs almost nothing (Sheets omits `rowData` for untouched cells), while densely-formatted ranges cost ~700-780 bytes/cell. The cap check runs on the actual serialized response, after the fetch, not an estimate from range size. A live binary search against Claude Code's default MCP client found the real failure point around 48,000-51,000 characters — consistent with Claude Code's documented 25,000-token default per-tool-response cap (`MAX_MCP_OUTPUT_TOKENS`) at ~2 chars/token for this kind of repeated-key JSON. The server-side default (`MAX_GRID_DATA_RESPONSE_CHARS=40000`) sits below that with margin.
70
+ Note: cell *count* alone doesn't predict this — a live test found a 26,000-cell blank range costs almost nothing (Sheets omits `rowData` for untouched cells), while densely-formatted ranges cost ~700-780 bytes/cell. The cap check runs on the actual serialized response, after the fetch, not an estimate from range size. A live binary search against Claude Code's default MCP client found the real failure point around 48,000-51,000 characters — consistent with Claude Code's documented 25,000-token default per-tool-response cap (`MAX_MCP_OUTPUT_TOKENS`) at ~2 chars/token for this kind of repeated-key JSON. The server-side default (`MAX_TOOL_RESPONSE_CHARS=40000`, named `MAX_GRID_DATA_RESPONSE_CHARS` at the time this test was first run — renamed and generalized to 5 more tools by issue #242) sits below that with margin.
71
71
 
72
72
  **Checks**
73
73
  - The Sheets API fetch itself succeeds (it's fast either way) — the error comes from the size check on the result, not a failed API call
74
- - Error message states the actual response size in characters, the cap, and mentions narrowing the range, `local_path`, or `MAX_GRID_DATA_RESPONSE_CHARS` as options
74
+ - Error message states the actual response size in characters, the cap, and mentions narrowing the range, `local_path`, or `MAX_TOOL_RESPONSE_CHARS` as options
75
75
  - Clean up: remove the formatting from A1:Z200 afterward so it doesn't affect other test cases
76
76
 
77
77
  **Result (2026-07-02) ✅ PASS**
@@ -96,16 +96,16 @@ Same scratch-sheet setup as TC-R03c. Call returned `{"local_path": "/tmp/qa_grid
96
96
 
97
97
  ---
98
98
 
99
- ### TC-R03e: MAX_GRID_DATA_RESPONSE_CHARS raises the cap (issue #235)
99
+ ### TC-R03e: MAX_TOOL_RESPONSE_CHARS raises the cap (issue #235)
100
100
 
101
101
  **Setup**
102
- Set `MAX_GRID_DATA_RESPONSE_CHARS=200000` in server config and restart the server (e.g. to match a `MAX_MCP_OUTPUT_TOKENS` raised in your MCP client config). Repeat the same call as TC-R03c: a formatted range whose response is over the *default* 40,000-character cap but under 200,000.
102
+ Set `MAX_TOOL_RESPONSE_CHARS=200000` in server config and restart the server (e.g. to match a `MAX_MCP_OUTPUT_TOKENS` raised in your MCP client config). Repeat the same call as TC-R03c: a formatted range whose response is over the *default* 40,000-character cap but under 200,000.
103
103
 
104
104
  **Checks**
105
105
  - Call now succeeds instead of raising — confirms the cap is actually read from the env var, not hardcoded
106
- - Restore `MAX_GRID_DATA_RESPONSE_CHARS` (unset it) after this test
106
+ - Restore `MAX_TOOL_RESPONSE_CHARS` (unset it) after this test
107
107
 
108
- **Result:** ⏳ Pending — not yet live-tested. Unlike TC-R03b-d, this requires a server restart with a changed env var (not just an MCP reconnect), which wasn't done this pass. Covered by unit tests (`test_cap_is_configurable`, `test_env_var_sets_cap_at_import_time` in `tests/sheets/test_data.py`) in the meantime.
108
+ **Result:** ⏳ Pending — not yet live-tested. Unlike TC-R03b-d, this requires a server restart with a changed env var (not just an MCP reconnect), which wasn't done this pass. Covered by unit tests (`test_cap_is_configurable` in `tests/sheets/test_data.py`, `test_env_var_sets_cap_at_import_time` in `tests/test_response_limits.py`) in the meantime. Var renamed from `MAX_GRID_DATA_RESPONSE_CHARS` by issue #242 (generalized to 5 more tools).
109
109
 
110
110
  ---
111
111
 
@@ -491,3 +491,38 @@ Clear `userEnteredFormat` from B2.
491
491
 
492
492
  **Result (2026-06-20) ✅ PASS**
493
493
  - `effectiveFormat.numberFormat = {type:"CURRENCY", pattern:"$#,##0.00"}`, `formattedValue = "$100.00"`, `effectiveValue.numberValue = 100`
494
+
495
+ ---
496
+
497
+ ### TC-R34: get_multiple_sheet_data — many small queries trips the response-size cap (issue #242)
498
+
499
+ **Background:** #242 generalized #235's response-size safety net beyond `get_sheet_data`. For `get_multiple_sheet_data`, the unbounded axis is query *count*, not just per-query range size — many small results can add up.
500
+
501
+ **Setup**
502
+ No fixture setup needed — repeat the same tiny query (`{spreadsheet_id: {SPREADSHEET_ID}, sheet: "Sales"}`, a 6x4 range) 200 times in one `queries` list.
503
+
504
+ **Checks**
505
+ - Call raises `ValueError` mentioning the actual response size, the 40,000-character cap, and `MAX_TOOL_RESPONSE_CHARS`
506
+ - Same call with only 2 queries + `local_path` set succeeds, returns `{local_path, query_count, bytes_written}`, and the file on disk contains the full per-query results
507
+
508
+ **Result (2026-07-03) ✅ PASS**
509
+ 200 queries against the small `Sales` range (6 rows x 4 cols) raised: `get_multiple_sheet_data: the response is 150106 characters, over the 40000-character safety cap. Pass local_path to write the result to disk instead of returning it inline (bypasses this cap), or set MAX_TOOL_RESPONSE_CHARS if your MCP client can handle larger responses (e.g. a raised MAX_MCP_OUTPUT_TOKENS).` `local_path` with 2 queries succeeded, returned `{"local_path":"/tmp/qa_multiple_sheet_data_242.json","bytes_written":2048,"query_count":2}`; file verified on disk then cleaned up.
510
+
511
+ ---
512
+
513
+ ### TC-R35: find_in_spreadsheet — max_results bounds count, not size (issue #242)
514
+
515
+ **Background:** `max_results` (default 50) caps how many matches are returned, not how large each matched cell value is. A handful of large matching cells can exceed the response-size cap even with few matches.
516
+
517
+ **Setup**
518
+ Write 10 cells (`Empty!A1:A10`) each containing a ~4,785-character string with a shared marker substring (e.g. repeated "PADTEST marker text..." sentence), then search for the marker.
519
+
520
+ **Checks**
521
+ - Call raises `ValueError` mentioning the actual response size and the cap, even though match count (10) is well under `max_results` (50)
522
+ - Same call with `local_path` set succeeds, returns `{local_path, spreadsheet_id, query, match_count, bytes_written}`, and the file on disk contains all matches
523
+
524
+ **Teardown**
525
+ `clear_values` on the `Empty` sheet to remove the test data.
526
+
527
+ **Result (2026-07-03) ✅ PASS**
528
+ 10 matches (42,491 chars total) raised: `find_in_spreadsheet: the response is 42491 characters, over the 40000-character safety cap. ...` despite being far under the `max_results=50` default — confirming match-count capping alone doesn't bound response size. `local_path` call succeeded: `{"local_path":"/tmp/qa_find_in_spreadsheet_242.json","bytes_written":42491,"spreadsheet_id":"...","query":"PADTEST","match_count":10}`; file verified then cleaned up. Test data cleared from the `Empty` sheet afterward.
@@ -31,12 +31,12 @@ Features are ordered by practical priority within each tier, cross-domain. Items
31
31
 
32
32
  | Version | Scope | Signal |
33
33
  |---------|-------|--------|
34
- | **v0.7.0** | ✅ First stable PyPI release — 63 tools across Sheets, Drive, Docs, Calendar | Published 2026-06-21 |
35
- | **v0.8.0** | ✅ Tier 1 complete — all "frequently needed" items across all domains (84 tools) | Published 2026-06-29 |
36
- | **v0.8.1** | Defect & documentation cleanup — no new tools, ships the QA/refactor work already on `develop` plus fixes for #235, #213, #239, #236 | Stabilizes before Tier 2 feature work begins |
37
- | **v0.9.0** | Tier 2 complete — power-user and structured-work layer, features only (~20 tools) | Covers most real workflows |
38
- | **v1.0.0** | API stability declaration — Tier 3 items that make the cut + any breaking cleanups from v0.8–0.9 | Backwards-compatibility commitment |
39
- | **v1.1.0+** | Future domains — Tasks, Gmail (separate minor releases, each needs a new API client) | Expanded scope |
34
+ | [**v0.7.0**](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av0.7) | ✅ First stable PyPI release — 63 tools across Sheets, Drive, Docs, Calendar | Published 2026-06-21 |
35
+ | [**v0.8.0**](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av0.8) | ✅ Tier 1 complete — all "frequently needed" items across all domains (84 tools) | Published 2026-06-29 |
36
+ | [**v0.8.1**](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av0.8.1) | Defect & documentation cleanup — no new tools, ships the QA/refactor work already on `develop` plus fixes for #235, #242, #213, #239, #236 | Stabilizes before Tier 2 feature work begins |
37
+ | [**v0.9.0**](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av0.9) | Tier 2 complete — power-user and structured-work layer, features only (~20 tools) | Covers most real workflows |
38
+ | [**v1.0.0**](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av1.0) | API stability declaration — Tier 3 items that make the cut + any breaking cleanups from v0.8–0.9 | Backwards-compatibility commitment |
39
+ | [**v1.1.0+**](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3A%22v1.1%2B%22) | Future domains — Tasks, Gmail (separate minor releases, each needs a new API client) | Expanded scope |
40
40
 
41
41
  Tier 4 items remain backlog with no assigned version.
42
42
 
@@ -44,7 +44,7 @@ Tier 4 items remain backlog with no assigned version.
44
44
 
45
45
  ## Roadmap
46
46
 
47
- ### Tier 1 — High value, frequently needed _(target: v0.8.0)_
47
+ ### Tier 1 — High value, frequently needed _(target: [v0.8.0](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av0.8))_
48
48
 
49
49
  **Infrastructure**
50
50
  - [x] PyPI publish — OIDC trusted publishing ([#55](https://github.com/khuisman/mcp-gee-sweet/issues/55)) — v0.7.0 stable live; `uvx mcp-gee-sweet` works
@@ -71,7 +71,7 @@ Tier 4 items remain backlog with no assigned version.
71
71
  - [x] `list_recent_files` — files recently accessed or modified ([#136](https://github.com/khuisman/mcp-gee-sweet/issues/136))
72
72
  - [x] `get_storage_quota` — Drive storage usage and limits ([#137](https://github.com/khuisman/mcp-gee-sweet/issues/137))
73
73
 
74
- ### v0.8.1 — Defect & documentation cleanup _(target: v0.8.1, before Tier 2 begins)_
74
+ ### v0.8.1 — Defect & documentation cleanup _(target: [v0.8.1](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av0.8.1), before Tier 2 begins)_
75
75
 
76
76
  No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature work.
77
77
 
@@ -83,6 +83,7 @@ No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature wo
83
83
 
84
84
  **Remaining defects to fix before cutting the release**
85
85
  - [ ] `get_sheet_data(include_grid_data=True)` without a range fetches the full padded grid instead of the used range ([#235](https://github.com/khuisman/mcp-gee-sweet/issues/235))
86
+ - [x] Generalize the #235 response-size safety net (post-fetch size check + configurable cap + `local_path` bypass) to other tools that can return large responses — `export_file`, `get_doc_content`, `get_multiple_sheet_data`, `find_in_spreadsheet`, `list_file_activity`. Oversized responses silently kill the MCP session today (client-side output cap, e.g. Claude Code's `MAX_MCP_OUTPUT_TOKENS`), requiring a server restart to recover — confirmed live, not hypothetical. Renamed `MAX_GRID_DATA_RESPONSE_CHARS` to `MAX_TOOL_RESPONSE_CHARS` (single global cap); live-verified thresholds per tool ([#242](https://github.com/khuisman/mcp-gee-sweet/issues/242))
86
87
  - [ ] `create_doc_from_file` markdown `$` escape renders as literal backslash+dollar in the doc ([#213](https://github.com/khuisman/mcp-gee-sweet/issues/213))
87
88
  - [ ] Unknown tool parameters are silently dropped instead of raising a validation error — FastMCP's generated pydantic arg models default to ignoring extra fields, so a typo'd kwarg (e.g. `parent_id` instead of `parent_folder_id`) silently falls back to default behavior instead of erroring ([#239](https://github.com/khuisman/mcp-gee-sweet/issues/239))
88
89
  - [x] Auto-generate `docs/tools.md` from tool source as a pre-commit hook — makes the manual tools.md backfill in #236 unnecessary (PR #240) ([#94](https://github.com/khuisman/mcp-gee-sweet/issues/94))
@@ -90,7 +91,7 @@ No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature wo
90
91
  - [ ] Rewrite `CONTRIBUTING.md` — fix broken links to removed README anchors, fix stale "Available Tool Names" and `bug`-label references, add better local-dev setup examples including observability (`DEBUG_LEVEL`/`LOG_FILE`/`ACCESS_LOG_FILE`) ([#95](https://github.com/khuisman/mcp-gee-sweet/issues/95))
91
92
  - [ ] Define and document community PR expectations — template, testing bar for non-tool PRs, scope/size convention, CLA/DCO/code-of-conduct decision, review turnaround ([#237](https://github.com/khuisman/mcp-gee-sweet/issues/237))
92
93
 
93
- ### Tier 2 — Useful for structured work, features only _(target: v0.9.0)_
94
+ ### Tier 2 — Useful for structured work, features only _(target: [v0.9.0](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av0.9))_
94
95
 
95
96
  **Sheets**
96
97
  - [ ] `update_borders` — border style, width, color on a range ([#122](https://github.com/khuisman/mcp-gee-sweet/issues/122)) _(freema/mcp-gsheets)_
@@ -129,7 +130,7 @@ No new tools. Stabilize on what Tier 1 shipped before starting Tier 2 feature wo
129
130
  **Infrastructure**
130
131
  - [ ] Cache reliability & configurability — runtime-configurable TTL, smarter invalidation for shared files ([#99](https://github.com/khuisman/mcp-gee-sweet/issues/99)); harden concurrent-session access — fail-open on cache read/write errors, `busy_timeout` ([#234](https://github.com/khuisman/mcp-gee-sweet/issues/234))
131
132
 
132
- ### Tier 3 — Advanced / occasionally needed _(target: v1.0.0)_
133
+ ### Tier 3 — Advanced / occasionally needed _(target: [v1.0.0](https://github.com/khuisman/mcp-gee-sweet/issues?q=is%3Aissue+label%3Av1.0))_
133
134
 
134
135
  **Sheets**
135
136
  - [ ] `add_conditional_formatting` / `delete_conditional_formatting` ([#129](https://github.com/khuisman/mcp-gee-sweet/issues/129)) _(freema/mcp-gsheets)_
@@ -10,9 +10,9 @@
10
10
  |---|---|---|
11
11
  | `get_sheet_data` | Get data from a specific sheet in a Google Spreadsheet. | `spreadsheet_id`, `sheet`, `range?`, `include_grid_data?`, `local_path?` |
12
12
  | `get_sheet_formulas` | Get formulas from a specific sheet in a Google Spreadsheet. | `spreadsheet_id`, `sheet`, `range?` |
13
- | `get_multiple_sheet_data` | Get data from multiple specific ranges in Google Spreadsheets. | `queries` |
13
+ | `get_multiple_sheet_data` | Get data from multiple specific ranges in Google Spreadsheets. | `queries`, `local_path?` |
14
14
  | `get_multiple_spreadsheet_summary` | Get a summary of multiple Google Spreadsheets, including sheet names, headers, and the first few rows of data for each sheet. | `spreadsheet_ids`, `rows_to_fetch?` |
15
- | `find_in_spreadsheet` | Find cells containing a specific value in a Google Spreadsheet. | `spreadsheet_id`, `query`, `sheet?`, `case_sensitive?`, `max_results?` |
15
+ | `find_in_spreadsheet` | Find cells containing a specific value in a Google Spreadsheet. | `spreadsheet_id`, `query`, `sheet?`, `case_sensitive?`, `max_results?`, `local_path?` |
16
16
  | `clear_values` | Clear cell content in a range without touching formatting. | `spreadsheet_id`, `sheet`, `range?` |
17
17
  | `update_cells` | Update cells in a Google Spreadsheet. | `spreadsheet_id`, `sheet`, `range`, `data` |
18
18
  | `batch_update_cells` | Batch update multiple ranges in a Google Spreadsheet. | `spreadsheet_id`, `sheet`, `ranges` |
@@ -112,7 +112,7 @@ Requires the `drive.activity.readonly` scope; uses the Drive Activity API v2.
112
112
  |---|---|---|
113
113
  | `create_doc` | Create a new Google Doc, optionally with initial content. | `title`, `content?`, `folder_id?`, `content_format?` |
114
114
  | `create_doc_from_file` | Create a Google Doc from a local .md or .html file. | `local_path`, `title?`, `folder_id?` |
115
- | `get_doc_content` | Get the plain text content of a Google Doc. | `file_id` |
115
+ | `get_doc_content` | Get the plain text content of a Google Doc. | `file_id`, `local_path?` |
116
116
  | `write_doc_content` | Replace the full content of an existing Google Doc. | `doc_id`, `content`, `content_format?` |
117
117
  | `get_doc_structure` | Return the full structural map of a Google Doc body with element indices. | `doc_id` |
118
118
  | `insert_doc_text` | Insert text at one or more positions in a Google Doc. | `doc_id`, `insertions` |
@@ -29,10 +29,12 @@ ACCESS_LOG_FILE=/tmp/mcp-gee-sweet-access.log
29
29
  # CACHE_TTL=1800
30
30
  # CACHE_DB_PATH=/tmp/mcp_gee_sweet.db
31
31
 
32
- # Safety cap on get_sheet_data(include_grid_data=True) response size, in characters.
33
- # Raise to match a raised MAX_MCP_OUTPUT_TOKENS in your MCP client (~2 chars/token
34
- # observed for this densely-formatted JSON see docs/qa/tests/sheets_read.md TC-R03c/d).
35
- # MAX_GRID_DATA_RESPONSE_CHARS=40000
32
+ # Safety cap on response size, in characters, for tools that can return large inline
33
+ # payloads (get_sheet_data, get_multiple_sheet_data, find_in_spreadsheet,
34
+ # get_doc_content, export_file, list_file_activity). Raise to match a raised
35
+ # MAX_MCP_OUTPUT_TOKENS in your MCP client (~2 chars/token observed for densely
36
+ # formatted JSON — see docs/qa/tests/sheets_read.md TC-R03c/d).
37
+ # MAX_TOOL_RESPONSE_CHARS=40000
36
38
 
37
39
  # QA OAuth automation (scripts/oauth_setup.py — see docs/qa/playwright_oauth.md)
38
40
  # GOOGLE_TEST_EMAIL=
@@ -10,6 +10,7 @@ from mcp.server.fastmcp import Context
10
10
  from mcp.types import ToolAnnotations
11
11
 
12
12
  from ..drive import _SA_QUOTA_ERROR
13
+ from ..response_limits import enforce_response_size_cap, write_capped_result_to_disk
13
14
  from .ast import Table
14
15
  from .emitter import ast_to_requests, fill_tables
15
16
  from .html_parser import html_to_ast
@@ -251,44 +252,63 @@ def register(tool):
251
252
  }
252
253
 
253
254
  @tool(annotations=ToolAnnotations(title="Get Document Content", readOnlyHint=True))
254
- def get_doc_content(file_id: str, ctx: Context = None) -> dict[str, Any]:
255
+ def get_doc_content(
256
+ file_id: str, local_path: str | None = None, ctx: Context = None
257
+ ) -> dict[str, Any]:
255
258
  """
256
259
  Get the plain text content of a Google Doc.
257
260
 
258
261
  Args:
259
262
  file_id: The Google Drive file ID of the document.
263
+ local_path: Optional local filesystem path (file or directory) to write the
264
+ result to instead of returning it inline. Bypasses the response-size cap.
265
+ Same caveat as download_file/download_folder: this path is resolved on the
266
+ *server's* filesystem, not the caller's.
260
267
 
261
268
  Returns:
262
269
  Dictionary with the document's text content and metadata. Results are
263
270
  cached; call refresh_cache(doc_id=file_id) to invalidate, or
264
- refresh_cache() to clear all caches.
271
+ refresh_cache() to clear all caches. Raises ValueError if the response
272
+ exceeds a safety cap (default 40,000 characters, set MAX_TOOL_RESPONSE_CHARS
273
+ to change it) and local_path is not set. If local_path is set, returns
274
+ {local_path, id, bytes_written} instead.
265
275
  """
266
276
  lc = ctx.request_context.lifespan_context
267
277
  drive_service = lc.drive_service
268
278
  doc_cache = lc.doc_cache
269
279
 
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
280
+ result = doc_cache.get(file_id)
281
+ if result is None:
282
+ metadata = (
283
+ drive_service.files()
284
+ .get(
285
+ fileId=file_id,
286
+ fields="id, name, modifiedTime, webViewLink",
287
+ supportsAllDrives=True,
288
+ )
289
+ .execute()
278
290
  )
279
- .execute()
280
- )
281
291
 
282
- content = drive_service.files().export(fileId=file_id, mimeType="text/plain").execute()
292
+ content = drive_service.files().export(fileId=file_id, mimeType="text/plain").execute()
283
293
 
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)
294
+ result = {
295
+ "id": metadata["id"],
296
+ "name": metadata["name"],
297
+ "modified_time": metadata.get("modifiedTime"),
298
+ "web_link": metadata.get("webViewLink"),
299
+ "content": content.decode("utf-8") if isinstance(content, bytes) else content,
300
+ }
301
+ doc_cache.store(file_id, result)
302
+
303
+ if local_path:
304
+ return write_capped_result_to_disk(
305
+ result,
306
+ local_path,
307
+ default_filename=f"{file_id}_content.json",
308
+ manifest_extra={"id": file_id},
309
+ )
310
+
311
+ enforce_response_size_cap(result, tool_name="get_doc_content")
292
312
  return result
293
313
 
294
314
  @tool(annotations=ToolAnnotations(title="Write Document Content", destructiveHint=True))
@@ -5,6 +5,8 @@ from googleapiclient.errors import HttpError
5
5
  from mcp.server.fastmcp import Context
6
6
  from mcp.types import ToolAnnotations
7
7
 
8
+ from ..response_limits import enforce_response_size_cap
9
+
8
10
  logger = logging.getLogger(__name__)
9
11
 
10
12
  # Maps Drive Activity API primaryActionDetail keys to human-readable action types.
@@ -83,6 +85,10 @@ def register(tool):
83
85
  - file_id: echoed back for reference
84
86
  - activities: list of activity entries (timestamp, action, actors)
85
87
  - next_page_token: present when more results are available
88
+ Raises ValueError if the response exceeds a safety cap (default 40,000
89
+ characters, set MAX_TOOL_RESPONSE_CHARS to change it) — lower page_size and
90
+ paginate via next_page_token instead of raising the cap; a single activity's
91
+ actors list can be large on files with many collaborators.
86
92
 
87
93
  Note:
88
94
  The Drive Activity API requires the drive.activity.readonly OAuth scope.
@@ -118,5 +124,12 @@ def register(tool):
118
124
  if npt := response.get("nextPageToken"):
119
125
  result["next_page_token"] = npt
120
126
 
127
+ enforce_response_size_cap(
128
+ result,
129
+ tool_name="list_file_activity",
130
+ hint="Lower page_size and paginate via next_page_token, or ",
131
+ local_path_available=False,
132
+ )
133
+
121
134
  logger.debug("list_file_activity: %d activities for file %s", len(activities), file_id)
122
135
  return result
@@ -12,6 +12,7 @@ from googleapiclient.http import MediaFileUpload, MediaInMemoryUpload, MediaIoBa
12
12
  from mcp.server.fastmcp import Context
13
13
  from mcp.types import ToolAnnotations
14
14
 
15
+ from ..response_limits import enforce_response_size_cap
15
16
  from . import _SA_QUOTA_ERROR
16
17
 
17
18
  logger = logging.getLogger(__name__)
@@ -80,7 +81,12 @@ def register(tool):
80
81
  Returns:
81
82
  fileId, name, mime_type, format, encoding ('utf-8' or 'base64'), content.
82
83
  Text formats (txt, html, csv, rtf) are returned as plain strings; all others
83
- are base64-encoded bytes.
84
+ are base64-encoded bytes. Raises ValueError if the response exceeds a safety
85
+ cap (default 40,000 characters, set MAX_TOOL_RESPONSE_CHARS to change it) —
86
+ base64 encoding inflates raw file size by ~33%, so binary exports hit this
87
+ cap at a much smaller *file* size than text ones. Call download_file instead
88
+ for anything but small files; it writes raw bytes straight to disk with no
89
+ base64/JSON overhead.
84
90
  """
85
91
  _TEXT_MIME_PREFIXES = ("text/",)
86
92
 
@@ -102,53 +108,40 @@ def register(tool):
102
108
  while not done:
103
109
  _, done = downloader.next_chunk()
104
110
  raw_bytes = buf.getvalue()
105
- is_text = any(file_mime.startswith(p) for p in _TEXT_MIME_PREFIXES)
106
- if is_text:
107
- return {
108
- "fileId": file_id,
109
- "name": metadata["name"],
110
- "mime_type": file_mime,
111
- "format": export_format,
112
- "encoding": "utf-8",
113
- "content": raw_bytes.decode("utf-8", errors="replace"),
114
- }
115
- return {
116
- "fileId": file_id,
117
- "name": metadata["name"],
118
- "mime_type": file_mime,
119
- "format": export_format,
120
- "encoding": "base64",
121
- "content": base64.b64encode(raw_bytes).decode("ascii"),
122
- }
123
-
124
- if export_format not in _EXPORT_MIME:
125
- raise ValueError(
126
- f"Unknown export_format '{export_format}'. "
127
- f"Valid options: {', '.join(_EXPORT_MIME)}, raw"
111
+ target_mime = file_mime
112
+ content_bytes = raw_bytes
113
+ else:
114
+ if export_format not in _EXPORT_MIME:
115
+ raise ValueError(
116
+ f"Unknown export_format '{export_format}'. "
117
+ f"Valid options: {', '.join(_EXPORT_MIME)}, raw"
118
+ )
119
+ target_mime = _EXPORT_MIME[export_format][0]
120
+ content_bytes = (
121
+ drive_service.files().export(fileId=file_id, mimeType=target_mime).execute()
128
122
  )
129
- target_mime = _EXPORT_MIME[export_format][0]
123
+ if isinstance(content_bytes, str):
124
+ content_bytes = content_bytes.encode("utf-8")
130
125
 
131
- content_bytes = drive_service.files().export(fileId=file_id, mimeType=target_mime).execute()
132
126
  is_text = any(target_mime.startswith(p) for p in _TEXT_MIME_PREFIXES)
133
- if is_text:
134
- return {
135
- "fileId": file_id,
136
- "name": metadata["name"],
137
- "mime_type": target_mime,
138
- "format": export_format,
139
- "encoding": "utf-8",
140
- "content": content_bytes.decode("utf-8")
141
- if isinstance(content_bytes, bytes)
142
- else content_bytes,
143
- }
144
- return {
127
+ result = {
145
128
  "fileId": file_id,
146
129
  "name": metadata["name"],
147
130
  "mime_type": target_mime,
148
131
  "format": export_format,
149
- "encoding": "base64",
150
- "content": base64.b64encode(content_bytes).decode("ascii"),
132
+ "encoding": "utf-8" if is_text else "base64",
133
+ "content": content_bytes.decode("utf-8", errors="replace")
134
+ if is_text
135
+ else base64.b64encode(content_bytes).decode("ascii"),
151
136
  }
137
+ enforce_response_size_cap(
138
+ result,
139
+ tool_name="export_file",
140
+ hint="Base64 encoding inflates raw file size by ~33%. Call download_file "
141
+ "instead to write the file straight to disk without this overhead, or ",
142
+ local_path_available=False,
143
+ )
144
+ return result
152
145
 
153
146
  @tool(annotations=ToolAnnotations(title="List Revisions", readOnlyHint=True))
154
147
  def list_revisions(file_id: str, ctx: Context = None) -> list[dict[str, Any]]:
@@ -0,0 +1,45 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ # See docs/decisions/decision-grid-data-size-cap.md and
7
+ # docs/decisions/decision-response-size-cap-generalization.md for the live-tested
8
+ # numbers behind this default and cap/local_path design.
9
+ MAX_TOOL_RESPONSE_CHARS = int(os.environ.get("MAX_TOOL_RESPONSE_CHARS", "40000"))
10
+
11
+
12
+ def enforce_response_size_cap(
13
+ result: Any, *, tool_name: str, hint: str = "", local_path_available: bool = True
14
+ ) -> None:
15
+ """Raise ValueError if the serialized result exceeds the configured cap."""
16
+ size = len(json.dumps(result))
17
+ if size > MAX_TOOL_RESPONSE_CHARS:
18
+ local_path_clause = (
19
+ "Pass local_path to write the result to disk instead of returning it inline "
20
+ "(bypasses this cap), or set"
21
+ if local_path_available
22
+ else "set"
23
+ )
24
+ raise ValueError(
25
+ f"{tool_name}: the response is {size} characters, over the "
26
+ f"{MAX_TOOL_RESPONSE_CHARS}-character safety cap. {hint}"
27
+ f"{local_path_clause} MAX_TOOL_RESPONSE_CHARS if your MCP client can "
28
+ "handle larger responses (e.g. a raised MAX_MCP_OUTPUT_TOKENS)."
29
+ )
30
+
31
+
32
+ def write_capped_result_to_disk(
33
+ result: Any, local_path: str, *, default_filename: str, manifest_extra: dict[str, Any]
34
+ ) -> dict[str, Any]:
35
+ """Write result (dict or list) to local_path and return a manifest dict.
36
+
37
+ Resolves a directory path via default_filename, mirroring get_sheet_data's
38
+ original local_path handling from issue #235.
39
+ """
40
+ dest = Path(local_path)
41
+ if dest.is_dir():
42
+ dest = dest / default_filename
43
+ dest.parent.mkdir(parents=True, exist_ok=True)
44
+ dest.write_text(json.dumps(result))
45
+ return {"local_path": str(dest), "bytes_written": dest.stat().st_size, **manifest_extra}