exa: fix verify.py rejecting every API key (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 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.
This commit is contained in:
Daniele
2026-08-20 23:02:30 +01:00
parent f02bb78ebd
commit 040a0b2320
5 changed files with 139 additions and 54 deletions
+13
View File
@@ -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
+6 -6
View File
@@ -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
}
]
},
+4 -4
View File
@@ -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",
+3 -3
View File
@@ -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",
+113 -41
View File
@@ -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: <reason>"}
{"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()