Built-in tools
exray ships 15 MCP tools. Scopes come from TOOL_REQUIRED_SCOPE (admin satisfies any
requirement):
| Tool | Scope | Purpose |
|---|---|---|
scrape | tools.execute | Render a page → markdown/html/text/screenshot |
extract | tools.execute | Render + pull structured JSON via a flat schema |
crawl | tools.execute | Async chained crawl, returns a job_id |
get_crawl_results | tools.read | Page through per-page crawl artifacts |
list_definitions | tools.read | List assets registered by this token |
define_fetcher | tools.define | Register a custom fetcher (TS source) |
define_extractor | tools.define | Register a custom extractor (TS source) |
define_handler | tools.define | Register a result-page handler (TS source, entry handle(request, ctx)) |
define_crawl | tools.define | Register a reusable crawl definition |
delete_definition | tools.define | Delete a definition |
define_eval | tools.define | Register an evaluation |
run_eval | tools.define | Run an evaluation |
publish_definition | tools.publish | Publish (draft → published; with a revision id, roll back) |
deprecate_definition | tools.publish | Deprecate (still runnable, flagged, drops out of tools/list) |
disable_definition | tools.publish | Disable (not runnable) |
All 15 appear in tools/list and can be invoked via tools/call.
⚠️ But what they register differs: once published, define_fetcher / define_extractor
assets show up in tools/list as fetcher_<name> / extractor_<name> for the agent to call.
A handler registered by define_handler does not join the tool surface — it runs in response
to public HTTP requests instead. See result pages.
scrape
Render a URL and return it in the requested format.
{
"name": "scrape",
"arguments": {
"url": "https://example.com",
"format": "markdown",
"wait_for": ".main-content",
"timeout_ms": 30000
}
}format:markdown(default) /html/text/screenshot(base64 PNG)wait_for: optional CSS selector to wait for after renderactions: pre-render steps (click/wait_for_selector/scroll_to_bottom/wait_for_ms)- Failure codes:
browser_render_failed/target_unreachable/http_error/budget_exceeded
extract
Render plus AI extraction against a flat schema.
{
"name": "extract",
"arguments": {
"url": "https://news.ycombinator.com",
"schema": {
"title": "string",
"score": "number",
"comments": "number"
}
}
}schemasupports four types only:string/number/boolean/string[]- Returns
{ data, confidence, model_used };confidenceis a heuristic based on how many fields were populated - Default model:
@cf/meta/llama-3.1-8b-instruct
crawl
Async: seeds are queued and it returns { job_id, seeds, stream_url } immediately. Poll with
get_crawl_results.
{
"name": "crawl",
"arguments": {
"seed_urls": ["https://blog.example.com/"],
"include_patterns": ["https://blog.example.com/*"],
"follow": "links",
"max_depth": 2,
"max_pages": 50,
"extractor_params": { "schema": { "title": "string", "author": "string" } }
}
}follow:links(HTML anchors filtered byinclude_patterns) orresult:<json-path>(take next URLs from an extractor's output field — giving a scrape→extract→scrape→extract chain)def: reference a registered crawl definition (seedefine_crawl)- Hard caps:
max_depth ≤ 10,max_pages ≤ 1000 - Progress: MCP
notifications/progresswithprogressToken = job_id
get_crawl_results
{
"name": "get_crawl_results",
"arguments": { "job_id": "<uuid>", "limit": 50 }
}Returns { job_id, status, count, items, cursor }. Each item:
{
"url": "...",
"final_url": "...",
"depth": 1,
"metadata": { "title": "..." },
"markdown": "...",
"extracted": { }
}When cursor isn't null, pass { job_id, cursor } to read the next page.
define_fetcher / define_extractor
Register custom TypeScript. See Writing extractors.
define_crawl
Register a reusable crawl configuration, shared by three trigger paths — crawl { def }, cron,
and external triggers:
{
"name": "define_crawl",
"arguments": {
"name": "blog-daily",
"seed_urls": ["https://blog.example.com/"],
"follow": "links",
"include_patterns": ["https://blog.example.com/*"],
"max_depth": 2,
"cron": "0 9 * * *",
"trigger_secret": "s3cr3t"
}
}cron: the server scans every 5 minutes and fires on match, so 5 minutes is the effective granularitytrigger_secret: stored as an Argon2 hash;POST /api/triggers/<id>fires the crawl with it
Error codes
| Code | Meaning |
|---|---|
invalid_input | Arguments failed validation |
forbidden | Insufficient scope |
budget_exceeded | Monthly quota exhausted |
unknown_tool | No such tool |
unknown_crawl_def | No such crawl definition |
browser_render_failed / target_unreachable / http_error | Render failed |
bundle_missing | Code bundle not found in R2 |
definition_incompatible_api | The exray-api major the agent compiled against differs from runtime |
fetcher_invalid_output / fetcher_runtime_error | Custom fetcher failed |
extractor_invalid_output / extractor_runtime_error | Custom extractor failed |
extract_failed | Built-in extract failed |
not_found | Crawl job doesn't exist or doesn't belong to this token |