SoftCreator AI WebSearch v1.0.0
Endpoints Connect Try it API docs Health
SoftCreator internal service

Give your AI the live web,
not its training data.

Two calls — search the web, and pull any page back as clean markdown. Available as a plain HTTP API and as an MCP server, behind one API key per consuming project.

Try it with your key Connect your project
checking service status…

Why this exists

Every AI project needed the same two things.

Search and page reading kept being rebuilt per project, each with its own engine accounts, its own scraping code and its own way of failing. This service does it once: the engine accounts, the JavaScript rendering, the caching and the safety checks live in one place, and every project calls it the same way.

🔍

Search without engine accounts

Four independent search indexes are asked at once and their rankings are fused, so a page several of them agree on comes back first. Consumers never hold an engine key.

📄

Pages as markdown

HTML, plain text and PDF come back as markdown ready for a prompt. Script-heavy pages are re-fetched through a headless Chromium when static extraction returns too little.

🔑

One key per consumer

Each project gets its own key with its own rate budget, so a runaway loop in one project cannot spend another's allowance — and a leaked key is revoked alone.

How it works

Two containers, one published port.

Only api is reachable. It authenticates the caller, enforces the rate budget, checks every destination address before a page is fetched, and answers from its SQLite cache when it can. The renderer sits on the internal network with no published port at all.

AI Magic Claude Code (MCP) any HTTP client X-API-Key api · FastAPI /v1/search /v1/fetch /mcp auth · rate limit SSRF guard SQLite cache port 85 · TLS at the proxy brave · tavily serper · exa ddgs free floor · in-process renderer Chromium · internal only search APIs keyed · the open web target page html · text · pdf on empty thin page direct fetch when the page needs no JavaScript

One published port — everything that touches untrusted content runs behind it.

API

Two endpoints do the work.

Base URL https://websearch.softcreator.com. Everything except /v1/health carries an X-API-Key header. Errors are always JSON, never an HTML error page, in the shape {"error": {"code": …, "message": …}}.

POST /v1/search web search · 30 requests/min per key · cached 15 min
FieldDefaultMeaning
query—required, 1–500 characters
max_results101–20
time_rangenullday, week, month, year
language"en"result language
categoriesnullnews, science, it
allowed_domainsnullkeep only these hosts, subdomains included
blocked_domainsnulldrop these hosts — cannot be combined with the row above
safesearch00, 1 or 2

Response

{"results": [{"title": …, "url": …, "snippet": …, "engine": …,
              "published": …, "found_by": 3}],
 "provider": "brave_api",
 "providers": ["brave_api", "tavily", "serper", "exa"],
 "cached": false}

found_by

How many independent indexes returned that page. On the deployed service 90% of results come back above 1 — weigh it, a page four indexes agree on is not the same as a page one returned.

POST /v1/fetch page to markdown · 20 requests/min per key · cached 1 hour
FieldDefaultMeaning
url—required, http or https only
max_length30000characters returned by this call
start_index0where to continue from on the next call
headings_onlyfalsereturn just the outline
sectionnullreturn only the section under this heading
Long pages arrive in parts. metadata.truncated says there is more; call again with start_index = the previous start_index plus the length you received. headings_only and section are applied before that split, so total_length always describes the text you are being served, not the whole document.

Response

{"content": "# Background Tasks\n\nYou can define …",
 "metadata": {"url": …, "final_url": …, "content_type": "text/html",
              "title": …, "truncated": true, "total_length": 7707,
              "fetched_at": …, "rendered": false}}
GET /v1/health no key required · what the monitoring polls

Reports ok (two or more keyed providers configured), degraded (one) or down (none, leaving only the free engine), together with the commit the running container was built from. No provider is probed — a probe is a billed query. The badge at the top of this page is that endpoint.

GET / POST /mcp streamable HTTP · tools web_search and web_fetch

The same two operations exposed as MCP tools, authenticated with the same X-API-Key sent as a transport header. See Connect.

Getting started

Connect in two minutes.

Ask for the key that belongs to your project — there is one per consumer — then pick the way in. If your code already runs a tool-calling loop, use HTTP: two POSTs and no protocol layer. If your client speaks MCP, point it at /mcp and the two tools appear on their own.

Search

curl -sS https://websearch.softcreator.com/v1/search \
  -H "X-API-Key: $WEBSEARCH_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"query": "fastapi background tasks", "max_results": 5}'

Fetch a page

curl -sS https://websearch.softcreator.com/v1/fetch \
  -H "X-API-Key: $WEBSEARCH_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://fastapi.tiangolo.com/tutorial/background-tasks/"}'
import httpx

client = httpx.Client(
    base_url="https://websearch.softcreator.com",
    headers={"X-API-Key": WEBSEARCH_KEY},
    timeout=60,
)

hits = client.post("/v1/search", json={"query": "pydantic v2 validators"}).json()
page = client.post("/v1/fetch", json={"url": hits["results"][0]["url"]}).json()
print(page["content"][:2000])

One command registers the server. web_search and web_fetch then appear in the MCP panel with no integration code at all.

claude mcp add --transport http websearch https://websearch.softcreator.com/mcp \
  --header "X-API-Key: $WEBSEARCH_KEY"

For clients configured through a file — Cursor, Claude Desktop, IDE extensions:

{
  "mcpServers": {
    "websearch": {
      "type": "http",
      "url": "https://websearch.softcreator.com/mcp",
      "headers": { "X-API-Key": "<your key>" }
    }
  }
}

Try it

Run the two calls from this page.

Paste your key, then search and fetch against the live service. The requests go from this browser to the same host that served the page — the same two endpoints your code will call, with the same errors. The key stays in this browser's local storage and is never sent anywhere except as the X-API-Key header of these requests.

No key set — both calls will come back 401.


	

Limits

What one key is allowed to do.

Exceeding a per-key limit returns 429 with a Retry-After header. A separate pacer, shared by every consumer, caps how hard the upstream engines are hit, so no single key can burn them for everyone else.

LimitDefaultNotes
Search requests30 / min per keycounted per consumer, not per project instance
Fetch requests20 / min per keya cache hit still counts
Download size20 MBlarger responses are refused, not truncated
Search cache15 mincached: true marks a cached answer
Fetch cache1 hourkeyed on the URL and the extraction options
Content typeshtml · text · pdfanything else returns 415

Security

Fetching arbitrary URLs, carefully.

🛡

No reaching inward

/v1/fetch resolves the hostname and refuses private, loopback, link-local, reserved, multicast and cloud-metadata addresses — and re-checks after every redirect.

⧉

The renderer is contained

Chromium executes untrusted page scripts, so it has no published port, no volumes and no access to the environment file. The api validates a destination before handing it over.

🔐

Keys compared in constant time

A key is checked against every configured key with compare_digest, so timing never reveals how much of a guess was correct.

The stack

Three containers, deployed together.

api
Published · port 85

FastAPI. Authentication, rate limiting, provider selection, the extraction chain, the SQLite cache and the MCP transport at /mcp. Runs a single uvicorn worker — the rate limiter's buckets live in process memory.

renderer
Internal only

Headless Chromium side-car, used only when static extraction returns too little. If it fails, the request still returns whatever the static extraction found.