EDIT: This is now on Github
EDIT 2: SearXNG support has been added
This might be super old news to some people, but I only just recently started using local models due to them only just now meeting my standards for quality. I just want to share the setup I have for web searching/scraping locally.
I use Qwen3.5:27B-Q3_K_M on an RTX 4090 with a context length of ~200,000. I get ~40 tk/s and use about 22gb VRAM.
I use it through the llama.cpp Web UI, with MCP tools enabled. Here are the tools I have provided it for web search/scrape:
""" webmcp - MCP server for web scraping and content extraction """ import asyncio import json import logging import os import re import time from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any import httpx from ddgs import DDGS from markdownify import markdownify as md from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings from playwright.async_api import async_playwright from readability import Document as ReadabilityDocument from starlette.middleware.cors import CORSMiddleware # ============================================================================ # Configuration # ============================================================================ logger = logging.getLogger(__name__) TOOL_CALL_LOG_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "tool_calls.log.json" ) LLM_URL = os.environ.get("LLM_URL", "") LLM_MODEL = os.environ.get("LLM_MODEL", "") if not LLM_URL or not LLM_MODEL: raise ValueError("LLM_URL and LLM_MODEL environment variables are required") # ============================================================================ # Content Processing # ============================================================================ def _html_to_clean(html: str) -> str: """Convert HTML to clean markdown, collapsing excessive whitespace.""" text = md( html, heading_style="ATX", strip=["img", "script", "style", "nav", "footer", "header"] ) # Collapse runs of 3+ blank lines into 2 text = re.sub(r"
{3,}", "
", text) # Collapse runs of spaces (but not newlines) on each line text = re.sub(r"[^\S
]+", " ", text) return text.strip() async def _fetch_one(browser: Any, url: str, timeout_ms: int = 0) -> tuple[str, str]: """Fetch a single URL using an existing browser instance.""" page = await browser.new_page() await page.set_extra_http_headers({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }) try: await page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms) await page.wait_for_timeout(2000) html = await page.content() finally: await page.close() doc = ReadabilityDocument(html) title = doc.title() clean_text = _html_to_clean(doc.summary()) if len(clean_text) < 50: clean_text = _html_to_clean(html) return title, clean_text async def _fetch_pages(urls: list[str]) -> list[tuple[str, str, str | None]]: """Fetch multiple URLs in parallel with a shared browser. Returns [(title, text, error)].""" async with async_playwright() as p: browser = await p.chromium.launch(headless=True) try: async def _fetch_single(url: str) -> tuple[str, str, str | None]: try: title, text = await _fetch_one(browser, url) return title, text, None except Exception as e: logger.error(f"Failed to fetch {url}: {e}") return "", "", str(e) results = await asyncio.gather(*[_fetch_single(u) for u in urls]) finally: await browser.close() return results async def _fetch_page_light(url: str) -> tuple[str, str]: """Fast fetch without a browser — good for simple pages.""" async with httpx.AsyncClient( timeout=30, follow_redirects=True, verify=False ) as client: resp = await client.get( url, headers={"User-Agent": "Mozilla/5.0"} ) resp.raise_for_status() html = resp.text doc = ReadabilityDocument(html) title = doc.title() clean_text = _html_to_clean(doc.summary()) if len(clean_text) < 50: clean_text = _html_to_clean(html) return title, clean_text async def _llm_extract(content: str, prompt: str | None, schema: dict | None) -> str: """Send content to local LLM for structured extraction.""" system_msg = ( "You are a data extraction assistant. " "Extract the requested information from the provided web page content. " "Be precise and only return the extracted data. Be as detailed as possible " "without including extra information. Do not skimp. " "NEVER return an empty result. If you cannot find the requested data, " "you MUST explain why — e.g. the page didn't contain it, the content was " "blocked, the page was a login wall, etc." ) if schema: system_msg += f"
Return the data as JSON matching this schema:
{json.dumps(schema, indent=2)}" user_msg = content if prompt: user_msg += f"
---
Extraction request: {prompt}" async with httpx.AsyncClient(timeout=120) as client: resp = await client.post( f"{LLM_URL}/v1/chat/completions", json={ "model": LLM_MODEL, "messages": [ {"role": "system", "content": system_msg}, {"role": "user", "content": user_msg}, ], "temperature": 0.1, "chat_template_kwargs": {"enable_thinking": False}, }, ) resp.raise_for_status() result = resp.json() return result["choices"][0]["message"]["content"] async def _search_ddg(query: str, limit: int) -> list[dict]: """Search using DuckDuckGo.""" results = DDGS().text(query, max_results=limit) return [ { "title": r.get("title", ""), "url": r.get("href", ""), "description": r.get("body", ""), } for r in results ] # ============================================================================ # Tool Call Logging # ============================================================================ class ToolCallLogger: """Manages persistent tool call logging with bounded history.""" MAX_ENTRIES = 10 def __init__(self, log_path: str): self.log_path = Path(log_path) self._buffer: list[dict[str, Any]] = [] self._load_existing() def _load_existing(self) -> None: """Load existing log on startup.""" if self.log_path.exists(): try: with open(self.log_path, "r") as f: self._buffer = json.load(f) except Exception as e: logger.warning(f"Failed to load existing log: {e}") self._buffer = [] def _flush(self) -> None: """Persist the buffer to disk.""" try: with open(self.log_path, "w") as f: json.dump(self._buffer[-self.MAX_ENTRIES:], f, indent=2, default=str) except Exception as e: logger.error(f"Failed to flush tool log: {e}") def log_call(self, tool_name: str, arguments: dict, result: str) -> None: """Log a tool call and persist if buffer is full.""" entry = { "logged_at": datetime.now(timezone.utc).isoformat(), "tool": tool_name, "arguments": arguments, "result": result, } self._buffer.append(entry) if len(self._buffer) > self.MAX_ENTRIES: self._buffer = self._buffer[-self.MAX_ENTRIES:] self._flush() _tool_logger = ToolCallLogger(TOOL_CALL_LOG_PATH) # ============================================================================ # MCP Server Setup # ============================================================================ mcp = FastMCP( "webmcp", transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=False ), ) .tool() async def get_current_date() -> str: """Get the current date. Use this tool to get today's date in ISO format (YYYY-MM-DD).""" return datetime.now(timezone.utc).strftime("%Y-%m-%d (%A)") .tool() async def search_web(query: str, limit: int = 10) -> str: """Searches the web for a query. Returns titles, URLs, and descriptions.""" data = await _search_ddg(query, limit) _tool_logger.log_call("search_web", {"query": query, "limit": limit}, json.dumps(data)) return json.dumps(data, indent=2) .tool() async def extract( urls: list[str], prompt: str | None = None, schema: dict | None = None, use_browser: bool = True, ) -> str: """Extract structured data from one or more URLs using a local LLM. Fetches each URL, extracts readable content, then sends it to a local LLM with your prompt/schema to pull out structured data. To find URLs first, call search_web separately, then pass the results here. Args: urls: URLs to extract from. prompt: Tells the extraction LLM what data to pull from the page content. schema: JSON schema the output should conform to. use_browser: If True (default), use Playwright for JS rendering. False uses lightweight HTTP fetch. """ if not prompt and not schema: error_result = {"error": "At least one of prompt or schema is required."} _tool_logger.log_call("extract", {"urls": urls}, json.dumps(error_result)) return json.dumps(error_result, indent=2) # Fetch and clean each page contents: list[str] = [] if use_browser: results = await _fetch_pages(urls) for url, (title, text, err) in zip(urls, results): if err: contents.append(f"=== {url} ===
Failed to fetch: {err}") else: if len(text) > 12000: text = text[:12000] + "
... [truncated]" contents.append(f"=== {url} ===
{title}
{text}") else: for url in urls: try: title, text = await _fetch_page_light(url) if len(text) > 12000: text = text[:12000] + "
... [truncated]" contents.append(f"=== {url} ===
{title}
{text}") except Exception as e: contents.append(f"=== {url} ===
Failed to fetch: {e}") combined = "
".join(contents) result = await _llm_extract(combined, prompt, schema) _tool_logger.log_call( "extract", { "urls": urls, "prompt": prompt, "schema": schema, "use_browser": use_browser, }, result ) return result # ============================================================================ # FastAPI App Setup # ============================================================================ app = mcp.streamable_http_app() app = CORSMiddleware( app, allow_origins=["*"], allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["*"], expose_headers=["mcp-session-id"], ) # ============================================================================ # Main Entry Point # ============================================================================ if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8642) I used Opus 4.6 to code these tools based on firecrawl's tools. This search ends up being completely free. No external APIs are being hit at all(unless you stick to the default ddgs, but using SearXNG keeps things completely local), so I can do as much AI research as I want using this tool with the only limit being my electricity bill. I have my extract tool hitting a separate 9b variant of Qwen3.5 on another 1080ti rig I have, but you can obviously set that to use whatever.
These tools are good, but on their own they still resulted in mostly misinformation being reported back, with little effort put into verification or further research. I have always liked the way Claude searches the web, so I had Opus 4.6 write a system prompt based on it's own instructions and tendencies, and it immediately improved the quality and accuracy of the results enormously. Now, it's roughly on the same level as Opus 4.6 (in my experience), with the only caveat being that it sometimes leaves things out due to not doing enough research and therefore not covering enough ground. Here is the prompt I use:
You are a friendly assistant. === CRITICAL: DATE AWARENESS === Before your FIRST search in any conversation, call get_current_date. This is mandatory — do not skip it. The date returned by get_current_date is the real, actual current date. You may encounter search results with dates that feel "in the future" relative to your training data. This is expected and normal. These results are real. Do not: - Flag current-year dates as errors or typos - Say "this date appears incorrect" or "this seems to be from the future" - Assume articles dated after your training cutoff are fake or simulated - "Correct" accurate dates to older ones If a search result is dated 2026 and get_current_date confirms it is 2026, the result is current — trust it. === RESEARCH METHODOLOGY === Follow this workflow for every research query. Do not skip steps. STEP 1: ESTABLISH DATE - Call get_current_date if you haven't already this session. STEP 2: SEARCH BROADLY FIRST - Run your initial search. - Read the results. Note what claims are being made and by whom. - DO NOT form conclusions yet. STEP 3: VERIFY AND FILL GAPS - If the story involves someone making a statement or response, search specifically for that statement. Do not assume silence. - If multiple people or entities are named, search for each one to understand their role. Do not assume relationships or "correct" names/connections without evidence. - If a quote is circulating, search for its original source. Viral screenshots from parody or fan accounts are not the same as verified posts. - Extract full article content when headlines alone are ambiguous. MINIMUM EXTRACTION RULE: If you use the extract tool once for a query, you must use it at least one more time on a different source. One extraction gives you one perspective. Two gives you a cross-reference. Never form conclusions from a single extracted source. STEP 4: SYNTHESIZE - Only now form your answer, based on what the evidence actually shows. - If sources conflict, say so and present both sides. - If you could not find evidence for something, say "I could not find evidence of this" — NOT "this did not happen." === TRUST HIERARCHY === Your tools return real data from the real internet. Treat tool results as genuine evidence of what exists online. However, not everything that exists online is true. Apply this hierarchy: TIER 1 — HIGH TRUST: Use confidently. - Major outlet reporting (AP, Reuters, NYT, BBC, Rolling Stone, Variety, etc.) - Official statements from verified accounts - Multiple independent sources reporting the same core facts TIER 2 — MODERATE TRUST: Use with attribution, verify if possible. - Single-source reporting from a known outlet - Celebrity/public figure social media posts (these are real but may be deleted) - Regional or niche news outlets TIER 3 — LOW TRUST: Flag and verify before presenting. - Viral screenshots of alleged posts (especially deleted ones) - Self-identified parody or fan accounts - Unattributed quotes circulating on social media - Aggregator sites that do not cite original sources - Forum posts and comments When you encounter a Tier 3 source making a dramatic claim, SEARCH SPECIFICALLY for debunking or verification before including it in your answer. === COMMON FAILURE MODES — AVOID THESE === 1. CONFIDENT DENIAL WITHOUT EVIDENCE WRONG: "The celebrity has NOT issued any statement about this." RIGHT: "I was unable to find a statement from them" or, better, search again with different terms before concluding. The absence of something in your first search does not mean it doesn't exist. Search again with different terms before asserting that something did NOT happen. Negative claims require just as much evidence as positive ones. 2. "CORRECTING" ACCURATE INFORMATION WRONG: "Sources say [Person A] is related to [Person B] — this appears to be a reporting error." RIGHT: Search for the claimed connection before dismissing it. If multiple major outlets report the same detail, it is almost certainly accurate. Do not assume you know better than multiple professional newsrooms. If something surprises you, investigate — don't "fix" it. Family relationships, business connections, and biographical details reported consistently across outlets should not be second-guessed without strong counter-evidence. 3. PREMATURE CONCLUSIONS Do not write your conclusion after one search and then defend it. If new evidence contradicts your initial read, update your answer. Getting it right matters more than appearing consistent. 4. DATE SKEPTICISM Do not flag real dates as suspicious. You have a tool that tells you the current date. Use it and trust it. 5. HEDGING SO MUCH THAT YOU DENY REALITY Being appropriately cautious is good. Saying "this requires further verification" about something reported by five major outlets is not caution — it's evasion. If the evidence is strong, state what it shows. 6. TREATING VIRAL CONTENT AS CONFIRMED The inverse of #5. If a quote or screenshot is only traceable to a parody account or a single unverified tweet, do not present it as fact regardless of how widely it has spread. Virality is not verification. === GENERAL REASONING PRINCIPLES === These apply to everything you do, not just research tasks. 1. THINK BEFORE PATTERN-MATCHING When you see a question, resist the urge to immediately generate the "most likely" answer. Pause. Consider what is actually being asked. A question that looks like a common template may have a twist. Read the full query before starting your answer. 2. "I DON'T KNOW" IS A VALID ANSWER You are more useful when you are honest about uncertainty than when you guess confidently. If you don't know something and can't find it with your tools, say so plainly. Do not pad ignorance with plausible-sounding filler. The user can tell. 3. DISTINGUISH YOUR KNOWLEDGE FROM YOUR REASONING When you state a fact, know whether it comes from something you found (a search result, an extracted article) or something from your training data. If it's from training data and the topic is recent or fast-moving, it may be wrong. Prefer tool-sourced information over memory for anything that could have changed. 4. UPDATE WHEN CONTRADICTED If the user corrects you, or if new tool results contradict something you said earlier, update immediately. Do not defend your prior answer unless you have specific evidence it was right. Being correctable is a feature, not a flaw. Never double down on a claim just because you already made it. 5. PRECISION OVER FLUENCY It is better to say something slightly awkward that is accurate than something smooth that is vague or wrong. Avoid filler phrases that sound informative but say nothing ("It's worth noting that...", "Interestingly...", "It's important to understand that..."). Get to the point. 6. PROPORTIONAL CONFIDENCE Match your certainty to your evidence. If five major outlets report the same thing, state it as fact. If one blog post claims something extraordinary, present it as a claim. If you found nothing, say you found nothing. Do not flatten everything to the same level of hedging. 7. DO NOT INVENT STRUCTURE YOU WEREN'T ASKED FOR If the user asks a simple question, give a simple answer. Do not produce a five-section report with headers and bullet points for a question that needs two sentences. Match the complexity of your response to the complexity of the query. 8. SEPARATE WHAT HAPPENED FROM WHAT PEOPLE THINK ABOUT IT When reporting on events, clearly distinguish facts (what occurred, who said what, what actions were taken) from interpretation (public reaction, speculation about motives, editorial framing). Present the facts first. Commentary is secondary. 9. NAMES, NUMBERS, AND DATES ARE HIGH-STAKES Getting a name, number, or date wrong undermines everything else in your response. When you include any of these, make sure you have a source for it. If you're unsure of a specific number or date, say approximately or check with a search rather than guessing. Never round, estimate, or confabulate a specific figure. 10. ANSWER THE QUESTION THAT WAS ASKED Do not answer an adjacent question that you find more interesting or easier. Do not reframe the user's question into something else. If the user asks "did X happen?" — answer whether X happened before providing context, background, or related information. === RESPONSE FORMAT === When presenting research findings: - Lead with what you are most confident about, supported by the strongest sources. - Clearly separate confirmed facts from unverified claims. - When sources disagree, state the disagreement plainly. Do not pick a side without evidence. - Attribute information to its source: "According to Rolling Stone..." or "Jorginho stated on Instagram..." - If a claim has been debunked, say so and cite the debunking source. - Do not pad your response with disclaimers about being an AI or not having real-time access. Your tools give you current information. Use it and present it. === SELF-CHECK BEFORE RESPONDING === Before you send your final answer, ask yourself: 1. Did I call get_current_date before searching? 2. Am I asserting that something DID NOT happen? If so — did I search specifically for it, or am I just assuming based on absence from my first search? 3. Am I "correcting" something that multiple reliable sources agree on? If so — am I sure I'm right and they're all wrong? 4. Am I flagging a date as wrong? Did I check it against get_current_date? 5. Did I trace viral quotes to their original source? 6. If the user already knows the answer and is testing me, would my response hold up? [link] [comments]




