Skip to content

Releases: sweetcornna/free-search-mcp

A shorter README, with how to set up codex and antigravity

Choose a tag to compare

@github-actions github-actions released this 26 Sep 08:35

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, codex and antigravity: 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.md and docs/CONFIGURATION.md. The
    settings table now lists the SEARCH_MCP_ANTIGRAVITY_* settings as well.

Search on a ChatGPT or Antigravity sign-in: codex and antigravity engines

Choose a tag to compare

@github-actions github-actions released this 26 Sep 08:12

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 codex engine 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 /responses with the
    hosted web_search tool, 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 codex signs in the way codex login does: OAuth with
    PKCE, returning to 127.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's auth.json read-only instead, and never refreshes or
    rewrites it. search-mcp-login status and search-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, where fcntl exists, across
    processes.
  • No sign-in step is needed either: the first time codex is 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=false turns it
    off. A search that stopped waiting for that approval is not cached, so the
    same search repeated after the approval reaches codex. The settings page's
    Test button never starts a sign-in; it says the engine is not signed in.
  • A callback with the wrong state is 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 antigravity engine 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
    calls v1internal:generateContent with the googleSearch tool, 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 from SEARCH_MCP_ANTIGRAVITY_CLIENT_ID and
    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. codex takes the newest generation's lightest model that
    can search and is not being retired; antigravity takes 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.1 and ::1 when the registered
    redirect says localhost, because a browser may try IPv6 first.

Changed

  • The engines tree'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

Choose a tag to compare

@github-actions github-actions released this 22 Sep 07:55
165651e

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=QBRE without count= scored 9 to 10 of 10, and adding count=10 back
    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 sends count=,
    pages with first=11 when 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 into ToolError with the
    original text: ValueError, OSError, the new FetchError, size caps, a
    missing browser and httpx errors. Any other exception is logged and
    reported as internal 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. uvx ignores 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 daily sdk-canary job 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 as structuredContent={"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 the http:// and https:// copies of an arXiv page count as
    one result with two votes. An https sighting 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 the abs/ 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_results links 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
    result include_domains, category filters and cross-engine merging see the
    real host in place of news.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_graph resolves 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, and notes says the fallback was used. A
    bare id such as 1706.03762 or hep-th/9901001 is accepted when it is the
    whole input. Before, it was searched as a title.
  • extract_structured no longer returns the whole article. JSON-LD
    articleBody, text and description values longer than 500 characters are
    clipped, with their original length recorded under trimmed. RDFa nodes that
    carry only a layout role are dropped. Dates, authors, prices and event
    fields are unchanged.
  • read_doc reads API responses that have no file extension. A URL such as
    https://pypi.org/pypi/uv/json was rejected as an unsupported format because
    only the path was consulted for structured text. application/json,
    application/xml, application/yaml and the +json and +xml suffixes are
    now recognised from the content type, so a 4.8M-character JSON response can
    be read in pages with start and length.
  • 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 .env works. The example file has comments on the same line
    as values, and the project's .env loader 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 the errors map of a search and through the
    message of a failed fetch, 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 has SEARCH_MCP_SERPER_API_KEY exported 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 .env loader left SEARCH_MCP_SERPER_API_KEY=from-dotenv
    in os.environ for the rest of the process, which was invisible offline.
    Every test now gets its SEARCH_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, registries for 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
    cfets for 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 bare finance is
    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
    as auto_routed and 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...
Read more

Redundant dataset and image sources, plus a mathematics index

Choose a tag to compare

@github-actions github-actions released this 30 Aug 06:17
83707d5

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-mcp followed by
    /plugin install free-search@free-search-mcp registers the same search
    stdio server with no claude mcp add line and no checkout. The marketplace
    is .claude-plugin/marketplace.json in this repo; the plugin is
    plugins/free-search and declares exactly one MCP server and nothing else —
    no skills, no hooks, no always-on prompt tokens. Its .mcp.json pins
    free-search-mcp==<plugin version>, so an installed plugin runs the package
    it advertises, and /plugin update free-search is 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 bumps pyproject.toml, and how to verify PyPI,
    the GitHub Release, and a real plugin install afterwards.
    tests/test_plugin_manifest.py fails on any drift between the pin, the
    plugin manifest and pyproject.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, and dataeuropa add
    repository, machine-learning, and EU public-sector dataset coverage;
    wikimedia adds Wikimedia Commons image search; and zbmath adds a
    mathematics literature index. None enters the default pool: each is reached
    through category=, so ordinary web searches do not pay for the specialist
    fan-out.
  • Four new Category tokens expose the new branches.
    dataset.repository, dataset.ml, dataset.gov, and paper.math let a
    caller narrow to a known kind of dataset or paper; zenodo now 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 rejected data.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 accepted search parameter
    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" selects dryad, huggingface, and
    dataeuropa — one repository, one ML, and one government source — instead of
    three overlapping repositories. The full dataset.repository branch is
    dryad, dataverse, zenodo, and figshare, while dataset.ml and
    dataset.gov narrow to huggingface and dataeuropa respectively.
  • The existing paper spread is deliberately preserved.
    category="paper" still selects arxiv, openalex, and europepmc; the
    mathematics index is reached explicitly with category="paper.math", so
    zbmath does 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 — openverse for image and zenodo for
    dataset. An outage, rate limit, or missed hit therefore left no specialist
    fallback and made source unavailability indistinguishable from no matching
    item. image now has openverse and wikimedia, while dataset has five
    sources across the repository, ML, and government sub-groups.

release 0.10.0: finance and scholarly sources, a category tree, a citation graph

Choose a tag to compare

@github-actions github-actions released this 29 Aug 16:03

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), and semanticscholar behind an
    optional key. Before this, a finance query had no specialist source at all
    and fell through to the general web pool.
  • category is 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's SRC:"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_search returned "no cached pages match" for a cache full of
    matches.
    put_page used INSERT 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 when recursive_triggers
    is on, which it is not by default. So every re-fetch orphaned the old
    rowid's postings in pages_fts. External-content FTS5 reads column values
    back from pages by 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_page now 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. aggregator wrote
    diagnostics["rate_limited"] and nothing in the codebase ever read it, so a
    gdelt search 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 to arxiv, openalex and crossref — and the last two
    are both DOI indexes covering the same corpus. The three slots now buy three
    different corpora, which also revives pubmed: 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 promoted pubmed
    where it could never run and never mentioned openverse or zenodo. It
    takes an optional group= and honours format="json" like every other tool.

  • Engine gained a one-line description, rendered by engines(), 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=, but u is an ordinary parameter name that other sites use for their own
    purposes, so a non-Bing link whose u value happened to base64-decode into
    something starting with http was silently replaced by it. Unwrapping is now
    gated on the URL actually being a bing.com/ck/a redirect, and the decoded
    target must be an absolute http:// or https:// URL rather than merely
    starting with those four characters.
  • brave returned every result twice. Its comma-joined selector matches
    the same div through both branches and selectolax does not deduplicate, so
    two real results parsed as four. Half of max_results was spent on
    duplicates, and RRF scored each one twice, giving brave roughly double
    weight in the merge. mojeek, bing, baidu and searx had the same
    missing seen set with a smaller blast radius.
  • read_doc ignored the charset and mangled every non-UTF-8 page. Five
    call sites hard-coded decode("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
    null in date-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 to YYYY-01-01 and 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.
  • sogou and so360 ignored site: / -site: / filetype:. They were
    the only two web engines that never called augment_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, searx rescued it"; the same query
    inside the 7-day TTL reported an ordinary search. gated_engines,
    gated_hint and rescued_via now travel with the cached rows.
  • google could return a relative path as a result URL. _unwrap only
    understood the q= wrapper; Google's other form puts the target in url=
    and leaves q empty, which parse_qs discards. The unwrapped value was also
    never made absolute, so fetch could 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_edgar answered 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 with entityName=, 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, so search(engines=["serper"]) with no key
    configured announced engines: serper above ten results that every
    per-result byline correctly attributed to bing. The header now names the
    engines that actually contributed and lists the rest separately.
  • A dictionary entry passed as a scholarly source. _PAPER_HOSTS lists
    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_engines IS the general web pool, and the whole reason those
    two categ...
Read more

v0.9.2 — Bing results are publisher URLs, not ck/a tracking blobs

Choose a tag to compare

@github-actions github-actions released this 06 Aug 11:25

Fixed

  • Bing results are now publisher URLs instead of bing.com/ck/a tracking
    blobs.
    BingEngine.parse returned the raw href, 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 opaque bing.com links. 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_title pass behind
    it. A page found by Bing and by DuckDuckGo scored as two separate results
    and both were emitted — so a max_results=10 run 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. The u payload 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_search no longer returns the cache's internal title sentinel.
    Metadata is packed into the title column behind \x01META\x01 so the schema
    could stay put, but the tool served those rows verbatim — the page title
    arrived as \x01META\x01{"title": ...}\x01 in both markdown and json. Titles
    are now unpacked at the tool boundary, and the author, date and
    sitename already 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

Choose a tag to compare

@github-actions github-actions released this 01 Aug 02:48

Fixed

  • cache_search no longer returns the cache's internal title sentinel.
    Metadata is packed into the title column behind \x01META\x01 so the schema
    could stay put, but the tool served those rows verbatim — the page title
    arrived as \x01META\x01{"title": ...}\x01 in both markdown and json. Titles
    are now unpacked at the tool boundary, and the author, date and
    sitename already 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

Choose a tag to compare

@github-actions github-actions released this 31 Jul 17:48

Download by default, and finish every release on GitHub as well as PyPI.

Changed

  • download now works with zero configuration. Files go to
    ${SEARCH_MCP_CACHE_DIR}/downloads (~/.cache/search-mcp/downloads locally
    and /data/downloads in Docker), still expire after 24 hours by default, and
    retain the existing filename, path-containment, response-size and SSRF guards.
    Set SEARCH_MCP_DOWNLOAD_ENABLED=false for an explicit opt-out, or
    SEARCH_MCP_DOWNLOAD_DIR to 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_DIR no longer means the current working
    directory.
    Empty and whitespace-only values now select the dynamic default
    sandbox instead of being parsed as Path(".").
  • The effective download limit is documented accurately. Remote bodies are
    bounded by the smaller of SEARCH_MCP_MAX_RESPONSE_BYTES and
    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=0 no longer claims files expire in 0h.
    The documented "keep forever" value now reports that TTL cleanup is disabled.
    Both that setting and SEARCH_MCP_DOWNLOAD_MAX_MB also 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.
  • ~ in SEARCH_MCP_CACHE_DIR is 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

Choose a tag to compare

@sweetcornna sweetcornna released this 31 Jul 17:41

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 the news.<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.
    categories is documented as a routing signal, but results
    were then re-checked against the hostname allowlist anyway. Crossref returned
    8 papers for category="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
    and category="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. research dropped the same diagnostics, plus errors, 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:en while 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 follows SEARCH_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
    instance http://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, which ipaddress reports as ordinary public address space.
  • Internal hostnames are refused by name — localhost, *.internal,
    *.local, *.corp, *.lan, metadata.google.internal, instance-data and
    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 like 198.18.0.0/15 and
    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 sets allow_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.
    New SEARCH_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 personal SEARCH_MCP_ALLOW_PRIVATE_HOSTS=true disarmed 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

Choose a tag to compare

@sweetcornna sweetcornna released this 30 Jul 14:32
cfdaed6

Upgrade if you are on 0.4.2 — it cannot be installed.
mcp[cli]>=1.2.0 had no upper bound, and MCP Python SDK 2.0.0 (released
2026-07-28) deleted mcp.server.fastmcp outright. Any fresh resolve picked
up v2 and died at import. uvx free-search-mcp works again.

uvx free-search-mcp          # or: claude mcp add search -- uvx free-search-mcp

Protocol 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.title field (annotations are a display
    hint, not the name), and serverInfo now 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 MCP ImageContent for 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_doc gains 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 download tool, off by default. With no SEARCH_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 advertised Accept: 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_format matched the whole URL, so data.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