Skip to content

Commit 524ff95

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 d00fa46 commit 524ff95

11 files changed

Lines changed: 1476 additions & 3 deletions

File tree

everyrow-mcp/src/everyrow_mcp/auth.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,19 +125,24 @@ class EveryRowAuthorizationCode(AuthorizationCode):
125125

126126
supabase_access_token: str
127127
supabase_refresh_token: str
128+
google_access_token: str = ""
129+
google_refresh_token: str = ""
128130

129131

130132
class EveryRowRefreshToken(RefreshToken):
131133
"""Extends RefreshToken with the Supabase refresh token."""
132134

133135
supabase_refresh_token: str
136+
google_refresh_token: str = ""
134137

135138

136139
class SupabaseTokenResponse(BaseModel):
137140
"""Response from Supabase token exchange."""
138141

139142
access_token: str
140143
refresh_token: str
144+
provider_token: str = ""
145+
provider_refresh_token: str = ""
141146

142147

143148
class PendingAuth(BaseModel):
@@ -248,6 +253,10 @@ def _supabase_redirect_url(supabase_verifier: str) -> str:
248253
'flow_type': 'pkce',
249254
'code_challenge': supabase_challenge,
250255
'code_challenge_method': 's256',
256+
'scopes': (
257+
'https://www.googleapis.com/auth/spreadsheets '
258+
'https://www.googleapis.com/auth/drive.readonly'
259+
),
251260
}
252261
)
253262
}"
@@ -399,6 +408,8 @@ async def _create_authorisation_code(
399408
resource=pending.params.resource,
400409
supabase_access_token=supa_tokens.access_token,
401410
supabase_refresh_token=supa_tokens.refresh_token,
411+
google_access_token=supa_tokens.provider_token,
412+
google_refresh_token=supa_tokens.provider_refresh_token,
402413
)
403414
await self._redis.setex(
404415
name=build_key("authcode", code),
@@ -452,16 +463,27 @@ async def _issue_token_response(
452463
client_id: str,
453464
scopes: list[str],
454465
supabase_refresh_token: str,
466+
google_access_token: str = "",
467+
google_refresh_token: str = "",
455468
) -> OAuthToken:
456469
jwt_claims = _decode_trusted_server_jwt(access_token)
457470
expires_in = max(0, jwt_claims.get("exp", 0) - int(time.time()))
458471

472+
# Store Google tokens in Redis for Sheets tools
473+
if google_access_token:
474+
from everyrow_mcp.sheets_client import store_google_token # noqa: PLC0415
475+
476+
await store_google_token(
477+
"current", google_access_token, google_refresh_token or None
478+
)
479+
459480
rt_str = secrets.token_urlsafe(32)
460481
rt = EveryRowRefreshToken(
461482
token=rt_str,
462483
client_id=client_id,
463484
scopes=scopes,
464485
supabase_refresh_token=supabase_refresh_token,
486+
google_refresh_token=google_refresh_token,
465487
)
466488
await self._redis.setex(
467489
name=build_key("refresh", rt_str),
@@ -488,6 +510,8 @@ async def exchange_authorization_code(
488510
client_id=client.client_id,
489511
scopes=authorization_code.scopes,
490512
supabase_refresh_token=authorization_code.supabase_refresh_token,
513+
google_access_token=authorization_code.google_access_token,
514+
google_refresh_token=authorization_code.google_refresh_token,
491515
)
492516

493517
async def load_access_token(self, token: str) -> AccessToken | None: # noqa: ARG002
@@ -532,13 +556,18 @@ async def exchange_refresh_token(
532556
value=encrypt_value(refresh_token.model_dump_json()),
533557
)
534558
raise
559+
google_refresh = (
560+
supa_tokens.provider_refresh_token or refresh_token.google_refresh_token
561+
)
535562
assert client.client_id is not None
536563
logger.info("Token refresh successful user=%s", client.client_id)
537564
return await self._issue_token_response(
538565
access_token=supa_tokens.access_token,
539566
client_id=client.client_id,
540567
scopes=final_scopes,
541568
supabase_refresh_token=supa_tokens.refresh_token,
569+
google_access_token=supa_tokens.provider_token,
570+
google_refresh_token=google_refresh,
542571
)
543572

544573
async def revoke_token(self, token: AccessToken | EveryRowRefreshToken) -> None:

everyrow-mcp/src/everyrow_mcp/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ class Settings(BaseSettings):
122122
)
123123

124124
everyrow_api_key: str | None = Field(default=None, repr=False)
125+
google_sheets_credentials_json: str | None = Field(
126+
default=None,
127+
description="Path to a Google service account JSON file or inline JSON. "
128+
"Required for Google Sheets tools in stdio mode.",
129+
)
125130

126131
@property
127132
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
@@ -530,6 +530,12 @@ class StdioResultsInput(BaseModel):
530530
...,
531531
description="Full absolute path to the output CSV file (must end in .csv).",
532532
)
533+
output_spreadsheet_title: str | None = Field(
534+
default=None,
535+
description="Create a new Google Sheet with this title and write the full "
536+
"results there. Returns the spreadsheet URL. Fails if a sheet with "
537+
"this exact title already exists — pick a unique name.",
538+
)
533539

534540
@field_validator("task_id")
535541
@classmethod
@@ -561,6 +567,12 @@ def validate_task_id(cls, v: str) -> str:
561567
description="Full absolute path to the output CSV file (must end in .csv). "
562568
"Optional — results are returned as a paginated preview by default.",
563569
)
570+
output_spreadsheet_title: str | None = Field(
571+
default=None,
572+
description="Create a new Google Sheet with this title and write the full "
573+
"results there. Returns the spreadsheet URL. Fails if a sheet with "
574+
"this exact title already exists — pick a unique name.",
575+
)
564576
offset: int = Field(
565577
default=0,
566578
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 get_instructions, mcp
1314
from everyrow_mcp.config import settings

0 commit comments

Comments
 (0)