Read Tools — QA Test Cases¶
Source: src/mcp_gee_sweet/tools/read.py
Fixtures: see docs/qa/setup.md. Substitute your {SPREADSHEET_ID} from fixtures.local.md.
get_sheet_data¶
TC-R01: Happy path — fetch all data¶
Prompt
"Show me all the data in the Sales sheet of {SPREADSHEET_ID}"
Checks
- Returns 6 rows (header + 5 data rows)
- All 4 columns present: Product, Q1, Q2, Q3
- Row 6 Totals values are computed (650, 670, 705) — not formula strings
- No error field
TC-R02: Explicit range¶
Prompt
"Get the range A1:C3 from the Sales sheet of {SPREADSHEET_ID}"
Checks - Returns exactly 3 rows, 3 columns - Row 1: Product, Q1, Q2 - Row 2: Widget, 100, 120 - Row 3: Gadget, 200, 180
TC-R03: Grid data with an explicit range¶
Prompt
"Get A1:D6 from the Sales sheet of {SPREADSHEET_ID} with full grid data including formatting"
Checks
- Response includes rowData field
- include_grid_data=True was passed to the API (visible in raw response structure)
- Call includes range="A1:D6" — no auto-detection probe request happens when a range is given
TC-R03b: Grid data without a range auto-detects the used range (issue #235)¶
Setup
Call get_sheet_data(spreadsheet_id={SPREADSHEET_ID}, sheet="Sales", include_grid_data=True) — no range argument. The Sales sheet has 6 rows x 4 columns of actual content but Sheets' default padded grid is 1000x26.
Checks
- No error — the call succeeds
- Server makes a values-only probe request first, then a grid-data request scoped to A1:D6 — not the full padded grid
- Response includes rowData only for the 6x4 used range, not ~26,000 padded cells
- No "Connection closed" / oversized-response symptom (the original failure mode)
Result (2026-07-02) ✅ PASS
Called live against the actual Sales fixture (gridProperties: rowCount=3016, columnCount=33). Response's rowData covered exactly the 6x4 used range (header row + Widget/Gadget/Donut/Gizmo + Totals row, matching TC-R01's known fixture content) — confirmed scoped down from the sheet's real 3016x33 padding, not just the smaller 1000x26 default. No error, no truncation.
TC-R03c: Densely formatted range over the safety cap raises a clear error (issue #235)¶
Setup
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).
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.
Checks
- 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
- 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
- Clean up: remove the formatting from A1:Z200 afterward so it doesn't affect other test cases
Result (2026-07-02) ✅ PASS
Tested against a scratch sheet (temp tab, deleted after) rather than formatting the shared Sales fixture directly, to avoid leaving unwanted formatting on it — same effective setup (format_cells(range="A1:Z1000", bold=True, background_color=..., number_format_type="NUMBER"), then get_sheet_data(range="A1:Z200", include_grid_data=True)). Raised:
get_sheet_data(include_grid_data=True): the response is 4325100 characters, over the 40000-character safety cap. ... Narrow the range; pass local_path to write the result to disk instead of returning it inline (bypasses this cap); or set MAX_GRID_DATA_RESPONSE_CHARS if your MCP client can handle larger responses (e.g. a raised MAX_MCP_OUTPUT_TOKENS).
Exact size (4,325,100 chars) confirms the fetch completed before the check ran, as designed.
TC-R03d: Grid data with local_path writes to disk instead of returning inline (issue #235)¶
Setup
Same formatted range as TC-R03c, but call get_sheet_data(spreadsheet_id={SPREADSHEET_ID}, sheet="Sales", range="A1:Z200", include_grid_data=True, local_path="/tmp/qa_grid_data.json").
Checks
- Call succeeds (no cap error) despite the response exceeding the cap
- Response is a small dict — local_path, spreadsheet_id, sheet, range, bytes_written — not the grid data itself
- /tmp/qa_grid_data.json exists and contains the full grid-data JSON (rowData present, formatting visible)
Result (2026-07-02) ✅ PASS
Same scratch-sheet setup as TC-R03c. Call returned {"local_path": "/tmp/qa_grid_data_235.json", "spreadsheet_id": "...", "sheet": "SizeTest235d", "range": "SizeTest235d!A1:Z200", "bytes_written": 4325100} — no error despite exceeding the cap. Verified on disk: file exists, wc -c matches bytes_written exactly (4,325,100), and contains real rowData with formatting. Scratch sheet and temp file both cleaned up afterward.
TC-R03e: MAX_TOOL_RESPONSE_CHARS raises the cap (issue #235)¶
Setup
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.
Checks
- Call now succeeds instead of raising — confirms the cap is actually read from the env var, not hardcoded
- Restore MAX_TOOL_RESPONSE_CHARS (unset it) after this test
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).
TC-R04: Non-existent sheet name¶
Prompt
"Get data from a sheet called 'DoesNotExist' in {SPREADSHEET_ID}"
Checks - Returns a clear error — not an empty result - Error message references the sheet name or indicates it was not found
TC-R05: Non-existent spreadsheet ID¶
Prompt
"Get data from the Sales sheet of spreadsheet 'invalidid123xyz'"
Checks - Returns a clear API error - Does not crash the server or return empty data silently
TC-R06: Range beyond data bounds¶
Prompt
"Get data from range A100:Z200 in the Sales sheet of {SPREADSHEET_ID}"
Checks
- Returns empty values, not an error
- No error field — the API accepts out-of-bounds ranges gracefully
TC-R07: Sheet name with spaces and special characters¶
Prompt
"Show me all data from the 'Notes & Misc' sheet in {SPREADSHEET_ID}"
Checks
- Returns 2 rows: header (Date, Note) and data row
- Sheet name with spaces and & resolved correctly
- Date cell shows today's date (computed from =TODAY())
get_sheet_formulas¶
TC-R08: Sheet with formulas — returns formula strings¶
Prompt
"Show me the formulas in the Sales sheet of {SPREADSHEET_ID}"
Checks
- Row 6 B–D cells show formula strings: =SUM(B2:B5), =SUM(C2:C5), =SUM(D2:D5)
- Data rows return literal values (100, 200, etc.), not formula strings
TC-R09: Sheet with no formulas¶
Prompt
"Show me the formulas in the Empty sheet of {SPREADSHEET_ID}"
Checks - Returns empty result or empty values — not an error
TC-R10: Mixed cells — formulas and literals¶
Prompt
"Get formulas from the Notes & Misc sheet of {SPREADSHEET_ID}"
Checks
- A2 returns =TODAY() (formula string) — Notes & Misc is laid out Date (col A) / Note (col B)
- B2 ("Setup complete") returns a literal string, not a formula
- No cell returns a computed value where a formula exists
TC-R11: No range provided — fetches entire sheet¶
Prompt
"Get all formulas from the Sales sheet of {SPREADSHEET_ID} — no range filter"
Checks - All 6 rows returned - Formula cells in row 6 show formula strings - Equivalent to TC-R08 — confirms default behavior with no range arg
get_multiple_sheet_data¶
TC-R12: Multiple valid queries¶
Prompt
"Get data from two sheets at once: the Sales sheet and the Notes & Misc sheet, both from {SPREADSHEET_ID}"
Checks
- Returns two results, one per sheet
- Each result has the correct data for its sheet
- No error field on either result
TC-R13: One query with missing required keys¶
Prompt
"Fetch multiple sheets: first the Sales sheet from {SPREADSHEET_ID}, second a query with no sheet name specified"
Checks
- Sales sheet result succeeds
- Invalid query returns an error field — does not crash the other result
TC-R14: All queries fail¶
Prompt
"Fetch data from two sheets: 'FakeSheet1' and 'FakeSheet2', both from {SPREADSHEET_ID}"
Checks
- Both results have error fields
- Response is a list of two error objects — not a top-level error
TC-R15: Empty queries list¶
Prompt
"Fetch multiple sheets from {SPREADSHEET_ID} — pass an empty list of queries"
Checks
- Returns [] — empty list, not an error
get_multiple_spreadsheet_summary¶
TC-R16: Happy path — multiple spreadsheet IDs¶
Prompt
"Give me a summary of {SPREADSHEET_ID} — just a quick overview of its sheets and first few rows"
Checks - Returns entries for all 3 sheets: Sales, Empty, Notes & Misc - Sales entry includes headers (Product, Q1, Q2, Q3) and first data rows - Empty sheet entry has empty headers and empty first_rows
TC-R17: Cache hit — second call skips API¶
Prompt (run twice in the same session)
"Summarize {SPREADSHEET_ID} again"
Checks
- Second call returns same data
- Server logs show cache hit for the second call (check make logs)
TC-R18: rows_to_fetch=1 — only header returned¶
Prompt
"Give me a summary of {SPREADSHEET_ID} fetching only 1 row per sheet"
Checks
- headers contains the header row for Sales
- first_rows is empty (no data rows beyond the header)
TC-R19: rows_to_fetch=0 — clamped to 1¶
Prompt
"Summarize {SPREADSHEET_ID} with rows_to_fetch set to 0"
Checks
- Server clamps to 1 (max(1, 0))
- Behaves identically to TC-R18
- 🔍 Product decision: should 0 return only headers, or is clamping to 1 the right behavior?
Result (2026-07-04) ❌ FAIL, then fixed On a cold cache, correctly clamped (data.py:304's max(1, rows_to_fetch)). On a warm cache, cache.py:189's truncation slice (first_rows[:rows_to_fetch - 1]) lacked the same clamp — rows_to_fetch=0 became [:-1] and returned 3 rows instead of an empty list, disagreeing with the cold-cache result for the same input. Filed as #254, fixed in #257 (applies the same clamp on the cache-hit path). Re-verified live (2026-07-05) after merge: warmed the cache with rows_to_fetch=5, then called rows_to_fetch=0 — first_rows: [] for Sales, matching cold-cache behavior.
TC-R20: Spreadsheet with empty sheet¶
Prompt
"Summarize {SPREADSHEET_ID} — I want to see what the Empty sheet looks like in the summary"
Checks
- Empty sheet entry: headers: [], first_rows: []
- No error field for the empty sheet
- Other sheets unaffected
TC-R21: Invalid spreadsheet ID in list¶
Prompt
"Summarize these two spreadsheets: {SPREADSHEET_ID} and 'invalidid123xyz'"
Checks
- Valid spreadsheet returns normal summary
- Invalid ID entry has an error field
- Both results present — partial failure, not a top-level error
TC-R22: Range format verification¶
Prompt
"Give me a summary of {SPREADSHEET_ID} with rows_to_fetch=3"
Checks
- Sales sheet returns header + 2 data rows (rows 2–3)
- Verify data from columns B, C, D is present — not just column A
- 🔍 Product decision: A1:3 range format — does the API return all columns or just column A? See notes-read.md
find_in_spreadsheet¶
TC-R23: Match found in specific sheet¶
Prompt
"Find 'Gadget' in the Sales sheet of {SPREADSHEET_ID}"
Checks - Returns at least one match with row/column/value information - Match is in the Sales sheet, row 3
TC-R24: Match across all sheets¶
Prompt
"Search for 'Setup complete' across all sheets in {SPREADSHEET_ID}"
Checks - Match found in Notes & Misc sheet - No sheet filter applied — all sheets searched
TC-R25: Case-insensitive match (default)¶
Prompt
"Find 'gadget' (lowercase) in {SPREADSHEET_ID}"
Checks - Returns match for "Gadget" despite case difference - Confirms default is case-insensitive
TC-R26: Case-sensitive match¶
Prompt
"Find 'gadget' in {SPREADSHEET_ID} using case-sensitive matching"
Checks - Returns no matches (fixture data has "Gadget" with capital G) - Confirms case-sensitive flag is respected
TC-R27: max_results respected¶
Prompt
"Find 'Q' in {SPREADSHEET_ID} but limit results to 2"
Checks
- Returns exactly 2 results (headers Q1, Q2, Q3 would otherwise produce 3+)
- No more than max_results entries in response
TC-R28: No matches¶
Prompt
"Find 'ZZZnoMatch' in {SPREADSHEET_ID}"
Checks
- Returns [] — empty list, not an error
TC-R29: Sheet name not found¶
Prompt
"Find 'Widget' in a sheet called 'DoesNotExist' in {SPREADSHEET_ID}"
Checks
- Returns [{"error": ...}] — error entry, not a top-level exception
- Error message references the sheet name
TC-R30: Multiple column matches in same row¶
Prompt
"Find 'Q' in the Sales sheet of {SPREADSHEET_ID}"
Checks - Returns separate results for Q1, Q2, Q3 in row 1 (each column is its own result) - Confirms per-cell result granularity, not per-row
get_sheet_data — effectiveFormat assertions¶
These tests follow a setup→assert→teardown pattern. Apply format via batch_update, assert via get_sheet_data(include_grid_data=True), then clear via batch_update { cell: {}, fields: "userEnteredFormat" }.
See docs/design/effectiveformat-spike.md for full field reference and RGB precision notes.
TC-R31: Bold text format readable via effectiveFormat¶
Setup
Apply bold to Sales A1 (sheetId=0, row 0, col 0) via batch_update → repeatCell, field mask userEnteredFormat.textFormat.bold.
Prompt
"Get Sales A1:A1 from {SPREADSHEET_ID} with include_grid_data=True"
Checks
- sheets[0].data[0].rowData[0].values[0].effectiveFormat.textFormat.bold is true
- formattedValue is "Product" (value unchanged)
- effectiveFormat.textFormat.italic is false (only bold set)
Teardown
Clear userEnteredFormat from A1 via batch_update → repeatCell { cell: {}, fields: "userEnteredFormat" }.
Result (2026-06-20) ✅ PASS
- effectiveFormat.textFormat.bold = true, italic = false, formattedValue = "Product"
TC-R32: Background color and italic readable via effectiveFormat¶
Setup
Apply italic=true and backgroundColor={red:1, green:0.9, blue:0.6} to Sales B1 (sheetId=0, row 0, col 1) via batch_update → repeatCell, field mask userEnteredFormat.backgroundColor,userEnteredFormat.textFormat.italic.
Prompt
"Get Sales B1:B1 from {SPREADSHEET_ID} with include_grid_data=True"
Checks
- effectiveFormat.textFormat.italic is true
- effectiveFormat.backgroundColor.red ≈ 1.0
- effectiveFormat.backgroundColor.green ≈ 0.898 (API returns 229/255 ≈ 0.8980392)
- effectiveFormat.backgroundColor.blue ≈ 0.6
- effectiveFormat.textFormat.bold is false
Teardown
Clear userEnteredFormat from B1.
Result (2026-06-20) ✅ PASS
- effectiveFormat.textFormat.italic = true, bold = false, backgroundColor = {red:1, green:0.8980392, blue:0.6}
TC-R33: Number format and formattedValue readable via effectiveFormat¶
Setup
Apply numberFormat={type:"CURRENCY", pattern:"$#,##0.00"} to Sales B2 (sheetId=0, row 1, col 1) via batch_update → repeatCell, field mask userEnteredFormat.numberFormat. (B2 contains the value 100.)
Prompt
"Get Sales B2:B2 from {SPREADSHEET_ID} with include_grid_data=True"
Checks
- effectiveFormat.numberFormat.type is "CURRENCY"
- effectiveFormat.numberFormat.pattern is "$#,##0.00"
- formattedValue is "$100.00"
- effectiveValue.numberValue is still 100 (underlying value unchanged)
Teardown
Clear userEnteredFormat from B2.
Result (2026-06-20) ✅ PASS
- effectiveFormat.numberFormat = {type:"CURRENCY", pattern:"$#,##0.00"}, formattedValue = "$100.00", effectiveValue.numberValue = 100
TC-R34: get_multiple_sheet_data — many small queries trips the response-size cap (issue #242)¶
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.
Setup
No fixture setup needed — repeat the same tiny query ({spreadsheet_id: {SPREADSHEET_ID}, sheet: "Sales"}, a 6x4 range) 200 times in one queries list.
Checks
- Call raises ValueError mentioning the actual response size, the 40,000-character cap, and MAX_TOOL_RESPONSE_CHARS
- 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
Result (2026-07-03) ✅ PASS
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.
TC-R35: find_in_spreadsheet — max_results bounds count, not size (issue #242)¶
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.
Setup
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.
Checks
- Call raises ValueError mentioning the actual response size and the cap, even though match count (10) is well under max_results (50)
- 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
Teardown
clear_values on the Empty sheet to remove the test data.
Result (2026-07-03) ✅ PASS
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.