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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user