Skip to main content
Glama

search

A self-hosted MCP server for web search that reports how much of each answer to believe

MCP registry License Python Self-hosted ag-mcp-search MCP server โ€” quality and maintenance score on Glama


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

web_search

Finds pages and reads the top ones โ€” one call, links with their text

query

read: false for links only ยท read_top how many to read ยท min_engines to force breadth ยท corroborate

web_read

Reads pages by address: text, PDF, or a scan recognised by a vision model

urls

mode: browser for JS-rendered pages ยท expect to assert what must be there ยท offset to continue

web_image_search

Finds images: the address of the FILE and, separately, of the page it sits on

query

max_results, page

web_screenshot

A PNG of a page plus its text from the same visit, so the two can be cross-checked

url

max_chars for how much text ยท full_page ยท expect

web_deep_search

Composes its own queries, reads in waves, and answers from several sources โ€” saying what it could not confirm

question

waves

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
docker compose -f docker-compose.yml -f wiring/expose-localhost.yml up -d --build
curl -s http://127.0.0.1:8081/healthz

The fourth line is not decoration. Without a value in SEARXNG_SECRET the very next 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.

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.

python adapter/server.py --stdio        # or MCP_TRANSPORT=stdio

One 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.

The sidecars do not depend on the choice. Started by a client with no compose project around it, the module still works and names what 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 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

search_aborted, engines_unasked

trouble.search_aborted, trouble.engines_unasked

unresponsive_engines

trouble.unresponsive_engines

engines_irrelevant

trouble.engines_irrelevant

pool_source: "seed"

trouble.pool_unmeasured, with the reason

arguments_adjusted

trouble.arguments_adjusted

corroborated_by_url, corroborated_by_domain โ€” always present, 1 on the cheap path

absent when only one engine found results: there 1 meant "nobody else was asked", not a measurement

count, query, page, read, read_top, pages_*, timing_ms, engines_skipped, engines_used, tiers_used, pool_source, pool_reason

returned when you ask: verbose: true

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 full value-by-value history, including the Russian field values of /1, is in the contracts: contracts/ag.search.v3.md and contracts/ag.images.v3.md, section "What changed". 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

SEARXNG_SECRET

yes

Session key for the metasearch. Any long random string that is not from somebody's history.

LLM_API_KEY

no

Key for any OpenAI-compatible endpoint. Secret.

LLM_API_BASE

no

Base URL of that endpoint. Take it from your provider's documentation, not by analogy โ€” the obvious guess can answer 429: Insufficient balance because the subscription lives on a different path of the same domain.

LLM_MODEL_TEXT

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.

LLM_MODEL_VISION

no

Model that reads scanned PDFs. Unset, such documents return an explicit refusal naming the reason.

LLM_DISABLE_THINKING

no

Set for providers whose reasoning budget swallows the answer, leaving it empty with finish_reason: length.

READ_CONTACT

no

Contact placed in the User-Agent when fetching pages. Defaults to this repository; set your own if you run this at scale.

READ_LANGUAGES

no

Accept-Language when reading. Unset by default โ€” the language of the pages you read is not ours to choose.

Pacing, pool size and read limits have their own variables with measured defaults; see .env.example, which explains each one where you set it.

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 only when you ask to read such a document, never behind your back in a search.

๐ŸŽฏ 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

read: false, 6 links

3.8 KB

0.6 s

0

read_top: 1, 3 links

8.8 KB

1.6 s

0

read_top: 3, 6 links

9.8 KB

6.3 s

0

web_deep_search

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

trouble.unresponsive_engines

silently returns nothing

the difference between engines_asked and engines_answered

answers a different question

trouble.engines_irrelevant โ€” its results are already discarded

substitutes the subject with a better-indexed namesake

engines_trust, earned against references

The same applies to reading: seven distinct outcomes, and a page that returned a shield is stub, not empty text.

โš–๏ธ 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.

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.

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 /1 and /2 for anyone with stored answers to read.

๐Ÿ”’ Deployment and exposure

wiring/expose-localhost.yml publishes the adapter on 127.0.0.1 only. Anything wider is a separate overlay, and its header 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.

โœ… Tests

IMAGE=ag-mod-search/adapter:0.3.0 bash tests/in-image.sh

Three suites โ€” the protocol and search against a fake metasearch, reading against a fake site, the computed pool against a database built in memory. 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 tools
web_readA
Read-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.

  1. 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.

  2. ok is 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 โ€” empty is not worth repeating, refused and unreachable are; not_reached is news about US (out of time, the per-domain rate limit, or beyond the batch ceiling) and says nothing about the page.

  3. 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 recognition field, 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.

  4. 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.

ParametersJSON Schema
NameRequiredDescriptionDefault
modeNohow 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
urlsYespage 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
freshNofalse 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
linksNofalse 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"
expectNomarkers 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
formatNomarkdown (default) โ€” text with headings and links; text โ€” text only, cheaper in characters; html โ€” as it came, for parsing the markup
offsetNothe 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_charsNohow 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

A5/5.0
Behavior5/5

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.

Conciseness5/5

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.

Completeness5/5

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.

Parameters5/5

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.

Purpose5/5

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.

Usage Guidelines5/5

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_screenshotA
Read-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.

  1. shot_taken โ€” THE SHOT WAS TAKEN: the browser is alive, the page loaded.

  2. expected_found โ€” WHAT WE EXPECTED IS ON THE PAGE (when expect was 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.

ParametersJSON Schema
NameRequiredDescriptionDefault
urlYesthe page address
expectNomarkers 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_pageNofalse by default โ€” the visible area. true captures the whole page and costs more
max_charsNohow 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

A4.9/5.0
Behavior5/5

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.

Conciseness4/5

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.

Completeness5/5

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.

Parameters5/5

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.

Purpose5/5

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.

Usage Guidelines5/5

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.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 5 tool updatesv0.2.1
    • First observedweb_deep_search
    • First observedweb_image_search
    • First observedweb_read
    • First observedweb_screenshot
    • First observedweb_search

TDQS

A4.9/5.0

Scored across 5 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness5/5

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

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    An agent-based tool that provides web search and advanced research capabilities including document analysis, image description, and YouTube transcript retrieval.
    17
    Apache 2.0
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables 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.
    -
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI agents to perform live web searches across 9 engines, scrape web pages into clean formats, and run agentic research with citations via MCP.
    2
    MIT