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:
+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