From 040a0b2320150034c26056e065e9a3aa437b7bf9 Mon Sep 17 00:00:00 2001 From: Daniele Date: Thu, 20 Aug 2026 23:02:30 +0100 Subject: [PATCH] exa: fix verify.py rejecting every API key (v5 / 1.0.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activating Exa always failed with "Exa API key is invalid or unauthorized (HTTP 403)" — the key was never actually tested. Two bugs stacked: 1. mcp.exa.ai is behind Cloudflare, which bans urllib's default Python-urllib/3.x agent with 403 / "error code: 1010" before Exa sees the request. verify.py sent no User-Agent and mapped any 403 to "API key is invalid". Reproduced on the server with no key set at all — same "invalid key" message. 2. The probed endpoint cannot validate a key anyway: JSON-RPC initialize against the MCP endpoint returns 200 no matter what ?exaApiKey= carries (checked with a real key, a bogus key, and no key). Fixing only the headers would have flipped the bug to accepting every key, including garbage. With a key, the probe is now a minimal POST to api.exa.ai/search with the key in the x-api-key header — the only call that exercises the credential (200 valid, 401/403 + Exa JSON error invalid, 402 out of credits, 429 valid but throttled). With no key it probes MCP initialize and reports reachability only, never validity. Both requests send a User-Agent; the MCP one also sends Accept: application/json, text/event-stream (else HTTP 406). An opaque 401/403 with no Exa JSON error is now reported as "blocked before reaching the API — the key was not tested", instead of blaming the credential. Also re-aligns manifest/fragment versions to 5 / 1.0.4 (were 2/1.0.1 vs 4/1.0.3; skald reads installed_version from the manifest, so the update badge would never have appeared) and raises verify.timeout_secs 15 -> 20. --- CHANGELOG.md | 13 +++ connectors/connectors.json | 12 +-- connectors/exa/connector.json | 8 +- connectors/exa/fragment.json | 6 +- connectors/exa/verify.py | 154 +++++++++++++++++++++++++--------- 5 files changed, 139 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c74ad1..4d05e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## 2026-08-20 + +### Fixed + +- **exa: `verify.py` rejected every API key, valid or not (v5 / 1.0.4)** — activating Exa always failed with `Exa API key is invalid or unauthorized (HTTP 403)`. The key was never actually tested; two independent bugs stacked up. + - **Cloudflare blocked the probe before Exa saw it.** `mcp.exa.ai` sits behind Cloudflare, which bans urllib's default `Python-urllib/3.x` agent with HTTP 403 / `error code: 1010` (browser-signature ban). The script sent no `User-Agent`, and mapped any 403 straight to "API key is invalid" — so the message named the key for a failure the key had nothing to do with. Reproduced on the server with **no key set at all**: `{"ok": false, "message": "Exa API key is invalid or unauthorized (HTTP 403)"}`. + - **The endpoint it probed cannot validate a key anyway.** The probe was a JSON-RPC `initialize` against the MCP endpoint, which answers HTTP 200 regardless of what `?exaApiKey=` carries — verified against a real key, a syntactically valid bogus key, and no key at all. Fixing only the headers would have flipped the bug to the opposite failure: every key accepted, including garbage. + - Fix — when a key is configured the probe is now a minimal `POST https://api.exa.ai/search` (`numResults: 1`) with the key in the `x-api-key` header, the only call that exercises the credential: 200 → valid, 401/403 with Exa's own JSON error → invalid, 402 → out of credits, 429 → valid but throttled. With no key configured (Exa's free tier) it probes MCP `initialize` and reports *reachability only*, never validity. Both requests now send a `User-Agent`, and the MCP one also sends `Accept: application/json, text/event-stream` (without it the endpoint returns HTTP 406, `Client must accept both application/json and text/event-stream`). + - A non-2xx status is reported as an invalid key **only when Exa itself says so in a JSON error body**; an opaque 401/403 (Cloudflare, a proxy) is now reported as "blocked before reaching the API — the key was not tested". Mapping a bare status code to "bad key" is precisely what made this script reject valid keys. + - Re-aligned manifest↔fragment versions to `5` / `1.0.4` (they were 2/1.0.1 vs 4/1.0.3; skald reads `installed_version` from the manifest, so the update badge would never have appeared). `verify.timeout_secs` 15 → 20, for margin over the script's own 12s per-request timeout. + - Tested end-to-end: valid key → `{"ok": true, "message": "Exa API key is valid"}`; bogus key → `{"ok": false, "message": "Exa API key is invalid or unauthorized: Invalid API key"}`; no key → `{"ok": true, "message": "Exa MCP endpoint is reachable (free tier, no API key)"}` ✅. Runtime path also confirmed unaffected: `initialize` + `notifications/initialized` + a real `web_search_exa` call against `https://mcp.exa.ai/mcp?exaApiKey=…` returned results ✅ + - ⚠️ Same latent pattern elsewhere: `context7`, `tavily`, and `serpapi-flights` also probe over urllib with no `User-Agent` and map bare 401/403 to a credential verdict. They pass today because their hosts do not run Cloudflare's browser-signature check — not because the scripts are correct. + ## 2026-08-19 ### Added diff --git a/connectors/connectors.json b/connectors/connectors.json index 852f5a7..af56168 100644 --- a/connectors/connectors.json +++ b/connectors/connectors.json @@ -259,9 +259,9 @@ "type": "api_key" }, "folder": "exa", - "version": 4, - "version_string": "1.0.3", - "version_release_date": "2026-07-21", + "version": 5, + "version_string": "1.0.4", + "version_release_date": "2026-08-20", "tools": [ { "name": "web_search_exa", @@ -275,7 +275,7 @@ "files": [ { "path": "connector.json", - "sha256": "79e2fb2b125fd67c9cd5cbf5dbec481bab19b1e3891410336b4b9306ad323db7", + "sha256": "72f53af082e04e9dc90970f16f13889b19746f5e46d327017e25f1b759590740", "size": 1655 }, { @@ -290,8 +290,8 @@ }, { "path": "verify.py", - "sha256": "8e77da3ca3eca8f733296da134aaa964e5db8d01d4bfb89aa5832296e9dc45a6", - "size": 2125 + "sha256": "1928e52129931c542e624e9e964095bf6f601bca5330a039d9dd37be10d0be4b", + "size": 5083 } ] }, diff --git a/connectors/exa/connector.json b/connectors/exa/connector.json index a3883ab..300a5df 100644 --- a/connectors/exa/connector.json +++ b/connectors/exa/connector.json @@ -1,9 +1,9 @@ { "id": "exa", "name": "Exa", - "version": 2, - "version_string": "1.0.1", - "version_release_date": "2026-07-21", + "version": 5, + "version_string": "1.0.4", + "version_release_date": "2026-08-20", "type": "mcp_remote", "scope": "global", "tags": [ @@ -38,7 +38,7 @@ }, "verify": { "command": "python3 verify.py", - "timeout_secs": 15 + "timeout_secs": 20 }, "homepage": "https://exa.ai", "icon_small": "icon_sm.png", diff --git a/connectors/exa/fragment.json b/connectors/exa/fragment.json index 06c2fd4..d14ba7b 100644 --- a/connectors/exa/fragment.json +++ b/connectors/exa/fragment.json @@ -17,9 +17,9 @@ "type": "api_key" }, "folder": "exa", - "version": 4, - "version_string": "1.0.3", - "version_release_date": "2026-07-21", + "version": 5, + "version_string": "1.0.4", + "version_release_date": "2026-08-20", "tools": [ { "name": "web_search_exa", diff --git a/connectors/exa/verify.py b/connectors/exa/verify.py index a4b20ff..f6266d1 100644 --- a/connectors/exa/verify.py +++ b/connectors/exa/verify.py @@ -1,14 +1,31 @@ #!/usr/bin/env python3 -"""Verify connectivity to Exa's hosted MCP server. +"""Verify an Exa API key (or free-tier reachability). Reads exaApiKey from the environment (optional — Exa works in free tier without -one), sends a lightweight JSON-RPC initialize to https://mcp.exa.ai/mcp, and -prints a single JSON object: +one) and prints a single JSON object: - {"ok": true, "message": "Exa MCP endpoint is reachable"} - {"ok": false, "message": "Exa MCP endpoint is unreachable: "} + {"ok": true, "message": "Exa API key is valid"} + {"ok": false, "message": "Exa API key is invalid or unauthorized"} + +With a key, the probe is a minimal `POST https://api.exa.ai/search` carrying the +key in the `x-api-key` header — the only call that actually exercises the +credential. Exa's MCP endpoint answers `initialize` with HTTP 200 no matter what +key is on the URL, so it cannot tell a good key from a bad one; without a key we +therefore only report reachability, never validity. + +Two headers are mandatory on every request: + + * `User-Agent` — mcp.exa.ai and api.exa.ai sit behind Cloudflare, which + answers a request carrying urllib's default agent with HTTP 403 / "error + code: 1010" (browser-signature ban) before Exa ever sees the key. + * `Accept: application/json, text/event-stream` — the MCP streamable-http + endpoint rejects anything else with HTTP 406. + +A non-2xx status is only reported as an invalid key when Exa itself says so in a +JSON error body; an opaque 401/403 (Cloudflare, a proxy) is reported as blocked, +because mapping a bare status code to "bad key" is what made this script reject +valid keys. The API key is never printed. -The API key is never printed. HTTP-level verification only — no search queries. stdlib only (urllib). """ import json @@ -17,54 +34,109 @@ import sys import urllib.error import urllib.request +SEARCH_URL = "https://api.exa.ai/search" +MCP_URL = "https://mcp.exa.ai/mcp" +USER_AGENT = "skald-verify/1.0" +TIMEOUT = 12 -def _result(ok, message): - print(json.dumps({"ok": ok, "message": message})) + +def _result(ok, message, details=None): + payload = {"ok": ok, "message": message} + if details: + payload["details"] = details + print(json.dumps(payload)) sys.exit(0 if ok else 1) -def main(): - api_key = os.environ.get("exaApiKey", "").strip() - # Also accept UPPER_SNAKE form - if not api_key: - api_key = os.environ.get("EXA_API_KEY", "").strip() - - url = "https://mcp.exa.ai/mcp" - if api_key: - url += f"?exaApiKey={api_key}" - - body = json.dumps({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": {"name": "skald-verify", "version": "1.0.0"}, - }, - }).encode("utf-8") - +def _post(url, headers, body): + """POST and return (status, body_text). Raises only on transport errors.""" req = urllib.request.Request( url, - data=body, - headers={"Content-Type": "application/json"}, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json", + "User-Agent": USER_AGENT, + **headers}, method="POST", ) - try: - with urllib.request.urlopen(req, timeout=15) as resp: - if 200 <= resp.status < 300: - label = "with API key" if api_key else "free tier" - _result(True, f"Exa MCP endpoint is reachable ({label})") - _result(False, f"Exa returned HTTP {resp.status}") + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + return resp.status, resp.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: - if e.code in (401, 403): - _result(False, "Exa API key is invalid or unauthorized " - f"(HTTP {e.code})") - _result(False, f"Exa returned HTTP {e.code}: {e.reason}") + return e.code, e.read().decode("utf-8", "replace") + + +def _exa_error(text): + """Exa's own error message from a JSON error body, or None if not ours.""" + try: + data = json.loads(text) + except (ValueError, TypeError): + return None + if isinstance(data, dict) and "error" in data: + return str(data["error"]) + return None + + +def check_key(api_key): + """Probe the REST API with the key. Only this call validates a credential.""" + try: + status, text = _post( + SEARCH_URL, + {"Accept": "application/json", "x-api-key": api_key}, + {"query": "skald connector verification", "numResults": 1}, + ) + except Exception as e: + _result(False, f"Exa API is unreachable: {e}") + + if 200 <= status < 300: + _result(True, "Exa API key is valid") + + detail = _exa_error(text) + if status in (401, 403): + if detail: + _result(False, f"Exa API key is invalid or unauthorized: {detail}") + # Opaque rejection — Cloudflare or a proxy, not Exa judging the key. + _result(False, f"Request to Exa was blocked before reaching the API " + f"(HTTP {status}). The key was not tested.") + if status == 402: + _result(False, "Exa account is out of credits " + f"({detail or 'HTTP 402'})") + if status == 429: + # A rate limit proves the key was accepted, just throttled right now. + _result(True, "Exa API key is valid (rate limit currently reached)") + _result(False, f"Exa returned HTTP {status}" + + (f": {detail}" if detail else "")) + + +def check_free_tier(): + """No key configured: report whether the MCP endpoint answers at all.""" + try: + status, text = _post( + MCP_URL, + {"Accept": "application/json, text/event-stream"}, + {"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "skald-verify", + "version": "1.0.0"}}}, + ) except Exception as e: _result(False, f"Exa MCP endpoint is unreachable: {e}") + if 200 <= status < 300: + _result(True, "Exa MCP endpoint is reachable (free tier, no API key)") + detail = _exa_error(text) + _result(False, f"Exa MCP endpoint returned HTTP {status}" + + (f": {detail}" if detail else "")) + + +def main(): + api_key = (os.environ.get("exaApiKey") + or os.environ.get("EXA_API_KEY") + or "").strip() + if api_key: + check_key(api_key) + else: + check_free_tier() + if __name__ == "__main__": main()