Search your AI agent can trust: building searxng-mcp-server
This week I published searxng-mcp-server on npm. It is an MCP server that connects AI coding clients to a self-hosted SearXNG instance: web, image, news, video and music search, plus page fetch as clean Markdown. No API keys, and no third party logging the queries. This is the build log, including the parts that cost an evening.
Why does an agent need its own search?#
Most search MCP servers wrap a paid API. You bring a vendor key, the agent calls the tool, and the vendor bills the request and records the query. For a person, one billed lookup is nothing. An agent that researches a problem for twenty minutes fires dozens of them, and every query lands in a provider's logs next to an account ID. The tool sees your work in real time.
SearXNG answers the other half. It is a metasearch engine you host yourself, aggregating results from up to 260 search services, and its documentation carries the tagline "Search without being tracked." with the promise that users "are neither tracked nor profiled" (SearXNG docs). One Docker container, one URL, zero keys. The missing piece was the MCP layer between it and the editor. MCP describes itself as "an open-source standard for connecting AI applications to external systems", and a stdio server is the smallest such layer there is.
The server registers seven tools, each with a Zod schema; the search tools share a hard result cap: max_results defaults to 10 and stops at 50.
- search: web results with title, URL and snippet per hit.
- fetch_content: the article body of a page, converted to Markdown.
- image_search, news_search, video_search, music_search: the same result shape, one category each.
- list_engines: the engines and categories enabled on the connected instance, so a search can target what actually exists there.
stdio is the transport in the box: the client spawns the process, with no network and no auth surface. Since 0.3.0 an opt-in Streamable HTTP endpoint covers the remote case of one server serving many clients. It speaks only the current spec revision, stays stateless per request, authenticates a bearer token through a timing-safe comparison, validates Host and Origin, and refuses an unauthenticated start on a non-localhost bind. Shared team environments are the newest reason to want that shape: JetBrains Air promises MCP connections configured once and used by a whole team and its agents.
How a page becomes context#
The interesting tool is fetch_content. Raw HTML is a poor payload for a context window: navigation, scripts and cookie banners wrapped around the actual text, all of it counted in tokens. The pipeline is Readability for article extraction, linkedom for parsing without a browser, and turndown for the Markdown conversion. What enters the model is readable text with its structure intact.
Its input is a URL chosen by whoever wrote the prompt: the user, a file the agent read, or a web page the agent fetched earlier. That makes fetch_content the tool an attacker would want most. Two failure modes come with the territory.
The first is SSRF, the class where an attacker makes your machine fetch a target of their choosing; OWASP's summary is that they "abuse functionality on the server to read or update internal resources" (OWASP). A URL like http://169.254.169.254/latest/meta-data/ is harmless in a browser and meaningful from inside the network the agent runs on. The second arrives by redirect: you validate https://example.com, it answers 302, and the hop lands on localhost. Validation at request time checks only the first URL. The guard in searxng-mcp-server therefore re-runs the private-range check on every redirect hop, so a chain that starts public and ends internal is rejected mid-chain.
Page content is a third, quieter channel: whatever fetch_content returns lands inside the model's context, where text behaves like instructions. Every fetched page arrives wrapped and flagged with an UNTRUSTED_WEB_CONTENT marker, so the client treats the payload as data first. The mechanics get their own article: what your coding agent reads when it fetches a page.
What publishing on npm now involves#
The package lives on npm, and publishing there has turned into a supply-chain exercise. Releases run through GitHub Actions with OIDC trusted publishing: a tag push triggers npm publish --provenance, which attaches attestations tying the artifact to the repository and workflow that built it (npm docs). No npm token sits in any secret store.
Quality gates run before anything leaves the machine: prepublishOnly executes lint, typecheck, tests and build, and CI holds a coverage gate at 95 percent lines on a baseline that only ratchets upward. Renovate updates dependencies under supply-chain presets, pinning GitHub Actions by digest and holding npm packages back until they reach a minimum release age. A transport test drives a full tools/call round-trip over an in-memory transport and asserts the untrusted-content marker on the way out. The changelog travels inside the npm tarball, next to the code it describes.
Two registry lessons cost an evening. The MCP registries (server.json for the official directory, plus smithery.yaml and glama.json for the listing services) each validate metadata differently, and one of them enforces a 100-character limit on the description field. The second lesson: a package name on npm is never fully yours. The registry shows a version 1.0.0 of searxng-mcp-server timestamped April 2026, months before this project existed; someone else published and removed it. That number is burned for good, so the roadmap jumps from 0.1.x to 1.0.1.
Where the interesting work was#
Not in the plumbing. Connecting a stdio server to SearXNG's JSON API is a weekend of ordinary TypeScript; the repository runs on Node 22.19 or newer and has no runtime dependencies beyond the protocol SDK and the parsing chain. The interesting work sat at the boundaries: which URL may be fetched, what happens at hop three of a redirect chain, how a page is marked so a model treats it as material rather than orders. Boundary work is invisible when it succeeds and expensive when it is missing.
The same pattern runs through this site: the support agent validates tool results with Zod before they re-enter the model, and the misalignment reports from OpenAI read as one long argument for treating every channel into a context as untrusted input. Search was the last unguarded surface in my own toolchain.
What the repository shows#
Everything claimed above is checkable. The GitHub repository carries the coverage thresholds in vitest.config.ts, the release workflow with its OIDC permissions, the renovate presets, and a design document that explains the guards. The case study on this site adds the architecture in one place: searxng-mcp-server. If you run your own SearXNG, the server is one line away: npx -y searxng-mcp-server with SEARXNG_URL pointing at your instance.
Search is one half of agent infrastructure. The other half is how the task itself gets driven, which I compare across Claude Code, Codex, and ZCode in goals and workflows.