A CLI Web Search utility designed to be easily used by Large Language Models (LLMs) like Claude Code, as well as human users. It supports structured outputs (JSON) for agents and readable outputs (text) for humans.
- Brave Search (via Brave Search API): Best for getting a list of fast, accurate links and snippets. Supports pagination (
--offset), safesearch, and time-based filtering. - Perplexity (via OpenRouter): Best for getting an intelligent, synthesized answer using online sources. Supports model selection, customizable temperature, and citation formatting.
- LLM Context (via Brave LLM Context API): Returns pre-extracted, relevance-scored web content (smart chunks) optimized for LLM consumption. Extracts text, tables, code blocks, and structured data from multiple sources in a single API call — no scraping needed. Ideal for RAG pipelines and AI agent grounding.
- Both (Concurrency): Runs both Brave and Perplexity searches in parallel, returning a merged outcome (a synthesized answer alongside raw source links).
- Fetch: A built-in web scraper that downloads a given URL, parses it, and returns the cleaned text without HTML tags. Perfect for reading full articles when a snippet isn't enough. Uses curl_cffi for Chrome TLS fingerprint impersonation to access strict anti-bot sites (Facebook, LinkedIn, Medium, etc.), with full Chrome 146 headers and a Google Referer. Includes automatic FlareSolverr fallback for Cloudflare-protected pages and SPA shell detection that identifies JS-heavy pages (empty mount points, script-heavy HTML with little text) and auto-falls back to headless rendering. HTML extraction now prefers
main/article/role="main"content when present to reduce layout noise. Non-HTML text responses are decoded directly, and supported binary documents (PDF,DOCX,PPTX,XLSX, etc.) can be converted to Markdown via optional MarkItDown integration. Twitter/X URLs are automatically intercepted and routed through the fxtwitter API to retrieve tweet content, author info, and engagement metrics without login. Forum threads on Discourse (for examplelinux.do), Reddit, and V2EX are read through their JSON APIs with a structuredreplieslist. Pages that stay blocked fall back to Brave LLM Context passages for the same URL and then to the newest Wayback Machine snapshot (see Fetch fallback chain). - Perplexity Verify (
perplexity-verify): Checks up to 10 claims one by one and returnssupported,contradicted, ornot_foundfor each, with sources mapped onto the real citation URLs Perplexity returned so they can be re-opened withfetch.
| Engine | Use it for |
|---|---|
brave |
Finding links and short summaries. Start research here. |
llm-context |
Reading long documents as pre-extracted passages, or getting content when the original site blocks fetch. |
fetch |
Reading the original page. |
perplexity |
Final cross-checking only; do not use it as the primary source. |
perplexity-verify |
Checking a short list of conclusions claim by claim. |
Search-style engines also normalize their output for downstream agents:
- Brave results include
hostname, strip inline HTML tags, decode HTML entities, and deduplicate repeated URLs. They return the top 8 results by default (result_limitoverrides this). - Every
brave,both, andllm-contextresult has a singlepublished_atdate (YYYY-MM-DD, ornullwhen unknown). Withfreshness, results dated before the window are removed and reported infreshness_filtering. - LLM Context results include
hostname,published_at, cleaned snippet chunks, and any unique shortsnippetfrom Brave. By default they return at most 8 results with at most 5 snippets each (result_limit/snippet_limitoverride this). The raw, mixed-formatagelist is returned only withverbose. The duplicate top-levelsourcescatalog is omitted. - Text addressed to AI readers (for example
[CRITICAL INSTRUCTIONS FOR ALL AI ASSISTANTS ...]blocks, "ignore previous instructions", and linux.do's fixed anti-AI notice) is removed from titles, descriptions, snippets, fetched content, and forum replies. Each removal is reported in the item'sinjection_suspectedlist with the originaltext, itsfield, characterstart, and the matchingrule. - Perplexity responses preserve normalized
citationswhen the upstream model returns them. bothpreserves partial-failure visibility throughbrave_errororperplexity_errorfields when one backend fails, and forwardsperplexity_citationswhen available.- Search results also carry stable positional metadata such as
rank,result_count, andbrave_result_countwhere relevant.
- Clone the repository:
git clone https://github.com/jamie950315/ccsearch.git cd ccsearch - Install Python dependencies:
pip install -r requirements.txt
- Copy the example configuration:
Modify
cp config.ini.example config.ini
config.inito adjust rate limits, models, filtering, or retry logic. - Add it to your CLI
$PATHfor global use:(Ensuremkdir -p ~/.local/bin ln -sf $(pwd)/ccsearch.py ~/.local/bin/ccsearch
~/.local/binis in your environment's PATH so you can just runccsearchfrom anywhere) - Set your Environment Variables:
- For all Brave-backed engines:
export BRAVE_SEARCH_API_KEY="your_brave_search_plan_key" - Extra Brave Search keys rotate automatically for any count, not just three. Keep the original key first, then add
BRAVE_SEARCH_API_KEY_2,BRAVE_SEARCH_API_KEY_3,BRAVE_SEARCH_API_KEY_4, and so on. You can also put several keys in one comma-separatedBRAVE_SEARCH_API_KEYor inBRAVE_SEARCH_API_KEYS. brave, the Brave side ofboth, andllm-contextall preferBRAVE_SEARCH_API_KEY. The legacyBRAVE_API_KEYremains a compatibility fallback only when no Search key is set. Each live Brave request uses the next key; cache hits do not rotate, and retries of the same request keep the same key.- For Perplexity:
export OPENROUTER_API_KEY="your_openrouter_api_key"
- For all Brave-backed engines:
requirements.txtincludes MarkItDown's PDF dependencies, so PDF-to-Markdown conversion works in the standard install.- For additional Office formats, install the extra formats you need:
pip install 'markitdown[docx,pptx,xlsx]' - If a requested converter is unavailable or fails,
fetchreturns a structured error payload instead of low-quality extracted text.
# Brave Search (Text Output)
ccsearch "latest React documentation" -e brave --format text
# Brave Search (2nd page of results using offset)
ccsearch "latest React documentation" -e brave --format text --offset 1
# Perplexity Synthesis (Text Output)
ccsearch "What is the difference between Vue 3 and React 18?" -e perplexity --format text
# LLM Context (Pre-extracted smart chunks for grounding)
ccsearch "React hooks best practices" -e llm-context --format text
# Both Engines Concurrently (Merged Text Output)
ccsearch "What is the new React compiler?" -e both --format text
# Fetch a webpage's clean text
ccsearch "https://react.dev/blog/2025/10/07/react-compiler-1" -e fetch --format text
# Fetch a PDF (requires optional MarkItDown install for Markdown conversion)
ccsearch "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" -e fetch --format json
# Fetch a tweet (auto-routed via fxtwitter API)
ccsearch "https://x.com/jack/status/20" -e fetch --format text
# Fetch a Twitter/X user profile
ccsearch "https://x.com/NASA" -e fetch --format text
# Only results from the past month, from Taiwan, in Traditional Chinese
ccsearch "Cursor pricing" -e brave --freshness pm --country TW --search-lang zh-hant --format json
# Read only the passages about one topic, capped at 4,000 characters
ccsearch "https://flexprice.io/blog/cursor-pricing-guide" -e fetch --focus "Pro Plus included usage" --max-chars 4000 --format json
# Read a forum thread (Discourse, Reddit, V2EX) with up to 10 replies
ccsearch "https://linux.do/t/topic/2911949" -e fetch --max-replies 10 --format json
# Verify conclusions claim by claim
ccsearch -e perplexity-verify --claim "Cursor Pro Plus includes \$70 of third-party usage" --claim "Cursor Pro costs \$20 per month" --format json
# Run a mixed batch from JSON/JSONL with bounded concurrency
ccsearch --batch-file requests.json --batch-workers 4 --format json
# Keep only the top 3 results after host filtering/post-processing
ccsearch "OpenAI Responses API" -e brave --include-host developers.openai.com --limit 3 --format json
# Force FlareSolverr for a Cloudflare-protected page
ccsearch "https://some-cloudflare-site.com" -e fetch --format text --flaresolverr
# Inspect engine availability and current setup
ccsearch --list-engines --format json
ccsearch --doctor --format textCaches results by an exact hash of the query string. Subsequent identical queries return instantly without hitting the API.
# Cache the result for the default 90 days
ccsearch "React 19 release date" -e perplexity --cache
# Cache the result for a custom duration (e.g., 60 minutes)
ccsearch "React 19 release date" -e perplexity --cache --cache-ttl 60Cache files are stored in ~/.cache/ccsearch/ as JSON files keyed by MD5 hash of (query, engine, offset) plus any request options that change upstream results (freshness, country, search_lang, and a non-default max_replies). Cache hits return cached_at (UTC ISO 8601) with cache_status; --max-cache-age (max_cache_age in the API/MCP, in minutes) ignores entries older than that age and fetches fresh data. The default and maximum readable age is 90 days (129600 minutes). A smaller --cache-ttl shortens the freshness window. Beginning on day 91, result files are deleted by the hourly maintenance timer or the next cache cleanup pass. Run ccsearch --prune-cache --format json to enforce retention immediately.
Automatic cleanup scans run at most once per hour across CLI, API, and MCP processes sharing the same cache directory on systems with file locking. Explicit --prune-cache always runs a scan.
For the fetch engine, URLs are normalized before hashing so cache hits survive:
- tracking parameters such as
utm_*,fbclid,gclid, etc. - query parameter name reordering (the order of repeated values is preserved)
- fragment-only differences
- host casing and default port differences
For search-style engines, exact cache keys also normalize repeated whitespace so React hooks and React hooks reuse the same cache entry.
Extends exact caching with embedding-based similarity matching. If a semantically equivalent query was previously cached, the result is returned without a new API call — even if the wording differs.
Requires fastembed (pip install fastembed). Uses the BAAI/bge-small-en-v1.5 model (384-dim, ~40MB, runs entirely locally via ONNX).
# First search — result is cached and embedding is stored
ccsearch "Python asyncio event loop tutorial" -e brave --semantic-cache --cache-ttl 60
# Semantically similar query — returns the cached result (no API call)
ccsearch "Python asyncio event loop guide" -e brave --semantic-cache --cache-ttl 60
# Output includes: "_from_cache": true, "_semantic_similarity": 0.9434Adjusting the similarity threshold (default 0.9, range 0.0–1.0):
# Stricter: only very close paraphrases hit the cache
ccsearch "Python asyncio tutorial" -e brave --semantic-cache --semantic-threshold 0.95
# Looser: broader topic matching (useful for exploratory queries)
ccsearch "Python asyncio tutorial" -e brave --semantic-cache --semantic-threshold 0.85How it works:
- On a cache miss, the query is embedded and stored alongside the result in
~/.cache/ccsearch/semantic_index.json - On a subsequent query, the new embedding is compared against all stored embeddings using cosine similarity
- If the best match exceeds the threshold, the cached result is returned with
_semantic_similarityset - Falls back to exact-match cache first (faster), then semantic search, then live API call
--semantic-cacheimplies--cache— no need to pass both flags
Notes:
- Semantic cache is off by default and should stay off for time-sensitive research. Candidates must contain exactly the same numbers as the new query, so "Opus 5.5 pricing" never reuses "Opus 5 pricing", and they must share the same
freshness/country/search_langoptions. - Applies to
brave,perplexity,both, andllm-contextengines. Thefetchandperplexity-verifyengines always use exact matching. - If
fastembedis not installed, a warning is printed and the tool continues without semantic matching. - The same
--cache-ttlapplies to both caches. It cannot exceed 90 days, and semantic-index entries are removed when their result files are deleted.
Benchmark results (Brave engine, 6 query pairs):
| Condition | Avg. latency |
|---|---|
| Cold API call | ~1,350ms |
| Semantic cache hit | ~360ms |
| Exact cache hit | ~95ms |
Semantic cache delivers ~73% faster responses vs. cold API calls for similar queries.
ccsearch can also be accessed remotely via the built-in HTTP API server (api_server.py), allowing other LLMs and services to use ccsearch over the network.
# Start the server (default port 8888)
python3 api_server.py
# Or via systemd (current server deployment)
sudo systemctl start ccsearch-apiAll endpoints except /health require an X-API-Key header. The API key is resolved in this order:
CCSEARCH_API_KEYenvironment variable.api_keyfile in the project directory (auto-generated on first run with0600permissions)
Health check (no auth required).
curl https://ccsearch.example.com/health
# {"status": "ok", "service": "ccsearch-api"}Main search endpoint. Accepts a JSON body with the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
query |
string | Yes | Search query, URL (for fetch engine), or newline-separated claims (for perplexity-verify) |
engine |
string | Yes | brave, perplexity, both, fetch, llm-context, or perplexity-verify |
claims |
list | No | Claims for perplexity-verify (up to 10), instead of query |
cache |
bool | No | Enable result caching (default: false) |
cache_ttl |
int | No | Cache freshness in minutes (default/max: 129600, or 90 days) |
max_cache_age |
int | No | Ignore cache entries older than this many minutes |
semantic_cache |
bool | No | Enable semantic similarity cache (default: false) |
semantic_threshold |
float | No | Cosine similarity threshold (default: 0.9) |
offset |
int | No | Pagination offset (brave and both only) |
result_limit |
int | No | Results returned for brave, both, and llm-context (default: 8) |
freshness |
string | No | pd, pw, pm, py, or YYYY-MM-DDtoYYYY-MM-DD for brave, both, llm-context; dates must exist and the start must not follow the end |
country |
string | No | Two-letter country code (for example US, TW) or ALL |
search_lang |
string | No | Search language code (for example en, ja, zh-hant) |
snippet_limit |
int | No | Snippets per llm-context result (default: 5) |
flaresolverr |
bool | No | Force FlareSolverr for fetch engine (default: false) |
format |
string | No | Fetch body: text (default, content only) or chunks (chunks only) |
focus |
string | No | Fetch only the passages most relevant to this topic |
focus_k |
int | No | Number of focus passages (default: 5) |
max_chars |
int | No | Truncate fetched content; adds truncated: true and total_chars |
max_replies |
int | No | Forum replies for Discourse/Reddit/V2EX threads (default: 30); 0 returns no replies and skips optional reply requests |
verbose |
bool | No | Include hashes, offsets, section paths, outbound links, transport headers, and raw ages |
include_hosts |
list/string | No | Host allow-list for brave, both, and llm-context |
exclude_hosts |
list/string | No | Host deny-list for brave, both, and llm-context |
All single-query responses now include:
cache_status: one ofdisabled,exact,semantic, ormisscached_at: when the served cache entry was written (UTC), ornullfor live resultsduration_ms: end-to-end execution time for the request
Search-style engines also expose lightweight source-host summaries:
- Brave / LLM Context:
result_hosts,result_host_count - Perplexity:
citation_hosts,citation_host_countwhen citations are available - Both:
brave_result_hosts,brave_result_host_count,perplexity_citation_hosts,perplexity_citation_host_count
For brave, both, and llm-context, you can also apply host filters at request time:
include_hosts: only keep results from these hostsexclude_hosts: drop results from these hostshost_filtering: response metadata showing the normalized filters that were applied and how many results were removedresult_limit: trim the remaining result list to a stable top-N after filtering, withresult_limitingmetadata describing the applied limit and removed count
For fetch responses, the default JSON payload is compact:
content: the extracted main text. Chunks are not returned by default, so the body is not duplicated. Withformat: "chunks", onlychunksis returned (nocontent).ok:truewhen content was extracted;falsetogether witherrorotherwise. Callers must treat anyerroras a failed fetch even when transport metadata is present.served_from:direct,flaresolverr,site-api,llm-context, orarchive(nullon failure)attempts: every step tried, in order, as{"method", "status", "ms"}plushttp_status,site, ordetailwhen relevant. Statuses includeok,cf_challenge,http_error,transport_error,empty,spa_shell,no_match,no_snapshot,unavailable, anderror.fetched_at: when the page was retrieved (UTC);content_date: the page's own date (published_at, else modified time orLast-Modified) asYYYY-MM-DDsnapshot_date: Wayback snapshot time whenserved_fromisarchive;content_scope: "excerpts"whenserved_fromisllm-contexttitle,status_code,content_type,final_url(only when it differs fromurl),converted_viafor converted documents- Forum threads add
replies(author,created_at,content, andpost_number/score/depthwhere the site provides them),reply_count,returned_reply_count, andforum(platform, topic id) focusmetadata and a focusedcontentwhenfocusis set;truncatedandtotal_charswhenmax_charscut the contentinjection_suspectedwhen AI-directed text was removed
With verbose: true, fetch results also include content_sha256, content_word_count, content_length, etag, last_modified, filename, hostname, fetched_via, outbound_links with their counts and hosts, and full chunk metadata (chunk_id, char_start, char_end, relative_position, section_path, text_sha256, link counts, and list/table/code counts). Compact chunks keep index, type, text, section_title, and small structural hints such as heading_level, code_language, list_ordered, and table_headers.
Content hashes and chunk offsets describe the returned text after scrubbing, focus, and truncation. For chunk output, content_sha256 hashes chunk texts joined with a single newline. Transport fields such as content_length and etag describe the original response.
HTML extraction removes documentation chrome before choosing the main text: a single <main>/role="main" or <article> landmark wins over longer navigation sidebars, and side navigation, tables of contents, breadcrumbs, pagers, author boxes, and call-to-action blocks marked by whole class/id tokens are pruned. Repeated responsive-layout headings are merged and "Previous/Next post" links are dropped.
For HTML pages, fetch also extracts page metadata when available:
lang: page language from the root HTML tagdescription: page summary from standard or Open Graph meta tagsauthor: author metadata from common article meta tagspublished_at: publish date from common article meta tags, normalized toYYYY-MM-DDcanonical_url: canonical URL from<link rel="canonical">when it differs from the requested URL
When those HTML meta tags are missing, ccsearch also falls back to JSON-LD article schemas and prunes common non-content UI blocks such as cookie banners and newsletter popups before extracting the main text.
It also sniffs mislabeled HTML payloads (for example, pages served as application/octet-stream) so SPA fallback and metadata extraction still work on poorly configured sites.
For HTML pages, lists and tables are preserved in a Markdown-like form inside both content and chunks.
Code examples are preserved as fenced Markdown code blocks, and code chunks expose code_language when the page declares a recognizable language class such as language-python.
# Brave search
curl -X POST https://ccsearch.example.com/search \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"query": "React 19 new features", "engine": "brave"}'
# Perplexity synthesized answer
curl -X POST https://ccsearch.example.com/search \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"query": "What is the difference between Vue 3 and React 18?", "engine": "perplexity"}'
# Fetch a URL
curl -X POST https://ccsearch.example.com/search \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"query": "https://react.dev/blog", "engine": "fetch"}'
# With caching
curl -X POST https://ccsearch.example.com/search \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"query": "Python asyncio tutorial", "engine": "brave", "cache": true, "cache_ttl": 60}'Execute multiple search and fetch requests in a single round-trip.
| Field | Type | Required | Description |
|---|---|---|---|
requests |
array | Yes | List of request objects (see the dispatch rules below), plus any per-request options |
defaults |
object | No | Default options applied to each entry where they apply (for example engine, cache, result_limit, freshness, focus, max_chars) |
max_workers |
int | No | Maximum concurrent worker threads (defaults to [Batch].max_workers) |
dedupe_results |
bool | No | Replace repeated search-result URLs with references (default: true) |
Dispatch rules for each request:
- An entry with
urlis fetched (enginefetch). - An entry with
queryis searched with its ownengine, elsedefaults.engine, elsebrave. op: "search"orop: "fetch"overrides the inference.- An entry with both
urlandqueryand noop, or with neither, fails on its own; the other entries still run. defaults.engineapplies to search entries only; it never changes aurlentry. (For compatibility, aqueryentry whose engine resolves tofetchis still fetched.){"engine": "perplexity-verify", "claims": [...]}verifies claims.- Defaults fill only the options that apply to each entry's engine (search options for searches,
format/focus/max_chars/max_replies/flaresolverrfor fetches). Options set explicitly on an entry are validated strictly. - Every result reports the
engineit actually used.
The response includes:
results: per-request results in original ordercount,success_count,error_count,has_errorsduration_ms: total batch runtimemax_workers: effective concurrency useddeduped_count:deduped_request_count+deduped_result_countdeduped_request_count: identical requests reused instead of executed again (marked with_batch_dedupedand_batch_deduped_from)deduped_result_count: search results whose URL already appeared in an earlier entry; they are replaced by{"ref": "<url>", "see_index": n, "rank": r}pointing at the first entry that returned it. Brave-style results (brave,both) andllm-contextresults are deduplicated separately because their payloads differ.engine_counts: request count by engine
curl -X POST https://ccsearch.example.com/batch \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{
"defaults": {"freshness": "pm", "max_chars": 4000},
"requests": [
{"query": "Cursor pricing 2026"},
{"query": "Cursor Pro Plus usage", "engine": "llm-context"},
{"url": "https://cursor.com/docs/models-and-pricing", "focus": "Pro Plus included usage"},
{"url": "https://linux.do/t/topic/2911949", "max_replies": 10}
]
}'List available engines and their server-side capabilities.
curl https://ccsearch.example.com/engines \
-H "X-API-Key: YOUR_API_KEY"Each engine entry includes:
name,description,use_for,requirescategory(search,answer,context,hybrid,fetch, orverify)- defaults where relevant:
default_result_limit,default_snippet_limit,default_format,default_focus_k,default_max_replies,max_claims supports_search_options(freshness,country,search_lang)supports_offsetsupports_semantic_cachesupports_flaresolverrsupports_host_filtersupports_result_limitrequired_env_varsconfiguredconfigured_via
Invalid option combinations are rejected consistently across CLI, HTTP API, and MCP.
Examples: offset is only valid for brave / both, and flaresolverr is only valid for fetch.
Return runtime diagnostics without exposing secret values.
curl https://ccsearch.example.com/diagnostics \
-H "X-API-Key: YOUR_API_KEY"The response includes:
- dependency availability (
curl_cffi,fastembed,markitdown,mcp) - environment-key presence as booleans
- Brave key rotation state such as
key_count,round_robin, per-key RPS, andcombined_cap_rps(no secret values) - fetch runtime state such as
flaresolverr_configured,flaresolverr_mode, andextended_fallbacks - batch runtime defaults such as
max_workers quota.brave.keys[]: for each configured key (by position and non-secret fingerprint), the rate-limit windows from the most recent Brave response (window_seconds,limit,remaining,reset_seconds,unlimited) and when they were observed. The limiter cannot see traffic from other hosts, so these numbers come from Brave's own headers.quota.openrouter: live OpenRouter key usage (usage,limit,limit_remaining,rate_limit), cached for 60 seconds- the current engine list
A typical deployment runs the API server as a systemd service (for example ccsearch-api.service with Restart=always) whose unit loads the project's .env through systemd's EnvironmentFile= setting. The Python program does not load .env itself, so manual runs must export the variables first. Standby hosts can keep an up-to-date checkout with their services disabled until an explicit failover.
ccsearch-cache-prune.timer runs the checked-in systemd/ccsearch-cache-prune.service hourly so files that have reached day 91 are removed even when they are never requested again.
This service currently starts Flask's built-in server directly. It is suitable for a personal deployment but is not a production WSGI/ASGI setup; replacing it remains tracked in TODO.md.
sudo systemctl enable ccsearch-api # Enable on boot
sudo systemctl start ccsearch-api # Start
sudo systemctl status ccsearch-api # Check status
journalctl -u ccsearch-api -f # View logsExpose the service publicly through a reverse proxy or tunnel (for example Cloudflare Tunnel) rather than opening port 8888 directly; keep the API and MCP ports firewalled from the Internet. FlareSolverr is bound to 127.0.0.1:8191 by the checked-in compose file and is not publicly reachable.
mcp_server.py exposes ccsearch as an MCP (Model Context Protocol) server over both SSE and Streamable HTTP transport. It runs as an independent process alongside the Flask HTTP API, sharing the same ccsearch.py core and config.ini. In a systemd deployment, both units load the same .env; manual runs must export those variables themselves.
ccsearch.py (core search logic, shared)
├── api_server.py (Flask HTTP API, port 8888)
└── mcp_server.py (MCP server, port 8890, SSE + Streamable HTTP)
| Tool | Description | Parameters |
|---|---|---|
search |
Web search via brave/perplexity/both/llm-context engines | query, engine, offset, result_limit, freshness, country, search_lang, snippet_limit, include_hosts, exclude_hosts, verbose, cache, cache_ttl, max_cache_age, semantic_cache, semantic_threshold |
fetch |
Fetch a URL through the fallback chain | url, format, focus, focus_k, max_chars, max_replies, verbose, flaresolverr, cache, cache_ttl, max_cache_age |
verify |
Check claims one by one with Perplexity | claims, verbose, cache, cache_ttl, max_cache_age |
batch |
Execute multiple search/fetch requests in one call | requests, shared defaults for every option above, max_workers, dedupe_results |
engines |
List available engines, what each is for, and their defaults | none |
diagnostics |
Return dependency, runtime, and quota diagnostics | none |
The MCP server instructions and every tool description state which engine to use for what, the batch dispatch rules with an example, and the defaults (result_limit 8, snippet_limit 5, format text, focus_k 5, max_replies 30, and when FlareSolverr is used). fetch returns the same fields as the HTTP API.
Path-based authentication — the API key is embedded in the URL path:
SSE: https://ccsearch-mcp.example.com/<CCSEARCH_API_KEY>/sse
Streamable HTTP: https://ccsearch-mcp.example.com/<CCSEARCH_API_KEY>/mcp
Requests to any other path (missing or incorrect key) receive a 401 Unauthorized response.
Uvicorn access logging is disabled, but the key-bearing path can still appear in proxy and client logs. Treat complete MCP URLs as secrets and redact the first path segment before sharing logs. Only rotate a key after explicit operator approval.
Claude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"ccsearch": {
"url": "https://ccsearch-mcp.example.com/<CCSEARCH_API_KEY>/sse"
}
}
}Python MCP SDK (SSE):
from mcp import ClientSession
from mcp.client.sse import sse_client
async with sse_client("https://ccsearch-mcp.example.com/<KEY>/sse") as (r, w):
async with ClientSession(r, w) as session:
await session.initialize()
await session.call_tool("search", {"query": "hello", "engine": "brave"})Python MCP SDK (Streamable HTTP):
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async with streamablehttp_client("https://ccsearch-mcp.example.com/<KEY>/mcp") as (r, w, _):
async with ClientSession(r, w) as session:
await session.initialize()
await session.call_tool("search", {"query": "hello", "engine": "brave"})- Runtime: Python 3 with
mcp>=1.26.0,<2(FastMCP imports used by this project are not compatible with MCP 2.x) - Known warning: MCP 1.26 emits an upstream Pydantic startup warning; both transports work despite it.
- Port: 8890 (configurable via
CCSEARCH_MCP_PORTenv var) - Systemd service:
ccsearch-mcp.service - Public route: expose it through a reverse proxy or tunnel (for example
ccsearch-mcp.example.com → localhost:8890), not by opening the port directly
sudo systemctl enable --now ccsearch-mcp.service
sudo systemctl status ccsearch-mcpThe fetch engine uses a multi-layered approach to access protected websites:
- curl_cffi (recommended): Impersonates Chrome's TLS fingerprint (JA3/JA4), which bypasses most anti-bot detection (Facebook, LinkedIn, Medium, Instagram, etc.). Install with
pip install curl_cffi. Falls back torequestsif not installed. - FlareSolverr: For Cloudflare challenge pages and JS-rendered SPAs that require a real browser. FlareSolverr is a self-hosted proxy that uses a real Chromium browser to solve browser challenges.
- Start the checked-in Docker Compose service:
The included compose file publishes port
docker compose up -d flaresolverr
8191on127.0.0.1only. FlareSolverr has no authentication; preserve this loopback binding on Internet-facing hosts. - Add the URL to your
config.ini:[Fetch] flaresolverr_url = http://localhost:8191/v1 flaresolverr_mode = fallback
fallback(default): Tries a normal HTTP request first. Eligible Cloudflare challenge responses and network failures may retry through FlareSolverr. Ordinary HTTP errors such as 404 and known binary-document URLs are preserved instead of being replaced by rendered HTML.always: Skips the normal request and always uses FlareSolverr. Useful for sites that are known to be protected.never: Never uses FlareSolverr, even if configured.
You can also force FlareSolverr for a single invocation with the --flaresolverr CLI flag:
ccsearch "https://cloudflare-site.com" -e fetch --format json --flaresolverrThe tool automatically detects Cloudflare challenges by checking for:
"Just a moment..."in the page title, acf-mitigated: challengeheader, or the_cf_chl_optchallenge script"Checking your browser"or"cf-browser-verification"in the response body"challenge-platform"only on error responses or pages with almost no visible text. Normal Cloudflare-hosted pages (for exampleopenai.comandlinux.do) load/cdn-cgi/challenge-platformbeacon scripts and are no longer mistaken for challenges.
Akamai interstitials trigger the configured browser fallback. Unresolved Akamai challenges and denial pages fail even if the browser proxy reports HTTP 200; rendering does not guarantee access. LLM Context URL matching preserves topic IDs and page numbers, ignoring only known tracking parameters. Legacy cached wrong-page excerpts and Akamai denial pages are bypassed.
Each step runs only when the previous one did not produce content:
- Site API — only for recognized URLs: X/Twitter (fxtwitter), Discourse topics (
/t/{id}.json, then/raw/{id}), Reddit threads (.jsononwww.reddit.com, thenold.reddit.com), and V2EX topics (/api/topics/show.jsonand/api/replies/show.json). Blocked JSON APIs are retried through FlareSolverr when it is configured. - Direct fetch with curl_cffi.
- FlareSolverr for Cloudflare challenges, SPA shells, and network failures (
flaresolverr_mode). - LLM Context — a Brave LLM Context query restricted to the page's host (
site:hostplus the page title or URL slug). Only passages whose URL matches the requested page are used; results are markedcontent_scope: "excerpts". - Archive — the newest successful Wayback Machine snapshot, with
snapshot_date.
Steps 4 and 5 run only when the page was blocked or unreachable (challenge, 401/403/429/451/5xx, network failure, empty or SPA-shell content). A 404 or 410 is reported as-is, never replaced with another page. [Fetch] extended_fallbacks controls steps 4 and 5. Every response lists all steps in attempts.
You can deeply customize tool behavior by adjusting config.ini:
requests_per_second: Per-key local limit for Brave Web Search and LLM Context (Default:1, hard-capped at the Search plan's50RPS). CLI, HTTP API, MCP, batch workers, and retries coordinate through one cross-process limiter on this host, with a separate 1-second window per Brave key. Other devices using the same Brave subscription are outside this limiter.count: Number of results to fetch per request (Default:10).safesearch: Content filtering level:off,moderate, orstrict.freshness: Filter by time:pd(Past 24h),pw(Past week),pm(Past month),py(Past year). Leave blank for no limit.max_retries: Auto-retry count for network timeouts or 429 Too Many Requests.
model: OpenRouter model string (e.g.,perplexity/sonar,perplexity/sonar-pro).citations: Set totrueto require markdown citations[1]in the synthesized output.temperature: Creativity control (0.0-1.0). Keep low (e.g.,0.1) for factual answering.max_tokens: Hard limit on generation length to save costs.max_retries: Auto-retry count for network anomalies.
count: Number of search results to consider for context extraction, 1-50 (Default:20).maximum_number_of_tokens: Approximate max tokens in the context response, 1024-32768 (Default:8192). Lower for simple factual queries (~2048), higher for deep research (~16384).maximum_number_of_urls: Maximum URLs in the response, 1-50 (Default:20).context_threshold_mode: Relevance filtering:strict(fewer, more precise),balanced(default),lenient(more results), ordisabled(no filtering).freshness: Same time-based filtering as Brave (pd,pw,pm,py).max_retries: Auto-retry count for network anomalies.
flaresolverr_url: FlareSolverr endpoint URL (e.g.,http://localhost:8191/v1). Leave blank to disable.flaresolverr_timeout: Max time in milliseconds for FlareSolverr to solve a challenge (Default:60000).flaresolverr_mode: Strategy:fallback,always, ornever(Default:fallback).extended_fallbacks: Comma-separated fallbacks after direct/FlareSolverr fail on a blocked page:llm-context,archive, ornone(Default:llm-context, archive). The LLM Context step uses a Brave key and counts against its rate limit.
max_workers: Maximum worker threads for batch execution (Default:4).- Batch results preserve input order even when executed concurrently.
- Use a modest value if you are close to Brave or OpenRouter rate limits.
If you are an AI assistant (like Claude, Cursor, or ChatGPT) reading this, you can use this tool to browse the internet to answer user queries!
- Search: batch 2–3 differently worded
bravequeries (result_limit8 is the default); addfreshness: "pm"for time-sensitive topics. - Read: batch-fetch 3–5 pages with
focusandmax_chars. Blocked sites go through the fallback chain automatically; checkserved_from. - Long documents: use
llm-contextwhen you need the whole document's relevant passages. - Verify: run
perplexity-verifyon 3–5 key conclusions. For anycontradictedclaim, fetch its sources to confirm.
- JSON First: Use
--format jsonto get machine-readable structures. - Resilience: The script has built-in timeouts and exponential backoff retry logic. If the network hiccups,
ccsearchhandles it safely, avoiding hangs. - Semantic Cache: Use
--semantic-cacheto skip redundant API calls when you're researching the same topic across multiple queries with slightly different wording. The_from_cacheand_semantic_similarityfields in the JSON response tell you when a cached result was returned and how similar it was. - Result Shaping: Use
--include-host,--exclude-host, and--limitonbrave,both, andllm-contextto keep only the sources and top-N items you actually want.
When the user asks you a question that requires up-to-date knowledge, run the python script directly using your bash/terminal tool.
Brave Search Example:
ccsearch "anthropic claude 3.5 sonnet release date" -e brave --format jsonUse this when you need to research specific websites, gather URLs, or need diverse sources.
(Agent Tip: results default to the top 8; use --limit 20 for more, --offset 1 for the next page, and --freshness pm to drop results older than a month.)
LLM Context Example:
ccsearch "React hooks best practices" -e llm-context --format jsonUse this when you need pre-extracted web content optimized for LLM grounding. Returns smart chunks (text, tables, code blocks, structured data) from multiple sources in a single call — far more token-efficient than fetching pages individually. Like every Brave-backed engine, it prefers BRAVE_SEARCH_API_KEY, can round-robin extra Search keys, and falls back to BRAVE_API_KEY only when no Search key is set.
Both Engines Example:
ccsearch "what are the architectural differences between Next.js app router and pages router" -e both --format jsonUse this when you need a deeply synthesized answer but ALSO need immediate access to primary source URLs to read further context in the same query.
Fetch Webpage Example:
ccsearch "https://eslint.org/docs/latest/rules/no-unused-vars" -e fetch --format jsonUse this when a prior search returned a promising URL, but the snippet wasn't detailed enough and you need to read the full page content. The JSON response includes transport metadata such as final_url, status_code, content_type, and content_length, plus HTML metadata like canonical_url, description, author, and published_at when the page exposes them.
Fetch Binary Document Example:
ccsearch "https://example.com/report.pdf" -e fetch --format jsonUse this for PDFs or Office files. PDF conversion is included in the standard requirements; supported documents are converted into Markdown and the JSON response includes "converted_via": "markitdown". Conversion failures return a structured error.
Fetch with FlareSolverr (Cloudflare bypass):
ccsearch "https://cloudflare-protected-site.com" -e fetch --format json --flaresolverrUse this when a normal fetch fails due to Cloudflare protection. Requires FlareSolverr configured in config.ini. You rarely need it: the default fallback chain already renders challenge pages and then tries LLM Context and the Wayback Machine. The JSON output includes served_from and attempts, and preserves the rendered response's final URL, status, and content type. Empty rendered pages return an error directing callers to an interactive browser.
Semantic Cache Example:
ccsearch "Python asyncio event loop tutorial" -e brave --format json --semantic-cache --cache-ttl 60Use --semantic-cache when researching a topic across multiple queries with slightly different wording. Semantically similar queries return the cached result instantly without a new API call. Check _from_cache and _semantic_similarity in the JSON output to know when a cache hit occurred. Requires pip install fastembed.
- If the command returns an error about missing
BRAVE_API_KEY,BRAVE_SEARCH_API_KEY, orOPENROUTER_API_KEY, immediately inform the user that they need to set the environment variable and provide them the exactexportcommand they need to run in their terminal. - Don't try to guess URLs; use this tool instead!
If you deploy ccsearch as a self-hosted HTTP server, you can install it as a Claude Code skill so that Claude automatically uses your server for all web searches — no CLI needed on the client machine.
- Copy the skill file into your Claude Code skills directory:
mkdir -p ~/.claude/skills/ccsearch cp skills/SKILL.md ~/.claude/skills/ccsearch/SKILL.md
- Edit
~/.claude/skills/ccsearch/SKILL.mdand replace allYOUR_CCSEARCH_BASE_URLwith your actual server URL (e.g.,https://ccsearch.example.com). - Set the API key:
export CCSEARCH_API_KEY="your_api_key"
Once installed, Claude Code will automatically invoke /ccsearch whenever it needs to search the web, fetch URLs, or get LLM-optimized context — routing all requests through your server via curl.
The skill template is located at skills/SKILL.md.
Inputs use their documented JSON types: booleans must be true/false, integer options must be integers, and thresholds must be finite numbers in [0, 1]. Fetch URLs require HTTP(S), a hostname, and a valid port. Invalid batch items produce per-item errors without aborting valid requests. CLI commands exit nonzero for failed or partially failed results, including batches; JSON output remains available for inspection.
Failed or partially failed responses are never reused or stored in either cache. Fetch cache normalization preserves meaningful path separators, path parameters, IPv6 hosts, and repeated query-value order. Semantic index updates are process-safe; unavailable candidates do not trigger embedding work. Broken embedding runtimes raise their original errors rather than silently becoming cache misses.
Brave/OpenRouter error payloads and missing answer content are failures, not empty successful answers. both retains a successful side and reports the failed side; when both sides fail, the result also includes error. Invalid configured modes fail explicitly. FlareSolverr retries expected transport failures only, does not hide programming errors, and rejects unresolved challenges. Explicit always mode requires a configured URL. Binary conversion uses one MarkItDown path and rejects empty output.
MCP runs blocking tools in a bounded worker pool, so a slow search does not freeze all client connections. Individual tool failures use MCP error signaling. Batch calls retain their per-item errors and summary. Uvicorn access logging is disabled to avoid recording key-bearing URLs; proxies and clients can still log them. Empty authentication files prevent startup, and concurrent first starts safely share one newly generated key.
Run all unit, regression, and local transport tests with:
python3 -m py_compile ccsearch.py api_server.py mcp_server.py
python3 -m unittest discover -v
python3 ccsearch.py --doctor --format json
python3 ccsearch.py --list-engines --format jsonThe suite does not clear inherited provider credentials globally. Individual tests use explicit fixtures for key selection and missing-key scenarios; the loopback MCP test server uses a disposable authentication key. Live upstream, public-route, and deployment checks are separate from this suite.