Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
2e9e3bf
Add HTTP transport, tool helpers, and deploy config
RafaelPo Feb 21, 2026
0cab42f
Add tests for HTTP transport, middleware, routes, and result store
RafaelPo Feb 21, 2026
3b0fe01
Refactor settings out of ServerState, add _BaseHttpSettings, simplify…
RafaelPo Feb 21, 2026
cea583c
Fix review findings: auth constructor, Transport enum, middleware ord…
RafaelPo Feb 21, 2026
feb0ad5
Remove _with_ui indirection, use explicit stdio/http branching
RafaelPo Feb 21, 2026
21d25f5
Extract create_tool_response to deduplicate submission returns
RafaelPo Feb 21, 2026
da0ba97
Clean up ServerState: remove dead fields, add assignment validation
RafaelPo Feb 21, 2026
8b4ca81
Fix integration test fixture to use state.client instead of app._client
RafaelPo Feb 21, 2026
d7a4766
Add stdio-clean assertions to integration tests
RafaelPo Feb 21, 2026
aa5f12e
Migrate state.client to FastMCP lifespan context
RafaelPo Feb 21, 2026
b0a8068
Add typed SessionContext and EveryRowContext for lifespan context
RafaelPo Feb 21, 2026
35fb25d
Inline client factories, drop ClientFactory alias
RafaelPo Feb 21, 2026
9cb97cb
Add slots=True to SessionContext, document client_factory semantics
RafaelPo Feb 22, 2026
f2611e6
Wire token_budget setting into adaptive page sizing for everyrow_results
RafaelPo Feb 22, 2026
5afd492
Remove duplicated settings from ServerState, delegate to @lru_cache f…
RafaelPo Feb 22, 2026
d8322f9
Refactor TaskState to BaseModel, clean up http_config/routes/server
RafaelPo Feb 22, 2026
a6bebeb
Fix PR review issues: centralise state mutation, fix type mismatches,…
RafaelPo Feb 22, 2026
2ace6fe
Fix screen test assertion, rename settings factories to public API
RafaelPo Feb 22, 2026
06cc37a
Fix state.transport bug, remove progress widget, use app.openLink for…
RafaelPo Feb 22, 2026
5bfbeff
Improve results widget: linkify URLs, move download link, fix expand/…
RafaelPo Feb 22, 2026
b46ce3d
Exclude false positive in PR content check diff scan
RafaelPo Feb 22, 2026
6f5e163
Remove ServerState and RedisStore, merge settings into single Setting…
RafaelPo Feb 22, 2026
27f4762
Fix test_server.py: replace stale state/Transport imports with overri…
RafaelPo Feb 22, 2026
4161a80
Fix rate limiting behind reverse proxy using real client IP
RafaelPo Feb 22, 2026
7166a0e
Fix result_store: fall back to API on Redis cache miss, preserve page…
RafaelPo Feb 22, 2026
7d00aac
Trim tool input schemas per transport mode
RafaelPo Feb 23, 2026
515a146
Simplify tool input models: merge input_data+input_json into single d…
RafaelPo Feb 23, 2026
847eb62
Split everyrow_results into mode-specific functions, delete tool_desc…
RafaelPo Feb 23, 2026
479ea40
Stop ignoring ARG002: fix unused method argument violations
RafaelPo Feb 23, 2026
caec969
Fix _get_csv_url returning token=None when poll token expires
RafaelPo Feb 23, 2026
55b9872
Merge origin/main into feat/http-transport-clean
RafaelPo Feb 24, 2026
993d54c
Atomic rate limiter with pipeline + bump preview_size to 1000
RafaelPo Feb 24, 2026
4e6a6b0
Make ForecastInput accept inline data via _SingleSourceInput
RafaelPo Feb 24, 2026
5f37e87
Fall back to inline results when Redis is unavailable in HTTP mode
RafaelPo Feb 24, 2026
ef980e4
Fix HTTP results tool: remove before re-registering to replace schema
RafaelPo Feb 24, 2026
1cbdabc
Add test for HTTP results tool schema override
RafaelPo Feb 24, 2026
862ed60
Skip rate limit for unknown IPs; drop path existence check in HTTP mode
RafaelPo Feb 24, 2026
6a1b1ef
Fix auth rate limiter crash when client IP is None
RafaelPo Feb 24, 2026
c41723b
Harden MCP server for public deployment
RafaelPo Feb 24, 2026
9688394
Test real Redis results flow in HTTP integration tests
RafaelPo Feb 24, 2026
fc8f6c2
Merge remote-tracking branch 'origin/main' into fix/pr-review-hardening
RafaelPo Feb 24, 2026
5755cb7
Harden OAuth auth module (PR review findings #2, #4–#7)
RafaelPo Feb 24, 2026
f7fc2a3
Fix remaining code review findings (#2–#10)
RafaelPo Feb 24, 2026
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
15 changes: 15 additions & 0 deletions everyrow-mcp/deploy/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.git
.github
.venv
__pycache__
*.pyc
.env
*.env
!.env.example
.claude/
.vscode/
*.egg-info
dist/
build/
node_modules/
docs-site/
3 changes: 2 additions & 1 deletion everyrow-mcp/deploy/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
EVERYROW_API_KEY=sk-cho-your-api-key-here
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=sb_publishable_your-anon-key-here
REDIS_ENCRYPTION_KEY=generate-with-python-cryptography-fernet
MCP_SERVER_URL=https://your-tunnel-url.example.com
REDIS_PASSWORD=change-me-to-a-strong-random-password
10 changes: 8 additions & 2 deletions everyrow-mcp/deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ RUN uv sync --package everyrow-mcp --no-dev --no-sources --no-editable
# Stage 2: Slim runtime
FROM python:3.13-slim

RUN groupadd -r mcp && useradd -r -g mcp -d /app -s /sbin/nologin mcp

ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000

CMD ["everyrow-mcp", "--http", "--port", "8000", "--host", "0.0.0.0"]

WORKDIR /app
COPY --link --from=build /app/.venv .venv
RUN chown -R mcp:mcp /app

USER mcp

STOPSIGNAL SIGTERM
CMD ["everyrow-mcp", "--http", "--port", "8000", "--host", "0.0.0.0"]
20 changes: 16 additions & 4 deletions everyrow-mcp/deploy/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --requirepass "${REDIS_PASSWORD:?Set REDIS_PASSWORD}"
# No ports: — only reachable by other services on the Docker network.
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 3
deploy:
resources:
limits:
memory: 256M
restart: unless-stopped

mcp-server:
build:
context: ../..
dockerfile: everyrow-mcp/deploy/Dockerfile
ports:
- "8000:8000"
- "127.0.0.1:8000:8000"
depends_on:
redis:
condition: service_healthy
Expand All @@ -25,4 +30,11 @@ services:
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_DB: "13"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
EVERYROW_API_URL: "https://everyrow.io/api/v0"
TRUST_PROXY_HEADERS: "true" # Behind Cloudflare tunnel
deploy:
resources:
limits:
memory: 512M
restart: unless-stopped
2 changes: 2 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@


def _clear_task_state() -> None:
if settings.is_http:
return
if TASK_STATE_FILE.exists():
TASK_STATE_FILE.unlink()

Expand Down
93 changes: 61 additions & 32 deletions everyrow-mcp/src/everyrow_mcp/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import httpx
import jwt as pyjwt
import pydantic
from jwt import PyJWKClient
from mcp.server.auth.provider import (
AccessToken,
Expand Down Expand Up @@ -78,7 +79,7 @@ def _decode_jwt(self, token: str, signing_key) -> dict[str, Any]:
return pyjwt.decode(
token,
signing_key.key,
algorithms=["RS256", "ES256"],
algorithms=["RS256"],
issuer=self._issuer,
audience=self._audience,
options={"require": ["exp", "sub", "iss", "aud"]},
Expand Down Expand Up @@ -107,7 +108,7 @@ async def verify_token(self, token: str) -> AccessToken | None:
logger.warning("JWKS fetch timed out (10s)")
return None
except pyjwt.PyJWTError:
logger.debug("JWT verification failed", exc_info=True)
logger.debug("JWT verification failed")
return None


Expand Down Expand Up @@ -191,18 +192,20 @@ def _UNSAFE_decode_server_jwt(token: str) -> dict[str, Any]:
endpoint over HTTPS and was never exposed to the client.
NEVER use this for tokens received from end users.
"""
return pyjwt.decode(token, options={"verify_signature": False})
return pyjwt.decode(
token, options={"verify_signature": False}, algorithms=["RS256"]
)

@staticmethod
def _client_ip(request: Request) -> str:
return get_client_ip(request) or "unknown"

async def _check_rate_limit(self, action: str, client_ip: str) -> None:
rl_key = build_key("ratelimit", action, client_ip)
pipe = self._redis.pipeline()
pipe.incr(rl_key)
pipe.expire(rl_key, settings.registration_rate_window)
count, _ = await pipe.execute()
async with self._redis.pipeline() as pipe:
pipe.incr(rl_key)
pipe.expire(rl_key, settings.registration_rate_window, nx=True)
count, _ = await pipe.execute()
if count > settings.registration_rate_limit:
raise ValueError(f"{action.title()} rate limit exceeded")

Expand Down Expand Up @@ -246,9 +249,10 @@ def _supabase_redirect_url(supabase_verifier: str) -> str:
def _validate_redirect_url(
client: OAuthClientInformationFull, params: AuthorizationParams
) -> None:
if client.redirect_uris:
if str(params.redirect_uri) not in [str(u) for u in client.redirect_uris]:
raise ValueError("redirect_uri does not match any registered URI")
if not client.redirect_uris:
raise ValueError("Client must register at least one redirect_uri")
if str(params.redirect_uri) not in [str(u) for u in client.redirect_uris]:
raise ValueError("redirect_uri does not match any registered URI")

async def _validate_auth_request(
self, request: Request, action: str, state: str | None, *, consume: bool = False
Expand All @@ -272,15 +276,14 @@ async def _validate_auth_request(

async def _validate_client(self, pending: PendingAuth) -> None:
client_info = await self.get_client(pending.client_id)
if client_info is None or (
pending.params.redirect_uri
and client_info.redirect_uris
and str(pending.params.redirect_uri)
if client_info is None:
raise HTTPException(status_code=400, detail="Invalid client")
if pending.params.redirect_uri and (
not client_info.redirect_uris
or str(pending.params.redirect_uri)
not in [str(u) for u in client_info.redirect_uris]
):
raise HTTPException(
status_code=400, detail="Invalid client or redirect_uri"
)
raise HTTPException(status_code=400, detail="Invalid redirect_uri")

async def _validate_supabase_code(
self, code: str, supabase_code_verifier: str
Expand All @@ -289,10 +292,10 @@ async def _validate_supabase_code(
return await self._exchange_supabase_code(
code=code, code_verifier=supabase_code_verifier
)
except Exception:
logger.exception("Failed to exchange Supabase code")
except Exception as exc:
logger.error("Failed to exchange Supabase code: %s", type(exc).__name__)
raise HTTPException(
status_code=500, detail="Failed to authenticate with Supabase"
status_code=500, detail="Authentication failed. Please try again."
)

async def _validate_callback_request(
Expand Down Expand Up @@ -359,7 +362,7 @@ async def handle_start(self, request: Request) -> RedirectResponse:
value=request.path_params.get("state"),
max_age=settings.pending_auth_ttl,
httponly=True,
samesite="lax",
samesite="strict",
secure=True,
path="/auth/callback",
)
Expand Down Expand Up @@ -398,7 +401,7 @@ async def handle_callback(self, request: Request) -> RedirectResponse:
"mcp_auth_state",
path="/auth/callback",
httponly=True,
samesite="lax",
samesite="strict",
secure=True,
)
return response
Expand All @@ -411,11 +414,18 @@ async def load_authorization_code(
if len(authorization_code) > 256:
return None

code_data = await self._redis.getdel(build_key("authcode", authorization_code))
key = build_key("authcode", authorization_code)
# GETDEL atomically consumes the code — no race between concurrent requests.
code_data = await self._redis.getdel(key)
if code_data is None:
return None
code_obj = EveryRowAuthorizationCode.model_validate_json(code_data)
if code_obj.expires_at and code_obj.expires_at < time.time():
return None
if code_obj.client_id != client.client_id:
# Re-store so the legitimate client can still use it.
remaining = max(1, int((code_obj.expires_at or 0) - time.time()))
await self._redis.setex(key, remaining, code_data)
return None
return code_obj

Expand Down Expand Up @@ -470,12 +480,16 @@ async def load_refresh_token(
if len(refresh_token) > 256:
return None

data = await self._redis.getdel(build_key("refresh", refresh_token))
key = build_key("refresh", refresh_token)
# GET first, verify client_id, then DELETE — same pattern as
# load_authorization_code to avoid cross-client DoS.
data = await self._redis.get(key)
if data is None:
return None
rt = EveryRowRefreshToken.model_validate_json(data)
if rt.client_id != client.client_id:
return None
await self._redis.delete(key)
return rt

async def exchange_refresh_token(
Expand All @@ -485,9 +499,18 @@ async def exchange_refresh_token(
scopes: list[str],
) -> OAuthToken:
final_scopes = self._validate_scopes(scopes, refresh_token)
supa_tokens = await self._refresh_supabase_token(
refresh_token.supabase_refresh_token
)
try:
supa_tokens = await self._refresh_supabase_token(
refresh_token.supabase_refresh_token
)
except Exception:
# Re-store the consumed refresh token so the user isn't locked out.
await self._redis.setex(
name=build_key("refresh", refresh_token.token),
time=settings.refresh_token_ttl,
value=refresh_token.model_dump_json(),
)
raise
return await self._issue_token_response(
access_token=supa_tokens.access_token,
client_id=client.client_id,
Expand All @@ -500,9 +523,11 @@ async def revoke_token(self, token: AccessToken | EveryRowRefreshToken) -> None:
await self._redis.delete(build_key("refresh", token.token))
elif isinstance(token, AccessToken):
fp = SupabaseTokenVerifier._token_fingerprint(token.token)
remaining = max(0, (token.expires_at or 0) - int(time.time())) + 60
ttl = remaining if remaining > 60 else self._token_verifier._revocation_ttl
await self._redis.setex(
name=build_key("revoked", fp),
time=self._token_verifier._revocation_ttl,
time=ttl,
value="1",
)

Expand All @@ -519,10 +544,14 @@ async def _supabase_token_request(
)
resp.raise_for_status()
data = resp.json()
return SupabaseTokenResponse(
access_token=data["access_token"],
refresh_token=data["refresh_token"],
)
try:
return SupabaseTokenResponse.model_validate(data)
except pydantic.ValidationError:
logger.error(
"Supabase token response missing required fields: %s",
sorted(data.keys()),
)
raise ValueError("Invalid token response from identity provider")

async def _exchange_supabase_code(
self, code: str, code_verifier: str
Expand Down
6 changes: 6 additions & 0 deletions everyrow-mcp/src/everyrow_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ class Settings(BaseSettings):
)
redis_sentinel_master_name: str | None = Field(default=None)

trust_proxy_headers: bool = Field(
default=False,
description="Trust X-Forwarded-For and CF-Connecting-IP headers for client IP. "
"Enable only when behind a trusted reverse proxy (e.g. Cloudflare).",
)

# HTTP-only settings — unused in stdio mode
mcp_server_url: str = Field(default="")
supabase_url: str = Field(default="")
Expand Down
22 changes: 21 additions & 1 deletion everyrow-mcp/src/everyrow_mcp/http_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ def configure_http_mode(
mcp_server_url: str,
) -> None:
"""Configure the MCP server for HTTP transport."""
if not no_auth:
missing = []
if not settings.supabase_url:
missing.append("SUPABASE_URL")
if not settings.supabase_anon_key:
missing.append("SUPABASE_ANON_KEY")
if not settings.mcp_server_url:
missing.append("MCP_SERVER_URL")
if missing:
raise RuntimeError(
f"HTTP auth mode requires these environment variables: {', '.join(missing)}"
)

redis_client = get_redis_client()
if no_auth:
lifespan = no_auth_http_lifespan
Expand All @@ -52,7 +65,7 @@ def configure_http_mode(
mcp.settings.port = port

_register_widgets(mcp, mcp_server_url)
_register_routes(mcp, auth_provider if not no_auth else None)
_register_routes(mcp, redis_client, auth_provider if not no_auth else None)
_add_middleware(mcp, redis_client, rate_limit=not no_auth)


Expand All @@ -79,6 +92,7 @@ def _results_ui_http() -> str:

def _register_routes(
mcp: FastMCP,
redis: Redis,
auth_provider: EveryRowAuthProvider | None,
) -> None:
"""Register REST endpoints for widget polling, CSV download, health, and auth."""
Expand All @@ -88,6 +102,12 @@ def _register_routes(
)

async def _health(_request: Request) -> Response:
try:
await redis.ping()
except Exception:
return JSONResponse(
{"status": "unhealthy", "redis": "unreachable"}, status_code=503
)
return JSONResponse({"status": "ok"})

mcp.custom_route("/health", ["GET"])(_health)
Expand Down
Loading