Skip to content

Commit 2e7ec65

Browse files
RafaelPoclaude
andcommitted
Add overwrite guards to sheets_write and sheets_create
sheets_write: reads target range before overwriting. If data exists, returns a warning asking the user to confirm_overwrite=True or use append=True. Empty ranges proceed without confirmation. sheets_create: checks Drive for an existing spreadsheet with the same title before creating. Returns a warning if a duplicate is found. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b92c7c0 commit 2e7ec65

File tree

3 files changed

+118
-4
lines changed

3 files changed

+118
-4
lines changed

everyrow-mcp/src/everyrow_mcp/sheets_models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ class SheetsWriteInput(BaseModel):
9999
default=False,
100100
description="If True, append after existing data instead of overwriting.",
101101
)
102+
confirm_overwrite: bool = Field(
103+
default=False,
104+
description="Must be set to True to overwrite existing data when append=False. "
105+
"The tool will check if the range has data and warn you first.",
106+
)
102107

103108
@field_validator("spreadsheet_id")
104109
@classmethod

everyrow-mcp/src/everyrow_mcp/sheets_tools.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,23 @@ async def sheets_write(params: SheetsWriteInput) -> list[TextContent]:
227227
)
228228
]
229229
else:
230+
# Pre-check: warn if the target range already has data
231+
if not params.confirm_overwrite:
232+
existing = await client.read_range(
233+
params.spreadsheet_id, cell_range=params.range
234+
)
235+
if existing:
236+
existing_rows = len(existing)
237+
return [
238+
TextContent(
239+
type="text",
240+
text=f"The range '{params.range}' already contains {existing_rows} rows "
241+
f"(including headers). Writing will overwrite this data. "
242+
f"To proceed, call again with confirm_overwrite=True, "
243+
f"or use append=True to add rows after existing data.",
244+
)
245+
]
246+
230247
result = await client.write_range(
231248
params.spreadsheet_id, cell_range=params.range, values=values
232249
)
@@ -269,6 +286,21 @@ async def sheets_create(params: SheetsCreateInput) -> list[TextContent]:
269286
token = await get_google_token()
270287

271288
async with GoogleSheetsClient(token) as client:
289+
# Duplicate title guard
290+
existing = await client.list_spreadsheets(
291+
query=params.title, max_results=50
292+
)
293+
for f in existing:
294+
if f.get("name") == params.title:
295+
return [
296+
TextContent(
297+
type="text",
298+
text=f"A spreadsheet named '{params.title}' already exists "
299+
f"(id: {f['id']}). Pick a different title to avoid "
300+
f"creating a duplicate.",
301+
)
302+
]
303+
272304
metadata = await client.create_spreadsheet(params.title)
273305
spreadsheet_id = metadata["spreadsheetId"]
274306
url = metadata.get(

everyrow-mcp/tests/test_sheets_tools.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ async def test_url_extraction(self, _mock_google_token):
384384

385385
class TestSheetsWriteTool:
386386
@pytest.mark.asyncio
387-
async def test_write_overwrite(self, _mock_google_token):
387+
async def test_write_overwrite_confirmed(self, _mock_google_token):
388388
mock_resp = _mock_response(
389389
{
390390
"updatedRange": "Sheet1!A1:B3",
@@ -399,6 +399,51 @@ async def test_write_overwrite(self, _mock_google_token):
399399
SheetsWriteInput(
400400
spreadsheet_id="abc123def456ghi789jkl012mno345pqr678stu901v",
401401
data=[{"name": "Alice"}, {"name": "Bob"}],
402+
confirm_overwrite=True,
403+
)
404+
)
405+
406+
assert "Wrote" in result[0].text
407+
408+
@pytest.mark.asyncio
409+
async def test_write_overwrite_warns_if_existing_data(self, _mock_google_token):
410+
"""Writing without confirm_overwrite warns when range has data."""
411+
read_resp = _mock_response({"values": [["name"], ["Alice"]]})
412+
413+
with patch.object(
414+
httpx.AsyncClient, "get", new_callable=AsyncMock, return_value=read_resp
415+
):
416+
result = await sheets_write(
417+
SheetsWriteInput(
418+
spreadsheet_id="abc123def456ghi789jkl012mno345pqr678stu901v",
419+
data=[{"name": "Bob"}],
420+
)
421+
)
422+
423+
assert "already contains" in result[0].text
424+
assert "confirm_overwrite" in result[0].text
425+
426+
@pytest.mark.asyncio
427+
async def test_write_overwrite_proceeds_on_empty_range(self, _mock_google_token):
428+
"""Writing without confirm_overwrite proceeds when range is empty."""
429+
read_resp = _mock_response({}) # empty range
430+
write_resp = _mock_response({"updatedRange": "Sheet1!A1:B2", "updatedRows": 2})
431+
432+
with (
433+
patch.object(
434+
httpx.AsyncClient, "get", new_callable=AsyncMock, return_value=read_resp
435+
),
436+
patch.object(
437+
httpx.AsyncClient,
438+
"put",
439+
new_callable=AsyncMock,
440+
return_value=write_resp,
441+
),
442+
):
443+
result = await sheets_write(
444+
SheetsWriteInput(
445+
spreadsheet_id="abc123def456ghi789jkl012mno345pqr678stu901v",
446+
data=[{"name": "Bob"}],
402447
)
403448
)
404449

@@ -432,15 +477,27 @@ async def test_write_append(self, _mock_google_token):
432477
class TestSheetsCreateTool:
433478
@pytest.mark.asyncio
434479
async def test_create_empty(self, _mock_google_token):
435-
mock_resp = _mock_response(
480+
list_resp = _mock_response({"files": []}) # no duplicates
481+
create_resp = _mock_response(
436482
{
437483
"spreadsheetId": "new-id-123",
438484
"spreadsheetUrl": "https://docs.google.com/spreadsheets/d/new-id-123",
439485
}
440486
)
441487

442-
with patch.object(
443-
httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=mock_resp
488+
with (
489+
patch.object(
490+
httpx.AsyncClient,
491+
"get",
492+
new_callable=AsyncMock,
493+
return_value=list_resp,
494+
),
495+
patch.object(
496+
httpx.AsyncClient,
497+
"post",
498+
new_callable=AsyncMock,
499+
return_value=create_resp,
500+
),
444501
):
445502
result = await sheets_create(SheetsCreateInput(title="Test"))
446503

@@ -451,6 +508,7 @@ async def test_create_empty(self, _mock_google_token):
451508

452509
@pytest.mark.asyncio
453510
async def test_create_with_data(self, _mock_google_token):
511+
list_resp = _mock_response({"files": []}) # no duplicates
454512
create_resp = _mock_response(
455513
{
456514
"spreadsheetId": "new-id-456",
@@ -460,6 +518,12 @@ async def test_create_with_data(self, _mock_google_token):
460518
write_resp = _mock_response({"updatedRows": 2})
461519

462520
with (
521+
patch.object(
522+
httpx.AsyncClient,
523+
"get",
524+
new_callable=AsyncMock,
525+
return_value=list_resp,
526+
),
463527
patch.object(
464528
httpx.AsyncClient,
465529
"post",
@@ -480,6 +544,19 @@ async def test_create_with_data(self, _mock_google_token):
480544
data = json.loads(result[0].text)
481545
assert data["rows_written"] == 1
482546

547+
@pytest.mark.asyncio
548+
async def test_create_rejects_duplicate_title(self, _mock_google_token):
549+
"""sheets_create warns when a spreadsheet with the same title exists."""
550+
list_resp = _mock_response({"files": [{"id": "existing-id", "name": "Budget"}]})
551+
552+
with patch.object(
553+
httpx.AsyncClient, "get", new_callable=AsyncMock, return_value=list_resp
554+
):
555+
result = await sheets_create(SheetsCreateInput(title="Budget"))
556+
557+
assert "already exists" in result[0].text
558+
assert "existing-id" in result[0].text
559+
483560

484561
class TestSheetsInfoTool:
485562
@pytest.mark.asyncio

0 commit comments

Comments
 (0)