Releases: sweetcornna/free-search-mcp
Release list
A shorter README, with how to set up codex and antigravity
Documentation only; the server is the same as 0.13.0. This release exists so
that the package page shows the new README.
Changed
- The README is a quarter of its former length and says how to set up the two
sign-in engines,codexandantigravity: what each needs and spends, the
Antigravity warning, signing in, naming the engine, the settings, servers,
SSH and Docker, signing out, and the errors people meet with what to do. - The longer sections moved unchanged into
docs/HOW_IT_WORKS.md,
docs/INSTALL.md,docs/DELEGATION.mdanddocs/CONFIGURATION.md. The
settings table now lists theSEARCH_MCP_ANTIGRAVITY_*settings as well.
Search on a ChatGPT or Antigravity sign-in: codex and antigravity engines
Two opt-in engines that search on an account sign-in instead of an API key.
codex runs OpenAI's own web search on a ChatGPT plan, a use OpenAI allows
from third-party tools. antigravity runs Google Search through a Gemini model
on the sign-in of Google's Antigravity IDE, a use Google's terms forbid, with
account suspension as the stated consequence: read docs/ANTIGRAVITY_SEARCH.md
before signing in. Neither is in any default pool or route, so a search
reaches one only when a call names it, and both follow their backend's model
catalogue so a new model is used as soon as it is listed.
Added
- An opt-in
codexengine that runs OpenAI's own web search, the one Codex
uses, on the operator's ChatGPT plan. It needs no API key and no API
billing; each search counts against the plan's Codex usage. OpenAI supports
signing in with ChatGPT from third-party tools. The engine follows the rules
the key engines follow: it is in no pool, reserve or route, and named before
a sign-in it returns the usual "not configured" error, which now also tells
the agent not to ask for a sign-in. It calls the backend the way the current
Codex CLI does (openai/codex, read on 2026-09-26). The first choice is
alpha/search, whose structured results supply every title, URL and
snippet. On a deployment without that endpoint it uses/responseswith the
hostedweb_searchtool, and keeps only URLs the search itself cited. An
expired token is refreshed once and the search retried. A spent usage window
is reported with its reset time, and a model the plan lacks names
SEARCH_MCP_CODEX_MODEL. Neither counts against the engine in the breaker. search-mcp-login codexsigns in the waycodex logindoes: OAuth with
PKCE, returning to127.0.0.1:1455, or 1457 when 1455 is taken. Over SSH,
paste the address the browser lands on into the terminal.--use-codex-cli
links the Codex CLI'sauth.jsonread-only instead, and never refreshes or
rewrites it.search-mcp-login statusandsearch-mcp-login logout codex
show and forget the sign-in. Tokens live in
<config_dir>/oauth/codex.json(0600). OpenAI rotates refresh tokens, so
refreshes are serialised per process and, wherefcntlexists, across
processes.- No sign-in step is needed either: the first time
codexis named with no
sign-in stored, the server opens the ChatGPT sign-in page in the local
browser and finishes the search once it is approved. One search waits up to
45 s (SEARCH_MCP_CODEX_SIGNIN_WAIT_SECONDS), and the page stays answerable
for ten minutes for the next one. It happens only over stdio and where a
desktop browser can be started, and at most one unanswered page per server
run. The browser is launched with its output detached, because over stdio
stdout is the MCP connection.SEARCH_MCP_CODEX_AUTO_SIGNIN=falseturns it
off. A search that stopped waiting for that approval is not cached, so the
same search repeated after the approval reachescodex. The settings page's
Test button never starts a sign-in; it says the engine is not signed in. - A callback with the wrong
stateis refused and the sign-in keeps waiting,
as in the Codex CLI, so a web page cannot abort a sign-in in progress. The
browser tab says "Signed in" only once the tokens are stored, and shows the
error when the exchange fails. - The settings page has a Codex card with Sign in, Test and Sign out.
- New settings:
SEARCH_MCP_CODEX_MODEL(latest),
SEARCH_MCP_CODEX_TIMEOUT,SEARCH_MCP_CODEX_REASONING_EFFORT,
SEARCH_MCP_CODEX_BASE_URL,SEARCH_MCP_CODEX_AUTO_SIGNIN,
SEARCH_MCP_CODEX_SIGNIN_WAIT_SECONDS. The guide, with a Chinese summary, is
docs/CODEX_SEARCH.md. - An opt-in
antigravityengine that runs Google Search through a Gemini
model on the sign-in of Google's Antigravity IDE, at the operator's own
risk: Google's Antigravity terms forbid using that sign-in from third-party
tools and Google has suspended accounts for it. The backend licenses only
requests that identify as Antigravity, so the engine sends Antigravity's user
agent. It never opens a sign-in by itself;search-mcp-login antigravity
and the settings page card say what it risks before the sign-in starts. It
callsv1internal:generateContentwith thegoogleSearchtool, and every
result URL comes from the reply's grounding, resolved from Google's redirect
to the page. A reply without search results is asked once more and then
yields nothing, because some models answer from memory. The daily sandbox
host is tried before production, which refused every search with 429 for a
free-tier account. Antigravity's OAuth client is not in the package: the
sign-in reads it from the Antigravity install on the machine, recognised by
a fingerprint, or fromSEARCH_MCP_ANTIGRAVITY_CLIENT_IDand
SEARCH_MCP_ANTIGRAVITY_CLIENT_SECRET, and stores it with the tokens. New
settings:SEARCH_MCP_ANTIGRAVITY_MODEL(latest),
SEARCH_MCP_ANTIGRAVITY_TIMEOUT,SEARCH_MCP_ANTIGRAVITY_BASE_URLS,
SEARCH_MCP_ANTIGRAVITY_VERSION,SEARCH_MCP_ANTIGRAVITY_CLIENT_ID,
SEARCH_MCP_ANTIGRAVITY_CLIENT_SECRET. The guide is
docs/ANTIGRAVITY_SEARCH.md. - Both sign-in engines follow their model catalogue by default
(SEARCH_MCP_CODEX_MODEL=latest,SEARCH_MCP_ANTIGRAVITY_MODEL=latest),
looked up at most every six hours, so a new model is used as soon as the
backend lists it.codextakes the newest generation's lightest model that
can search and is not being retired;antigravitytakes the flash model
Antigravity offers, and asks Antigravity's own web-search model when that
one answers without searching. Each account has its own lookup, and a
model the service refuses is looked up again on the next search. A model
name pins it, as before. - The sign-in listener takes both
127.0.0.1and::1when the registered
redirect sayslocalhost, because a browser may try IPv6 first.
Changed
- The
enginestree's closing line says the opt-in extras run on the
operator's own API key or account sign-in, and asks the agent not to
request either.
free-search-mcp 0.12.0
Three of the four default engines had stopped contributing, and no structural
test could see it. Bing answered every multi-word query with ten well-formed
results about the first word only (the Steam page for "rust ownership
borrowing"). Mojeek sat behind a captcha on every request. Google News put
redirect links that cannot be deduplicated into ordinary searches. Rank fusion
interleaved all of that with DuckDuckGo's real answers at positions 3, 6 and 9.
The errors that would have helped were also lost. 0.11.0 declared
mcp[cli]>=2.0.0, so every uvx install ran SDK 2.2 while CI tested the
locked 2.0.0. Since SDK 2.1 the message of any exception other than ToolError
is replaced, so an actionable error such as "at most 20 URLs" reached the
client as Error executing tool fetch_batch.
This release fixes both problems. It also adds the signals an agent needs to
judge how old and how reliable a result is, makes keyless operation a tested
property, and makes the plugin the main way to install.
Upgrading: the search cache key changed, so the first search after the upgrade
is cold. You do not need to delete anything. Output changed in three places
that a program parsing it would notice, all listed under Changed.
Fixed
- Bing returns results about the whole query again. Measured on 2026-09-21
with one fresh query per variant, because Bing caches per query: the 0.11.0
request (?q=…&count=10) scored 0 of 10 on topic, a warmed cookie jar plus
form=QBREwithoutcount=scored 9 to 10 of 10, and addingcount=10back
produced decoys again. The engine now mints one cookie jar per proxy egress
with a 30-minute lifetime. Minting is single-flight, a failed warm-up is
remembered for 60 s, and it never raises. The engine never sendscount=,
pages withfirst=11when more than ten results are requested, and discards
the jar and retries once when a response still looks like a decoy. - Error messages reach the client. Every tool is registered through a boundary
that converts the exceptions a caller can act on intoToolErrorwith the
original text:ValueError,OSError, the newFetchError, size caps, a
missing browser andhttpxerrors. Any other exception is logged and
reported asinternal error (<Type>), described as a server bug, without its
message, because the message may contain paths or response bodies. - The SDK under test is the SDK users run. The requirement is now
mcp[cli]>=2.2.0,<2.3.uvxignores lockfiles, which makes the declared
range the only constraint in production, and 2.1.0 was a minor release that
changed handler semantics. CI gained a dailysdk-canaryjob with two legs.
One resolves the newest SDK inside the range and is a hard gate that the
release reuses. The other ignores the upper bound and may fail, which shows
in advance what the next bump will meet. Its first run found two things a
fresh install already met: trafilatura 2.2 keeps no heading from a page
with a single paragraph, and curl_cffi 0.16 removed the helper a test used
to check impersonation profiles. The tests now hold on both the locked and
the newest versions. - Markdown is no longer delivered inside JSON. The SDK derived an output schema
of{"result": <str | dict>}for the dual-format tools and sent the markdown
twice, as a text block and asstructuredContent={"result": "…"}. Clients
that prefer structured content, Claude Code among them, showed the model the
second copy, a single JSON string with every newline escaped. That cancelled
the token saving the markdown default exists for. - The same page is merged when engines disagree about its address. Rank fusion
is keyed on an identity that ignores scheme,www., default port and
fragment, so thehttp://andhttps://copies of an arXiv page count as
one result with two votes. Anhttpssighting upgrades the URL that is
printed. The key itself is never emitted, because emitting it regressed two
ranking cases. Title de-duplication used to treat a number that appears in
only one of two titles as a version mismatch, which kept theabs/and
pdf/copies of one paper apart. It now compares numbers only when both
titles have them. - Google News results are publisher URLs. The first
max_resultslinks are
resolved four at a time within four seconds in total. Resolution stops after
three consecutive failures and keeps the original link when it fails. As a
resultinclude_domains, category filters and cross-engine merging see the
real host in place ofnews.google.com/rss/articles/…. cache://page/{url}finds pages whose URL contains an escape. The handler
unquoted a value the SDK had already decoded, so every non-ASCII Wikipedia
link was a permanent miss.paper_graphresolves arXiv identifiers. OpenAlex answers 404 for DataCite's
10.48550/arXiv.*DOIs, so those now go through the arXiv API for the title
and then an exact-title lookup, andnotessays the fallback was used. A
bare id such as1706.03762orhep-th/9901001is accepted when it is the
whole input. Before, it was searched as a title.extract_structuredno longer returns the whole article. JSON-LD
articleBody,textanddescriptionvalues longer than 500 characters are
clipped, with their original length recorded undertrimmed. RDFa nodes that
carry only a layoutroleare dropped. Dates, authors, prices and event
fields are unchanged.read_docreads API responses that have no file extension. A URL such as
https://pypi.org/pypi/uv/jsonwas rejected as an unsupported format because
only the path was consulted for structured text.application/json,
application/xml,application/yamland the+jsonand+xmlsuffixes are
now recognised from the content type, so a 4.8M-character JSON response can
be read in pages withstartandlength.- arXiv queries match more than the exact phrase. The query is now
all:"<phrase>" OR (all:w1 AND all:w2 …)with a small stopword list and at
most eight terms. Quoted and one-word queries pass through unchanged. engines(group=…)rejects a mistyped group and lists the valid ones. It used
to return an empty tree.cp .env.example .envworks. The example file has comments on the same line
as values, and the project's.envloader read them as part of the value, so
a verbatim copy failed validation at import with four errors. The loader now
ends an unquoted value at a#that follows whitespace, which leaves URL
fragments and passwords containing#intact. The example file also keeps
its comments on their own lines now.- Browser errors arrive without Playwright's call log. A failed navigation
reaches the model through theerrorsmap of a search and through the
message of a failedfetch, and both now stop at the first line, for example
Page.goto: net::ERR_CONNECTION_CLOSED at <url>. - The test suite removes provider keys from the environment, so a developer
who hasSEARCH_MCP_SERPER_API_KEYexported runs the same product as CI. - A live test run (
SEARCH_MCP_TEST_NETWORK=1) no longer sends a fake key to
Serper. A test of the.envloader leftSEARCH_MCP_SERPER_API_KEY=from-dotenv
inos.environfor the rest of the process, which was invisible offline.
Every test now gets itsSEARCH_MCP_*environment restored afterwards. The
live tests for Mojeek, Zhihu, Sogou and Serper also skip with a stated reason
when the site shows a detected wall, cannot be reached, or rejects the key.
An empty answer with no wall detected still fails.
Added
- Twenty-four direct-fact sources, all keyless JSON APIs, under eight new
groups and three new finance sub-groups:software(endoflife,
github_releases,pypi,npm,crates,registriesfor Maven Central,
RubyGems, the Go proxy, Homebrew, Docker Hub, Packagist and NuGet,
appstore),security(nvd,osv,cisakev),reference(wikidata,
rdap),weather(openmeteo),docs(mdn,ietf),gov
(federalregister,govuk),stats(wdi, World Bank indicators),
calendar(holidays,worldclock),finance.fx(frankfurter, with
the open ExchangeRate-API endpoint for currencies the ECB lacks, and
cfetsfor the PBOC's RMB central parity),finance.entity(gleif) and
finance.crypto(coingecko). They return the record itself, dated by its
publisher: the current release of a package with its upload date, the
support end date of a Python or Ubuntu cycle, a CVE with its CVSS score and
whether CISA lists it as exploited, the advisories touching a package with
the fixed version, a Wikidata item's population or inception with the date
Wikidata attaches, a country's GDP or population with the revision date,
the ECB rate between two currencies, today's forecast for a named place,
this year's holidays with China's make-up working days, the time in a
named city from the server clock, a domain's expiry from its registry, an
RFC with its standards level. Package names, places, countries,
currencies, coins and entities are read from the words of the question in
English or Chinese. All stay out of the default pool; the finance
sub-groups are registered after the existing three so barefinanceis
unchanged. - Claims routing: each record source declares offline whether it can answer
a question (Engine.claims), and a search with no category and no engines
seats the first three that say yes next to the default pool
(SEARCH_MCP_CLAIM_ENGINE_LIMIT,SEARCH_MCP_AUTO_ROUTE_ENABLED). An
agent no longer needs the category tree to reach a registry:
"CVE-2024-3094", "100 usd to cny", "上海明天天气", "latest fastapi version"
and "现在东京几点" each reach their source unasked. The claimants are listed
asauto_routedand named in the markdown. Measured 2026-09-22 on eleven
such questions: the record led in every one, in 2.0 to 7.2 s. - A search deadline...
Redundant dataset and image sources, plus a mathematics index
image and dataset no longer replace the general pool with a single
specialist source: this release adds redundancy to both exclusive categories,
while paper.math adds the missing mathematics index without changing the
existing paper spread. It also carries the Claude Code plugin, which makes
this the first version installable without a claude mcp add line.
Added
- Install as a Claude Code plugin.
/plugin marketplace add sweetcornna/free-search-mcpfollowed by
/plugin install free-search@free-search-mcpregisters the samesearch
stdio server with noclaude mcp addline and no checkout. The marketplace
is.claude-plugin/marketplace.jsonin this repo; the plugin is
plugins/free-searchand declares exactly one MCP server and nothing else —
no skills, no hooks, no always-on prompt tokens. Its.mcp.jsonpins
free-search-mcp==<plugin version>, so an installed plugin runs the package
it advertises, and/plugin update free-searchis what moves a user to a
newer server. docs/RELEASING.md. The release process was only ever encoded in
.github/workflows/release.yml; it is now written down, including the plugin
step it gained here — the four files a version lives in, why the pin has to
move in the same commit that bumpspyproject.toml, and how to verify PyPI,
the GitHub Release, and a real plugin install afterwards.
tests/test_plugin_manifest.pyfails on any drift between the pin, the
plugin manifest andpyproject.toml, and the release workflow re-checks both
against the pushed tag before it builds anything.- Seven new keyless sources bring the registry from 42 to 49 engines.
dryad,dataverse,figshare,huggingface, anddataeuropaadd
repository, machine-learning, and EU public-sector dataset coverage;
wikimediaadds Wikimedia Commons image search; andzbmathadds a
mathematics literature index. None enters the default pool: each is reached
throughcategory=, so ordinary web searches do not pay for the specialist
fan-out. - Four new
Categorytokens expose the new branches.
dataset.repository,dataset.ml,dataset.gov, andpaper.mathlet a
caller narrow to a known kind of dataset or paper;zenodonow also declares
dataset.repository, putting all four repository sources in the same branch. - Rejected candidates now have an explicit reason to stay out. The source
probe rejecteddata.gov(the legacy CKAN endpoint returned 404 and its
replacement requires an API key), Eurostat, OECD, and UN UNdata (no working
keyless free-text search), IETF Datatracker (its acceptedsearchparameter
is silently ignored), and Reddit (anonymous search returns 403 and current
API access requires OAuth). OSF Preprints and HAL are technically capable
adapters, but operator robots policy — not adapter capability — kept them
out.
Changed
- Bare dataset routing now spends its three slots across three different
sub-groups.category="dataset"selectsdryad,huggingface, and
dataeuropa— one repository, one ML, and one government source — instead of
three overlapping repositories. The fulldataset.repositorybranch is
dryad,dataverse,zenodo, andfigshare, whiledataset.mland
dataset.govnarrow tohuggingfaceanddataeuroparespectively. - The existing paper spread is deliberately preserved.
category="paper"still selectsarxiv,openalex, andeuropepmc; the
mathematics index is reached explicitly withcategory="paper.math", so
zbmathdoes not displace one of the three existing corpora.
Fixed
- Exclusive image and dataset searches no longer have a single point of
failure. Before this release, the general web pool was intentionally
replaced by exactly one specialist —openverseforimageandzenodofor
dataset. An outage, rate limit, or missed hit therefore left no specialist
fallback and made source unavailability indistinguishable from no matching
item.imagenow hasopenverseandwikimedia, whiledatasethas five
sources across the repository, ML, and government sub-groups.
release 0.10.0: finance and scholarly sources, a category tree, a citation graph
Finance and scholarly literature get real sources instead of hostname filters,
category becomes a two-level tree the agent can navigate, and a batch of
accuracy bugs that quietly corrupted results are fixed.
Added
- Ten new keyless sources. Finance:
sec_edgar(US filings full text),
cninfo(A-share / HK announcements),yahoofinance(ticker resolution and
market news),worldbank(Documents & Reports),imf(DataMapper series,
WEO forecasts included). Scholarly:europepmc(40M life-science records
including preprints),dblp(computer science),doaj(open access),
clinicaltrials(registered human trials), andsemanticscholarbehind an
optional key. Before this, a finance query had no specialist source at all
and fell through to the general web pool. categoryis now a two-level tree. A bare group widens, a dotted
sub-group narrows:paper.index,paper.preprint,paper.biomed,
paper.cs,paper.openaccess,paper.trial,finance.filings,
finance.market,finance.macro,news.world. Every token appears in the
tool schema's enum, so an agent discovers the whole tree without a second
call. Preprint search runs through Europe PMC'sSRC:"PPR"clause because
bioRxiv's own API cannot keyword-search at all.paper_graph— the eleventh tool. Takes a DOI, an OpenAlex ID or an
exact title and returns what the paper cites, what cites it (ordered by how
much the field cited those in turn, so a five-year-old paper leads to the
current state of the art), and any Crossref retraction, correction or
expression of concern. A full walk costs four HTTP requests regardless of
limit, because every neighbour's metadata is selected in the call that
lists it.cache_searchreturned "no cached pages match" for a cache full of
matches.put_pageusedINSERT OR REPLACE, which resolves the url
conflict by deleting the old row and inserting a new one with a NEW rowid —
and SQLite fires DELETE triggers on that path only whenrecursive_triggers
is on, which it is not by default. So every re-fetch orphaned the old
rowid's postings inpages_fts. External-content FTS5 reads column values
back frompagesby rowid, so the first orphan made every query raise
fts5: missing row N from content table, which the malformed-query handler
swallowed into an empty result.put_pagenow upserts (keeping the rowid),
existing cache files are rebuilt once on open, and a desynced index is
repaired on read instead of being reported as "nothing cached".- Rate-limited engines are now reported.
aggregatorwrote
diagnostics["rate_limited"]and nothing in the codebase ever read it, so a
gdeltsearch skipped for its 6/min bucket looked identical to one that
genuinely found nothing.
Changed
-
category=now changes the ORDER of results, not just which engines run.
A specialist is usually the only source returning a given document, so its
hit scored 1/61 under plain RRF while three general engines agreeing on a
blog post about the topic scored 3/61 and won —category="finance.filings"
put NVIDIA's actual 10-K fourth, behind commentary about it, and put A-share
announcements seventh behind Wikipedia. Engines that natively index the
requested category now count double in the fusion. Measured on 14 queries
against real engine output, scoring the one result a knowledgeable person
would call correct:hit@1 hit@3 MRR before 6/14 9/14 0.605 after 8/14 13/14 0.747 Six queries improved and none regressed. Two other candidate changes were
measured on the same set and dropped for lack of evidence: retuning RRF's
damping constant moved MRR by under 0.01 at any value between 5 and 60, and
a lexical query/title overlap bonus made every configuration worse
(0.747 -> 0.645). -
category="paper"round-robins across its sub-groups before the engine
limit truncates.SEARCH_MCP_CATEGORY_ENGINE_LIMIT(default 3) used to
hand all three slots toarxiv,openalexandcrossref— and the last two
are both DOI indexes covering the same corpus. The three slots now buy three
different corpora, which also revivespubmed: it was the fourth engine in a
three-slot list, i.e. dead code that four separate documents advertised. -
engines()returns the source tree, derived from the registry. The
hand-maintained buckets it replaces had drifted — they promotedpubmed
where it could never run and never mentionedopenverseorzenodo. It
takes an optionalgroup=and honoursformat="json"like every other tool. -
Enginegained a one-linedescription, rendered byengines(), so
picking a source no longer means guessing from its name.
Fixed
bing's redirect unwrapping (shipped in 0.9.2) no longer rewrites URLs it
should leave alone. It located the payload by searching the whole URL for
u=, butuis an ordinary parameter name that other sites use for their own
purposes, so a non-Bing link whoseuvalue happened to base64-decode into
something starting withhttpwas silently replaced by it. Unwrapping is now
gated on the URL actually being abing.com/ck/aredirect, and the decoded
target must be an absolutehttp://orhttps://URL rather than merely
starting with those four characters.bravereturned every result twice. Its comma-joined selector matches
the samedivthrough both branches and selectolax does not deduplicate, so
two real results parsed as four. Half ofmax_resultswas spent on
duplicates, and RRF scored each one twice, givingbraveroughly double
weight in the merge.mojeek,bing,baiduandsearxhad the same
missingseenset with a smaller blast radius.read_docignored the charset and mangled every non-UTF-8 page. Five
call sites hard-codeddecode("utf-8", errors="replace")while the fetcher
had already retrieved the content type. GBK, Big5 and Shift-JIS documents
came back as replacement characters. They now go through the same
header-then-<meta>decoder the rest of the package uses.- Crossref dates were wrong in two ways, and trusted anyway. An internal
nullindate-parts([[2024, null, 5]]) was filtered out rather than
treated as a stop, promoting the day into the month slot; and a
year-only record was padded toYYYY-01-01and marked confident, so
freshness="month"dropped a paper actually published in December. Dates now
truncate at the first gap, and year-only precision is no longer confident. sogouandso360ignoredsite:/-site:/filetype:. They were
the only two web engines that never calledaugment_query_with_operators, so
they returned unconstrained results that the post-filter then discarded
wholesale —engines=["so360"], include_domains=["python.org"]reliably
returned nothing.- Merging two copies of a URL could throw away the trustworthy date. The
representative was picked by snippet length, so a structured source with an
exact publication date lost to an HTML scrape with a longer teaser, and the
date was then dropped as empty. The merge is now field-wise: best date, and
longest snippet, from whichever copy has it. - A cache hit erased the provenance of the search it replayed. The first
query reported "duckduckgo was gated,searxrescued it"; the same query
inside the 7-day TTL reported an ordinary search.gated_engines,
gated_hintandrescued_vianow travel with the cached rows. googlecould return a relative path as a result URL._unwraponly
understood theq=wrapper; Google's other form puts the target inurl=
and leavesqempty, whichparse_qsdiscards. The unwrapped value was also
never made absolute, sofetchcould not open it and host-based filtering
silently dropped it.fetch(max_age_hours=…)never hit the cache for Google News links. The
pre-check used the raw URL while the cache is keyed on the publisher URL the
fetcher resolves it to, so every call re-fetched.sec_edgaranswered a ticker query with unrelated issuers' filings.
EDGAR's relevance ranking is dominated by structured-product pricing
supplements, so "NVDA risk factors" led with four 497Ks from ProShares and
Investment Managers Series Trust that merely mention the ticker. A query that
writes a ticker as a ticker is now scoped withentityName=, which returns
NVIDIA's own 10-K and 10-Qs. The match is case-sensitive — lower-cased, the
ticker space is full of ordinary words (IT,ALL,ON,NOW,GO) — and
an empty scoped search is re-run unscoped, so a wrong guess costs a round
trip rather than the answer.- The search header credited engines that produced nothing.
payload["engines"]is the REQUEST — the list the cache key is built from —
and when a requested engine fails, the rescue pass substitutes another. The
header printed the request, sosearch(engines=["serper"])with no key
configured announcedengines: serperabove ten results that every
per-result byline correctly attributed tobing. The header now names the
engines that actually contributed and lists the rest separately. - A dictionary entry passed as a scholarly source.
_PAPER_HOSTSlists
publisher domains and the matcher accepts any subdomain, which is what keeps
the list short — but the big presses also run dictionaries and bookshops on
the same domain.category="paper"on "what is reciprocal rank fusion"
dropped 54 of 56 raw results and kept Cambridge Dictionary's entry for the
WORD "reciprocal" as its single source. Known non-scholarly siblings are now
excluded before the allowlist is consulted. category="image"and"dataset"could be rescued into the web pool.
settings.rescue_enginesIS the general web pool, and the whole reason those
two categ...
v0.9.2 — Bing results are publisher URLs, not ck/a tracking blobs
Fixed
- Bing results are now publisher URLs instead of
bing.com/ck/atracking
blobs.BingEngine.parsereturned the rawhref, and on the www4 SERP
essentially every organic href is wrapped in a click-tracking redirect
(/ck/a?…&u=a1<base64url>&ntb=1). Half of a default four-engine run therefore
came back as opaquebing.comlinks. This was not merely cosmetic: the blob
carries a per-impression hash, so it is unique on every search, which defeats
both the URL key the RRF merge fuses on and the_dedup_by_titlepass behind
it. A page found by Bing and by DuckDuckGo scored as two separate results
and both were emitted — so amax_results=10run spent slots on duplicates of
pages it had already returned (observed: "Defining schemas | Zod" at rank 1
from DuckDuckGo and again at rank 8 as a Bing blob). The wrapper also left the
caller unable to tell what a result even was without fetching it, which is the
opposite of what a snippet-bearing search result is for. Theupayload is
now base64url-decoded to the real target; anything that is not a decodable
wrapper passes through untouched, since a working redirect link still beats
dropping the result. Present since the engine was added.
Fixed
cache_searchno longer returns the cache's internal title sentinel.
Metadata is packed into the title column behind\x01META\x01so the schema
could stay put, but the tool served those rows verbatim — the page title
arrived as\x01META\x01{"title": ...}\x01in both markdown and json. Titles
are now unpacked at the tool boundary, and theauthor,dateand
sitenamealready stored alongside them are returned instead of discarded.
Rows written before metadata capture hold a plain title and are unaffected.
Present since 0.4.2.
v0.9.1 — cache_search no longer leaks the internal title sentinel
Fixed
cache_searchno longer returns the cache's internal title sentinel.
Metadata is packed into the title column behind\x01META\x01so the schema
could stay put, but the tool served those rows verbatim — the page title
arrived as\x01META\x01{"title": ...}\x01in both markdown and json. Titles
are now unpacked at the tool boundary, and theauthor,dateand
sitenamealready stored alongside them are returned instead of discarded.
Rows written before metadata capture hold a plain title and are unaffected.
Present since 0.4.2.
v0.9.0 — download by default; every release lands on GitHub too
Download by default, and finish every release on GitHub as well as PyPI.
Changed
downloadnow works with zero configuration. Files go to
${SEARCH_MCP_CACHE_DIR}/downloads(~/.cache/search-mcp/downloadslocally
and/data/downloadsin Docker), still expire after 24 hours by default, and
retain the existing filename, path-containment, response-size and SSRF guards.
SetSEARCH_MCP_DOWNLOAD_ENABLED=falsefor an explicit opt-out, or
SEARCH_MCP_DOWNLOAD_DIRto override only the destination.- Download policy is operator-owned instead of process-global client state.
The old elicitation answer applied to every caller sharing one HTTP process.
Removing that session flag means one caller can no longer silently authorize
downloads for another; disabled calls are rejected before any network request. - Release tags now produce a recoverable GitHub Release as well as PyPI
artifacts. The workflow validates tag, project, lockfile and changelog
versions, builds once, stages the wheel and sdist on a draft Release, publishes
those same files to PyPI, then exposes the GitHub Release as Latest.
Fixed
- A blank
SEARCH_MCP_DOWNLOAD_DIRno longer means the current working
directory. Empty and whitespace-only values now select the dynamic default
sandbox instead of being parsed asPath("."). - The effective download limit is documented accurately. Remote bodies are
bounded by the smaller ofSEARCH_MCP_MAX_RESPONSE_BYTESand
SEARCH_MCP_DOWNLOAD_MAX_MB; the defaults therefore allow 25,000,000 bytes
(about 23.8 MiB), not the full 100 MiB disk-layer cap. SEARCH_MCP_DOWNLOAD_TTL_HOURS=0no longer claims files expire in0h.
The documented "keep forever" value now reports that TTL cleanup is disabled.
Both that setting andSEARCH_MCP_DOWNLOAD_MAX_MBalso reject negative
values instead of accepting a configuration that refuses every file.- A missing or unreadable download directory no longer blocks startup. TTL
cleanup logs and skips the scan rather than raising out of server start, and
it now runs before the network fetch as documented. ~inSEARCH_MCP_CACHE_DIRis expanded once, in settings. The cache
database and the derived download sandbox can no longer resolve to different
roots.
v0.8.0 — search in any language; close three SSRF guard bypasses
Search in your own language, and keep the SSRF guard switched on.
Two themes, both found by asking why a Chinese-language news search returned
nothing. The filters were discarding correct results, and the diagnostics that
should have said so were the one thing never shown.
Fixed
category="news"no longer discards the non-English web. The category
filter matched results against a hand-written tuple of 33 Anglosphere
outlets, so a Chinese news search dropped 17 of 17 hits —news.sina.com.cn
included. The list now covers major outlets in Chinese, Japanese, Korean,
French, German, Spanish, Portuguese, Russian, Hebrew and more, and also
accepts thenews.<domain>naming convention, because no hand-maintained
tuple will ever hold every news site on earth.- Category-native engines are no longer filtered out by their own
category.categoriesis documented as a routing signal, but results
were then re-checked against the hostname allowlist anyway. Crossref returned
8 papers forcategory="paper"and all 8 were dropped for being on
doi.org; OpenAlex kept 1 of 8; GDELT — which exists to index news in 100+
languages — had every non-Western outlet discarded. An engine that natively
indexes the requested category is now trusted for it. Domain, text, freshness
andcategory="pdf"checks still apply. - Filter diagnostics are shown when there are no results at all. The
aggregator computed "filters dropped 17 of 17 raw results (kept 0), most by
category=news" and the Markdown renderer returned before ever printing it.
Callers saw only the silent-engine note and went hunting for an IP block that
wasn't happening.researchdropped the same diagnostics, pluserrors, on
the floor entirely. - Google News is asked in the query's language. The edition was pinned to
hl=en-US&gl=US&ceid=US:enwhile every other region-aware engine reads
SEARCH_MCP_REGION. That endpoint is edition-scoped, so a Chinese query got
an empty feed — 0 items where the Simplified Chinese edition had 35. The
edition now followsSEARCH_MCP_REGION, and falls back to the query's
writing system when the configured one cannot serve it: 23 scripts, from
Cyrillic and Arabic to Tamil and Georgian. Measured gains include Thai
12→100, Hebrew 31→100, Arabic 49→100, Bengali 7→100. Latin-script queries
are deliberately left alone — the US edition already serves them at full
volume, and script alone cannot tell German from English. - A rate-limited source is no longer reported as a silent IP block. The
keyless-JSON never-raise rule turned GDELT's HTTP 429 into an empty list, and
the aggregator advised configuring a proxy for what was a documented
6-requests-per-minute limit. Refusals are now reported with their status
code, separately from genuinely silent engines. - Mojeek's CAPTCHA is detected. It serves an ALTCHA proof-of-work wall that
shares no markup with the Google or DuckDuckGo walls, so a captcha-blocked
Mojeek — one of the four default engines — read as an unexplained empty.
Google's JavaScript-redirect interstitial is likewise classified now instead
of looking like "this query found nothing".
Security
- The SSRF guard was bypassable via the browser fallback.
fetch_page
caught the guard's rejection as if it were a transport error and handed the
same URL to the Chromium render, which ran no check at all — so any blocked
target was reachable by being unreachable over plain HTTP first. On a cloud
instancehttp://169.254.169.254/returned instance credentials that way.
A refusal is no longer a failure to fall back from, and the browser path is
independently guarded (render="browser"skips the HTTP branch entirely). - A cache hit bypassed the guard. The page cache was read before any check,
so anything fetched while the guard was permissive stayed retrievable
afterwards and tightening the setting had no effect on it. Cache reads now
run the DNS-free layers first. - Alibaba Cloud's metadata endpoint (
100.100.100.200) is blocked. It sits
in CGNAT space, whichipaddressreports as ordinary public address space. - Internal hostnames are refused by name —
localhost,*.internal,
*.local,*.corp,*.lan,metadata.google.internal,instance-dataand
friends — with no DNS lookup, so the check holds on setups where resolution
says nothing useful.
Changed
- The SSRF guard's resolve-every-address layer now runs only when this
process's resolver decides what gets connected to. It is skipped behind an
outbound proxy (the proxy resolves and connects) and on a fake-IP VPN in TUN
mode, where every hostname is mapped into a range like198.18.0.0/15and
the answer is a handle, not a destination. Both setups previously refused
every fetch, whose real-world outcome is not "one request blocked" but
"operator setsallow_private_hosts=true" and gives up loopback and metadata
protection too. The DNS-free layers run in every mode. Fake-IP detection uses
canary hostnames that are public by definition and can only ever stand down
for tunnel ranges — never loopback, link-local or RFC1918.
NewSEARCH_MCP_SSRF_RESOLVE_ADDRESSES=auto(default) |always|
never. - Google is asked as Chrome, Bing as Edge. Engines can now declare their
own TLS/header fingerprint. Measured caveat: Google answered its JS
interstitial under every profile tried, so its gate is behavioural rather
than a fingerprint check — this is about presenting a coherent identity, not
about unblocking it. - The offline test suite is hermetic with respect to configuration, not just
DNS. A personalSEARCH_MCP_ALLOW_PRIVATE_HOSTS=truedisarmed the guard
under test and failed 26 SSRF cases on that machine while passing everywhere
else; a suite whose result depends on who runs it cannot review a change to
the thing it covers.
v0.7.0 — MCP 2026-07-28 · 15 new sources · universal fetch
Upgrade if you are on 0.4.2 — it cannot be installed.
mcp[cli]>=1.2.0had no upper bound, and MCP Python SDK 2.0.0 (released
2026-07-28) deletedmcp.server.fastmcpoutright. Any fresh resolve picked
up v2 and died at import.uvx free-search-mcpworks again.
uvx free-search-mcp # or: claude mcp add search -- uvx free-search-mcpProtocol 2026-07-28
Migrated to the SDK v2 MCPServer API. One process serves both protocol
eras, so older clients keep working — verified against a real stdio
subprocess: server/discover → ["2026-07-28"], while a legacy initialize
still negotiates 2025-06-18.
- Tool titles moved to the real
Tool.titlefield (annotations are a display
hint, not the name), andserverInfonow carries title, version, project
URL and tool-selection instructions. - Cache hints (SEP-2549): the tool/prompt/resource lists advertise
ttlMs=3600000, cacheScope=public— they're fixed for the process lifetime,
so clients can stop re-listing. - A missing cached resource reports
-32602(invalid params) instead of
-32603(internal error), which had been telling clients to retry something
that could never succeed.
New streamable-http transport (--transport streamable-http), stdio
still the default. DNS-rebinding protection is always installed — the SDK
leaves it off when unconfigured, which would let any web page drive the
server through a user's browser.
15 new keyless sources, and category that actually routes
category= used to only filter general web results by hostname. It now sends
the query to sources that natively index it:
category |
engines |
|---|---|
paper |
arxiv, openalex, crossref, pubmed |
github / forum |
github, github_code (keyed), stackexchange, hackernews |
news |
gdelt (100+ languages) |
image / dataset |
openverse, zenodo — these replace the web pool |
| general | wikipedia, openlibrary, sogou, so360 |
New sources stay out of the default pool, so ordinary searches pay nothing.
Engines may declare a stricter rate limit and are skipped rather than
queued when their bucket is empty — search is a parallel fan-out, so waiting
on one slow source would add that delay to every other engine's results.
Search and fetch anything, not just pages
- Images / video / audio / binaries are described (media type, size,
dimensions, sha256) instead of decoded into a screen of U+FFFD.
fetch(inline=True)returns the image itself as MCPImageContentfor a
vision-capable model — off by default, because a 1MB image costs well over a
thousand tokens and the description usually settles whether it's worth it. read_docgains xlsx (one table per sheet), pptx (per slide, with
speaker notes), epub, csv/tsv, source code (fenced with its language), and
zip/tar. Archives are listed, never extracted, and anything expanding
more than 100× is flagged — decompressing untrusted members is how zip bombs
win.- New
downloadtool, off by default. With noSEARCH_MCP_DOWNLOAD_DIR
set it asks permission first and the answer applies to that session only;
declining writes nothing. Files auto-delete after 24h.
Bugs found while building this
- JSON engines never asked for JSON. The shared curl_cffi session
impersonates Chrome, so it advertisedAccept: text/html— Openverse (Django
REST Framework) answered 200 with its browsable HTML API, which failed to
parse and looked exactly like "no results". - Zenodo is the inverse: it
403s clients presenting a browser
fingerprint. Engines can now opt out of impersonation and identify honestly. - A keyed engine's "missing key" error was swallowed by the keyless
never-raise boundary, so an unconfigured engine reported "no results" —
indistinguishable from "nothing matched". _detect_formatmatched the whole URL, sodata.csv?token=abc.png
classified as an image.
Compatibility
No breaking tool changes — every existing tool keeps its parameters; the only
addition is an optional inline on fetch. 845 tests (was 552), green on
Python 3.11 / 3.12 / 3.13.
Full changelog: https://github.com/sweetcornna/free-search-mcp/blob/main/CHANGELOG.md