Skip to content

Commit d46c7a3

Browse files
RafaelPoclaude
andcommitted
Add native Google Sheets tools (list, read, write, create, info)
5 new MCP tools for Google Sheets integration: - sheets_list: search/list user's spreadsheets via Drive API - sheets_read: read data as JSON records (compatible with input_json) - sheets_write: write/append JSON records to a sheet - sheets_create: create new spreadsheet with optional initial data - sheets_info: get sheet metadata (title, tabs, dimensions) Token management supports HTTP mode (OAuth via Supabase with Google provider tokens stored in Redis) and stdio mode (service account JWT). Also adds: - Google token passthrough in OAuth flow (auth.py) - output_spreadsheet_title option in everyrow_results - Authenticated Google URL fetches in fetch_csv_from_url - google_sheets_credentials_json setting for stdio mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 450f211 commit d46c7a3

11 files changed

Lines changed: 1578 additions & 3 deletions

File tree

everyrow-mcp/src/everyrow_mcp/auth.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,19 +116,24 @@ class EveryRowAuthorizationCode(AuthorizationCode):
116116

117117
supabase_access_token: str
118118
supabase_refresh_token: str
119+
google_access_token: str = ""
120+
google_refresh_token: str = ""
119121

120122

121123
class EveryRowRefreshToken(RefreshToken):
122124
"""Extends RefreshToken with the Supabase refresh token."""
123125

124126
supabase_refresh_token: str
127+
google_refresh_token: str = ""
125128

126129

127130
class SupabaseTokenResponse(BaseModel):
128131
"""Response from Supabase token exchange."""
129132

130133
access_token: str
131134
refresh_token: str
135+
provider_token: str = ""
136+
provider_refresh_token: str = ""
132137

133138

134139
class PendingAuth(BaseModel):
@@ -236,6 +241,10 @@ def _supabase_redirect_url(supabase_verifier: str) -> str:
236241
'flow_type': 'pkce',
237242
'code_challenge': supabase_challenge,
238243
'code_challenge_method': 's256',
244+
'scopes': (
245+
'https://www.googleapis.com/auth/spreadsheets '
246+
'https://www.googleapis.com/auth/drive.readonly'
247+
),
239248
}
240249
)
241250
}"
@@ -380,6 +389,8 @@ async def _create_authorisation_code(
380389
resource=pending.params.resource,
381390
supabase_access_token=supa_tokens.access_token,
382391
supabase_refresh_token=supa_tokens.refresh_token,
392+
google_access_token=supa_tokens.provider_token,
393+
google_refresh_token=supa_tokens.provider_refresh_token,
383394
)
384395
await self._redis.setex(
385396
name=build_key("authcode", code),
@@ -425,16 +436,27 @@ async def _issue_token_response(
425436
client_id: str,
426437
scopes: list[str],
427438
supabase_refresh_token: str,
439+
google_access_token: str = "",
440+
google_refresh_token: str = "",
428441
) -> OAuthToken:
429442
jwt_claims = self._UNSAFE_decode_server_jwt(access_token)
430443
expires_in = max(0, jwt_claims.get("exp", 0) - int(time.time()))
431444

445+
# Store Google tokens in Redis for Sheets tools
446+
if google_access_token:
447+
from everyrow_mcp.sheets_client import store_google_token # noqa: PLC0415
448+
449+
await store_google_token(
450+
"current", google_access_token, google_refresh_token or None
451+
)
452+
432453
rt_str = secrets.token_urlsafe(32)
433454
rt = EveryRowRefreshToken(
434455
token=rt_str,
435456
client_id=client_id,
436457
scopes=scopes,
437458
supabase_refresh_token=supabase_refresh_token,
459+
google_refresh_token=google_refresh_token,
438460
)
439461
await self._redis.setex(
440462
name=build_key("refresh", rt_str),
@@ -459,6 +481,8 @@ async def exchange_authorization_code(
459481
client_id=client.client_id,
460482
scopes=authorization_code.scopes,
461483
supabase_refresh_token=authorization_code.supabase_refresh_token,
484+
google_access_token=authorization_code.google_access_token,
485+
google_refresh_token=authorization_code.google_refresh_token,
462486
)
463487

464488
async def load_access_token(self, token: str) -> AccessToken | None: # noqa: ARG002
@@ -488,11 +512,16 @@ async def exchange_refresh_token(
488512
supa_tokens = await self._refresh_supabase_token(
489513
refresh_token.supabase_refresh_token
490514
)
515+
google_refresh = (
516+
supa_tokens.provider_refresh_token or refresh_token.google_refresh_token
517+
)
491518
return await self._issue_token_response(
492519
access_token=supa_tokens.access_token,
493520
client_id=client.client_id,
494521
scopes=final_scopes,
495522
supabase_refresh_token=supa_tokens.refresh_token,
523+
google_access_token=supa_tokens.provider_token,
524+
google_refresh_token=google_refresh,
496525
)
497526

498527
async def revoke_token(self, token: AccessToken | EveryRowRefreshToken) -> None:
@@ -522,6 +551,8 @@ async def _supabase_token_request(
522551
return SupabaseTokenResponse(
523552
access_token=data["access_token"],
524553
refresh_token=data["refresh_token"],
554+
provider_token=data.get("provider_token", ""),
555+
provider_refresh_token=data.get("provider_refresh_token", ""),
525556
)
526557

527558
async def _exchange_supabase_code(

everyrow-mcp/src/everyrow_mcp/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ class Settings(BaseSettings):
6262
description="Refresh token TTL in seconds (7 days)",
6363
)
6464
everyrow_api_key: str | None = None
65+
google_sheets_credentials_json: str | None = Field(
66+
default=None,
67+
description="Path to a Google service account JSON file or inline JSON. "
68+
"Required for Google Sheets tools in stdio mode.",
69+
)
6570

6671
@property
6772
def is_http(self) -> bool:

everyrow-mcp/src/everyrow_mcp/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,12 @@ class StdioResultsInput(BaseModel):
388388
...,
389389
description="Full absolute path to the output CSV file (must end in .csv).",
390390
)
391+
output_spreadsheet_title: str | None = Field(
392+
default=None,
393+
description="Create a new Google Sheet with this title and write the full "
394+
"results there. Returns the spreadsheet URL. Fails if a sheet with "
395+
"this exact title already exists — pick a unique name.",
396+
)
391397

392398
@field_validator("output_path")
393399
@classmethod
@@ -408,6 +414,12 @@ class HttpResultsInput(BaseModel):
408414
description="Full absolute path to the output CSV file (must end in .csv). "
409415
"Optional — results are returned as a paginated preview by default.",
410416
)
417+
output_spreadsheet_title: str | None = Field(
418+
default=None,
419+
description="Create a new Google Sheet with this title and write the full "
420+
"results there. Returns the spreadsheet URL. Fails if a sheet with "
421+
"this exact title already exists — pick a unique name.",
422+
)
411423
offset: int = Field(
412424
default=0,
413425
description="Row offset for pagination. Default 0 returns the first page.",

everyrow-mcp/src/everyrow_mcp/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from pydantic import BaseModel
1010

11+
import everyrow_mcp.sheets_tools
1112
import everyrow_mcp.tools # noqa: F401 — registers @mcp.tool() decorators
1213
from everyrow_mcp.app import mcp
1314
from everyrow_mcp.config import settings

0 commit comments

Comments
 (0)