ag-mcp-search
A self-hosted MCP server for web search, page reading, image search, screenshots, and deep question answering with explicit trust and failure signals.
web_search— Search the web via metasearch; returns links, snippets, engine provenance, corroboration, and optionally reads top pages.web_read— Read known URLs as text, PDF, or vision-recognized scans; supports browser mode, offsets, marker checks, and stub detection.web_image_search— Find image files and the pages they sit on, with source domains and engine info.web_screenshot— Capture a PNG plus same-visit page text for visual/textual cross-checking.web_deep_search— Compose its own queries, read in waves, and return a digested answer with sources, outcome, ambiguity, and confirmation status.Configuration — Runs self-hosted over SearXNG, optionally uses an OpenAI-compatible model key for deep search and scanned PDF recognition, and supports auth/TLS controls.
Provides web search, page reading, image search, screenshot, and multi-source deep search capabilities through the SearXNG metasearch engine.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ag-mcp-searchWhat are the latest advancements in quantum computing?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
search
A self-hosted MCP server for web search that reports how much of each answer to believe
A search tool fails in ways that look exactly like success. An engine answers with somebody else's subject. A page returns text that is an anti-bot shield. Sixteen sources turn out to be two engines counted eight times. None of that raises an error, and the model on the other end builds on it.
This server's job is to make those cases distinguishable, in fields you can branch on. It runs on your machine, over your own metasearch instance, with your own model key — or none at all.
🔧 Tools
Tool | What it does | Required | Notable options |
| Finds pages and reads the top ones — one call, links with their text |
|
|
| Reads pages by address: text, PDF, or a scan recognised by a vision model |
|
|
| Finds images: the address of the FILE and, separately, of the page it sits on |
|
|
| A PNG of a page plus its text from the same visit, so the two can be cross-checked |
|
|
| Composes its own queries, reads in waves, and answers from several sources — saying what it could not confirm |
|
|
Full argument reference, response shapes and failure modes: HOWTO-CALL.md.
Related MCP server: mcp-web-tools
📦 Install
From an open repository page to a working answer. Nothing is assumed to be on your disk already:
git clone https://github.com/AG-Bureau/mcp-search
cd mcp-search
cp .env.example .env
echo "SEARXNG_SECRET=$(openssl rand -hex 32)" >> .env
echo "BROWSER_WS_SECRET=$(openssl rand -hex 16)" >> .env
echo '{}' > door-tokens.json && mkdir -p tls
docker compose -f docker-compose.yml -f wiring/expose-localhost.yml up -d --build
curl -s http://127.0.0.1:8081/healthzThe fourth and fifth lines are not decoration. Without a value in
SEARXNG_SECRET the up command refuses — and that refusal is deliberate: with
no key of its own the metasearch does not fail, it comes up with a publicly known
one from its image template, silently. BROWSER_WS_SECRET is the secret path of
the browser sidecar's endpoint, and up refuses without it too: on a guessable
path, anything that can open a WebSocket to the sidecar — any container on the
compose network, and any page the browser itself opens — gets a browser of its
own, outside every check the module applies to the pages it reads. The compose
file hands the same value to the sidecar and, at the end of BROWSER_WS_URL, to
the adapter; neither prints it.
The sixth line creates the two paths the compose file mounts for caller
authentication and TLS — both off by default. A path that does not exist is
created by Docker as a root-owned DIRECTORY, and a token "file" that is a
directory reads as an unreadable list (see Configuration below).
Both are in .gitignore: they hold secrets once you use them.
The overlay publishes the port on loopback only. A published container port does not go through the host firewall's usual chain, so exposing it more widely is a separate, deliberate step — see Deployment.
Two transports
MCP has two, and they answer different questions. HTTP — the commands above — is for a server that is already running somewhere. stdio is the protocol's default: the client starts the server as a process and talks to it through the pipes, which is how most desktop clients and wrappers work.
docker compose exec -T ag-search python /app/server.py --stdio # or MCP_TRANSPORT=stdio
# outside compose: SEARXNG_URL=http://<a metasearch you can reach> python adapter/server.py --stdioOne JSON-RPC object per line in, one answer per line out. The mode is chosen explicitly and never guessed from whether a terminal is attached — that sign merely sits next to the subject, and one day it answers for a case nobody meant.
In stdio mode stdout is the protocol: answers and nothing else, with the log on stderr. One stray line of anything else breaks the client reading it.
Each process started this way is an instance of its own, even inside the
running container: its browser sessions (BROWSER_MAX_SESSIONS), its engine and
domain pacing and its /stats counters are separate from the HTTP server's.
Several stdio clients on one sidecar add up in its pids_limit like several
adapters do.
The sidecars do not depend on the choice; the metasearch does: SEARXNG_URL
must reach one, or every search refuses with cause: upstream (the default,
http://searxng:8080, is a name only the compose network knows). Started by a
client with no compose project around it, the module names what else is missing
instead of pretending: the browser path reports not_wired_up, and trouble
carries pool_unmeasured — the engine pool was never computed from observation.
⬆️ Upgrading to 0.3.5 — no new variable
From 0.3.4: the same command as the install block, with the same -f files.
Only the adapter image changes (ag-mod-search/adapter:0.3.5, used by the
adapter and the prober); the sidecar stays ag-mod-search/browser:1.49.1-3.
Two things you may notice: the door now logs one line per request (the
caller's address, the path, the status and the caller's token name — never the
token, the body or the query string), and a model call that runs out of the
time its caller had now returns a refusal that says so instead of running on.
From 0.3.3 or earlier, the 0.3.4 steps below apply too.
⬆️ Upgrading to 0.3.4 — no new variable, one stricter one
From 0.3.3: the same command as the install block, with the same -f files.
Only the adapter image changed; the sidecar stays ag-mod-search/browser:1.49.1-3.
Check AG_DOOR_AUTH first: a value that is not off, warn or on (in any
case, spaces around it ignored) now stops the adapter from starting, where it
used to mean off. From 0.3.2 or earlier, the 0.3.3 steps below apply too.
⬆️ Upgrading to 0.3.3 — one new required variable
BROWSER_WS_SECRET must be in .env before up: add it as the install block
does, with openssl rand -hex 16. Then run the install block's own command,
with the same -f files you installed with:
docker compose -f docker-compose.yml -f wiring/expose-localhost.yml up -d --build.
It rebuilds both images and recreates the adapter, the prober and the sidecar,
since the image or the environment of each changed. Name no services on it:
up … browser ag-search leaves the prober on the old image, and leaving out
the -f overlay recreates the adapter without its published port. The sidecar
image is ag-mod-search/browser:1.49.1-3; with the old sidecar, or with only one
side recreated, the browser path reads "not responding".
A malformed secret stops the whole module. Compose checks only that the value
is not empty. Characters outside A-Z a-z 0-9 _ - (base64 gives + / =) make the
sidecar exit with 78 in a loop; the adapter waits for a healthy sidecar, so it
never starts and up says "dependency failed to start".
If you run the door in warn or on, also read "Moving to on" under
Configuration: the prober now takes a token, a token with no scopes (or with
scopes written as a string) may call no tool, and /healthz?deep=1 needs a
token that may search or read.
⬆️ Upgrading from 0.2.x — the answer changed shape
If you already run 0.2.1 or earlier, read this before updating. Nothing here is a new feature you may ignore; it is what your existing calls will return differently.
The search and image answers carry fewer fields by default, and they say so
in their name: ag.search/3 and ag.images/3 instead of /2. A caller that
branched on contract will break loudly, which is the intent — a field that
simply vanished would read as "nothing was wrong" in most languages.
in 0.2.x | in 0.3 |
|
|
|
|
|
|
|
|
|
|
| absent when only one engine found results: there |
| returned when you ask: |
trouble is always present and empty when nothing went wrong, so if not trouble replaces the four separate checks. Nothing was deleted from the module —
the accounting moved behind a request.
Booleans are now parsed rather than cast. In 0.2.x "read": "false" over MCP
read the pages anyway — eight times the wall clock — while the plain door
understood the same word correctly. Both doors now accept true/false, 1/0,
yes/no, on/off, y/n, t/f and Python's True/False, case-blind. A value that cannot be read
turns the flag off and is named in arguments_adjusted, and an EMPTY value
counts as unreadable: read="" no longer buys the expensive default in silence.
read_top: 0 means "no preference", not "read nothing" — for nothing, use
read: false. The schema used to declare minimum: 1 while accepting zero.
The field-by-field table is in the contracts:
contracts/ag.search.v3.md and
contracts/ag.images.v3.md, section "What changed
against /2".
Reading (ag.read/2), deep search (ag.deep/2) and screenshots (ag.shot/1)
kept their numbers: they gained fields, and an addition breaks nobody.
⚙️ Configuration
Variable | Required | What it is |
| yes | Session key for the metasearch. Any long random string that is not from somebody's history. |
| yes | The browser sidecar's secret endpoint path, 16-256 characters of |
| no | Key for any OpenAI-compatible endpoint. Secret. |
| no | Base URL of that endpoint. Take it from your provider's documentation, not by analogy — the obvious guess can answer with a billing error such as |
| no | Model that plans queries and composes answers. No default is shipped: a default would silently ask your provider for a model it may not have. |
| no | Model that reads scanned PDFs. Unset, such documents return an explicit refusal naming the reason. |
| no | Set for providers whose reasoning budget swallows the answer, leaving it empty with |
| no | How many model calls may be in flight at once, ours to hold rather than the provider's to refuse (default 20). A provider enforces its limit by REFUSING the extra call — after the queries were composed and the pages read, where it costs the most; waiting a turn at the start costs a pause. Since 0.3.5 the wait is bounded: a call that finds no free slot within its own time is refused by us ( |
| no | How long to wait when the provider answers 429 without a usable |
| no | Your provider's own error codes that mean "too many" besides HTTP 429, comma-separated. Compared exactly against |
| no | The longest wait deep search will sit through for its one retry after a rate limit (default 10); a longer wait answers |
| no | Who may call: |
| no | The token list's path INSIDE the container. The compose file mounts |
| no | The prober's token for the door: one from the token list with the scope |
| no | TLS on a port of its own, beside the plain one. Paths INSIDE the container — the compose file mounts |
| no | Contact placed in the |
| no |
|
A token file that cannot be READ is not an empty token file. Seen on a real
deployment: the file was mounted into the container with mode 600 owned by root
while the container runs as an unprivileged user. The door opened
nothing, refused nothing, and looked correctly configured — the inability to look
had been handed back as the result of looking. /stats therefore carries three
separate fields: door_auth (the mode), tokens_configured (is a file named at
all) and tokens_readable (did it yield at least one token — a readable {}
reads false too). An unreadable list refuses
everyone in on — the safe direction for a file we could not read — and in
warn it serves everyone, which is exactly why the two cases must not share one
field. A missing path is worse still: Docker creates it as a DIRECTORY, which
reads like an unreadable file — so create the file before the first up (the
install block above does) and check that it is a file.
Who owns the secret files. The adapter runs as uid 10001. Once the token
file holds tokens, and for a TLS key: chown 10001:10001 door-tokens.json tls/key.pem && chmod 600 door-tokens.json tls/key.pem. The tempting fix for the
failure above, chmod 644, makes the tokens and the private key readable to
every user of the host.
Moving to on. Give the prober a token first (PROBE_DOOR_TOKEN), then go
off → warn, and switch to on once unauthenticated in /stats has stopped
growing — with the prober on a token, what is left there is callers you have not
given one to yet. Since 0.3.5 the log says who they are: every request prints
one line, [door] <address> <method> <path> -> <status> in <ms> ms, caller: <token name> (no token without one, unknown token for one the token list
does not have — the refusal a caller gets does not tell the two apart, the log
does; - where no door verdict was taken: a request refused before it could be
read, a method the door does not serve such as HEAD or OPTIONS, or a POST
anywhere but /mcp), with ?… where the request had a query string and the
client's own X-Forwarded-For, unverified, when it sent one. The token, the
other headers, the body and the query string are never logged.
The browser's session and watchdog limits are set in .env; see
.env.example, which explains each one where you set it. Pacing,
pool size and read limits have measured defaults in adapter/server.py,
adapter/reader.py and adapter/pool.py; docker-compose.yml does not pass
them through.
A model key is optional. Search, reading, image search and screenshots are
HTTP requests and spend no model tokens. A model is called in exactly two places,
and both are named in the answer: web_deep_search, and recognising a PDF with no
text layer — which happens when you ask to read such a document, never behind
your back in web_search. web_deep_search reads the pages it picks with
recognition on, so a scan among them is recognised as part of that search's own
model spend. With the door on, both need a token with the paid scope.
🎯 What a bundled search tool does not do
Cost you control. One argument changes the answer by an order of magnitude:
call | payload | time | model tokens |
| 3.8 KB | 0.6 s | 0 |
| 8.8 KB | 1.6 s | 0 |
| 9.8 KB | 6.3 s | 0 |
| full account | 36 s | 6 calls |
Measured on one machine, one query. Take the shape, not the digits. A consumer measured the same fork from outside and got 4.8-10.5 s against 0.7 s — the shape holds, the digits depend on the pages the query happens to find.
The choice is made before the call, not after the bill. read: false when
you are mapping what exists or working under a narrow context ceiling; the
default when you want the text of the top results and would otherwise fetch it
yourself. read_top: 0 means "no preference", not "read nothing".
The engine list maintains itself. A hand-written list goes stale in silence: an engine that was the best returns nothing weeks later and says nothing about it. Ours was revised three times in a single day — each revision against the previous one, each correct on its own data. The problem was never the engines: a decision freezes while observation goes on.
So the list is not written here. A prober asks every known engine, continuously, with questions whose correct answer is known in advance, and the pool is the best few by reference hit share — recomputed on its own. Verified by falsification: a planted bad run took an engine out of the pool with no code change, and restoring the run brought it back by itself.
Until enough observation accumulates, the pool is a seed list and every answer
says so: trouble.pool_unmeasured by default, pool_source itself under
verbose.
Failure is distinguishable from success. Four ways an engine can fail, and what shows each:
how it fails | what shows it |
answers with a refusal: captcha, rate limit, ban |
|
silently returns nothing | the difference between |
answers a different question |
|
substitutes the subject with a better-indexed namesake |
|
The same applies to reading: seven distinct outcomes, and a page that returned a
shield is stub, not empty text.
🩺 If the module stops and nothing looks wrong
One failure of this system does not show up in anything you would normally watch, so it is written down here rather than left to be rediscovered.
The symptom. Containers report unhealthy, docker compose up refuses to
raise the adapter, and docker exec fails with runc: nsexec ... setns. Load,
memory and disk are all fine.
The check, one command:
ps -eo comm | sort | uniq -c | sort -rn | headIf headless_shell is in the thousands, the browser sidecar has accumulated
processes — a browser that was opened and never closed keeps its renderers alive,
and past a few thousand the container runtime can no longer start a process at
all. Since both health checks work by exec, everything then reports sick at
once, and the cause looks like the last thing you changed.
The cure: docker compose up -d --force-recreate browser. Measured on our own
instance: 4619 processes before, 126 after.
What 0.3.1 changed. The processes were never alive: chromium's children are
orphaned when a visit ends, and a PID 1 that does not call wait() leaves each
of them a zombie holding its slot — forty after ten visits, measured. The sidecar
image now runs a real init that reaps them (and init: true in the compose file
says the same for anyone who replaces the image), and the module closes every
browser on every path including the ones that fail.
The second silent failure: every browser read hangs, then the disk fills
The symptom. web_read with the browser, and /healthz itself, stop
answering while search still works; if several adapters share one sidecar,
all of their browser reads fail; the machine's disk then fills with no log growing.
The check — count THREADS, not processes:
docker stats --no-stream --format '{{.Name}} {{.PIDs}}' | grep browserpids_limit counts every thread of the container. An idle Chromium tree is
about 42 threads, run-server's own base about 14; at the cap every new browser
fails to create a thread and crashes, while the sidecar's own health check — a
TCP connect — stays green. ps shows a handful of processes and nothing wrong.
What 0.3.2 changed, after exactly this on 2026-09-23 — a burst of concurrent reads filled the cap, eleven sessions hung for six hours in stages that have no timeout, the health probe hung behind a lock, and each crashing Chromium wrote a core dump into the container layer until the disk was full:
every browser session runs in a child process of its own and is killed with its whole process group when it outlives its budget plus
BROWSER_KILL_GRACE_S(default 10 s); the caller getsnot_reachedsaying so, and saying whether the sidecar or the page was to blame — a page whose script never yields is the page's fault and does not mark the sidecar sick;one adapter instance holds at most
BROWSER_MAX_SESSIONSsessions at once (default 2, health probes included); a read over that is told the browser is busy after at mostBROWSER_SLOT_WAIT_S(default 5 s) instead of starting a browser the sidecar cannot run; the time spent waiting comes off the session, whose budget is cut to what the call has left — only a session that hangs anyway is killedBROWSER_KILL_GRACE_S(10 s) after that, and one that answered at the last moment gets up to 3 s more to exit, so a call can overrun its ceiling by up to 13 s with the defaults; and a slot whose session may have left a browser behind on the sidecar (killed during the launch, or a close that hung) is held back forBROWSER_COOLDOWN_S(default 150 s) while the sidecar's watchdog clears it;/healthznever launches a browser and never waits for one: the browser state is the outcome of the last real session, with its age, plus a port check, and a real probe runs in the background at most everyBROWSER_PROBE_S(default 600 s) — or every health tick while the last verdict is "not responding", so a recovery shows within minutes rather than ten; until the first probe after a start it readsnot checked yet: …and counts as degraded;the sidecar's main command is a watchdog (
browser/supervise.py) that kills any Chromium browser older thanBROWSER_MAX_AGE_S(default 120 s — the longest legitimate session is 42.5 s), whatever the client did;the adapter, the prober and the browser set
ulimits: core: 0, so none of them writes core dumps, and the adapter image has an init of its own.
The cure, if it happens anyway: docker compose up -d --force-recreate browser. The adapters need nothing: since 0.3.2 their /healthz keeps
answering, and their browser state turns back to alive by itself within a
health tick of their session slots coming free. Slots whose sessions were
killed while the sidecar was wedged are held back for BROWSER_COOLDOWN_S
(150 s), so the whole recovery takes up to about three minutes — measured on a
test instance: 167 s from recreating the sidecar to alive, with no read in between.
GET /pages runs a real browser session there and then once a slot is free;
docker compose restart ag-search skips the wait if it matters more. An adapter
older than 0.3.2 is the exception: its /healthz can hang, and it needs
docker compose up -d --force-recreate ag-search — and an upgrade.
⚖️ What it does with robots.txt, and why you must decide
The module REPORTS a site's rules and does not enforce them. Every read
carries robots: allowed, disallowed_by_site, or not_checked when the file
could not be read. A page a site forbids is still fetched, and the answer says so.
allowed means we read the rules and they permit this path — not merely that
we saw no ban. A site that says the file is not there (404) has no rules, and
that is allowed. A file that did not arrive at all — 5xx, a timeout, a torn
connection, 401/403 — or arrived in an encoding we could not unpack is
not_checked. Those are different events and the field keeps them apart: no data
means "not checked", never "permitted".
That is a decision, not an omission, and it belongs to whoever runs this rather
than to the module. Two reasons. Whether a tool called by a person obeys
robots.txt is the operator's call — a rule written for crawlers indexing the web
is not obviously a rule for fetching one page a user asked for. And the reference
behaviour — treat 401/403 on robots.txt as a ban — produces false bans on
ordinary sources, because the same sites answer 401 to everyone from behind an
anti-bot service. We do not call that a ban. We do not call it permission either:
the answer is not_checked, which says what actually happened — the rules exist
or do not, and we did not get to see them.
So the gate is yours to add. If your use requires obeying robots, branch on
the field: robots == "disallowed_by_site" means the site says no. If you obey
it, treat not_checked as a stop too — it means we could not read the rules, not
that there are none.
📖 How it works
ALGORITHM.md — what happens, step by step, on each call.
contracts/ — the call contracts, versioned separately from the code that implements them.
measures/ — dated measurements: which engines were alive, what the load ladder gives, what the transport change bought. Numbers, with what was measured and when.
contracts/ag.search.v3.md — and its neighbours: what each capability promises, plus the table of what changed against
/1and/2for anyone with stored answers to read.
🔒 Deployment and exposure
wiring/expose-localhost.yml publishes the adapter on 127.0.0.1 only, and it
is the only overlay that ships. Anything wider is an overlay you write yourself,
and the header of wiring/expose-localhost.yml says what to check first: Docker
passes traffic to published ports through FORWARD after DNAT, while the
firewall's own chain sits before its hooks — so a firewall that says "closed" can
be open to the internet on a published port.
A search server open to the outside is an open proxy that goes to the network in the machine owner's name.
Known limits of this release, named so that none reads as absent:
The browser path keeps pages out of the perimeter with scripts and routing inside the browser, not with the network. Three cases stay open, and they differ. DNS rebinding is not closed and the page is not withheld: the inside/outside verdict is kept for the session, and the browser connects to whatever its own lookup returns. A redirect hop that went inside, and a prefetch from speculation rules the page's scripts cannot reach (inside a closed declarative shadow root, or an open one the parser made after pausing on its host), are seen and the page is withheld, but only after the request was made. A prefetch from a popup the page opens is not watched, and that page is not withheld. Since 0.3.5 the rules a page's scripts can reach are removed. The full list is in contracts/ag.read.v2.md; closing it needs a proxy or egress rules on the sidecar.
The door log can name the wrong forwarded-for. On a keep-alive connection, a request refused before its headers are parsed (400, 414, 431,
is logged with the previous request's
X-Forwarded-For. The field is marked unverified either way; this is open in 0.3.5 and fixed in the next release.
Deep search words its own queue's refusal as the provider's. When no model slot comes free in time, the answer's
causeisbusy, as documented, but itserrortext says the model is rate limited rather than namingLLM_MAX_CONCURRENT. Only the text is wrong.BROWSER_WS_SECRETis visible to root inside the sidecar. tini and the sidecar's supervisor keep it in the environment they were started with, and root in that container reads it through/proc. The sidecar runs as root and its browsers with--no-sandbox, so a page that took over a renderer could read it; it would then already be root in the sidecar the secret protects.The call ceiling of
web_readdoes not bound everything beneath it. A browser session that hangs, and one scan page's render that began just before its stop, can end past it. Since 0.3.5 the wait for a free model slot and the model's whole answer are bound by it. See the limits table in HOWTO-CALL.md.
✅ Tests
IMAGE=ag-mod-search/adapter:0.3.5 bash tests/in-image.shFour suites — the protocol and search against a fake metasearch, reading against
a fake site, the computed pool against a database built in memory, and the parse
of a model provider's answers against canned replies. Not one of
them makes a single outbound request, and the runner holds that with
--network none rather than on trust: for reading it matters more than for
search, because a test that went to the internet would spend the very resource
the tool protects — the reputation of the one address it calls from.
They run inside the built image rather than on the machine where the code is edited: the PDF parser lives in the image, and a suite run outside would skip everything that touches it. The skip is not silent — the check goes red with a note saying where to run it.
What these suites cannot check is written down in tests/README.md.
🤝 Contributing
A capability, engine or heuristic is not accepted until its reference attribute is declared — a property of the correct answer that the thing being tested could not have told us itself — and a pool of checked questions is attached. See CONTRIBUTING.
📄 License
GNU Affero General Public License v3.0. Run it, change it, build on it. If you make it available to others OVER A NETWORK, the changes you made go back out under the same licence — that is the one obligation, and running a service counts as making it available.
For whoever cannot live with that clause, a commercial licence is a question to ask rather than a fork to make.
Available Tools
5 toolsweb_deep_searchARead-only
Deep search: find, read and DIGEST AN ANSWER. The fifth tool of the module and the only one that answers a question rather than handing back material.
WHEN TO CALL. The question requires several sources to be brought together: what is happening with something, how one thing differs from another, what the figures of a specific organisation are. The tool composes the queries itself, reads the pages and writes an answer with references to the sources.
WHEN NOT TO CALL. You need a list of links — web_search is tens of times cheaper. You need the text of a known page — web_read. This tool spends a model and minutes; call it on a question, not on a query.
WHAT IT RETURNS. answer — the digested answer with [1]-style references; sources[] — the pages that were read; markers — the features used to check that the pages are about THE SUBJECT ASKED ABOUT; timing_ms — where the time went (searching, reading, the model); usage.by_model — tokens per model, with money left to whoever holds the price registry.
HOW TO READ THE ANSWER — five things.
THE MAIN FIELD IS
outcome, NOTanswer. Five values: found — the markers met on a page; ambiguous — the sources hold SEVERAL DIFFERENT subjects under this name, and they are listed in ambiguity.variants; off_target — material was found but about ANOTHER subject (a namesake, a different city); not_found — there are no sources; unknown — there were no markers, so there was nothing to check with. On off_target the answer looks convincing and is about the wrong thing. On ambiguous the answer applies to THE LARGEST GROUP and not to all of them: the other variants are real, and if one of them is wanted, ask the person or refine the question rather than choosing yourself.summarised_from_on_target says whether the answer was digested from verified pages or from whatever was found. False means read the answer as a draft.
stopped_because and waves_done show HOW MUCH work was done. A full answer and a short one look alike; this is the only place they can be told apart.
THREE NUMBERS ABOUT SOURCES, AND THEY ARE DIFFERENT. sources_total — how many were found; sources_with_content — how many could be read (a block returns zero characters and stays in the list); sources_on_target — on how many the markers met. The answer stands on the third number and sounds weighty because of the first.
sources_confirmed_2plus and confirmed_by_engines count INDEPENDENCE, not correctness: how many different engines found the same link. On an ambiguous name the most corroboration goes to the best-indexed namesake. If the answer looks confident while the question admits several different subjects under one name, look at
outcomefirst:ambiguousmeans the tool composed exactly that answer — the variants are in ambiguity.variants, and the digest applies to the largest group only. Corroboration counts do NOT decide between them.
| Name | Required | Description | Default |
|---|---|---|---|
| waves | No | at most this many search waves, default 3. A wave stops by itself as soon as the markers meet — the ceiling bounds the worst case, not the ordinary one | |
| question | Yes | the whole question, in your own words. Not a search query: the tool composes the queries itself |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description goes far beyond the annotations by explaining nuanced behaviors: outcome values like ambiguous, off_target, and not_found; the meaning of summarised_from_on_target; how stopped_because and waves_done reveal work done; and how corroboration counts indicate independence rather than correctness. It also warns about misleadingly confident answers on ambiguous names. This is exceptionally transparent.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long and highly detailed, but it is well-structured with clear sections and a numbered list. Some points are repeated for emphasis (e.g., the ambiguity caveat appears both in the outcome list and later in the corroboration section), which adds length but also reinforces critical guidance. Overall, the structure justifies the length given the tool's complexity.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With no output schema, the description carries the full burden of explaining return values and interpretation. It thoroughly covers the fields outcome, summarised_from_on_target, stopped_because, waves_done, sources_total, sources_with_content, sources_on_target, sources_confirmed_2plus, confirmed_by_engines, and ambiguity.variants, and explains how to distinguish a full answer from a short one. Nothing essential is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema already documents both parameters with descriptions, but the tool description adds valuable usage nuance: the question should be in the agent's own words rather than a search query because the tool composes queries itself, and the waves parameter bounds the worst case rather than the typical case. This meaningfully supplements the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's purpose: deep search that finds, reads, and digests an answer to a question rather than returning raw material. It explicitly distinguishes itself from sibling tools like web_search and web_read, making its unique role unambiguous.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides explicit WHEN TO CALL and WHEN NOT TO CALL guidance. It states to use this tool when a question requires synthesizing several sources, and to use web_search for lists of links or web_read for known page text. This gives the agent clear decision rules and mentions the alternatives by name.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
web_image_searchARead-only
Image search through our own metasearch layer. The third tool of the module: web_search finds pages, web_read extracts their content, this one finds IMAGES.
WHEN TO CALL. You need a picture of an object, a product, a building, a person, a diagram. You need the address of the image file itself rather than of a page about it.
WHEN NOT TO CALL. You need text about the object — that is web_search. You need the content of a specific page — web_read. This tool does NOT look at the pictures and does not describe them: it finds addresses, and whoever can see looks at them.
WHAT IT RETURNS. results[] with image_url (the file itself), page_url (the page it was found on), domain (the site the image is SOURCED from), page_domain, thumbnail, title, author, published, via.
HOW TO READ THE ANSWER — three things.
AN IMAGE HAS TWO ADDRESSES and they must not be confused: image_url is the file, page_url is the page. Showing the page instead of the image is a mistake only a human notices.
AN EMPTY LIST IS A SUCCESS, not a failure: we looked and found nothing. A failure arrives with ok=false and a reason.
engines_irrelevant names engines whose results were discarded ENTIRELY as not being about the query. With images this is common: an engine returns its own catalogue regardless of the query and looks excellent by result count.
| Name | Required | Description | Default |
|---|---|---|---|
| page | No | the result page, from zero; default 0 | |
| query | Yes | what to look for; a non-Latin script switches the results to that language | |
| max_results | No | how many images to return, default 12 |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already mark readOnlyHint=true and destructiveHint=false; the description adds context beyond this: it states the tool does not analyze image content, only retrieves addresses, and explains that an empty result list is a successful outcome while failures carry ok=false. It also details the engines_irrelevant field, enriching the agent's understanding of result semantics.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but well-organized into purposeful sections (WHEN TO CALL, WHEN NOT TO CALL, WHAT IT RETURNS, HOW TO READ THE ANSWER). It is front-loaded with the core purpose and avoids redundancy, though some sentences could be trimmed without loss of value.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With no output schema, the description fully compensates by listing return fields (image_url, page_url, domain, etc.) and providing interpretive guidance (distinguishing image_url from page_url, empty list as success). It covers all relevant aspects for an agent to correctly invoke and interpret the tool, given its annotations.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100% and the description does not add meaning beyond the schema's parameter definitions. It does not elaborate on 'query', 'page', or 'max_results' beyond what the schema provides, so the baseline of 3 applies. The non-Latin script note is already in the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb and resource: it finds IMAGES via a metasearch layer, and explicitly contrasts with web_search (pages) and web_read (content). It clarifies it returns image file addresses, not descriptions, which distinguishes it from siblings without ambiguity.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicit 'WHEN TO CALL' and 'WHEN NOT TO CALL' sections name the exact alternatives (web_search for text, web_read for page content) and specify conditions for image search. It also cautions that it doesn't look at or describe images, providing clear routing guidance.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
web_readARead-only
Read a web page by address and return its text.
HOW IT DIFFERS FROM web_search, WHICH ALSO READS. That one reads the top three links of its own results at 6000 characters each — enough for an answer. This one takes the addresses YOU name, up to five at a time, reads them in full with a cursor over a long document, can demand the browser and can check for a marker. If you need an answer, search is enough; if you need to work with a document, come here.
WHEN TO CALL. You need the text of a specific page whose address is already known — from web_search results or from the user. You need facts from an article rather than a snippet about it. Read a long page in parts: the same call with the offset named at the end of the truncated text.
WHEN NOT TO CALL. There is no address yet — use web_search first. You need an office document (DOCX, XLSX) — the tool does not parse those and will say so plainly; PDF, however, IS read. A search-engine result page must not be read: it merges neighbouring results into one text and hands you facts about a namesake.
WHAT IT RETURNS. results[] per address: content — the page text, status — what became of it, title, published, lang, final_url (where a redirect led), stub_check — whether this is a block; text_source — HOW the text was obtained.
HOW TO READ THE ANSWER — four things that are easy to get wrong.
EMPTY CONTENT IS A SUCCESS, not a failure: the page opened and has no text in it. Repeating is pointless, take another source. On a refusal or a failure to open, repeating does make sense.
okis about the TOOL, not about the pages: it stays true even if not one page was read. Look at count and failed, and at the status of each address:read,empty,stub,refused,unreachable,forbidden,not_reached. They mean different things and call for different next steps —emptyis not worth repeating,refusedandunreachableare;not_reachedis news about US (out of time, the per-domain rate limit, or beyond the batch ceiling) and says nothing about the page.text_source IS REQUIRED READING when it says the text was recognised. That is a scanned PDF with no text layer: the pages were rendered and read by a vision model, and such text MUST NOT be quoted as exact — a measurement recovered 94% of the reference numbers. The details are in the
recognitionfield, including the dpi and whether the model's answer was cut off. A text layer means "copied out of the file" and is quotable verbatim.A stub status means we met an anti-bot shield or a paywall: text arrived, but it is not from the page. Do not retell it as the content. A stub_check of "not checked" is NOT "clean".
PDF. It is read; pages and pages_read say how many pages the document has and how many were parsed. An empty result on a PDF means "there are pages and no text" — that is a scan, cured by recognition rather than by repeating. And remember: in a PDF the characters can be extracted correctly while the reading order falls apart, so labels come away from their values. Do not assemble "property: value" pairs out of adjacent PDF lines without checking that they really are adjacent.
| Name | Required | Description | Default |
|---|---|---|---|
| mode | No | how to fetch. auto (default) and plain use an ordinary request. browser goes through a real browser, for pages with a script-based check; if that path is not wired up the call returns a did-not-get-through status — see paths_available in the answer | |
| urls | Yes | page addresses, from 1 to 5 per call. Take them verbatim from web_search results, do not guess at them. Addresses beyond the ceiling come back with a did-not-get-through status — they never vanish silently | |
| fresh | No | false by default. Do not take the result from the read cache (it lives 300 s). Use it when the page is known to be changing as you watch | |
| links | No | false by default. Return the page links as a list. links_total is ALWAYS returned: an empty list with links_total > 0 means "you did not ask", not "there are none" | |
| expect | No | markers that MUST occur in the text if this is the right page. Set them when the address was found by an organisation or person name: a status code and a title are forged by a stub, knowledge of the content is not. Not found gives expected_found=false | |
| format | No | markdown (default) — text with headings and links; text — text only, cheaper in characters; html — as it came, for parsing the markup | |
| offset | No | the character to continue reading from, counted from zero. The tool names the continuation number itself at the end of the truncated text — take it from there rather than computing it | |
| max_chars | No | how many characters of content to return per address, default 20000. The remainder is not lost — it is fetched by the next call with an offset |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description goes far beyond the readOnlyHint and destructiveHint annotations, detailing caching behavior, fetch modes, status semantics, PDF recognition caveats, and anti-bot stub handling. It even warns that empty content is a success and that 'ok' refers to the tool, not the pages. No contradictions with annotations exist.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Although lengthy, the description is tightly organized with clear section headers (WHEN TO CALL, WHEN NOT TO CALL, WHAT IT RETURNS, HOW TO READ THE ANSWER, PDF). Each paragraph addresses a distinct concern, and the detail is justified given the tool's multiple edge cases and status semantics.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With no output schema present, the description thoroughly explains the return structure (results[], status, text_source, stub_check, recognition) and how to interpret each status. It also covers failure modes, cache behavior, rate limits, and PDF handling, leaving no critical context unexplained.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Every parameter is explained in the schema, and the description adds crucial operational meaning: offset should be taken from the tool's truncation note, links_total is always returned, expect markers help verify pages found by name, and max_chars default is clarified. This goes well beyond the schema's basic field descriptions.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb and object: 'Read a web page by address and return its text.' It clearly distinguishes itself from web_search, which returns snippets from search results, making the tool's unique purpose unmistakable.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicit WHEN TO CALL and WHEN NOT TO CALL sections state exactly when to use this tool versus web_search, when to avoid office documents, and how to handle pagination with offset. This leaves no ambiguity about appropriate invocation.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
web_screenshotARead-only
A PNG screenshot of a web page. The fourth tool of the module.
WHEN TO CALL. You need to SHOW a page to a person — the layout, the design, what the text does not carry. And you need to CROSS-CHECK: the shot and the text are obtained in one browser visit but by different routes — the pixels are drawn by the layout engine, the text comes from the DOM. A disagreement between them catches what neither route sees alone.
WHEN NOT TO CALL. You need the text of the page — that is web_read, many times cheaper. A screenshot costs a browser launch.
WHAT IT RETURNS. png_base64 — the shot itself; bytes — its size; page_text — the text of THE SAME visit, up to max_chars; page_text_chars — the length of the whole text, which may be greater; page_text_truncated; browser_version.
HOW TO READ THE ANSWER — two things, and they are DIFFERENT.
shot_taken — THE SHOT WAS TAKEN: the browser is alive, the page loaded.
expected_found — WHAT WE EXPECTED IS ON THE PAGE (when
expectwas given). A shot can be taken flawlessly and show the wrong thing: a stub, a captcha, an error page. Do not confuse these two fields — a page that honestly failed a check and a browser that never opened are different events.
A signal worth seeing: there is a shot and page_text_chars is near zero — the page drew, and has nothing to say.
| Name | Required | Description | Default |
|---|---|---|---|
| url | Yes | the page address | |
| expect | No | markers that must be present on the page. They are checked against the TEXT of the same visit: a screenshot cannot check itself. Not found gives expected_found=false, and the shot is still taken | |
| full_page | No | false by default — the visible area. true captures the whole page and costs more | |
| max_chars | No | how much of the page text to return beside the shot; 2000 by default. The text is here to be compared with the picture — for the whole document call web_read, which has a cursor |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description discloses return fields (png_base64, bytes, page_text, page_text_chars, page_text_truncated, browser_version) and explains the distinction between shot_taken and expected_found, including that a shot is still taken even if expected markers are absent. It also warns about cost (browser launch) and the behavior of page_text truncation. No contradiction with annotations (readOnlyHint, etc.).
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with clear sections and a front-loaded purpose statement. It is slightly verbose due to stylistic capitalization and dramatic phrasing, but every section serves a purpose and adds information without bloat.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description covers the output fields, the meaning of key result flags, edge cases (captcha, error pages), the signal of near-zero page_text_chars, and cost considerations. It even points to an alternative tool for full text retrieval, making it complete for an agent deciding to call this tool.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
All four parameters have detailed schema descriptions that go beyond simple types: expect explains the check is against text and that a miss still yields a shot; full_page notes cost implications; max_chars explains the default and points to web_read for full documents. The description adds value to the schema by linking parameters to the tool's purpose.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states 'A PNG screenshot of a web page' and contrasts with web_read for text. It explicitly names the resource (web page) and the action (screenshot), and distinguishes it from sibling tools.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The 'WHEN TO CALL' and 'WHEN NOT TO CALL' sections explicitly specify when to use the tool (to show layout/design, to cross-check visuals with text) and when not to (when only text is needed, use web_read). This provides direct, unambiguous guidance relative to alternatives.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
web_searchARead-only
Web search through our own metasearch layer over several independent search engines. Returns links together with information about WHO found them and how far that source can be trusted.
WHEN TO CALL. You need fresh information from the web; you need to find an organisation or a person by name. For the second and third page of results, use the same call with page=1, 2 and so on; pages are numbered from zero.
TO CHECK A FACT AGAINST INDEPENDENT SOURCES, ASK FOR IT. By default the sweep STOPS at the first engine that gave enough links — that is the cheap path, and one engine is one witness. min_engines: 3 (or corroborate: true) keeps asking, and only then do corroborated_by_url and corroborated_by_domain count anything. It costs several times the outbound requests.
IT READS BY DEFAULT. The top three links are fetched and their text arrives in the same answer in the content field — no second call is needed for the content. If you only need an overview, set read=false and the call again costs a fraction of a second.
WHEN NOT TO CALL. You need the text of a KNOWN page (you already have the address) — that is web_read, which reads up to five addresses and offers a cursor over a long document. You need a finished ANSWER across several sources rather than material — that is web_deep_search. Not suitable for searching inside a known document or repository.
HOW IT DIFFERS FROM web_deep_search. Here there is ONE pass: what was found is what was read, and you compose the answer yourself from content. There the tool composes the queries itself, goes in waves and returns a DIGESTED ANSWER together with what it failed to find. This one hands you material, that one hands you a judgement.
WHAT IT RETURNS. results[] with title, url, snippet, domain, content (the page text for the ones that were read), chars, read_status; pages_read, pages_empty, pages_failed — how many pages were paid for and how many of them turned out to be a block; timing_ms split into search and reading; via — which engines found this particular link; corroborated_by_url and corroborated_by_domain — by how many engines the page and the site are independently corroborated; search_aborted is non-empty if the metasearch stopped answering MID-SWEEP, in which case the results are incomplete by no decision of ours and the engines that were missed are named in engines_unasked.
HOW TO READ THE ANSWER — five things that are easy to get wrong.
AN EMPTY LIST IS A SUCCESS, not a failure: we looked and found nothing. A failure arrives separately, with ok=false and a reason. Do not repeat the query because the list was empty — repeat it rephrased.
engines_skipped means "not asked" (the rate limit applied), NOT "asked and stayed silent". The silent ones are in unresponsive_engines, the ones that answered a different question are in engines_irrelevant.
CORROBORATION IS NOT CORRECTNESS. corroborated_by_url means "this many independent engines found this same link" and does NOT mean "this answer is truer". On an ambiguous query the most corroboration goes to the best-indexed namesake rather than to the subject asked about: one name can belong to a retailer, an investigations platform, a maker of enclosures and a maker of portable power supplies at once, and all of them are real. If the sources describe DIFFERENT subjects under one name, the correct answer is "there are several, here they are", not a choice by the number of corroborations.
engines_trust labels each engine: clean — checked against references and does not substitute the subject; substitutes — has answered about a namesake; not checked or unavailable — there is no information. If all_engines_clean is false, the results are worth verifying against features of the subject sought: an engine may have answered about a different company of the same name. The label
candidatemeans "no misses yet, but fewer than three observations" — not "clean".A RESULT WITH text_source="not_recognised (scan)" IS A DOCUMENT WE DID NOT READ, not a page without text. It is a scanned PDF: search does not pay for a vision model on a link it merely found. If you need it, call web_read on that address — reading recognises it.
| Name | Required | Description | Default |
|---|---|---|---|
| page | No | the result page, from zero; default 0. A second page is for when the first did not contain what you sought, not for collecting more links at once | |
| read | No | TRUE by default — the tool reads the top pages and returns their content. Set false when you only need an overview of the results: the call then costs a fraction of a second instead of seconds, but there will be no content | |
| query | Yes | the search query, in any language; a non-Latin script switches the results to that language | |
| read_top | No | how many top links to read, 1..8; default 3. This is the main cost of the call: every page is a separate download | |
| per_engine | No | how many links to take from EACH engine; 0 means however many are still missing from max_results | |
| corroborate | No | a deprecated name for min_engines=3. Kept for older callers; in new code set min_engines as a number | |
| max_results | No | how many links to return, 1..50, default 6. The more you ask for, the more engines have to be queried — that is, dearer and slower | |
| min_engines | No | query AT LEAST this many engines, however many links were collected earlier. 0 (the default) means stop as soon as max_results are collected. Set 3 or more when you need independence of sources: `via` and corroborated_by_url will then name different witnesses. THE PRICE: as many times more outbound requests |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description is exceptionally transparent about failure semantics (empty list is success), engine statuses, corroboration limits, scanned documents, mid-sweep aborts, and cost behavior. Annotations readOnlyHint=true and destructiveHint=false are consistent; no contradiction.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but highly structured with clear headers and numbered caveats. Every section addresses a distinct operational concern and earns its place; the front-loaded summary immediately conveys what the tool does and how it differs from siblings.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Despite having no output schema, the description fully details the return shape (results[], pages_read, timing, via, corroboration fields) and provides the five easy-to-miss interpretation rules. The tool's complex behavior is covered completely enough for correct invocation and result handling.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100%, and the description enriches every parameter with practical meaning: cost implications, deprecation of corroborate, page semantics, read_top limits, min_engines witness independence, and default behaviors. This goes well beyond the schema alone.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb and resource: a metasearch over several independent engines returning links and content. It explicitly contrasts with sibling tools web_deep_search and web_read, making the tool's unique role unmistakable.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
WHEN TO CALL, WHEN NOT TO CALL, and HOW IT DIFFERS give explicit conditions and name the alternatives (web_read, web_deep_search). Includes clear guidance on when to set min_engines, when to set read=false, and how to interpret empty results.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
5 tool updates
v0.2.1- First observed
web_deep_search - First observed
web_image_search - First observed
web_read - First observed
web_screenshot - First observed
web_search
TDQS
Scored across 5 tools
Each tool has a clearly distinct purpose: web_search finds pages, web_read extracts content from a known address, web_deep_search synthesizes answers from multiple sources, web_image_search locates images, and web_screenshot captures visual page previews. No two tools overlap in a way that would confuse an agent.
All tool names share the predictable 'web_' prefix and follow a consistent pattern: web_search, web_read, web_image_search, web_screenshot, web_deep_search. The naming convention is uniform and signals the tool's function clearly.
Five tools is an ideal size for a web-focused module. It covers search, reading, image lookup, deep synthesis, and screenshotting without redundancy or bloat. Each tool earns its place.
The tool surface covers the full web research lifecycle: discovering pages, reading their content, synthesizing multi-source answers, finding images, and visual verification. There are no obvious missing capabilities or dead ends within the stated purpose.
Maintenance
Related MCP Connectors
Hosted browser for agents: open pages, search the web, extract data, read PDFs and take screenshots.
Web search, browser automation, scraping, crawling and CAPTCHA solving for AI agents.
Live web research with related queries, browser-rendered sources, images, and evidence.
Live web access for agents: scrape, SERP search, crawl/map, 100+ collectors, datasets, proxies.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceAn agent-based tool that provides web search and advanced research capabilities including document analysis, image description, and YouTube transcript retrieval.17Apache 2.0
- AlicenseAqualityDmaintenanceProvides web access capabilities for LLMs including search, fetching, content extraction, PDF reading, image viewing, and screenshots.346MIT
- FlicenseNot gradedqualityDmaintenanceEnables AI agents to perform web searches, extract webpage content, and conduct end-to-end search-and-extract operations using multiple search providers and content extraction methods.-
- AlicenseNot gradedqualityAmaintenanceEnables AI agents to perform live web searches across 9 engines, scrape web pages into clean formats, and run agentic research with citations via MCP.115 PyPI3MIT