Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions everyrow-mcp/deploy/docker-compose.local.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Local development overrides — NOT for production use.
# Local development overrides.
# Usage: docker compose -f docker-compose.yaml -f docker-compose.local.yaml up
services:
redis:
Expand All @@ -9,4 +9,5 @@ services:
environment:
MCP_SERVER_URL: "${MCP_SERVER_URL:-http://localhost:8000}"
TRUST_PROXY_HEADERS: "${TRUST_PROXY_HEADERS:-false}"
EXTRA_ALLOWED_HOSTS: "host.docker.internal:*" # local dev only — widens DNS rebinding allowlist
ENABLE_SHEETS_TOOLS: "${ENABLE_SHEETS_TOOLS:-false}"
EXTRA_ALLOWED_HOSTS: "${EXTRA_ALLOWED_HOSTS:-}"
20 changes: 20 additions & 0 deletions everyrow-mcp/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,26 @@
{
"name": "everyrow_use_list",
"description": "Import a reference list into your session and save it as a CSV file."
},
{
"name": "sheets_list",
"description": "List the user's Google Sheets, optionally filtered by name."
},
{
"name": "sheets_read",
"description": "Read data from a Google Sheet and return it as JSON records."
},
{
"name": "sheets_write",
"description": "Write data to a Google Sheet."
},
{
"name": "sheets_create",
"description": "Create a new Google Sheet, optionally populated with data."
},
{
"name": "sheets_info",
"description": "Get metadata about a Google Sheet: title, sheet names, and dimensions."
}
],
"user_config": {
Expand Down
39 changes: 39 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,19 +128,24 @@ class EveryRowAuthorizationCode(AuthorizationCode):

supabase_access_token: str
supabase_refresh_token: str
google_access_token: str = ""
google_refresh_token: str = ""


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

supabase_refresh_token: str
google_refresh_token: str = ""


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

access_token: str
refresh_token: str
provider_token: str = ""
provider_refresh_token: str = ""


class PendingAuth(BaseModel):
Expand Down Expand Up @@ -239,6 +244,10 @@ def _supabase_redirect_url(supabase_verifier: str) -> str:
'flow_type': 'pkce',
'code_challenge': supabase_challenge,
'code_challenge_method': 's256',
'scopes': (
'https://www.googleapis.com/auth/spreadsheets '
'https://www.googleapis.com/auth/drive.metadata.readonly'
),
}
)
}"
Expand Down Expand Up @@ -391,6 +400,8 @@ async def _create_authorisation_code(
resource=pending.params.resource,
supabase_access_token=supa_tokens.access_token,
supabase_refresh_token=supa_tokens.refresh_token,
google_access_token=supa_tokens.provider_token,
google_refresh_token=supa_tokens.provider_refresh_token,
)
await self._redis.setex(
name=build_key("authcode", code),
Expand Down Expand Up @@ -449,6 +460,8 @@ async def _issue_token_response(
client_id: str,
scopes: list[str],
supabase_refresh_token: str,
google_access_token: str = "",
google_refresh_token: str = "",
) -> OAuthToken:
# SECURITY: Extract exp from the Supabase JWT without signature
# verification. This is safe ONLY because the token was just received
Expand All @@ -462,12 +475,31 @@ async def _issue_token_response(
)
expires_in = max(0, jwt_claims.get("exp", 0) - int(time.time()))

# Store Google tokens in Redis for Sheets tools
if google_access_token:
from everyrow_mcp.sheets_client import store_google_token # noqa: PLC0415

try:
await store_google_token(
jwt_claims.get("sub", "unknown"),
google_access_token,
google_refresh_token or None,
expires_in=expires_in,
)
except Exception:
logger.error(
"Google token storage failed for user=%s — Sheets tools will be unavailable",
jwt_claims.get("sub", "unknown"),
exc_info=True,
)

rt_str = secrets.token_urlsafe(32)
rt = EveryRowRefreshToken(
token=rt_str,
client_id=client_id,
scopes=scopes,
supabase_refresh_token=supabase_refresh_token,
google_refresh_token=google_refresh_token,
)
await self._redis.setex(
name=build_key("refresh", rt_str),
Expand All @@ -494,6 +526,8 @@ async def exchange_authorization_code(
client_id=client.client_id,
scopes=authorization_code.scopes,
supabase_refresh_token=authorization_code.supabase_refresh_token,
google_access_token=authorization_code.google_access_token,
google_refresh_token=authorization_code.google_refresh_token,
)

async def load_access_token(self, token: str) -> AccessToken | None:
Expand Down Expand Up @@ -556,13 +590,18 @@ async def exchange_refresh_token(
value=encrypt_value(refresh_token.model_dump_json()),
)
raise
google_refresh = (
supa_tokens.provider_refresh_token or refresh_token.google_refresh_token
)
assert client.client_id is not None
logger.info("Token refresh successful user=%s", client.client_id)
return await self._issue_token_response(
access_token=supa_tokens.access_token,
client_id=client.client_id,
scopes=final_scopes,
supabase_refresh_token=supa_tokens.refresh_token,
google_access_token=supa_tokens.provider_token,
google_refresh_token=google_refresh,
)

async def revoke_token(self, token: AccessToken | EveryRowRefreshToken) -> None:
Expand Down
10 changes: 10 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ class Settings(BaseSettings):
description="Upload rate limit sliding window in seconds (1 hour)",
)

enable_sheets_tools: bool = Field(
default=False,
description="Enable Google Sheets tools (requires HTTP mode with Google OAuth)",
)
sheets_rate_limit: PositiveInt = Field(
default=60, description="Max sheets ops per user per rate window"
)
sheets_rate_window: PositiveInt = Field(
default=60, description="Sheets rate limit window in seconds"
)
everyrow_api_key: str | None = Field(default=None, repr=False)

@property
Expand Down
6 changes: 6 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,12 @@ def validate_task_id(cls, v: str) -> str:
description="Full absolute path to the output CSV file (must end in .csv). "
"Optional — results are returned as a paginated preview by default.",
)
output_spreadsheet_title: str | None = Field(
default=None,
description="Create a new Google Sheet with this title and write the full "
"results there. Returns the spreadsheet URL. Fails if a sheet with "
"this exact title already exists — pick a unique name.",
)
offset: int = Field(
default=0,
description="Row offset for pagination. Default 0 returns the first page.",
Expand Down
11 changes: 11 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ def main():
settings.transport = transport.value
mcp._mcp_server.instructions = get_instructions(is_http=input_args.http)

# Register sheets tools after transport is set (they require HTTP mode)
if settings.enable_sheets_tools and settings.is_http:
import everyrow_mcp.sheets_tools # noqa: F401, PLC0415

# tools.py registers everyrow_results_stdio by default.
# Override with the HTTP variant when running in HTTP mode.
# ToolManager.add_tool() is a no-op for existing names, so remove first.
Expand All @@ -92,6 +96,13 @@ def main():
meta=_RESULTS_META,
)(everyrow_results_http)

# Strip output_spreadsheet_title from results schema when sheets disabled
if not settings.enable_sheets_tools:
tool = mcp._tool_manager.get_tool("everyrow_results")
if tool:
http_def = tool.parameters.get("$defs", {}).get("HttpResultsInput", {})
http_def.get("properties", {}).pop("output_spreadsheet_title", None)

if input_args.http:
# ── HTTP mode logging ──────────────────────────────────────
# INFO level so operational events show up in Cloud Logging.
Expand Down
Loading