mcp-searxng-relay

by littleoffice

Not rated
GitHub

About

Hardened MCP web search through your own SearXNG — bearer auth, per-identity audit logs, SSRF-protected fetching, reproducible container builds.

Details

Author
littleoffice
Categories
Search, Other, Security

Setup

Install mcp-searxng-relay in your MCP client (Claude Desktop, Cursor, Windsurf, and others).

Repository: https://github.com/littleoffice/mcp-searxng-relay

Follow the installation instructions in the repository README, then restart your MCP client.

A Model Context Protocol (MCP) server giving AI agents web search and URL fetching through your own self-hostedSearXNGinstance — built for environments where search must stay on approved infrastructure and every query must be auditable. No third-party search APIs, no external data brokers; queries never leave infrastructure you control.

Who this is for.Teams running AI agents in corporate or government environments where outbound search is restricted, monitored, or both — and where "we use a hosted search API" is not an acceptable answer. The project prioritizes a defensible security posture and a clean audit trail over breadth of features.

Companion project.This relay is designed to be deployed alongsidesearxng-helm, a hardened Helm chart for SearXNG on Kubernetes (rootless, read-only rootfs, deny-by-default NetworkPolicies, cosign-signed). The chart deploys both SearXNG and this relay as a pair; see its README for the full infrastructure security story. The relay also ships minimal standalone K8s manifests for quick testing — seeKubernetesbelow.

This MCP server supports both thestdiotransport (for local use with Claude Desktop and similar clients) and theStreamable HTTPtransport (for networked or containerised deployments).

- Features
-
Requirements

- Enabling JSON format in SearXNG

- Docker (recommended)
-
Docker Compose
-
Building the container image
-
Kubernetes

- Token file format
-
Session modes

- Tuning the session janitor

- searxng_web_search
-
searxng_read_url
-
searxng_url_metadata
-
searxng_session_sources

- Health endpoint
-
--healthcheckCLI flag
-
Graceful shutdown
-
HTTP server timeouts

- A running SearXNG instance with the JSON output format enabled
- Go 1.26+ (for building from source) or Docker

Add the following to your SearXNGsettings.yml:

docker run -d \ -e SEARXNG_URL=https://your-searxng-instance.example.com \ -e MCP_PORT=8080 \ -e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \ -p 8080:8080 \ ghcr.io/littleoffice/mcp-searxng-relay:latest
services: mcp-searxng: image: ghcr.io/littleoffice/mcp-searxng-relay:latest restart: unless-stopped environment: SEARXNG_URL: https://your-searxng-instance.example.com MCP_PORT: "8080" MCP_AUTH_TOKEN: your-strong-random-token ports: - "8080:8080"

Compute the two reproducibility inputs once, then choose your build tool:

SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct HEAD)" SERVER_VERSION="$(git describe --tags --always)"
docker buildx build \ --build-arg SERVER_VERSION="${SERVER_VERSION}" \ --build-arg SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH}" \ --output type=docker,rewrite-timestamp=true \ -t mcp-searxng-relay:"${SERVER_VERSION}" .
podman build \ --build-arg SERVER_VERSION="${SERVER_VERSION}" \ --build-arg SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH}" \ --timestamp "${SOURCE_DATE_EPOCH}" \ -t mcp-searxng-relay:"${SERVER_VERSION}" .

The multi-stage build compiles the binary on a digest-pinnedgolang:1.26.6-trixiebuilder and copies only the static binary and CA certificates into ascratchruntime image.

Reproducibility.Given the same source commit andSOURCE_DATE_EPOCH(canonically the commit's own timestamp), either invocation produces a byte-identical image — verifiable viadocker save <image> | sha256sumorpodman save <image> | sha256sum. The toolchain is pinned by content digest, the module graph is frozen bygo.sum, and the build sets-trimpath,-buildvcs=false,-buildid=, and-Wl,--build-id=noneso neither paths, VCS state, nor link-time build IDs leak into the binary. BuildKit'srewrite-timestampand Podman's--timestampboth pin all layer file timestamps to the same value so the image envelope is reproducible, not just the binary inside. Seesupply-chain.mdfor the full provenance statement and verification steps.

Note that Docker and Podman use slightly different on-disk manifest encodings, so images built with one and saved through the other will not have matching SHA-256s even when functionally identical. Pick a build tool and stick with it for cross-machine reproducibility checks.

For production, usesearxng-helm.The chart deploys SearXNG and this relay together with a locked-down security context, deny-by-default NetworkPolicies, Secret-managed credentials, and cosign-signed releases. It is the reference deployment for the threat model this relay is built for. See its README for the full infrastructure security story, themcpRelayvalues block, and the GitOps / external-secret-store integration notes.

Minimal standalone manifestsare included indeploy/kubernetes/for quick cluster testing without a Helm release:deployment.yamlwith a locked-downsecurityContext,service.yaml,kustomization.yaml, andsecret.example.yamlas a template forMCP_AUTH_TOKEN_FILE. These are intentionally minimal — single replica, no Ingress, no NetworkPolicy — and are a starting point, not a hardened deployment. Apply withkubectl apply -k deploy/kubernetes/after creating a real Secret out-of-band fromsecret.example.yaml(copy tosecret.yaml, fill in tokens, apply once; it is deliberately not listed inkustomization.yamlso a re-apply cannot roll a real Secret back to the placeholder values). The full deployment-shape, token-rotation, and external-secret-store guidance is indeploy/kubernetes/README.md.

All configuration is via environment variables. The server will refuse to start ifSEARXNG_URLis not set. At least one ofMCP_AUTH_TOKEN/MCP_AUTH_TOKENS/MCP_AUTH_TOKEN_FILEis required whenMCP_PORTis set.

¹ HTTP mode requiresat least oneof the three auth-token variables. They can also be combined: later sources override earlier ones if the same digest appears in more than one. All tokens are independently validated against a 32-character minimum.

When usingMCP_AUTH_TOKEN_FILE, each non-comment line isidentity:token. The split is on the first:, so tokens may contain colons; identities may not. Identities are arbitrary strings used only for log correlation — typically a username, agent name, or service account label.

# This is a comment. alice:7f3a8c2e9b1d4f6a0c8e2b9d4f6a0c8e2b9d4f6a0c8e2b9d4f6a0c8e2b9d4f6a bob:0e1d2c3b4a596877665544332211ffeedccbbaa998877665544332211ffeedc service-ci:9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba # Identity rotation: both lines below are accepted for "alice" until # the old one is removed. Useful for zero-downtime token rotation. alice:newtokenvaluefor32charsminimum0123456789abcdef0123456789abcdef

Set the file mode to0600and place it ontmpfs(or a Docker secret / Kubernetes projected volume) if your threat model includes other users on the host.

The MCP Streamable HTTP transport is stateful by default: the SDK assigns a session ID oninitialize, the client echoes it on every subsequent request, and the SDK looks it up in an in-memory map. When the server restarts, that map is rebuilt empty — the client's old session ID returns 404, and many MCP clients fail to re-initialize automatically despite the spec requiring it. The result is "I redeployed and my agent is stuck until I restart it."

For audit correlation in stateful mode, every tool-call log line carries bothidentity(which token authenticated the request) andsession_id(which initialize handshake the request belongs to). Thesession_idconnects tool calls back to the"session initialized"log line for the same session — that's where the client's identity is recorded at handshake time. Idle sessions are reaped afterMCP_SESSION_MAX_AGEby a background janitor (defaults to 7 days); sessions cleanly closed by the client (DELETE) are tracked too and freed immediately.

In stateless mode thesession_idfield is still present and stable across requests from one client, but the binding is weaker: the SDK uses whatever value the client sends inMcp-Session-Idwithout validating it (only generating a fresh one if the client didn't supply any). This means an authenticated client could, in principle, forge anothersession_idof its choice — which is harmless for honest clients but means you can't treatsession_idas a server-verified attribute.identityremains server-validated in both modes, and is the canonical join key when forgery-resistance matters.

If you don't want client-assertedsession_idshowing up in your logs at all, setmcp.ServerOptions.GetSessionIDtofunc() string { return "" }inserver.go:buildMCPServer. The SDK will then omit theMcp-Session-Idresponse header andreq.Session.ID()returns empty for every request — true "sessionless" mode. Not exposed as an env var because the use case is narrow.

The two janitor knobs serve different purposes and are worth understanding before changing the defaults:

-

MCP_SESSION_MAX_AGEis apolicysetting. It caps how long any one session is allowed to live. Lower it (e.g.24h) when your environment rotates auth tokens daily — sessions older than the rotation period are using a token that no longer exists in the table, so reaping them forces a clean re-handshake with the current one. Lower it further for compliance frameworks that require periodic re-authentication. Raise it (e.g.720h/ 30d) for batch or scheduled agents that legitimately go idle for long stretches.

MCP_SESSION_JANITOR_INTERVALis amechanismsetting. It controls how often the cleanup pass runs. Shorter intervals catch expired sessions sooner at the cost of a small amount of mutex contention; longer intervals are cheaper but allow more overshoot pastMCP_SESSION_MAX_AGE. The default of15mmeans a session might live up to 15 minutes past its max age before being closed — fine for the policy "approximately a week" but worth lowering if your max age is itself short.

If you don't see the session cap (mcp_active_sessionsin/metrics) climbing under load, the defaults are working and there's nothing to tune.

Execute a web search and return titles, URLs, and snippets.

{ "query": "fusion energy breakthrough", "categories": "news", "language": "en", "time_range": "month", "num_results": 5 }

Output shape.Each result is rendered as a text block of the form:

Title: Example article title URL: https://example.com/article Snippet: First sentence or two of the page… Engines: google, bing, duckduckgo

TheEnginesline is omitted when SearXNG didn't return the field (older SearXNG versions, or results from a single-engine configuration). The list reflects the engines that returned this URL, in the order SearXNG provides them. No score is computed on top — the agent is free to read engine count as a corroboration signal or ignore it.

Fetch a URL and return its content. Handles HTML (converted to structured Markdown), PDF (text extracted viapdf_oxide), Office documents (DOCX, XLSX, PPTX, plus legacy DOC, XLS, PPT — converted to Markdown viaoffice_oxide), plain text (charset-decoded), and images (JPEG, PNG, GIF, WebP returned as MCPImageContentblocks for vision-model consumption — SVG is intentionally excluded, since it is more useful to the model as text than as a base64-encoded binary blob). Caches results by default; image responses bypass the text cache.

Long documents are paginated. Each response returns a window of at most 100,000 characters of the extracted text; when there is more, the response ends with a notice like[content truncated — showing chars 0-100000 of 348211; call searxng_read_url again with start_index=100000 to continue]. The full extracted text (up toMAX_EXTRACTED_CHARS) is cached on the first fetch, so follow-up pages are cache hits and cost no upstream request. Offsets in the notice are exact — the agent echoes them back verbatim; the server snaps any offset that would split a multibyte character and guarantees each page advances, so following continuation hints always terminates.

PDF text is delimited by--- [PDF page N of M] ---marker lines, one per page, so agents can answer "what's on page 47", cite page numbers, and orient themselves inside any pagination window. The markers are advisory: they sit inside the untrusted content fence, and a malicious PDF can embed lookalike text (seeSECURITY.md). Office documents get no page markers — DOCX has no intrinsic pages (pagination is computed at render time, not stored in the file), so the Markdown headings preserved by the converter are the navigational anchors there; PPTX slides and XLSX sheets surface as heading breaks.

Both pagination parameters are ignored for image URLs, which are returned whole as image content blocks.

{ "url": "https://example.com/article", "force_refresh": true }

Example — continue reading a long document from where the last response stopped:

{ "url": "https://example.com/big-report.pdf", "start_index": 100000 }

Fetch only the structured metadata for a URL — title, author, publish date, language, site name, description, image, categories, and tags — without returning the page body. For PDFs,page_countis also returned, so an agent can gauge whether a candidate is a 3-page memo or a 400-page report before committing to a full read (it is deliberately absent for Office documents: DOCX has no intrinsic page count, since pagination is computed at render time). Roughly an order of magnitude cheaper in tokens thansearxng_read_url, and intended as a triage step before committing to read a candidate URL in full. Results are cached and the cache is shared withsearxng_read_url: a metadata fetch followed by a content fetch (or vice versa) costs one upstream HTTP request, not two.

Example — triage three candidates before reading one in full:

{ "url": "https://example.com/article-a" } { "url": "https://example.com/article-b" } { "url": "https://example.com/article-c" }

Output shape.A JSON object with the curated metadata fields. Fields the extractor could not populate are omitted rather than rendered as empty strings ornull, so the response is variable-shape; at minimumurlis always present:

{ "url": "https://example.com/article", "title": "Example article title", "author": "Jane Doe", "description": "First paragraph or meta-description.", "site_name": "Example.com", "date": "2026-03-12T14:23:00Z", "language": "en", "image": "https://example.com/article/cover.jpg", "categories": ["technology"], "tags": ["distributed-systems", "go"] }

When to use this vssearxng_read_url.Usesearxng_url_metadatato triage which of several candidate URLs is worth reading in full, for citation building, and for date/author/site verification when the body itself is not needed. Usesearxng_read_urlonce you've committed to reading a specific URL. The two tools share a cache, so triaging with metadata first and then reading the chosen URLs in full does not double the upstream load.

Return the URLs this relay has fetched for the calling identity, newest first, byte-exact.

The problem it addresses is not retrieval — it is transcription. A model composing a final answer containing ten URLs is reproducing them from context that scrolled past thousands of tokens earlier, token by token, with nothing to check against. That step happens after the last tool call, in a message no MCP server ever sees, so nothing on the wire can validate it. This tool moves the correct bytes back to the position immediately before the answer is written, which is the only place a server can help. The same list answers the second failure — a plausible-looking URL for a page that was never fetched at all — because a URL absent from the list was not fetched.

Output shape.A JSON object, one row per distinct URL rather than per fetch:

{ "note": "URLs below are byte-exact as fetched by this relay …", "total_fetches": 12, "returned": 9, "elided": 0, "sources": [ { "url": "https://www.example.com/psu/flex-atx-350w", "requested_url": "https://example.com/psu/flex-atx-350w", "title": "FlexATX 350W review", "read": "full", "outcome": "ok", "chars_read": 18422, "total_chars": 18422, "fetched_at": "2026-08-18T09:14:02Z", "tool": "searxng_read_url", "seq": 12, "fetches": 2 } ] }

readis the field that distinguishes a source an agent may claim to have read from one it merely looked at:full(the whole extracted text),partial(one pagination window of a longer document),metadata(searxng_url_metadataonly — the body was never returned),image, ornone(the fetch failed). Failed fetches appear withoutcome: "error"and the error text; omitting them would make a 404 indistinguishable from a URL never tried.requested_urlappears only when a redirect moved the URL, and the post-redirecturlis the one to cite — it is the only URL in the exchange the model never saw and so cannot reconstruct at all.

History scope.Per caller (identity + session ID), in-memory, last 50 entries with older ones reported as anelidedcount. Keying on identity as well as session matters: underMCP_STATELESS=true, and in the documented sessionless configuration, the session ID is client-asserted or empty, and keying on it alone would let one caller read another's fetched URLs. History does not survive a restart and does not cross replicas — it only needs to outlive the conversation, and shared storage would widen it to "everything this identity ever fetched", which makes the list worse for its purpose rather than better. For multi-replica deployments, configure session affinity at the ingress.

Fence encoding.This response is wrapped in a<sec:fence>carryingencoding="cdata". The ordinary escaped fence turns every&into&amp;, which for a payload whose entire purpose is byte-exact URLs is a self-inflicted corruption channel — and query-string-dense URLs hit it on nearly every entry. The signature still covers the pre-encoding bytes exactly as on the escaped path;encodingis inside the canonical signed form, so a verifier can tell how to recover them and an attacker cannot flip it. The rating staysuntrusted: the relay authors the assertion ("I fetched X at T") but not the values — titles come from fetched pages — and marking it trusted would let any site launder text into a trusted fence by being fetched once.

Add the following to yourclaude_desktop_config.json:

{ "mcpServers": { "searxng": { "command": "/path/to/mcp-searxng-relay", "env": { "SEARXNG_URL": "https://your-searxng-instance.example.com" } } } }

NoMCP_PORTorMCP_AUTH_TOKENneeded in stdio mode — the process communicates over stdin/stdout and is not network-accessible.

If you prefer to run the server as a persistent background process rather than spawning it per-session:

{ "mcpServers": { "searxng": { "type": "http", "url": "http://localhost:8080", "headers": { "Authorization": "Bearer your-strong-random-token" } } } }

Note:Run the HTTP server behind a TLS-terminating reverse proxy (nginx, Caddy, Traefik) in any non-local deployment. The server itself speaks plain HTTP.

SEARXNG_TOKENSlets several relays share one SearXNG instance while each reaches only its own engines — useful when separate teams have separate internal search backends and must not read each other's.

Mark the engine private in SearXNG'ssettings.yml.tokens:gates who may select the engine; the engine's own credential (api_keyor equivalent) is what limits what it can see:

engines: - name: teama-confluence engine: json_engine base_url: https://confluence-a.corp/rest/api/search api_key: "<team A service account token>" shortcut: cfa categories: [general] disabled: true tokens: ['ENGINE-TOKEN-A']

Then give each relay only its own token:

docker run -d \ -e SEARXNG_URL=https://searxng.corp \ -e SEARXNG_TOKENS=ENGINE-TOKEN-A \ -e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \ -e MCP_PORT=8080 -p 8080:8080 \ ghcr.io/littleoffice/mcp-searxng-relay:latest

- disabled: trueis not redundant.Without it the engine sits in its category and fires on every ordinary web search, adding latency and putting internal results in front of unrelated queries. Naming an engine explicitly through theenginessearch parameter builds the engine reference directly and is unaffected by the disabled-by-default state, so the engine still runs when actually asked for.
- The boundary is enforced by SearXNG, not by this relay.SearXNG resolves the full engine reference list — categories, theenginesparameter, and!bangsyntax inside the query string alike — and only then drops engines whosetokens:are unsatisfied. A filter in this process on theenginesparameter would miss the bang path; presenting the wrong token cannot be worked around from the agent side.
- Tokens are per-process, not per-caller.Every identity in the token table shares them. Where two groups of callers must be separated, run one relay per group. The identities inMCP_AUTH_TOKEN_FILEare audit labels, not an authorization boundary.
- tokensas a query parameter is undocumented upstream.SearXNG's Search API docs describe engine tokens only as a Preferences-page setting. That they are also accepted as a request parameter follows fromwebapp.pre_requestmergingrequest.argsinto the preferences it parses. It is long-standing behaviour, but pin your SearXNG image by digest and keep a test asserting the negative case — a search naming another team's engine without its token returns no results.
- Search only.searxng_read_urldoes not use these tokens. If the relay must be kept away from another team's internal hosts, that isFETCH_ALLOWED_HOSTS/FETCH_ALLOWED_CIDRS, set per relay.

Prompt injection.Both tools return content sourced from the open web — titles, snippets, and page bodies written by third parties. A malicious site can embed instructions in that content (including in invisible or hidden elements) in an attempt to hijack the agent's behaviour, cause unexpected tool calls, or exfiltrate conversation context. This is the primary runtime risk when using this server with an LLM agent.

This server implements the prompt-fencing specification fromPeh, S. (2025), "Prompt Fencing: A Cryptographic Approach to Establishing Security Boundaries in Large Language Model Prompts" (arXiv:2511.19727). Every tool response is wrapped in a<sec:fence>element with structured metadata, preceded by a short awareness preamble that tells the consuming model how to interpret the boundary:

<sec:fence xmlns:sec="http://promptfence.org/security/1.0" signature="MEYCIQDx5w2l7..." kid="3f9a1c7e2b4d8056" nonce="a9f7e2c14b8d6f31..." rating="untrusted" source="https://example.com/article" timestamp="2026-05-07T14:23:00Z" type="content" version="1.0"> <extracted content> </sec:fence>

- Key identification and format versioning.Every fence carrieskid— the same fingerprint reported by/fence/public-key— andversion.kidlets a verifier holding several keys select one instead of trial-verifying against all of them, which is what makes key rotation workable: fences signed by an outgoing key stay in the context window and keep arriving while the new key rolls out, and withoutkid"signed by a key I have since retired" and "forged" both present as "nothing in my set verifies this". Both attributes are inside the canonical signed form, so an attacker cannot rewritekidto name a key they control, or downgradeversionto reach an older verification path, without invalidating the signature.
- Boundary-escape protection.Each fence carries a 128-bit randomnonce(fromcrypto/rand). An attacker who controls fetched content cannot guess the nonce, so they cannot forge a closing tag that prematurely ends the fence or open a new "trusted" fence inside it. The awareness preamble tells the consuming model to honour only the boundary identified by the per-response nonce.
- Forward-compatible signatures.Every fence carries an Ed25519 signature so a future fence-verifying client (or an external verifying gateway) can authenticate that fenced content was emitted by this specific server process. The signed bytes are a domain-separated, length-prefixed serialisation —"PromptFence/v1.0" || 0x00 || uint64_be(len(content)) || content || canonical_metadata— fed to PureEd25519 per RFC 8032 §5.1 (the signing operation hashes the message internally with SHA-512; we do not pre-hash). This is a deliberate deviation from paper §4.3's literalEd25519(SHA-256(C || M))construction, which silently changes the security argument by feeding a 32-byte digest into a signature scheme that already hashes its input. The domain tag prevents cross-protocol signature confusion; the length prefix removes the boundary ambiguity a barecontent || canonical_metadataconcatenation would leave. Content is signed in its pre-XML-escape form, so a verifier xml-unescapes the parsed element body before verifying. The exact wire format is documented in thefence.gocomputeFenceSignatureandbuildFenceSigningInputcomment blocks.No MCP client currently verifies these signatures; they are present for forward compatibility.

- Without a verifier, the signatures provide no cryptographic guarantee. Boundary-escape protection comes entirely from the per-response nonce.
- The Prompt Fencing paper measured 100% prevention of direct injection in their experimental setting (n=300 attempts across two frontier models), but that result depends on model compliance with the awareness preamble. Smaller or specialised models may behave differently.
- Semantic attacks — where untrusted content tries topersuaderather thanimpersonate— are not addressed by any fencing scheme.

Public key.The Ed25519 public key for the running server is exposed atGET /fence/public-key(HTTP mode, unauthenticated — a public key is by definition not a secret). The startup banner prints the same key's fingerprint, so the two can be cross-checked. Thatfingerprintfield is also the value each fence carries as itskid, so a verifier can key its trusted-key set on it directly; the field is deliberately not renamed tokidin the endpoint response, since anything already parsing it expectsfingerprint.

Fence signing key.By default the signing key is generated fresh at every process start, so the fingerprint changes across process lifetimes. That default is deliberate: without an external trust anchor (a CA, a published JWK set, a KMS), persisting a key would imply a continuity property this server cannot deliver on its own.

No reviews yet — be the first

Sign in to leave a review

Use Google, GitHub, or an email account so ratings stay tied to real people.

Email sign in

No reviews posted yet.