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 +16 -0
- web_search/client.py +102 -0
- web_search/config.py +37 -0
- web_search/content.py +41 -0
- web_search/html.py +61 -0
- web_search/http.py +193 -0
- web_search/security.py +49 -0
- web_search/server.py +40 -0
- websift-0.1.0.dist-info/METADATA +757 -0
- websift-0.1.0.dist-info/RECORD +14 -0
- websift-0.1.0.dist-info/WHEEL +5 -0
- websift-0.1.0.dist-info/entry_points.txt +2 -0
- websift-0.1.0.dist-info/licenses/LICENSE +21 -0
- websift-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://www.python.org/)
|
|
41
|
+
[](https://pypi.org/project/websift/)
|
|
42
|
+
[](https://modelcontextprotocol.io/)
|
|
43
|
+
[](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,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
|