Files
skald-connectors/connectors/exa/verify.py
T
Daniele 040a0b2320 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.
2026-08-20 23:02:30 +01:00

143 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""Verify an Exa API key (or free-tier reachability).
Reads exaApiKey from the environment (optional — Exa works in free tier without
one) and prints a single JSON object:
{"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.
stdlib only (urllib).
"""
import json
import os
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, details=None):
payload = {"ok": ok, "message": message}
if details:
payload["details"] = details
print(json.dumps(payload))
sys.exit(0 if ok else 1)
def _post(url, headers, body):
"""POST and return (status, body_text). Raises only on transport errors."""
req = urllib.request.Request(
url,
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=TIMEOUT) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
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()