11"""Async Google Sheets API client using httpx.
22
3- Handles token resolution for both HTTP mode (Redis-stored OAuth tokens)
4- and stdio mode (service account JWT exchange).
3+ Handles token resolution for HTTP mode (Redis-stored OAuth tokens obtained
4+ during the Supabase/Google OAuth flow). Sheets tools are not available in
5+ stdio mode.
56"""
67
78from __future__ import annotations
1213from typing import Any
1314
1415import httpx
15- import jwt as pyjwt
1616
17- from everyrow_mcp .config import settings
1817from everyrow_mcp .redis_store import build_key , get_redis_client
1918
2019logger = logging .getLogger (__name__ )
2120
2221SHEETS_API_BASE = "https://sheets.googleapis.com/v4/spreadsheets"
2322DRIVE_API_BASE = "https://www.googleapis.com/drive/v3"
24- GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
25- SCOPES = "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive.readonly"
2623
2724# Google token TTL and refresh buffer
2825GOOGLE_TOKEN_TTL = 3600 # 1 hour
3431
3532
3633async def get_google_token () -> str :
37- """Resolve a valid Google access token.
38-
39- - HTTP mode: reads from Redis (stored during OAuth flow), auto-refreshes if near expiry.
40- - stdio mode: generates from service account JSON via JWT assertion.
41- """
42- if settings .is_http :
43- return await _get_google_token_http ()
44- return await _get_google_token_stdio ()
45-
46-
47- async def _get_google_token_http () -> str :
48- """Get Google token from Redis (HTTP mode).
34+ """Resolve a valid Google access token from Redis.
4935
5036 The token is stored during the OAuth callback when the user logs in
51- via Google through Supabase.
37+ via Google through Supabase. Auto-refreshes if near expiry.
38+
39+ Only available in HTTP mode — sheets tools are removed in stdio mode.
5240 """
41+
5342 redis = get_redis_client ()
5443
5544 # Try to get the stored token
@@ -81,6 +70,8 @@ async def _get_google_token_http() -> str:
8170
8271async def _refresh_google_token_http (refresh_token : str ) -> str :
8372 """Refresh a Google access token using the Supabase-stored refresh token."""
73+ from everyrow_mcp .config import settings # noqa: PLC0415
74+
8475 async with httpx .AsyncClient (timeout = 10.0 ) as client :
8576 # Refresh through Supabase which proxies to Google
8677 resp = await client .post (
@@ -104,69 +95,6 @@ async def _refresh_google_token_http(refresh_token: str) -> str:
10495 return provider_token
10596
10697
107- async def _get_google_token_stdio () -> str :
108- """Get Google token via service account JWT exchange (stdio mode)."""
109- creds_json = settings .google_sheets_credentials_json
110- if not creds_json :
111- raise RuntimeError (
112- "GOOGLE_SHEETS_CREDENTIALS_JSON not set. "
113- "Set it to a path to a service account JSON file or inline JSON."
114- )
115-
116- # Load service account credentials
117- sa_info = _load_service_account_info (creds_json )
118-
119- # Sign JWT assertion
120- now = int (time .time ())
121- payload = {
122- "iss" : sa_info ["client_email" ],
123- "sub" : sa_info ["client_email" ],
124- "scope" : SCOPES ,
125- "aud" : GOOGLE_TOKEN_URL ,
126- "iat" : now ,
127- "exp" : now + GOOGLE_TOKEN_TTL ,
128- }
129-
130- assertion = pyjwt .encode (
131- payload ,
132- sa_info ["private_key" ],
133- algorithm = "RS256" ,
134- )
135-
136- # Exchange JWT for access token
137- async with httpx .AsyncClient (timeout = 10.0 ) as client :
138- resp = await client .post (
139- GOOGLE_TOKEN_URL ,
140- data = {
141- "grant_type" : "urn:ietf:params:oauth:grant-type:jwt-bearer" ,
142- "assertion" : assertion ,
143- },
144- )
145- resp .raise_for_status ()
146- token_data = resp .json ()
147-
148- return token_data ["access_token" ]
149-
150-
151- def _load_service_account_info (creds_json : str ) -> dict [str , Any ]:
152- """Load service account info from a file path or inline JSON string."""
153- import os # noqa: PLC0415
154-
155- # If it looks like a file path, read it
156- if os .path .isfile (creds_json ):
157- with open (creds_json ) as f :
158- return json .load (f )
159-
160- # Otherwise treat as inline JSON
161- try :
162- return json .loads (creds_json )
163- except json .JSONDecodeError as e :
164- raise ValueError (
165- f"GOOGLE_SHEETS_CREDENTIALS_JSON is neither a valid file path "
166- f"nor valid JSON: { e } "
167- ) from e
168-
169-
17098async def store_google_token (
17199 user_id : str ,
172100 access_token : str ,
0 commit comments