websift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
web_search/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """
2
+ web_search - Self-contained web search & page fetch utility.
3
+
4
+ Usage:
5
+ from web_search import WebSearchClient
6
+
7
+ client = WebSearchClient()
8
+ client.search("python asyncio tutorial")
9
+ client.fetch("https://docs.python.org/3/library/asyncio.html")
10
+ """
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ from web_search.client import WebSearchClient
15
+
16
+ __all__ = ["WebSearchClient", "__version__"]
web_search/client.py ADDED
@@ -0,0 +1,102 @@
1
+ """WebSearchClient: search and fetch with SSRF protection."""
2
+
3
+ import re
4
+ from typing import Optional
5
+ from urllib.parse import urlparse
6
+
7
+ from web_search.config import MAX_FETCH_BYTES, MAX_PAGE_CHARS, MAX_PDF_FETCH_BYTES
8
+ from web_search.content import looks_like_html, looks_like_html_document
9
+ from web_search.html import html_to_markdown, truncate
10
+ from web_search.http import fetch_raw
11
+
12
+ _GITHUB_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
13
+ _GITHUB_NON_OWNER_SEGMENTS = {
14
+ "features", "pricing", "about", "contact", "login", "signup",
15
+ "topics", "trending", "explore", "marketplace", "settings",
16
+ "notifications", "issues", "pulls", "discussions",
17
+ }
18
+
19
+
20
+ class WebSearchClient:
21
+ """
22
+ Self-contained web search + page fetch.
23
+ No API key required. Uses DuckDuckGo (ddgs) for search,
24
+ urllib for fetch, with SSRF protection + DNS pinning.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ max_results: int = 5,
30
+ timeout: int = 30,
31
+ max_page_chars: int = MAX_PAGE_CHARS,
32
+ ):
33
+ self.max_results = max_results
34
+ self.timeout = timeout
35
+ self.max_page_chars = max_page_chars
36
+
37
+ def search(self, query: str) -> str:
38
+ if not query or not query.strip():
39
+ return "No query provided."
40
+ try:
41
+ from ddgs import DDGS
42
+ except ImportError:
43
+ return "Error: ddgs not installed. Run: pip install ddgs"
44
+ try:
45
+ results = DDGS(timeout=self.timeout).text(query.strip(), max_results=self.max_results)
46
+ except Exception as e:
47
+ return f"Search failed: {e}"
48
+ if not results:
49
+ return "No results found."
50
+ parts = [
51
+ f"Title: {r.get('title', '')}\nURL: {r.get('href', '')}\nSnippet: {r.get('body', '')}"
52
+ for r in results
53
+ ]
54
+ return "\n\n---\n\n".join(parts) + (
55
+ "\n\n---\n\nIMPORTANT: These are short snippets only. "
56
+ "Call fetch(url) with a specific URL to get full page content."
57
+ )
58
+
59
+ def fetch(self, url: str) -> str:
60
+ url = url.strip()
61
+ if not url:
62
+ return "No URL provided."
63
+
64
+ readme_url = self._github_readme_api_url(url)
65
+ if readme_url:
66
+ err, body, _ = fetch_raw(
67
+ readme_url, self.timeout, MAX_FETCH_BYTES, MAX_PDF_FETCH_BYTES,
68
+ extra_headers={
69
+ "Accept": "application/vnd.github.raw+json",
70
+ "X-GitHub-Api-Version": "2022-11-28",
71
+ },
72
+ )
73
+ if err is None and body.strip():
74
+ content = html_to_markdown(body, main_content=True) if looks_like_html_document(body) else body
75
+ if content.strip():
76
+ return truncate(f"README of {url} (via GitHub API):\n\n{content}", self.max_page_chars)
77
+
78
+ err, body, content_type = fetch_raw(url, self.timeout, MAX_FETCH_BYTES, MAX_PDF_FETCH_BYTES)
79
+ if err is not None:
80
+ return err
81
+
82
+ if "html" not in content_type and not looks_like_html(body):
83
+ return truncate(body.strip(), self.max_page_chars)
84
+
85
+ return truncate(html_to_markdown(body, main_content=True), self.max_page_chars)
86
+
87
+ def _github_readme_api_url(self, url: str) -> Optional[str]:
88
+ parsed = urlparse(url)
89
+ host = (parsed.hostname or "").lower().lstrip("www.")
90
+ if host != "github.com":
91
+ return None
92
+ parts = [p for p in parsed.path.split("/") if p]
93
+ if len(parts) != 2:
94
+ return None
95
+ owner, repo = parts
96
+ if owner.lower() in _GITHUB_NON_OWNER_SEGMENTS:
97
+ return None
98
+ if repo.endswith(".git"):
99
+ repo = repo[:-4]
100
+ if not (_GITHUB_NAME_RE.match(owner) and _GITHUB_NAME_RE.match(repo)):
101
+ return None
102
+ return f"https://api.github.com/repos/{owner}/{repo}/readme"
web_search/config.py ADDED
@@ -0,0 +1,37 @@
1
+ """Shared constants for web_search."""
2
+
3
+ MAX_FETCH_BYTES = 2 * 1024 * 1024 # 2 MB for normal pages
4
+ MAX_PDF_FETCH_BYTES = 20 * 1024 * 1024 # 20 MB for PDFs
5
+ MAX_PAGE_CHARS = 32_000 # chars fed to LLM
6
+ MIN_MAIN_CONTENT_CHARS = 200
7
+ MAX_REDIRECTS = 5
8
+
9
+ USER_AGENTS = [
10
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
11
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
12
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
13
+ "(KHTML, like Gecko) Version/17.4.1 Safari/605.1.15",
14
+ "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
15
+ ]
16
+
17
+ UNICODE_BOM_CODECS = [
18
+ (b"\xff\xfe\x00\x00", "utf-32-le"),
19
+ (b"\x00\x00\xfe\xff", "utf-32-be"),
20
+ (b"\xff\xfe", "utf-16-le"),
21
+ (b"\xfe\xff", "utf-16-be"),
22
+ (b"\xef\xbb\xbf", "utf-8-sig"),
23
+ ]
24
+
25
+ BINARY_MAGIC = [
26
+ b"\x89PNG", b"GIF8", b"\xff\xd8\xff", # images
27
+ b"PK\x03\x04", # zip/docx/xlsx
28
+ b"\x1f\x8b", # gzip
29
+ b"BZh", # bzip2
30
+ b"\x7fELF", # ELF binary
31
+ b"MZ", # Windows PE
32
+ ]
33
+
34
+ TEXT_MIME_PREFIXES = (
35
+ "text/", "application/json", "application/xml",
36
+ "application/javascript", "application/xhtml",
37
+ )
web_search/content.py ADDED
@@ -0,0 +1,41 @@
1
+ """Content-type detection helpers."""
2
+
3
+ import re
4
+
5
+ from web_search.config import BINARY_MAGIC, TEXT_MIME_PREFIXES
6
+
7
+ _HTML_DOCUMENT_RE = re.compile(r"(<\?xml|<!doctype\s+html|<html[\s>])")
8
+
9
+
10
+ def has_pdf_magic(data: bytes) -> bool:
11
+ return data[:4] == b"%PDF"
12
+
13
+
14
+ def has_binary_magic(data: bytes) -> bool:
15
+ return any(data.startswith(sig) for sig in BINARY_MAGIC)
16
+
17
+
18
+ def is_text_mime(content_type: str) -> bool:
19
+ ct = content_type.lower()
20
+ return any(ct.startswith(p) for p in TEXT_MIME_PREFIXES)
21
+
22
+
23
+ def looks_binary(text: str, threshold: float = 0.02) -> bool:
24
+ """Heuristic: high ratio of control/replacement chars -> binary."""
25
+ if not text:
26
+ return False
27
+ ctrl = sum(
28
+ 1 for ch in text
29
+ if ch == "�" or (ord(ch) < 32 and ch not in "\t\n\r")
30
+ )
31
+ return ctrl / len(text) > threshold
32
+
33
+
34
+ def looks_like_html(body: str) -> bool:
35
+ probe = body.lstrip()[:512].lower()
36
+ return bool(re.search(r"<(html|head|body|div|p|span|script|meta)\b", probe))
37
+
38
+
39
+ def looks_like_html_document(body: str) -> bool:
40
+ probe = body.lstrip()[:256].lower()
41
+ return bool(_HTML_DOCUMENT_RE.match(probe))
web_search/html.py ADDED
@@ -0,0 +1,61 @@
1
+ """HTML to Markdown converter and text truncation."""
2
+
3
+ import re
4
+
5
+ from web_search.config import MIN_MAIN_CONTENT_CHARS
6
+
7
+
8
+ def html_to_markdown(html: str, main_content: bool = False) -> str:
9
+ try:
10
+ from bs4 import BeautifulSoup, Comment
11
+ except ImportError:
12
+ text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
13
+ text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
14
+ text = re.sub(r"<[^>]+>", " ", text)
15
+ text = re.sub(r"[ \t]+", " ", text)
16
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
17
+
18
+ soup = BeautifulSoup(html, "html.parser")
19
+
20
+ for tag in soup(["script", "style", "head", "noscript"]):
21
+ tag.decompose()
22
+ for tag in soup.find_all(True):
23
+ attrs = tag.attrs or {}
24
+ if attrs.get("hidden") or attrs.get("aria-hidden") == "true":
25
+ tag.decompose()
26
+ for comment in soup.find_all(string=lambda t: isinstance(t, Comment)):
27
+ comment.extract()
28
+
29
+ root = soup
30
+ if main_content:
31
+ for scope in ("article", "main"):
32
+ candidate = soup.find(scope)
33
+ if candidate:
34
+ text = candidate.get_text(separator="\n")
35
+ if len(text.strip()) >= MIN_MAIN_CONTENT_CHARS:
36
+ root = candidate
37
+ break
38
+
39
+ lines: list[str] = []
40
+ for el in root.descendants:
41
+ if not hasattr(el, "name"):
42
+ txt = str(el).strip()
43
+ if txt:
44
+ lines.append(txt)
45
+ elif el.name in ("h1", "h2", "h3", "h4", "h5", "h6"):
46
+ lines.append("\n" + "#" * int(el.name[1]) + " " + el.get_text(strip=True))
47
+ elif el.name == "li":
48
+ lines.append("- " + el.get_text(strip=True))
49
+ elif el.name in ("p", "br", "tr", "div"):
50
+ lines.append("")
51
+
52
+ text = "\n".join(lines)
53
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
54
+
55
+
56
+ def truncate(text: str, max_chars: int) -> str:
57
+ if not text:
58
+ return "(page returned no readable text)"
59
+ if len(text) > max_chars:
60
+ return text[:max_chars] + f"\n\n... (truncated, {len(text)} total chars)"
61
+ return text
web_search/http.py ADDED
@@ -0,0 +1,193 @@
1
+ """HTTP helpers: redirect suppression, SNI handler, body reader, PDF extractor."""
2
+
3
+ import http.client
4
+ import random
5
+ import socket
6
+ import ssl
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Optional
10
+
11
+ from web_search.config import MAX_REDIRECTS, UNICODE_BOM_CODECS, USER_AGENTS
12
+ from web_search.content import has_binary_magic, has_pdf_magic, is_text_mime, looks_binary
13
+ from web_search.security import resolve_host
14
+
15
+ from urllib.parse import urljoin, urlparse, urlunparse
16
+ import re
17
+
18
+
19
+ class _NoRedirect(urllib.request.HTTPErrorProcessor):
20
+ def http_response(self, request, response):
21
+ return response
22
+ https_response = http_response
23
+
24
+
25
+ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
26
+ """HTTPS connection to a pinned IP that presents the real hostname for SNI
27
+ and certificate validation."""
28
+
29
+ def __init__(self, host, sni_hostname: str, **kw):
30
+ kw.pop("context", None)
31
+ super().__init__(host, **kw)
32
+ self._sni_hostname = sni_hostname
33
+ self._ctx = ssl.create_default_context()
34
+
35
+ def connect(self):
36
+ sock = socket.create_connection((self.host, self.port), self.timeout)
37
+ self.sock = self._ctx.wrap_socket(sock, server_hostname=self._sni_hostname)
38
+
39
+
40
+ class _SNIHTTPSHandler(urllib.request.HTTPSHandler):
41
+ def __init__(self, sni_hostname: str):
42
+ super().__init__()
43
+ self._sni = sni_hostname
44
+
45
+ def https_open(self, req):
46
+ return self.do_open(
47
+ lambda host, **kw: _PinnedHTTPSConnection(host, sni_hostname=self._sni, **kw),
48
+ req,
49
+ )
50
+
51
+
52
+ def read_capped_body(resp, limit: int, chunk: int = 65536) -> tuple[Optional[str], bytes]:
53
+ chunks: list[bytes] = []
54
+ total = 0
55
+ try:
56
+ while True:
57
+ data = resp.read(min(chunk, limit - total))
58
+ if not data:
59
+ break
60
+ chunks.append(data)
61
+ total += len(data)
62
+ if total >= limit:
63
+ break
64
+ except Exception as e:
65
+ return f"Failed to read response body: {e}", b""
66
+ return None, b"".join(chunks)
67
+
68
+
69
+ def extract_pdf_text(raw: bytes) -> str:
70
+ try:
71
+ import io
72
+ import pypdf
73
+ reader = pypdf.PdfReader(io.BytesIO(raw))
74
+ text = "\n".join(page.extract_text() or "" for page in reader.pages).strip()
75
+ if text:
76
+ return text
77
+ except Exception:
78
+ pass
79
+ try:
80
+ import io
81
+ from pdfminer.high_level import extract_text as pm_extract
82
+ return (pm_extract(io.BytesIO(raw)) or "").strip()
83
+ except Exception:
84
+ pass
85
+ return ""
86
+
87
+
88
+ def fetch_raw(
89
+ url: str,
90
+ timeout: int,
91
+ max_fetch_bytes: int,
92
+ max_pdf_fetch_bytes: int,
93
+ extra_headers: Optional[dict] = None,
94
+ ) -> tuple[Optional[str], str, str]:
95
+ """Fetch URL with SSRF protection, DNS pinning, redirect following."""
96
+ parsed = urlparse(url)
97
+ if parsed.scheme not in ("http", "https"):
98
+ return f"Blocked: only http/https allowed (got {parsed.scheme!r}).", "", ""
99
+ if not parsed.hostname:
100
+ return "Blocked: URL missing hostname.", "", ""
101
+
102
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
103
+ ok, reason, pinned_ip = resolve_host(parsed.hostname, port)
104
+ if not ok:
105
+ return reason, "", ""
106
+
107
+ try:
108
+ current_url = url
109
+ current_host = parsed.hostname
110
+ ua = random.choice(USER_AGENTS)
111
+
112
+ for _ in range(MAX_REDIRECTS):
113
+ cp = urlparse(current_url)
114
+ ip_str = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip
115
+ ip_netloc = f"{ip_str}:{cp.port}" if cp.port else ip_str
116
+ pinned_url = urlunparse(cp._replace(netloc=ip_netloc))
117
+
118
+ opener = urllib.request.build_opener(_NoRedirect, _SNIHTTPSHandler(current_host))
119
+ headers = {"User-Agent": ua, "Host": current_host}
120
+ if extra_headers:
121
+ headers.update(extra_headers)
122
+
123
+ req = urllib.request.Request(pinned_url, headers=headers)
124
+ try:
125
+ resp = opener.open(req, timeout=timeout)
126
+ except urllib.error.HTTPError as e:
127
+ if e.code not in (301, 302, 303, 307, 308):
128
+ return f"Failed to fetch URL: HTTP {e.code} {getattr(e, 'reason', '')}", "", ""
129
+ location = e.headers.get("Location")
130
+ if not location:
131
+ return "Failed to fetch: redirect missing Location.", "", ""
132
+ current_url = urljoin(current_url, location)
133
+ rp = urlparse(current_url)
134
+ if rp.scheme not in ("http", "https") or not rp.hostname:
135
+ return "Blocked: redirect target not valid http/https.", "", ""
136
+ rp_port = rp.port or (443 if rp.scheme == "https" else 80)
137
+ ok2, reason2, pinned_ip = resolve_host(rp.hostname, rp_port)
138
+ if not ok2:
139
+ return reason2, "", ""
140
+ current_host = rp.hostname
141
+ continue
142
+
143
+ content_type = (resp.headers.get_content_type() or "").lower() if resp.headers.get("Content-Type") else ""
144
+ declared_pdf = content_type == "application/pdf"
145
+ read_limit = max_pdf_fetch_bytes + 1 if declared_pdf else max_fetch_bytes
146
+ err, raw_bytes = read_capped_body(resp, read_limit)
147
+ if err:
148
+ return err, "", ""
149
+
150
+ if not declared_pdf and len(raw_bytes) == max_fetch_bytes and has_pdf_magic(raw_bytes):
151
+ err2, tail = read_capped_body(resp, max_pdf_fetch_bytes - max_fetch_bytes + 1)
152
+ if err2:
153
+ return err2, "", ""
154
+ raw_bytes += tail
155
+
156
+ break
157
+ else:
158
+ return "Failed to fetch: too many redirects.", "", ""
159
+
160
+ is_pdf = declared_pdf or has_pdf_magic(raw_bytes)
161
+ if is_pdf:
162
+ if len(raw_bytes) > max_pdf_fetch_bytes:
163
+ return "(PDF exceeds download limit)", "", content_type
164
+ pdf_text = extract_pdf_text(raw_bytes)
165
+ return None, pdf_text or "(PDF contains no extractable text)", "application/pdf"
166
+
167
+ if content_type and not is_text_mime(content_type):
168
+ m = re.match(r"[\w.+-]+/[\w.+-]+", content_type)
169
+ return f"(non-text content: {m.group(0) if m else 'unknown type'})", "", content_type
170
+
171
+ if has_binary_magic(raw_bytes):
172
+ return f"(binary content, {len(raw_bytes)} bytes)", "", content_type
173
+
174
+ declared_enc = resp.headers.get_content_charset()
175
+ bom_codec = next((c for bom, c in UNICODE_BOM_CODECS if raw_bytes.startswith(bom)), None)
176
+ text = raw_bytes.decode(declared_enc or bom_codec or "utf-8", errors="replace")
177
+
178
+ if looks_binary(text):
179
+ if declared_enc in (None, "iso8859-1"):
180
+ alt = raw_bytes.decode("cp1252", "replace")
181
+ if not looks_binary(alt):
182
+ text = alt
183
+ else:
184
+ return f"(binary content, {len(raw_bytes)} bytes)", "", content_type
185
+ else:
186
+ return f"(binary content, {len(raw_bytes)} bytes)", "", content_type
187
+
188
+ return None, text, content_type
189
+
190
+ except urllib.error.HTTPError as e:
191
+ return f"Failed to fetch URL: HTTP {e.code} {getattr(e, 'reason', '')}", "", ""
192
+ except Exception as e:
193
+ return f"Failed to fetch URL: {e}", "", ""
web_search/security.py ADDED
@@ -0,0 +1,49 @@
1
+ """SSRF protection: private-IP detection and DNS resolution with pinning."""
2
+
3
+ import socket
4
+
5
+
6
+ def is_private_ip(ip: str) -> bool:
7
+ """Return True if the IP is loopback, private, or link-local."""
8
+ try:
9
+ packed = socket.inet_pton(socket.AF_INET6 if ":" in ip else socket.AF_INET, ip)
10
+ except OSError:
11
+ return True # unparseable -> block
12
+ if ":" in ip:
13
+ # IPv6 loopback ::1, link-local fe80::/10, ULA fc00::/7
14
+ if packed == b"\x00" * 15 + b"\x01":
15
+ return True
16
+ if packed[0] in (0xFE, 0xFF) and (packed[1] & 0xC0) == 0x80:
17
+ return True
18
+ if packed[0] in (0xFC, 0xFD):
19
+ return True
20
+ else:
21
+ a, b = packed[0], packed[1]
22
+ if a == 127:
23
+ return True
24
+ if a == 10:
25
+ return True
26
+ if a == 172 and 16 <= b <= 31:
27
+ return True
28
+ if a == 192 and b == 168:
29
+ return True
30
+ if a == 169 and b == 254:
31
+ return True
32
+ return False
33
+
34
+
35
+ def resolve_host(hostname: str, port: int) -> tuple[bool, str, str]:
36
+ """
37
+ Resolve hostname -> IP with SSRF validation.
38
+ Returns (ok, reason_or_empty, pinned_ip).
39
+ """
40
+ try:
41
+ infos = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
42
+ except socket.gaierror as e:
43
+ return False, f"Blocked: DNS resolution failed for {hostname!r}: {e}", ""
44
+ if not infos:
45
+ return False, f"Blocked: no address found for {hostname!r}.", ""
46
+ ip = infos[0][4][0]
47
+ if is_private_ip(ip):
48
+ return False, f"Blocked: {hostname!r} resolves to a private/loopback address.", ""
49
+ return True, "", ip
web_search/server.py ADDED
@@ -0,0 +1,40 @@
1
+ """MCP server module for web_search — can be run standalone or imported as a library."""
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from web_search.client import WebSearchClient
9
+
10
+ HOST = os.getenv("MCP_HOST", "0.0.0.0")
11
+ PORT = int(os.getenv("MCP_PORT", "8787"))
12
+ TRANSPORT = os.getenv("MCP_TRANSPORT", "streamable-http") # streamable-http | sse | stdio
13
+
14
+ mcp = FastMCP("web-search", host=HOST, port=PORT)
15
+
16
+ _client = WebSearchClient(
17
+ max_results=int(os.getenv("SEARCH_MAX_RESULTS", "5")),
18
+ timeout=int(os.getenv("SEARCH_TIMEOUT", "30")),
19
+ )
20
+
21
+
22
+ @mcp.tool()
23
+ async def web_search(query: str) -> str:
24
+ """Search the web using DuckDuckGo. Returns title, URL, and snippet for each result."""
25
+ return await asyncio.to_thread(_client.search, query)
26
+
27
+
28
+ @mcp.tool()
29
+ async def web_fetch(url: str) -> str:
30
+ """Fetch a web page and return its readable text content (HTML -> Markdown, PDF -> text)."""
31
+ return await asyncio.to_thread(_client.fetch, url)
32
+
33
+
34
+ def main():
35
+ """Entry point for the web-search-server console script."""
36
+ mcp.run(transport=TRANSPORT)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1,757 @@
1
+ Metadata-Version: 2.4
2
+ Name: websift
3
+ Version: 0.1.0
4
+ Summary: Self-hosted MCP server for web search (DuckDuckGo) + page fetching. No API key required.
5
+ Author-email: HuyPP03 <huypp03@users.noreply.github.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/HuyPP03/websift
8
+ Project-URL: Repository, https://github.com/HuyPP03/websift
9
+ Project-URL: Issues, https://github.com/HuyPP03/websift/issues
10
+ Keywords: mcp,web-search,duckduckgo,ai-agent,tool-use,websift
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: mcp>=1.9.0
25
+ Requires-Dist: ddgs>=9.0.0
26
+ Requires-Dist: beautifulsoup4>=4.12.0
27
+ Requires-Dist: pypdf>=4.0.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: build; extra == "dev"
30
+ Requires-Dist: twine; extra == "dev"
31
+ Requires-Dist: ruff; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+  
35
+
36
+ # websift
37
+
38
+ A lightweight, **free, self-hosted MCP (Model Context Protocol) server** that gives AI agents real-time web access — DuckDuckGo search + web page fetching (HTML → Markdown, PDF → text) — with built-in SSRF protection and DNS pinning. **No API key required.**
39
+
40
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
41
+ [![PyPI](https://img.shields.io/badge/PyPI-websift-orange.svg)](https://pypi.org/project/websift/)
42
+ [![MCP](https://img.shields.io/badge/MCP-streamable--http-green.svg)](https://modelcontextprotocol.io/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
44
+
45
+ ---
46
+
47
+ ## Table of Contents
48
+
49
+ - [What It Does](#what-it-does)
50
+ - [Why Use This](#why-use-this)
51
+ - [Comparison with Alternatives](#comparison-with-alternatives)
52
+ - [Architecture](#architecture)
53
+ - [Installation](#installation)
54
+ - [Option 1: PyPI (Recommended)](#option-1-pypi-recommended)
55
+ - [Option 2: Docker Compose](#option-2-docker-compose)
56
+ - [Option 3: Docker (Manual)](#option-3-docker-manual)
57
+ - [Option 4: Local Python (No Docker)](#option-4-local-python-no-docker)
58
+ - [Usage](#usage)
59
+ - [As a Python Library (Direct Import)](#as-a-python-library-direct-import)
60
+ - [As an MCP Server (For AI Clients)](#as-an-mcp-server-for-ai-clients)
61
+ - [Tools](#tools)
62
+ - [Configuration](#configuration)
63
+ - [Connecting to AI Clients](#connecting-to-ai-clients)
64
+ - [VS Code (GitHub Copilot)](#1-vs-code-github-copilot)
65
+ - [Claude Desktop](#2-claude-desktop)
66
+ - [Claude Code](#3-claude-code)
67
+ - [Copilot CLI](#4-copilot-cli)
68
+ - [Cursor](#5-cursor)
69
+ - [Windsurf (Codeium)](#6-windsurf-codeium)
70
+ - [JetBrains IDEs](#7-jetbrains-ides)
71
+ - [Any MCP Client (Generic)](#8-any-mcp-client-generic)
72
+ - [Use Cases](#use-cases)
73
+ - [Security](#security)
74
+ - [Development](#development)
75
+ - [FAQ](#faq)
76
+
77
+ ---
78
+
79
+ ## What It Does
80
+
81
+ This MCP server exposes two tools to any AI agent or LLM client:
82
+
83
+ | Tool | Input | Output |
84
+ | -------------- | ------------------ | ---------------------------------------------------------------------- |
85
+ | `web_search` | `query` (string) | Title, URL, and snippet for each DuckDuckGo result |
86
+ | `web_fetch` | `url` (string) | Readable text content from any webpage (HTML → Markdown, PDF → text) |
87
+
88
+ That's it — simple, focused, and reliable.
89
+
90
+ ---
91
+
92
+ ## Why Use This
93
+
94
+ ### Core Strengths
95
+
96
+ - **🆓 Completely Free** — No API keys, no subscriptions, no rate limits from a third-party provider. DuckDuckGo is free, and this server is free.
97
+ - **🪶 Lightweight** — Single Python process, ~4 dependencies, runs in a tiny Docker container (`python:3.12-slim` ≈ 150 MB).
98
+ - **🔒 Secure by Default** — SSRF protection with private-IP blocking, DNS resolution pinning, SNI validation, redirect limits, and content-type validation.
99
+ - **🌐 Universal MCP Compatibility** — Works with any MCP client (VS Code, Claude, Cursor, Windsurf, JetBrains, custom agents, etc.).
100
+ - **📄 Smart Content Extraction** — HTML → clean Markdown via BeautifulSoup, PDF → text via pypdf/pdfminer, binary detection, charset auto-detection.
101
+ - **🐙 GitHub README Shortcut** — Fetching a `github.com/owner/repo` URL automatically uses the GitHub API to grab the raw README.
102
+ - **🏠 Self-Hosted** — Full control over your data. No traffic routed through third-party services.
103
+
104
+ ### Ideal For
105
+
106
+ - **🐍 Python Scripts & Apps** — Import `WebSearchClient` directly in your code. No server, no Docker, no MCP overhead.
107
+ - **AI Agents & Agentic Workflows** — Give any autonomous agent the ability to search the web and read pages on demand.
108
+ - **Development Assistants** — Let Copilot, Claude, or Cursor look up documentation, error messages, or package info in real time.
109
+ - **Research & Analysis** — Fetch and summarize articles, papers, or documentation pages.
110
+ - **Cost-Sensitive Deployments** — Replace paid web-search APIs (Tavily, Firecrawl, Exa, etc.) with a free self-hosted alternative.
111
+ - **Air-Gapped / Private Networks** — Run entirely offline (search requires internet, but fetch can work with internal URLs if you adjust security rules).
112
+
113
+ ---
114
+
115
+ ## Comparison with Alternatives
116
+
117
+ | Feature | **websift** | Tavily MCP | Firecrawl MCP | Exa MCP | Brave Search MCP |
118
+ | -------------------------- | ------------------------ | --------------------------- | --------------------- | --------------------- | --------------------------- |
119
+ | **Price** | ✅ Free | 💰 Paid (free tier limited) | 💰 Paid | 💰 Paid | 💰 Paid (free tier limited) |
120
+ | **API Key Required** | ✅ No | ❌ Yes | ❌ Yes | ❌ Yes | ❌ Yes |
121
+ | **Self-Hosted** | ✅ Yes | ❌ No | ⚠️ Partial | ❌ No | ❌ No |
122
+ | **Web Search** | ✅ DuckDuckGo | ✅ Proprietary | ❌ (scrape only) | ✅ Proprietary | ✅ Brave |
123
+ | **Web Fetch** | ✅ HTML + PDF | ✅ Yes | ✅ Yes (deep) | ✅ Yes | ❌ No |
124
+ | **SSRF Protection** | ✅ Built-in | ⚠️ Managed | ⚠️ Managed | ⚠️ Managed | ⚠️ Managed |
125
+ | **Container Size** | ~150 MB | N/A (SaaS) | ~500 MB+ | N/A (SaaS) | N/A (SaaS) |
126
+ | **Dependencies** | 4 packages | N/A | Many | N/A | N/A |
127
+ | **Rate Limits** | DuckDuckGo only | Provider limits | Provider limits | Provider limits | Provider limits |
128
+ | **Privacy** | ✅ Full control | ⚠️ Data to provider | ⚠️ Data to provider | ⚠️ Data to provider | ⚠️ Data to provider |
129
+
130
+ ### When to Choose What
131
+
132
+ | Scenario | Recommended |
133
+ | ---------------------------------------------------------------------- | --------------------------- |
134
+ | You want**free, no-signup** web access for AI | **websift** ✅ |
135
+ | You need**deep scraping** (JS-rendered pages, sitemaps) | Firecrawl |
136
+ | You need**semantic search** (AI-powered relevance) | Exa |
137
+ | You want**agentic-optimized** search (Tavily's `extract` mode) | Tavily |
138
+ | You need**maximum privacy** (self-hosted, no external calls) | **websift** ✅ |
139
+ | You're building a**custom AI agent** with minimal infra | **websift** ✅ |
140
+
141
+ ---
142
+
143
+ ## Architecture
144
+
145
+ ```
146
+ ┌─────────────┐ MCP Protocol ┌──────────────────┐
147
+ │ AI Client │ ◄── (streamable-HTTP) ┤ MCP Server │
148
+ │ (Copilot, │ │ (FastMCP) │
149
+ │ Claude, │ └─────────┬────────┘
150
+ │ Cursor…) │ │
151
+ └─────────────┘ ┌────────────┴─────────┐
152
+ │ WebSearchClient │
153
+ ┌────────────┐ │ │
154
+ │ search() │──► DuckDuckGo (ddgs)│ │
155
+ │ fetch() │──► urllib + SSRF │ │
156
+ │ │ ├── html.py (BS4)│ │
157
+ │ │ ├── http.py │ │
158
+ │ │ ├── security.py │ │
159
+ │ │ └── content.py │ │
160
+ └────────────┘ └──────────────────────┘
161
+ ```
162
+
163
+ ### Module Structure
164
+
165
+ ```
166
+ web_search/
167
+ ├── __init__.py # exports WebSearchClient + __version__
168
+ ├── config.py # constants (size limits, user-agents, MIME types, ...)
169
+ ├── security.py # SSRF protection: private-IP check, DNS resolve + pin
170
+ ├── content.py # content-type detection (PDF, binary, HTML heuristics)
171
+ ├── http.py # raw HTTP fetch: redirect following, SNI pinning, charset decode
172
+ ├── html.py # HTML → Markdown conversion, text truncation
173
+ ├── client.py # WebSearchClient: search / fetch / GitHub README shortcut
174
+ └── server.py # MCP server module (FastMCP, importable or standalone)
175
+ server.py # thin entry point → delegates to web_search.server
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Installation
181
+
182
+ ### Option 1: PyPI (Recommended)
183
+
184
+ ```bash
185
+ pip install websift
186
+ ```
187
+
188
+ That's it — you can now use it as a **Python library** (direct import) or as an **MCP server** (for AI clients).
189
+
190
+ ### Option 2: Docker Compose
191
+
192
+ ```bash
193
+ # Clone and start
194
+ git clone <repo-url>
195
+ cd websift
196
+ docker compose up -d --build
197
+ ```
198
+
199
+ The MCP server will be available at `http://localhost:8787/mcp`.
200
+
201
+ ### Option 3: Docker (Manual)
202
+
203
+ ```bash
204
+ docker build -t websift .
205
+ docker run -d --name websift -p 8787:8787 websift
206
+ ```
207
+
208
+ ### Option 4: Local Python (No Docker)
209
+
210
+ ```bash
211
+ # Install dependencies
212
+ pip install -r requirements.txt
213
+
214
+ # Run the server
215
+ python server.py
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Usage
221
+
222
+ ### As a Python Library (Direct Import)
223
+
224
+ Use `WebSearchClient` directly in your Python code — **no server needed**:
225
+
226
+ ```python
227
+ from web_search import WebSearchClient
228
+
229
+ client = WebSearchClient()
230
+
231
+ # Search the web (DuckDuckGo)
232
+ results = client.search("Python 3.12 features")
233
+ print(results)
234
+
235
+ # Fetch a web page (HTML → Markdown, PDF → text)
236
+ content = client.fetch("https://docs.python.org/3/")
237
+ print(content)
238
+ ```
239
+
240
+ **Perfect for:**
241
+
242
+ - Custom scripts & automation
243
+ - Embedding in your own applications
244
+ - Data pipelines & ETL workflows
245
+ - Testing & prototyping
246
+
247
+ ### As an MCP Server (For AI Clients)
248
+
249
+ Run the server to expose tools to any MCP-compatible AI client:
250
+
251
+ ```bash
252
+ # Start the server (default: port 8787)
253
+ websift
254
+
255
+ # Custom port & transport
256
+ MCP_PORT=9000 MCP_TRANSPORT=sse websift
257
+ ```
258
+
259
+ Or via Python:
260
+
261
+ ```python
262
+ from web_search.server import main
263
+ main()
264
+ ```
265
+
266
+ **Perfect for:**
267
+
268
+ - VS Code (GitHub Copilot)
269
+ - Claude Desktop / Claude Code
270
+ - Cursor, Windsurf, JetBrains IDEs
271
+ - Any MCP-compatible agent
272
+
273
+ ---
274
+
275
+ ## Tools
276
+
277
+ ### `web_search(query: str) -> str`
278
+
279
+ Searches DuckDuckGo and returns formatted results with title, URL, and snippet.
280
+
281
+ **Example:**
282
+
283
+ ```
284
+ Agent: web_search("latest Python 3.12 features")
285
+ Server:
286
+ Title: What's New in Python 3.12
287
+ URL: https://docs.python.org/3/whatsnew/3.12.html
288
+ Snippet: Python 3.12 introduces several performance improvements...
289
+
290
+ ---
291
+
292
+ Title: Python 3.12 Release Notes
293
+ URL: https://www.python.org/downloads/release/python-3120/
294
+ Snippet: The Python 3.12 release includes bug fixes and...
295
+ ```
296
+
297
+ ### `web_fetch(url: str) -> str`
298
+
299
+ Fetches a URL and returns readable text content. Handles:
300
+
301
+ - **HTML pages** → converted to clean Markdown (BeautifulSoup, main-content extraction)
302
+ - **PDF files** → text extracted via pypdf / pdfminer
303
+ - **Plain text / JSON / XML** → returned as-is
304
+ - **GitHub repos** → automatically fetches README via GitHub API
305
+ - **Binary files** → detected and blocked (images, executables, archives)
306
+
307
+ **Example:**
308
+
309
+ ```
310
+ Agent: web_fetch("https://github.com/python/cpython")
311
+ Server:
312
+ README of https://github.com/python/cpython (via GitHub API):
313
+
314
+ # Python
315
+ The Python programming language...
316
+ ```
317
+
318
+ ---
319
+
320
+ ## Configuration
321
+
322
+ ### Environment Variables
323
+
324
+ | Variable | Default | Description |
325
+ | ---------------------- | ------------------- | ---------------------------------------------------- |
326
+ | `MCP_HOST` | `0.0.0.0` | Bind address |
327
+ | `MCP_PORT` | `8787` | Listen port |
328
+ | `MCP_TRANSPORT` | `streamable-http` | Transport:`streamable-http`, `sse`, or `stdio` |
329
+ | `SEARCH_MAX_RESULTS` | `5` | Max search results returned |
330
+ | `SEARCH_TIMEOUT` | `30` | Request timeout in seconds |
331
+
332
+ ### Internal Limits
333
+
334
+ | Setting | Value | Description |
335
+ | ---------------- | ------ | ------------------------- |
336
+ | Max page size | 2 MB | Normal page fetch limit |
337
+ | Max PDF size | 20 MB | PDF fetch limit |
338
+ | Max output chars | 32,000 | Characters sent to LLM |
339
+ | Max redirects | 5 | HTTP redirect chain limit |
340
+
341
+ ---
342
+
343
+ ## Connecting to AI Clients
344
+
345
+ ### 1. VS Code (GitHub Copilot)
346
+
347
+ > 📖 **Official docs**: [Add and manage MCP servers in VS Code](https://code.visualstudio.com/docs/agent-customization/mcp-servers) | [MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
348
+
349
+ #### Via Extensions View (Easiest)
350
+
351
+ 1. Open Extensions view (`Ctrl+Shift+X`)
352
+ 2. Search `@mcp` in the search field
353
+ 3. Install any MCP server from the gallery
354
+
355
+ #### Via `mcp.json` (Custom Server)
356
+
357
+ Create `.vscode/mcp.json` in your workspace:
358
+
359
+ ```json
360
+ {
361
+ "mcpServers": {
362
+ "web-search": {
363
+ "type": "http",
364
+ "url": "http://localhost:8787/mcp"
365
+ }
366
+ }
367
+ }
368
+ ```
369
+
370
+ Or for global (user-level) configuration, run `MCP: Open User Configuration` from the Command Palette and add the same entry.
371
+
372
+ #### Verify
373
+
374
+ Open Chat (`Ctrl+Cmd+I` / `Ctrl+Ctrl+I`) and ask: *"Search for the latest Python release notes"*
375
+
376
+ ### 2. Claude Desktop
377
+
378
+ > 📖 **Official docs**: [Getting Started with Local MCP Servers on Claude Desktop](https://support.claude.com/en/articles/10949351-getting-started-with-local-mcp-servers-on-claude-desktop) | [Desktop Extensions](https://www.anthropic.com/engineering/desktop-extensions)
379
+
380
+ #### Via Settings UI
381
+
382
+ 1. Open Claude Desktop → Settings → Extensions
383
+ 2. Click "Advanced settings" → "Install Extension…"
384
+ 3. Or manually add via the configuration file
385
+
386
+ #### Via Configuration File
387
+
388
+ Edit `~/.config/claude/claude_desktop_config.json` (Linux) or `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
389
+
390
+ ```json
391
+ {
392
+ "mcpServers": {
393
+ "web-search": {
394
+ "url": "http://localhost:8787/mcp"
395
+ }
396
+ }
397
+ }
398
+ ```
399
+
400
+ Restart Claude Desktop after editing.
401
+
402
+ ### 3. Claude Code
403
+
404
+ > 📖 **Official docs**: [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp)
405
+
406
+ ```bash
407
+ # Add the MCP server (HTTP transport)
408
+ claude mcp add --transport http web-search http://localhost:8787/mcp
409
+
410
+ # Verify it's connected
411
+ claude mcp list
412
+
413
+ # Use it in conversation
414
+ claude "Search for the latest Rust release and summarize the key changes"
415
+ ```
416
+
417
+ **Scopes:**
418
+
419
+ ```bash
420
+ # Project scope (default, stored in .mcp.json)
421
+ claude mcp add --transport http web-search http://localhost:8787/mcp
422
+
423
+ # User scope (available across all projects)
424
+ claude mcp add --transport http web-search --scope user http://localhost:8787/mcp
425
+ ```
426
+
427
+ ### 4. Copilot CLI
428
+
429
+ > 📖 **Official docs**: [Adding MCP servers for GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers)
430
+
431
+ #### Interactive Mode
432
+
433
+ ```
434
+ /mcp add
435
+ # Server Name: web-search
436
+ # Server Type: HTTP
437
+ # URL: http://localhost:8787/mcp
438
+ # Press Ctrl+S to save
439
+ ```
440
+
441
+ #### Command Line
442
+
443
+ ```bash
444
+ copilot mcp add web-search --transport http --url http://localhost:8787/mcp
445
+ ```
446
+
447
+ #### Config File
448
+
449
+ Edit `~/.github/copilot/mcp-config.json`:
450
+
451
+ ```json
452
+ {
453
+ "mcpServers": {
454
+ "web-search": {
455
+ "type": "http",
456
+ "url": "http://localhost:8787/mcp"
457
+ }
458
+ }
459
+ }
460
+ ```
461
+
462
+ ### 5. Cursor
463
+
464
+ > 📖 **Official docs**: [Model Context Protocol (MCP) | Cursor Docs](https://cursor.com/docs/mcp)
465
+
466
+ Create `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
467
+
468
+ ```json
469
+ {
470
+ "mcpServers": {
471
+ "web-search": {
472
+ "type": "http",
473
+ "url": "http://localhost:8787/mcp"
474
+ }
475
+ }
476
+ }
477
+ ```
478
+
479
+ Restart Cursor, then use Chat or Agent mode to invoke the tools.
480
+
481
+ ### 6. Windsurf (Codeium)
482
+
483
+ > 📖 **Official docs**: [Cascade MCP Integration](https://docs.windsurf.com/plugins/cascade/mcp)
484
+
485
+ Create `~/.codeium/windsurf/mcp_config.json`:
486
+
487
+ ```json
488
+ {
489
+ "mcpServers": {
490
+ "web-search": {
491
+ "type": "http",
492
+ "url": "http://localhost:8787/mcp"
493
+ }
494
+ }
495
+ }
496
+ ```
497
+
498
+ Alternatively, use the built-in UI: **Settings → Cascade → MCP Servers**.
499
+
500
+ ### 7. JetBrains IDEs
501
+
502
+ > 📖 **Official docs**: [MCP Server | IntelliJ IDEA Documentation](https://www.jetbrains.com/help/idea/mcp-server.html)
503
+
504
+ 1. Install the "MCP Client" plugin from the JetBrains Marketplace
505
+ 2. Go to **Settings → Tools → MCP**
506
+ 3. Add a new server:
507
+ - Name: `web-search`
508
+ - Type: `HTTP`
509
+ - URL: `http://localhost:8787/mcp`
510
+ 4. Apply and restart
511
+
512
+ ### 8. Any MCP Client (Generic)
513
+
514
+ For any MCP-compatible client, the server is accessible at:
515
+
516
+ - **Streamable HTTP** (recommended): `http://localhost:8787/mcp`
517
+ - **SSE**: `http://localhost:8787/mcp/sse` (set `MCP_TRANSPORT=sse`)
518
+ - **STDIO**: Run `python server.py` with `MCP_TRANSPORT=stdio`
519
+
520
+ Generic HTTP configuration:
521
+
522
+ ```json
523
+ {
524
+ "mcpServers": {
525
+ "web-search": {
526
+ "type": "http",
527
+ "url": "http://localhost:8787/mcp"
528
+ }
529
+ }
530
+ }
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Use Cases
536
+
537
+ ### AI Agent Web Research
538
+
539
+ ```
540
+ User: "Find the latest benchmarks for LLM inference optimization"
541
+ Agent: web_search("LLM inference optimization benchmarks 2025")
542
+ Agent: web_fetch("https://example.com/benchmark-article")
543
+ Agent: [Summarizes findings from fetched content]
544
+ ```
545
+
546
+ ### Documentation Lookup
547
+
548
+ ```
549
+ User: "How do I configure CORS in FastAPI?"
550
+ Agent: web_search("FastAPI CORS configuration")
551
+ Agent: web_fetch("https://fastapi.tiangolo.com/tutorial/cors/")
552
+ Agent: [Provides code example from documentation]
553
+ ```
554
+
555
+ ### Error Debugging
556
+
557
+ ```
558
+ User: "I'm getting 'ModuleNotFoundError: no module named '_sqlite3'"
559
+ Agent: web_search("ModuleNotFoundError _sqlite3 Python Docker")
560
+ Agent: [Finds solution: install python3-dev packages]
561
+ ```
562
+
563
+ ### Competitive Analysis
564
+
565
+ ```
566
+ User: "What are the latest features in React 19?"
567
+ Agent: web_search("React 19 new features")
568
+ Agent: web_fetch("https://react.dev/blog/2024")
569
+ Agent: [Summarizes new features]
570
+ ```
571
+
572
+ ### Agentic AI Workflows
573
+
574
+ This server is particularly well-suited for agentic AI because:
575
+
576
+ - **Deterministic tools** — `web_search` and `web_fetch` have clear, predictable inputs and outputs.
577
+ - **No authentication overhead** — agents don't need to manage API keys.
578
+ - **Self-contained** — single container, no external dependencies beyond DuckDuckGo.
579
+ - **SSRF-safe** — agents can safely fetch URLs without risking internal network exposure.
580
+ - **Markdown output** — clean, structured text that LLMs can process efficiently.
581
+
582
+ ---
583
+
584
+ ## Security
585
+
586
+ ### Built-in Protections
587
+
588
+ | Protection | How It Works |
589
+ | --------------------------- | --------------------------------------------------------------------------------------------- |
590
+ | **SSRF Prevention** | All resolved IPs are checked against private/loopback/link-local ranges |
591
+ | **DNS Pinning** | DNS resolution is pinned to the first resolved IP; SNI validation ensures certificate matches |
592
+ | **Redirect Limits** | Maximum 5 redirects to prevent redirect loops and SSRF bypass |
593
+ | **Scheme Validation** | Only`http://` and `https://` schemes are allowed |
594
+ | **Binary Detection** | Images, executables, archives, and other binary content are detected and blocked |
595
+ | **Size Limits** | 2 MB for normal pages, 20 MB for PDFs, 32,000 chars output limit |
596
+ | **Charset Detection** | BOM detection (UTF-8/16/32), Content-Type header parsing, meta tag fallback |
597
+
598
+ ### Network Considerations
599
+
600
+ - The server binds to `0.0.0.0` by default — restrict with `MCP_HOST=127.0.0.1` for local-only access.
601
+ - No authentication is built in — place behind a reverse proxy (nginx, Caddy) if exposing externally.
602
+ - Docker Compose isolates the server in its own container network.
603
+
604
+ ---
605
+
606
+ ## Development
607
+
608
+ ### Project Structure
609
+
610
+ ```
611
+ websift/
612
+ ├── pyproject.toml # Package metadata, dependencies, console script
613
+ ├── docker-compose.yml # Docker Compose setup
614
+ ├── Dockerfile # Python 3.12-slim container
615
+ ├── requirements.txt # Python dependencies
616
+ ├── server.py # MCP server entry point (delegates to web_search.server)
617
+ ├── .env.example # Environment variable template
618
+ ├── .mcp.json # VS Code MCP configuration
619
+ ├── README.md # This file
620
+ ├── docs/
621
+ │ └── README.vi.md # Vietnamese documentation
622
+ └── web_search/
623
+ ├── __init__.py # Package exports (WebSearchClient, __version__)
624
+ ├── config.py # Constants and configuration
625
+ ├── security.py # SSRF protection and DNS pinning
626
+ ├── content.py # Content-type detection
627
+ ├── http.py # HTTP fetching with SNI pinning
628
+ ├── html.py # HTML to Markdown conversion
629
+ ├── client.py # WebSearchClient (search + fetch)
630
+ └── server.py # MCP server module (importable or standalone)
631
+ ```
632
+
633
+ ### Running Locally
634
+
635
+ ```bash
636
+ # Install from PyPI
637
+ pip install websift
638
+
639
+ # Or install in editable mode (for development)
640
+ pip install -e .
641
+
642
+ # Run as MCP server
643
+ websift
644
+
645
+ # Run with custom settings
646
+ MCP_PORT=9000 MCP_TRANSPORT=sse websift
647
+
648
+ # Or use as a library (no server needed)
649
+ python -c "from web_search import WebSearchClient; print(WebSearchClient().search('test'))"
650
+ ```
651
+
652
+ ### Running with Docker
653
+
654
+ ```bash
655
+ # Build and start
656
+ docker compose up -d --build
657
+
658
+ # View logs
659
+ docker compose logs -f
660
+
661
+ # Stop
662
+ docker compose down
663
+ ```
664
+
665
+ ---
666
+
667
+ ## FAQ
668
+
669
+ ### Q: Does this require an API key?
670
+
671
+ **No.** DuckDuckGo search is free and doesn't require authentication. The entire server runs without any API keys.
672
+
673
+ ### Q: Can I use this behind a firewall?
674
+
675
+ Yes. The server only needs outbound HTTPS access to reach DuckDuckGo and target websites. Inbound access is only needed for the MCP endpoint (port 8787).
676
+
677
+ ### Q: How does this compare to Tavily or Firecrawl?
678
+
679
+ This is simpler and free, but doesn't offer JS rendering, deep scraping, or semantic search. For basic web search + page fetching, it's a solid free alternative. See the [Comparison table](#comparison-with-alternatives) for details.
680
+
681
+ ### Q: Can I add authentication?
682
+
683
+ The server itself doesn't include auth, but you can place it behind nginx/Caddy with basic auth or API key validation:
684
+
685
+ ```nginx
686
+ location /mcp {
687
+ auth_basic "MCP Server";
688
+ auth_basic_user_file /etc/nginx/.htpasswd;
689
+ proxy_pass http://localhost:8787/mcp;
690
+ }
691
+ ```
692
+
693
+ ### Q: What transport protocols are supported?
694
+
695
+ - **streamable-http** (recommended, default) — modern MCP standard
696
+ - **sse** — legacy Server-Sent Events (still supported)
697
+ - **stdio** — for local process communication
698
+
699
+ ### Q: Why is the output limited to 32,000 characters?
700
+
701
+ This keeps responses within typical LLM context windows while providing substantial content. You can adjust `MAX_PAGE_CHARS` in `web_search/config.py` if needed.
702
+
703
+ ### Q: Can I use this for internal/private websites?
704
+
705
+ By default, SSRF protection blocks private IP ranges. To allow internal sites, modify `web_search/security.py` to whitelist specific domains or IP ranges.
706
+
707
+ ### Q: Can I use this as a Python library (without MCP)?
708
+
709
+ **Yes!** Just `pip install websift` and import directly:
710
+
711
+ ```python
712
+ from web_search import WebSearchClient
713
+ client = WebSearchClient()
714
+ client.search("your query")
715
+ client.fetch("https://example.com")
716
+ ```
717
+
718
+ No server, no Docker, no MCP overhead — just pure Python.
719
+
720
+ ### Q: Can I publish my own version to PyPI?
721
+
722
+ Yes. After making changes:
723
+
724
+ ```bash
725
+ # Install build tools
726
+ pip install build twine
727
+
728
+ # Build the package
729
+ python -m build
730
+
731
+ # Test upload (TestPyPI)
732
+ twine upload --repository testpypi dist/*
733
+
734
+ # Real upload (PyPI)
735
+ twine upload dist/*
736
+ ```
737
+
738
+ ---
739
+
740
+ ## License
741
+
742
+ MIT — see [LICENSE](LICENSE) for details.
743
+
744
+ ## Acknowledgments
745
+
746
+ - [DuckDuckGo Search (ddgs)](https://github.com/deedy5/ddgs) — search backend
747
+ - [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/) — HTML parsing
748
+ - [pypdf](https://pypdf.readthedocs.io/) — PDF text extraction
749
+ - [FastMCP](https://github.com/modelcontextprotocol/python-sdk) — MCP server framework
750
+ - [Model Context Protocol](https://modelcontextprotocol.io/) — open protocol for AI tool integration
751
+ - [VS Code MCP Documentation](https://code.visualstudio.com/docs/agent-customization/mcp-servers) — official VS Code MCP guide
752
+ - [Claude Code MCP Documentation](https://code.claude.com/docs/en/mcp) — official Claude Code MCP guide
753
+ - [Cursor MCP Documentation](https://cursor.com/docs/mcp) — official Cursor MCP guide
754
+ - [Windsurf MCP Documentation](https://docs.windsurf.com/plugins/cascade/mcp) — official Windsurf MCP guide
755
+ - [Copilot CLI MCP Documentation](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers) — official GitHub Copilot CLI MCP guide
756
+ - [Claude Desktop MCP Documentation](https://support.claude.com/en/articles/10949351-getting-started-with-local-mcp-servers-on-claude-desktop) — official Claude Desktop MCP guide
757
+ - [JetBrains MCP Documentation](https://www.jetbrains.com/help/idea/mcp-server.html) — official JetBrains MCP guide
@@ -0,0 +1,14 @@
1
+ web_search/__init__.py,sha256=7Lw2-jl7HOQJ7sKecOS73ERYLWe4OlC2SGE6l0K2OMc,380
2
+ web_search/client.py,sha256=XYV9WMpo43y0Bm70TK0Vob0Ugqjqzq9Q_mroJiBwix4,3839
3
+ web_search/config.py,sha256=I5hjbcyfOzQJx_Vfvkk-R8mX-NJk28U_wyktkgoxsLw,1331
4
+ web_search/content.py,sha256=CKFwuTKUmIklblQoCmsd9gYTZB-dx8d9u05MscRaEVI,1130
5
+ web_search/html.py,sha256=NofkAMlsSCwQO1LTgWNaVZ6BFaSQTv3aNDq-ECGg1OY,2132
6
+ web_search/http.py,sha256=laHZNBf1gmA5biH8Y8FsAimDnwkTelm9rSvVyXgugMg,7357
7
+ web_search/security.py,sha256=fifCA4YV8MYmRkY8ce8UKOJ3RhjuCpCjy9g6SyqC_f4,1635
8
+ web_search/server.py,sha256=fbzeT2pX20gE1b0OS2O728BBnbuMTKIuxHzT0GMvIeU,1130
9
+ websift-0.1.0.dist-info/licenses/LICENSE,sha256=YM8wFZKmV0P_kbn_LRcnQY21isWS_Tx15xmTGPKD1EA,1084
10
+ websift-0.1.0.dist-info/METADATA,sha256=QZbAZTe-rNA26aIt4y_jUjUTUi912LWB2KoV5GsXOhk,27279
11
+ websift-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ websift-0.1.0.dist-info/entry_points.txt,sha256=7pLKLW73kk9j5U1zNNma1C-4KjRs_ys3lPrwA0KaV00,51
13
+ websift-0.1.0.dist-info/top_level.txt,sha256=PoNm8MJYw_y8RTMaNlY0ePLoNHxVUAE2IHDuL5fFubI,11
14
+ websift-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ websift = web_search.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 web-search-mcp contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ web_search