web-intel-mcp 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_intel_mcp/__init__.py +3 -0
- web_intel_mcp/__main__.py +3 -0
- web_intel_mcp/analyze.py +186 -0
- web_intel_mcp/billing.py +167 -0
- web_intel_mcp/fetch.py +122 -0
- web_intel_mcp/server.py +181 -0
- web_intel_mcp-0.1.0.dist-info/METADATA +108 -0
- web_intel_mcp-0.1.0.dist-info/RECORD +12 -0
- web_intel_mcp-0.1.0.dist-info/WHEEL +5 -0
- web_intel_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- web_intel_mcp-0.1.0.dist-info/licenses/LICENSE +70 -0
- web_intel_mcp-0.1.0.dist-info/top_level.txt +1 -0
web_intel_mcp/analyze.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Pure HTML analysis (no network): reader markdown, metadata, links, SEO audit, tech stack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from urllib.parse import urljoin, urlsplit
|
|
9
|
+
|
|
10
|
+
from bs4 import BeautifulSoup, Tag
|
|
11
|
+
from markdownify import markdownify
|
|
12
|
+
|
|
13
|
+
from .fetch import Page
|
|
14
|
+
|
|
15
|
+
_NOISE = ["script", "style", "noscript", "template", "svg", "iframe", "form", "nav", "header", "footer", "aside"]
|
|
16
|
+
_NOISE_HINT = re.compile(r"(cookie|consent|banner|newsletter|subscribe|share|social|sidebar|breadcrumb|menu|popup|modal|advert|promo)", re.I)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def soup_of(page: Page) -> BeautifulSoup:
|
|
20
|
+
return BeautifulSoup(page.text, "html.parser")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _meta(soup: BeautifulSoup, *names: str) -> Optional[str]:
|
|
24
|
+
for n in names:
|
|
25
|
+
tag = soup.find("meta", attrs={"name": n}) or soup.find("meta", attrs={"property": n})
|
|
26
|
+
if isinstance(tag, Tag) and tag.get("content"):
|
|
27
|
+
return str(tag["content"]).strip()
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _main_node(soup: BeautifulSoup) -> Tag:
|
|
32
|
+
for sel in ("main", "article", "[role=main]", "#content", ".content", ".post", ".entry-content"):
|
|
33
|
+
node = soup.select_one(sel)
|
|
34
|
+
if node and len(node.get_text(strip=True)) > 200:
|
|
35
|
+
return node
|
|
36
|
+
return soup.body or soup
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def to_markdown(page: Page, max_chars: int = 20000) -> dict[str, Any]:
|
|
40
|
+
soup = soup_of(page)
|
|
41
|
+
title = (soup.title.get_text(strip=True) if soup.title else None) or _meta(soup, "og:title")
|
|
42
|
+
for t in soup(_NOISE):
|
|
43
|
+
t.decompose()
|
|
44
|
+
for t in soup.find_all(attrs={"class": _NOISE_HINT}) + soup.find_all(attrs={"id": _NOISE_HINT}):
|
|
45
|
+
if isinstance(t, Tag) and t.name not in ("body", "main", "article", "html"):
|
|
46
|
+
t.decompose()
|
|
47
|
+
node = _main_node(soup)
|
|
48
|
+
for a in node.find_all("a", href=True):
|
|
49
|
+
a["href"] = urljoin(page.final_url, str(a["href"]))
|
|
50
|
+
for img in node.find_all("img", src=True):
|
|
51
|
+
img["src"] = urljoin(page.final_url, str(img["src"]))
|
|
52
|
+
md = markdownify(str(node), heading_style="ATX", bullets="-", strip=["span"])
|
|
53
|
+
md = re.sub(r"\n{3,}", "\n\n", md).strip()
|
|
54
|
+
words = len(md.split())
|
|
55
|
+
truncated = len(md) > max_chars
|
|
56
|
+
return {"url": page.final_url, "title": title, "word_count": words,
|
|
57
|
+
"truncated": truncated or page.truncated, "markdown": md[:max_chars]}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _jsonld_types(soup: BeautifulSoup) -> list[str]:
|
|
61
|
+
out: list[str] = []
|
|
62
|
+
for s in soup.find_all("script", attrs={"type": "application/ld+json"}):
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(s.string or "")
|
|
65
|
+
except (ValueError, TypeError):
|
|
66
|
+
continue
|
|
67
|
+
stack = data if isinstance(data, list) else [data]
|
|
68
|
+
while stack:
|
|
69
|
+
item = stack.pop()
|
|
70
|
+
if isinstance(item, dict):
|
|
71
|
+
t = item.get("@type")
|
|
72
|
+
out.extend(t if isinstance(t, list) else [t] if t else [])
|
|
73
|
+
stack.extend(item.get("@graph") or [])
|
|
74
|
+
return sorted({str(t) for t in out})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def metadata(page: Page) -> dict[str, Any]:
|
|
78
|
+
soup = soup_of(page)
|
|
79
|
+
canon = soup.find("link", rel="canonical")
|
|
80
|
+
html = soup.find("html")
|
|
81
|
+
headings = {f"h{i}": [h.get_text(" ", strip=True)[:160] for h in soup.find_all(f"h{i}")][:20] for i in (1, 2, 3)}
|
|
82
|
+
imgs = soup.find_all("img")
|
|
83
|
+
return {
|
|
84
|
+
"url": page.final_url, "status": page.status, "elapsed_ms": page.elapsed_ms, "redirects": page.redirects,
|
|
85
|
+
"title": soup.title.get_text(strip=True) if soup.title else None,
|
|
86
|
+
"description": _meta(soup, "description"),
|
|
87
|
+
"canonical": canon.get("href") if isinstance(canon, Tag) else None,
|
|
88
|
+
"lang": html.get("lang") if isinstance(html, Tag) else None,
|
|
89
|
+
"robots": _meta(soup, "robots"),
|
|
90
|
+
"open_graph": {k: _meta(soup, f"og:{k}") for k in ("title", "description", "image", "type", "url")},
|
|
91
|
+
"twitter_card": _meta(soup, "twitter:card"),
|
|
92
|
+
"jsonld_types": _jsonld_types(soup),
|
|
93
|
+
"headings": headings,
|
|
94
|
+
"images": {"total": len(imgs), "missing_alt": sum(1 for i in imgs if not (i.get("alt") or "").strip())},
|
|
95
|
+
"word_count": len(soup.get_text(" ", strip=True).split()),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def links(page: Page, limit: int = 200) -> dict[str, Any]:
|
|
100
|
+
soup = soup_of(page)
|
|
101
|
+
host = urlsplit(page.final_url).hostname
|
|
102
|
+
internal: dict[str, str] = {}
|
|
103
|
+
external: dict[str, str] = {}
|
|
104
|
+
for a in soup.find_all("a", href=True):
|
|
105
|
+
href = str(a["href"]).strip()
|
|
106
|
+
if href.startswith(("#", "mailto:", "tel:", "javascript:")):
|
|
107
|
+
continue
|
|
108
|
+
absu = urljoin(page.final_url, href).split("#")[0]
|
|
109
|
+
bucket = internal if urlsplit(absu).hostname == host else external
|
|
110
|
+
bucket.setdefault(absu, a.get_text(" ", strip=True)[:120])
|
|
111
|
+
fmt = lambda d: [{"url": u, "text": t} for u, t in list(d.items())[:limit]] # noqa: E731
|
|
112
|
+
return {"url": page.final_url, "internal_count": len(internal), "external_count": len(external),
|
|
113
|
+
"internal": fmt(internal), "external": fmt(external)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def seo_audit(page: Page) -> dict[str, Any]:
|
|
117
|
+
m = metadata(page)
|
|
118
|
+
checks: list[dict[str, Any]] = []
|
|
119
|
+
|
|
120
|
+
def add(cid: str, ok: bool, weight: int, fix: str, value: Any = None) -> None:
|
|
121
|
+
checks.append({"id": cid, "pass": ok, "weight": weight, "value": value, "fix": None if ok else fix})
|
|
122
|
+
|
|
123
|
+
title, desc = m["title"] or "", m["description"] or ""
|
|
124
|
+
add("status_200", m["status"] == 200, 10, "Serve the page with HTTP 200.", m["status"])
|
|
125
|
+
add("https", page.final_url.startswith("https://"), 8, "Serve over HTTPS and redirect HTTP to it.")
|
|
126
|
+
add("title_present", bool(title), 10, "Add a <title>.", title)
|
|
127
|
+
add("title_length", 30 <= len(title) <= 60, 5, "Keep the title between 30 and 60 characters.", len(title))
|
|
128
|
+
add("description_present", bool(desc), 8, "Add <meta name=description>.", desc[:160] or None)
|
|
129
|
+
add("description_length", 70 <= len(desc) <= 160, 4, "Keep the description between 70 and 160 characters.", len(desc))
|
|
130
|
+
add("single_h1", len(m["headings"]["h1"]) == 1, 7, "Use exactly one <h1>.", len(m["headings"]["h1"]))
|
|
131
|
+
add("canonical", bool(m["canonical"]), 5, "Add <link rel=canonical>.", m["canonical"])
|
|
132
|
+
add("lang", bool(m["lang"]), 3, "Set <html lang>.", m["lang"])
|
|
133
|
+
add("indexable", "noindex" not in (m["robots"] or "").lower()
|
|
134
|
+
and "noindex" not in page.headers.get("x-robots-tag", "").lower(), 10, "Remove noindex.", m["robots"])
|
|
135
|
+
add("open_graph", bool(m["open_graph"]["title"] and m["open_graph"]["image"]), 5,
|
|
136
|
+
"Add og:title and og:image for link previews.")
|
|
137
|
+
add("structured_data", bool(m["jsonld_types"]), 6, "Add JSON-LD (Organization, Product, Article, FAQPage...).",
|
|
138
|
+
m["jsonld_types"])
|
|
139
|
+
add("img_alt", m["images"]["missing_alt"] == 0, 4, "Add alt text to every meaningful image.", m["images"])
|
|
140
|
+
add("content_depth", m["word_count"] >= 300, 6, "Thin content: aim for 300+ words of real text.", m["word_count"])
|
|
141
|
+
add("viewport", bool(re.search(r'<meta[^>]+name=["\']viewport', page.text, re.I)), 5, "Add a viewport meta tag.")
|
|
142
|
+
add("fast_response", m["elapsed_ms"] < 1500, 4, "Server response is slow (>1.5 s).", m["elapsed_ms"])
|
|
143
|
+
total = sum(c["weight"] for c in checks)
|
|
144
|
+
score = round(100 * sum(c["weight"] for c in checks if c["pass"]) / total)
|
|
145
|
+
fixes = sorted((c for c in checks if not c["pass"]), key=lambda c: -c["weight"])
|
|
146
|
+
grade = next(g for g, floor in (("A", 90), ("B", 80), ("C", 70), ("D", 60), ("F", 0)) if score >= floor)
|
|
147
|
+
return {"url": page.final_url, "score": score, "grade": grade, "top_fixes": [c["fix"] for c in fixes[:5]], "checks": checks}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_SIGNATURES: list[tuple[str, str, str]] = [
|
|
151
|
+
# (name, where: html|header, regex)
|
|
152
|
+
("Next.js", "html", r"/_next/|__NEXT_DATA__"), ("Nuxt", "html", r"/_nuxt/|__NUXT__"),
|
|
153
|
+
("React", "html", r"data-reactroot|react-dom"), ("Vue", "html", r"data-v-[0-9a-f]{6}|vue(\.min)?\.js"),
|
|
154
|
+
("Svelte", "html", r"svelte-[a-z0-9]{5,}"), ("Astro", "html", r"astro-island|/_astro/"),
|
|
155
|
+
("Angular", "html", r"ng-version="), ("Gatsby", "html", r"___gatsby"), ("Remix", "html", r"__remixContext"),
|
|
156
|
+
("WordPress", "html", r"wp-content|wp-includes"), ("Shopify", "html", r"cdn\.shopify\.com|Shopify\.theme"),
|
|
157
|
+
("Webflow", "html", r"webflow\.(js|css)|data-wf-page"), ("Wix", "html", r"static\.wixstatic\.com"),
|
|
158
|
+
("Squarespace", "html", r"squarespace\.com"), ("Framer", "html", r"framerusercontent\.com"),
|
|
159
|
+
("Ghost", "html", r'content="Ghost'), ("Tailwind CSS", "html", r'class="[^"]*\b(?:flex|grid) [^"]*\b(?:px|py|mt|mb)-\d'),
|
|
160
|
+
("Bootstrap", "html", r"bootstrap(\.min)?\.(css|js)"), ("jQuery", "html", r"jquery(\.min)?\.js"),
|
|
161
|
+
("Google Analytics", "html", r"googletagmanager\.com/gtag|google-analytics\.com"),
|
|
162
|
+
("Google Tag Manager", "html", r"googletagmanager\.com/gtm\.js"), ("Plausible", "html", r"plausible\.io/js"),
|
|
163
|
+
("PostHog", "html", r"posthog"), ("Segment", "html", r"cdn\.segment\.com"), ("Hotjar", "html", r"hotjar\.com"),
|
|
164
|
+
("Intercom", "html", r"widget\.intercom\.io"), ("Crisp", "html", r"client\.crisp\.chat"),
|
|
165
|
+
("Stripe", "html", r"js\.stripe\.com"), ("Lemon Squeezy", "html", r"lemonsqueezy"),
|
|
166
|
+
("Cloudflare", "header", r"^server: cloudflare|^cf-ray:"), ("Vercel", "header", r"^server: vercel|^x-vercel-id:"),
|
|
167
|
+
("Netlify", "header", r"^server: netlify|^x-nf-request-id:"), ("Fly.io", "header", r"^fly-request-id:"),
|
|
168
|
+
("nginx", "header", r"^server: nginx"), ("Apache", "header", r"^server: apache"),
|
|
169
|
+
("AWS CloudFront", "header", r"^x-amz-cf-id:|^via: .*cloudfront"), ("PHP", "header", r"^x-powered-by: php"),
|
|
170
|
+
("Express", "header", r"^x-powered-by: express"),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def tech_stack(page: Page) -> dict[str, Any]:
|
|
175
|
+
hdr = "\n".join(f"{k}: {v}" for k, v in page.headers.items()).lower()
|
|
176
|
+
found = []
|
|
177
|
+
for name, where, rx in _SIGNATURES:
|
|
178
|
+
hay = page.text if where == "html" else hdr
|
|
179
|
+
if re.search(rx, hay, re.I | re.M):
|
|
180
|
+
found.append({"name": name, "evidence": where})
|
|
181
|
+
gen = re.search(r'<meta[^>]+name=["\']generator["\'][^>]+content=["\']([^"\']+)', page.text, re.I)
|
|
182
|
+
sec = {h: page.headers.get(h) is not None for h in
|
|
183
|
+
("strict-transport-security", "content-security-policy", "x-frame-options", "x-content-type-options",
|
|
184
|
+
"referrer-policy")}
|
|
185
|
+
return {"url": page.final_url, "technologies": found, "generator": gen.group(1) if gen else None,
|
|
186
|
+
"security_headers": sec, "server": page.headers.get("server")}
|
web_intel_mcp/billing.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Per-call credit billing on top of keyflint_verify.mcp.KeyFlint.
|
|
2
|
+
|
|
3
|
+
stdio: the license comes from KEYFLINT_LICENSE (one buyer per process).
|
|
4
|
+
HTTP: the license comes from each request (``Authorization: Bearer LIC-...`` or
|
|
5
|
+
``X-KeyFlint-License``), so one hosted server bills many buyers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import threading
|
|
14
|
+
import uuid
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from typing import Any, AsyncIterator, Mapping, Optional
|
|
18
|
+
|
|
19
|
+
from keyflint_verify.mcp import LICENSE_INVALID, KeyFlint, LicenseError
|
|
20
|
+
|
|
21
|
+
LICENSE_HEADERS = ("x-license-key", "authorization")
|
|
22
|
+
_MAX_CLIENTS = 1024
|
|
23
|
+
|
|
24
|
+
DEFAULT_GATEWAY = "https://licenses.keyflint.dev"
|
|
25
|
+
DEFAULT_CHECKOUT = "https://mcp.keyflint.dev/precios/"
|
|
26
|
+
_KEYRING_TTL = 6 * 3600
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _env(name: str) -> str:
|
|
30
|
+
return (os.environ.get("KEYFLINT_" + name) or os.environ.get("KEYFORGE_" + name) or "").strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fetch_keyring(gateway: str) -> str:
|
|
34
|
+
"""Gateway public keyring, cached in ~/.keyflint (stale copy used when offline)."""
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import time
|
|
38
|
+
import urllib.request
|
|
39
|
+
|
|
40
|
+
cache = pathlib.Path(os.environ.get("KEYFLINT_KEYRING_CACHE")
|
|
41
|
+
or pathlib.Path.home() / ".keyflint" / "keyring.json")
|
|
42
|
+
slot = hashlib.sha256(gateway.encode()).hexdigest()[:16]
|
|
43
|
+
try:
|
|
44
|
+
stored = json.loads(cache.read_text("utf-8"))
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
stored = {}
|
|
47
|
+
entry = stored.get(slot) or {}
|
|
48
|
+
if entry.get("keyring") and time.time() - float(entry.get("at", 0)) < _KEYRING_TTL:
|
|
49
|
+
return str(entry["keyring"])
|
|
50
|
+
try:
|
|
51
|
+
req = urllib.request.Request(gateway + "/api/v1/licenses/public-key",
|
|
52
|
+
headers={"Accept": "application/json"})
|
|
53
|
+
with urllib.request.urlopen(req, timeout=4) as resp: # noqa: S310 - https gateway
|
|
54
|
+
keyring = str(json.loads(resp.read().decode("utf-8"))["data"]["keyring"]).strip()
|
|
55
|
+
except Exception: # noqa: BLE001 - offline: fall back to the cached keyring
|
|
56
|
+
return str(entry.get("keyring") or "")
|
|
57
|
+
if keyring:
|
|
58
|
+
try:
|
|
59
|
+
stored[slot] = {"keyring": keyring, "at": time.time()}
|
|
60
|
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
cache.write_text(json.dumps(stored), "utf-8")
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
return keyring
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def apply_defaults() -> None:
|
|
68
|
+
"""Only KEYFLINT_LICENSE is required: gateway, keyring and checkout default to KeyFlint's."""
|
|
69
|
+
if not _env("URL"):
|
|
70
|
+
os.environ["KEYFLINT_URL"] = DEFAULT_GATEWAY
|
|
71
|
+
if not _env("CHECKOUT_URL"):
|
|
72
|
+
os.environ["KEYFLINT_CHECKOUT_URL"] = DEFAULT_CHECKOUT
|
|
73
|
+
if not (_env("PUBLIC_KEYS") or _env("PUBLIC_KEY")):
|
|
74
|
+
keyring = _fetch_keyring(_env("URL").rstrip("/"))
|
|
75
|
+
if keyring:
|
|
76
|
+
os.environ["KEYFLINT_PUBLIC_KEYS"] = keyring
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def license_from_headers(headers: Optional[Mapping[str, str]]) -> Optional[str]:
|
|
80
|
+
if not headers:
|
|
81
|
+
return None
|
|
82
|
+
lower = {k.lower(): v for k, v in headers.items()}
|
|
83
|
+
raw = lower.get("x-license-key") or ""
|
|
84
|
+
if not raw:
|
|
85
|
+
auth = lower.get("authorization") or ""
|
|
86
|
+
if auth[:7].lower() == "bearer ":
|
|
87
|
+
raw = auth[7:]
|
|
88
|
+
raw = raw.strip()
|
|
89
|
+
return raw or None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Billing:
|
|
93
|
+
"""Resolves the buyer's license and debits credits around a tool body."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, *, hosted: bool = False, **kf_kwargs: Any) -> None:
|
|
96
|
+
self.hosted = hosted
|
|
97
|
+
if "public_keys" not in kf_kwargs:
|
|
98
|
+
apply_defaults()
|
|
99
|
+
self._kf_kwargs = kf_kwargs
|
|
100
|
+
self._env_kf = KeyFlint(**kf_kwargs)
|
|
101
|
+
self._clients: "OrderedDict[str, KeyFlint]" = OrderedDict()
|
|
102
|
+
self._lock = threading.Lock()
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def checkout_url(self) -> Optional[str]:
|
|
106
|
+
return self._env_kf.checkout_url
|
|
107
|
+
|
|
108
|
+
def client(self, headers: Optional[Mapping[str, str]] = None) -> KeyFlint:
|
|
109
|
+
key = license_from_headers(headers)
|
|
110
|
+
if key is None:
|
|
111
|
+
if self.hosted:
|
|
112
|
+
text = "Send your KeyFlint license as 'Authorization: Bearer LIC-...'."
|
|
113
|
+
if self.checkout_url:
|
|
114
|
+
text += f" Get one: {self.checkout_url}"
|
|
115
|
+
raise LicenseError(LICENSE_INVALID, text, {"reason": "ERR_EMPTY_TOKEN",
|
|
116
|
+
"checkout_url": self.checkout_url})
|
|
117
|
+
return self._env_kf
|
|
118
|
+
with self._lock:
|
|
119
|
+
kf = self._clients.get(key)
|
|
120
|
+
if kf is None:
|
|
121
|
+
# Hosted: server clock is trusted, no node lock (the buyer's machine is not ours).
|
|
122
|
+
kf = KeyFlint(license_key=key, machine_id=None, clock_guard=False,
|
|
123
|
+
public_keys=self._env_kf.public_keys, gateway_url=self._env_kf.gateway_url,
|
|
124
|
+
checkout_url=self._env_kf.checkout_url, timeout=self._env_kf.timeout)
|
|
125
|
+
self._clients[key] = kf
|
|
126
|
+
if len(self._clients) > _MAX_CLIENTS:
|
|
127
|
+
self._clients.popitem(last=False)
|
|
128
|
+
else:
|
|
129
|
+
self._clients.move_to_end(key)
|
|
130
|
+
return kf
|
|
131
|
+
|
|
132
|
+
@asynccontextmanager
|
|
133
|
+
async def charge(self, credits: int, tool: str, headers: Optional[Mapping[str, str]] = None,
|
|
134
|
+
features: Optional[list[str]] = None) -> AsyncIterator[dict[str, Any]]:
|
|
135
|
+
"""Offline license check, debit, run body; refund if the body raises."""
|
|
136
|
+
kf = self.client(headers)
|
|
137
|
+
kf.check(features)
|
|
138
|
+
idem = f"wi-{uuid.uuid4().hex}"
|
|
139
|
+
receipt = await asyncio.to_thread(kf.deduct, credits, idempotency_key=idem, event_type=f"mcp:{tool}")
|
|
140
|
+
try:
|
|
141
|
+
yield receipt
|
|
142
|
+
except BaseException:
|
|
143
|
+
await asyncio.to_thread(kf.refund, idem, "tool_error")
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
async def balance(self, headers: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
|
|
147
|
+
import json
|
|
148
|
+
import urllib.parse
|
|
149
|
+
import urllib.request
|
|
150
|
+
|
|
151
|
+
kf = self.client(headers)
|
|
152
|
+
kf.check()
|
|
153
|
+
if not kf.gateway_url:
|
|
154
|
+
raise LicenseError(-32063, "KEYFLINT_URL is not set.", {"reason": "GATEWAY_NOT_CONFIGURED"})
|
|
155
|
+
url = f"{kf.gateway_url}/api/v1/quota/balance?" + urllib.parse.urlencode({"license_key": kf.license_key})
|
|
156
|
+
|
|
157
|
+
def _get() -> dict[str, Any]:
|
|
158
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
159
|
+
with urllib.request.urlopen(req, timeout=kf.timeout) as resp: # noqa: S310 - configured URL
|
|
160
|
+
data = json.loads(resp.read().decode("utf-8") or "{}")
|
|
161
|
+
return dict(data.get("data", data))
|
|
162
|
+
|
|
163
|
+
return await asyncio.to_thread(_get)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def from_env() -> Billing:
|
|
167
|
+
return Billing(hosted=os.environ.get("WEB_INTEL_TRANSPORT", "stdio") != "stdio")
|
web_intel_mcp/fetch.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Safe page fetching: http(s) only, no private networks, bounded size, manual redirects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import codecs
|
|
7
|
+
import ipaddress
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from urllib.parse import urljoin, urlsplit
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
16
|
+
|
|
17
|
+
MAX_BYTES = int(os.environ.get("WEB_INTEL_MAX_BYTES", 3_000_000))
|
|
18
|
+
TIMEOUT = float(os.environ.get("WEB_INTEL_TIMEOUT", 15))
|
|
19
|
+
TOTAL_TIMEOUT = float(os.environ.get("WEB_INTEL_TOTAL_TIMEOUT", 30)) # whole fetch, incl. redirects
|
|
20
|
+
MAX_REDIRECTS = 5
|
|
21
|
+
USER_AGENT = os.environ.get("WEB_INTEL_USER_AGENT", "WebIntelMCP/0.1")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FetchError(ToolError):
|
|
25
|
+
"""Bad input or unreachable page; the client sees the message as an isError result."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Page:
|
|
30
|
+
url: str
|
|
31
|
+
final_url: str
|
|
32
|
+
status: int
|
|
33
|
+
headers: dict[str, str]
|
|
34
|
+
text: str
|
|
35
|
+
elapsed_ms: int
|
|
36
|
+
redirects: list[str] = field(default_factory=list)
|
|
37
|
+
truncated: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _allow_private() -> bool:
|
|
41
|
+
return os.environ.get("WEB_INTEL_ALLOW_PRIVATE") == "1"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_url(url: str) -> str:
|
|
45
|
+
url = (url or "").strip()
|
|
46
|
+
if "://" not in url:
|
|
47
|
+
url = "https://" + url
|
|
48
|
+
parts = urlsplit(url)
|
|
49
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
50
|
+
raise FetchError(f"Only http(s) URLs are supported: {url!r}")
|
|
51
|
+
return url
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_peer(resp: httpx.Response) -> None:
|
|
55
|
+
"""Re-check the address actually connected to (defeats DNS rebinding between check and connect)."""
|
|
56
|
+
if _allow_private():
|
|
57
|
+
return
|
|
58
|
+
stream = resp.extensions.get("network_stream")
|
|
59
|
+
addr = stream.get_extra_info("server_addr") if stream is not None else None
|
|
60
|
+
if addr and not ipaddress.ip_address(addr[0]).is_global:
|
|
61
|
+
raise FetchError("The host resolved to a non-public address on connect; refused.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _check_host(host: str) -> None:
|
|
65
|
+
if _allow_private():
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
infos = await asyncio.to_thread(socket.getaddrinfo, host, None)
|
|
69
|
+
except socket.gaierror as exc:
|
|
70
|
+
raise FetchError(f"Cannot resolve host {host!r}.") from exc
|
|
71
|
+
for info in infos:
|
|
72
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
73
|
+
if not ip.is_global:
|
|
74
|
+
raise FetchError(f"Host {host!r} resolves to a non-public address; refused.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _encoding(name: str | None) -> str:
|
|
78
|
+
try:
|
|
79
|
+
return codecs.lookup(name or "utf-8").name
|
|
80
|
+
except LookupError:
|
|
81
|
+
return "utf-8"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def fetch(url: str) -> Page:
|
|
85
|
+
"""Fetch with a hard deadline, so a server that drips bytes cannot hold a worker forever."""
|
|
86
|
+
url = validate_url(url)
|
|
87
|
+
try:
|
|
88
|
+
return await asyncio.wait_for(_fetch(url), TOTAL_TIMEOUT)
|
|
89
|
+
except asyncio.TimeoutError as exc:
|
|
90
|
+
raise FetchError(f"{url} took longer than {TOTAL_TIMEOUT:g}s; gave up.") from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _fetch(url: str) -> Page:
|
|
94
|
+
current, redirects = url, []
|
|
95
|
+
start = time.perf_counter()
|
|
96
|
+
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False,
|
|
97
|
+
headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*;q=0.8"}) as client:
|
|
98
|
+
for _ in range(MAX_REDIRECTS + 1):
|
|
99
|
+
await _check_host(urlsplit(current).hostname or "")
|
|
100
|
+
try:
|
|
101
|
+
async with client.stream("GET", current) as resp:
|
|
102
|
+
_check_peer(resp)
|
|
103
|
+
if resp.is_redirect and "location" in resp.headers:
|
|
104
|
+
redirects.append(current)
|
|
105
|
+
current = validate_url(urljoin(current, resp.headers["location"]))
|
|
106
|
+
continue
|
|
107
|
+
buf, truncated = bytearray(), False
|
|
108
|
+
async for chunk in resp.aiter_bytes():
|
|
109
|
+
buf.extend(chunk)
|
|
110
|
+
if len(buf) > MAX_BYTES:
|
|
111
|
+
truncated = True
|
|
112
|
+
del buf[MAX_BYTES:]
|
|
113
|
+
break
|
|
114
|
+
enc = _encoding(resp.charset_encoding)
|
|
115
|
+
return Page(url=url, final_url=current, status=resp.status_code,
|
|
116
|
+
headers={k.lower(): v for k, v in resp.headers.items()},
|
|
117
|
+
text=buf.decode(enc, errors="replace"),
|
|
118
|
+
elapsed_ms=int((time.perf_counter() - start) * 1000),
|
|
119
|
+
redirects=redirects, truncated=truncated)
|
|
120
|
+
except httpx.HTTPError as exc:
|
|
121
|
+
raise FetchError(f"Could not fetch {current}: {exc.__class__.__name__}: {exc}") from exc
|
|
122
|
+
raise FetchError(f"Too many redirects (>{MAX_REDIRECTS}) starting at {url}.")
|
web_intel_mcp/server.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Web Intel MCP: web pages -> clean markdown, metadata, links, SEO audit, tech stack.
|
|
2
|
+
|
|
3
|
+
Every paid tool debits KeyFlint credits before it fetches and refunds them if the fetch fails.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mcp.server.mcpserver import Context, MCPServer
|
|
13
|
+
from mcp.types import ToolAnnotations
|
|
14
|
+
|
|
15
|
+
from . import analyze
|
|
16
|
+
from .billing import Billing
|
|
17
|
+
from .fetch import FetchError, fetch, validate_url
|
|
18
|
+
|
|
19
|
+
DEFAULT_PRICES = {"read_url": 1, "page_metadata": 1, "extract_links": 1, "tech_stack": 2, "seo_audit": 3}
|
|
20
|
+
MAX_COMPARE = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _prices() -> dict[str, int]:
|
|
24
|
+
prices = dict(DEFAULT_PRICES)
|
|
25
|
+
for item in filter(None, (p.strip() for p in os.environ.get("WEB_INTEL_PRICES", "").split(","))):
|
|
26
|
+
name, _, n = item.partition("=")
|
|
27
|
+
if name.strip() in prices and n.strip().isdigit() and int(n) > 0:
|
|
28
|
+
prices[name.strip()] = int(n)
|
|
29
|
+
return prices
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
PRICES = _prices()
|
|
33
|
+
billing = Billing(hosted=os.environ.get("WEB_INTEL_TRANSPORT", "stdio") != "stdio")
|
|
34
|
+
RO = ToolAnnotations(readOnlyHint=True, openWorldHint=True, idempotentHint=True)
|
|
35
|
+
|
|
36
|
+
mcp = MCPServer(
|
|
37
|
+
"web-intel-mcp",
|
|
38
|
+
title="Web Intel",
|
|
39
|
+
version="0.1.0",
|
|
40
|
+
website_url="https://github.com/ArceeDev/web-intel-mcp",
|
|
41
|
+
instructions=(
|
|
42
|
+
"Turns web pages into agent-ready data. Paid tools cost KeyFlint credits (see `pricing`); "
|
|
43
|
+
"a failed fetch is refunded automatically. Use `read_url` for content, `page_metadata` for SEO tags, "
|
|
44
|
+
"`seo_audit` for a scored checklist with fixes, `tech_stack` to identify frameworks and hosting."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _headers(ctx: Context) -> Any:
|
|
50
|
+
try:
|
|
51
|
+
return ctx.headers
|
|
52
|
+
except ValueError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ok(page: analyze.Page) -> analyze.Page:
|
|
57
|
+
if page.status >= 400:
|
|
58
|
+
raise FetchError(f"{page.final_url} returned HTTP {page.status}; credits refunded.")
|
|
59
|
+
return page
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run(fn: Any, *args: Any) -> Any:
|
|
63
|
+
"""Analysis guard: pathological HTML becomes a clean, refunded error instead of a crash."""
|
|
64
|
+
try:
|
|
65
|
+
return fn(*args)
|
|
66
|
+
except RecursionError as exc:
|
|
67
|
+
raise FetchError("The page HTML is nested too deeply to parse; credits refunded.") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _paid(ctx: Context, tool: str, url: str, fn: Any, *args: Any) -> tuple[dict, dict]:
|
|
71
|
+
url = validate_url(url) # bad input is rejected before any debit
|
|
72
|
+
async with billing.charge(PRICES[tool], tool, _headers(ctx)) as receipt:
|
|
73
|
+
out = _run(fn, _ok(await fetch(url)), *args)
|
|
74
|
+
return out, receipt
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _billing_note(receipt: dict, credits: int) -> dict[str, Any]:
|
|
78
|
+
return {"credits_charged": credits, "credits_remaining": receipt.get("remaining_balance")}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@mcp.tool(annotations=RO)
|
|
82
|
+
async def read_url(url: str, ctx: Context, max_chars: int = 20000) -> dict[str, Any]:
|
|
83
|
+
"""Fetch a web page and return its main content as clean Markdown (nav, ads and cookie banners removed).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
url: Page URL (https:// is assumed when no scheme is given).
|
|
87
|
+
max_chars: Cap on returned markdown length (default 20000, max 100000).
|
|
88
|
+
"""
|
|
89
|
+
max_chars = max(500, min(int(max_chars), 100_000))
|
|
90
|
+
url = validate_url(url)
|
|
91
|
+
out, receipt = await _paid(ctx, "read_url", url, analyze.to_markdown, max_chars)
|
|
92
|
+
return {**out, "billing": _billing_note(receipt, PRICES["read_url"])}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@mcp.tool(annotations=RO)
|
|
96
|
+
async def page_metadata(url: str, ctx: Context) -> dict[str, Any]:
|
|
97
|
+
"""Title, description, canonical, lang, robots, Open Graph, JSON-LD types, H1-H3 and image alt stats."""
|
|
98
|
+
out, receipt = await _paid(ctx, "page_metadata", url, analyze.metadata)
|
|
99
|
+
return {**out, "billing": _billing_note(receipt, PRICES["page_metadata"])}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@mcp.tool(annotations=RO)
|
|
103
|
+
async def extract_links(url: str, ctx: Context, limit: int = 200) -> dict[str, Any]:
|
|
104
|
+
"""Internal and external links on a page (deduplicated, absolute URLs, anchor text)."""
|
|
105
|
+
out, receipt = await _paid(ctx, "extract_links", url, analyze.links, max(1, min(int(limit), 1000)))
|
|
106
|
+
return {**out, "billing": _billing_note(receipt, PRICES["extract_links"])}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@mcp.tool(annotations=RO)
|
|
110
|
+
async def tech_stack(url: str, ctx: Context) -> dict[str, Any]:
|
|
111
|
+
"""Detect frameworks, CMS, analytics, payments, CDN/hosting and security headers of a site."""
|
|
112
|
+
out, receipt = await _paid(ctx, "tech_stack", url, analyze.tech_stack)
|
|
113
|
+
return {**out, "billing": _billing_note(receipt, PRICES["tech_stack"])}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@mcp.tool(annotations=RO)
|
|
117
|
+
async def seo_audit(url: str, ctx: Context) -> dict[str, Any]:
|
|
118
|
+
"""Scored on-page SEO audit (0-100, A-F) with 16 weighted checks and the top fixes to apply first."""
|
|
119
|
+
out, receipt = await _paid(ctx, "seo_audit", url, analyze.seo_audit)
|
|
120
|
+
return {**out, "billing": _billing_note(receipt, PRICES["seo_audit"])}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@mcp.tool(annotations=RO)
|
|
124
|
+
async def compare_seo(urls: list[str], ctx: Context) -> dict[str, Any]:
|
|
125
|
+
"""Audit 2-5 pages (e.g. you vs competitors) and rank them. Costs seo_audit price x number of URLs.
|
|
126
|
+
|
|
127
|
+
Unreachable URLs are reported, not billed: their share is refunded as one batch.
|
|
128
|
+
"""
|
|
129
|
+
clean = list(dict.fromkeys(validate_url(u) for u in urls))
|
|
130
|
+
if not 2 <= len(clean) <= MAX_COMPARE:
|
|
131
|
+
raise FetchError(f"Pass between 2 and {MAX_COMPARE} distinct URLs.")
|
|
132
|
+
billing.client(_headers(ctx)).check() # no fetching for callers without a valid license
|
|
133
|
+
unit = PRICES["seo_audit"]
|
|
134
|
+
results: list[dict[str, Any]] = []
|
|
135
|
+
failed: list[dict[str, str]] = []
|
|
136
|
+
# Debit only the URLs that actually fetched, so failures never cost anything.
|
|
137
|
+
pages = []
|
|
138
|
+
for u in clean:
|
|
139
|
+
try:
|
|
140
|
+
pages.append(_ok(await fetch(u)))
|
|
141
|
+
except FetchError as exc:
|
|
142
|
+
failed.append({"url": u, "error": str(exc)})
|
|
143
|
+
if not pages:
|
|
144
|
+
raise FetchError("None of the URLs could be fetched; nothing was charged.")
|
|
145
|
+
async with billing.charge(unit * len(pages), "compare_seo", _headers(ctx)) as receipt:
|
|
146
|
+
for p in pages:
|
|
147
|
+
a = _run(analyze.seo_audit, p)
|
|
148
|
+
results.append({"url": a["url"], "score": a["score"], "grade": a["grade"], "top_fixes": a["top_fixes"]})
|
|
149
|
+
results.sort(key=lambda r: -r["score"])
|
|
150
|
+
return {"ranking": results, "failed": failed, "billing": _billing_note(receipt, unit * len(pages))}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
|
|
154
|
+
def pricing() -> dict[str, Any]:
|
|
155
|
+
"""Credit price of each tool and where to buy credits. Free."""
|
|
156
|
+
return {"credits_per_call": {**PRICES, "compare_seo": f"{PRICES['seo_audit']} x URLs"},
|
|
157
|
+
"refunds": "Failed fetches are refunded automatically.",
|
|
158
|
+
"buy_credits": billing.checkout_url or "Set KEYFLINT_CHECKOUT_URL"}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
|
|
162
|
+
async def credits_balance(ctx: Context) -> dict[str, Any]:
|
|
163
|
+
"""Remaining credits on your KeyFlint license. Free."""
|
|
164
|
+
return await billing.balance(_headers(ctx))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main() -> None:
|
|
168
|
+
p = argparse.ArgumentParser(prog="web-intel-mcp", description=__doc__)
|
|
169
|
+
p.add_argument("--http", action="store_true", help="Serve Streamable HTTP (hosted, license per request).")
|
|
170
|
+
p.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0"))
|
|
171
|
+
p.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8080)))
|
|
172
|
+
args = p.parse_args()
|
|
173
|
+
if args.http:
|
|
174
|
+
billing.hosted = True
|
|
175
|
+
mcp.run("streamable-http", host=args.host, port=args.port, stateless_http=True, json_response=True)
|
|
176
|
+
else:
|
|
177
|
+
mcp.run("stdio")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: web-intel-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Metered MCP server: web pages to clean Markdown, metadata, links, SEO audits and tech stack. Billed per call with KeyFlint credits.
|
|
5
|
+
Author: ArceeDev
|
|
6
|
+
License-Expression: BUSL-1.1
|
|
7
|
+
Project-URL: Homepage, https://github.com/ArceeDev/web-intel-mcp
|
|
8
|
+
Project-URL: Source, https://github.com/ArceeDev/web-intel-mcp
|
|
9
|
+
Keywords: mcp,seo,markdown,scraping,agents
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: mcp>=2.0
|
|
14
|
+
Requires-Dist: keyflint-verify[fast]>=1.2.0
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
17
|
+
Requires-Dist: markdownify>=0.13
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Web Intel MCP
|
|
24
|
+
|
|
25
|
+
Agent-ready web intelligence as a pay-per-call MCP server. Licensing and credits are handled by
|
|
26
|
+
[KeyFlint](https://keyflint.dev) (offline Ed25519 license check + credit ledger), used here only as the billing backend. Failed fetches are refunded
|
|
27
|
+
automatically; invalid input is rejected before any debit.
|
|
28
|
+
|
|
29
|
+
| Tool | Credits | Returns |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `read_url` | 1 | Main content as clean Markdown (nav, footer, cookie banners removed) |
|
|
32
|
+
| `page_metadata` | 1 | Title, description, canonical, lang, robots, Open Graph, JSON-LD types, H1-H3, image alt stats |
|
|
33
|
+
| `extract_links` | 1 | Deduplicated internal / external links with anchor text |
|
|
34
|
+
| `tech_stack` | 2 | Frameworks, CMS, analytics, payments, CDN/hosting, security headers |
|
|
35
|
+
| `seo_audit` | 3 | 0-100 score, A-F grade, 16 weighted checks, top 5 fixes |
|
|
36
|
+
| `compare_seo` | 3 x URLs | Ranks 2-5 pages (you vs competitors); only fetched URLs are billed |
|
|
37
|
+
| `pricing`, `credits_balance` | free | Price list, remaining credits |
|
|
38
|
+
|
|
39
|
+
Prices are overridable: `WEB_INTEL_PRICES="seo_audit=5,read_url=2"`.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
All you need is your KeyFlint license (`LIC-...`). The gateway URL, its public key and the checkout link have defaults.
|
|
44
|
+
|
|
45
|
+
**Claude Desktop, one click:** download `web-intel-mcp-X.Y.Z.mcpb` from the [latest release](https://github.com/ArceeDev/web-intel-mcp/releases/latest), double-click it and paste your license. No terminal, no JSON.
|
|
46
|
+
|
|
47
|
+
**Claude Code:**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
claude mcp add web-intel -e KEYFLINT_LICENSE=LIC-... -- uvx web-intel-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Cursor, Windsurf, Claude Desktop (JSON):**
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"web-intel": {
|
|
59
|
+
"command": "uvx",
|
|
60
|
+
"args": ["web-intel-mcp"],
|
|
61
|
+
"env": { "KEYFLINT_LICENSE": "LIC-..." }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Optional: `KEYFLINT_URL`, `KEYFLINT_PUBLIC_KEYS` (fetched from the gateway and cached in `~/.keyflint/keyring.json` when unset), `KEYFLINT_CHECKOUT_URL`.
|
|
68
|
+
|
|
69
|
+
## Hosted mode (sellers)
|
|
70
|
+
|
|
71
|
+
Paid logic stays on your server. One process bills many buyers; each
|
|
72
|
+
request carries the buyer's license.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
docker build -t web-intel .
|
|
76
|
+
docker run -p 8080:8080 \
|
|
77
|
+
web-intel
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Buyers connect to `https://<host>/mcp` (Streamable HTTP, stateless) with
|
|
81
|
+
`Authorization: Bearer LIC-...` (or `X-License-Key`). Missing or forged licenses get `-32060`.
|
|
82
|
+
|
|
83
|
+
## Billing flow per call
|
|
84
|
+
|
|
85
|
+
1. URL validated (http/https only). Bad input: error, nothing charged.
|
|
86
|
+
2. Offline Ed25519 license check (`keyflint-verify`), cached 60 s.
|
|
87
|
+
3. `POST /api/v1/quota/deduct` with a fresh idempotency key. Out of credits: `-32062` with the checkout link.
|
|
88
|
+
4. Fetch + analysis. If anything fails: `POST /api/v1/quota/refund` (same key), the client gets `isError`.
|
|
89
|
+
|
|
90
|
+
## Safety
|
|
91
|
+
|
|
92
|
+
Requests to private, loopback and link-local addresses are refused (checked on every redirect hop),
|
|
93
|
+
responses are capped at 3 MB (`WEB_INTEL_MAX_BYTES`), 15 s timeout (`WEB_INTEL_TIMEOUT`), max 5 redirects.
|
|
94
|
+
`WEB_INTEL_ALLOW_PRIVATE=1` disables the address check (tests only).
|
|
95
|
+
|
|
96
|
+
## Tests
|
|
97
|
+
|
|
98
|
+
End-to-end with a fake KeyFlint quota gateway, locally signed licenses and a local test site
|
|
99
|
+
(no network needed):
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install -e ".[test]"
|
|
103
|
+
python -m pytest -q
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
Business Source License 1.1. Source available; production use needs a KeyFlint license (or must be non-commercial). Each version becomes Apache 2.0 four years after release. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
web_intel_mcp/__init__.py,sha256=VdPXr3SLgseHuKFhoWLRajKOv_QRECUXN9fqnf_m_2A,116
|
|
2
|
+
web_intel_mcp/__main__.py,sha256=3dYKHfmWsrdExFlTFlcR5a_icR9fAkn06Yh14TQkEd8,33
|
|
3
|
+
web_intel_mcp/analyze.py,sha256=fYseJhB7-2HdSS8_9B18RIPaaX0Bkm_yAlB1_V-eGVY,10352
|
|
4
|
+
web_intel_mcp/billing.py,sha256=OowbZvqPPZOiMc3dgU5J8JS5aiN9Ex4cSNck21WLoQs,6779
|
|
5
|
+
web_intel_mcp/fetch.py,sha256=hZ-JUBON08k0jrUTLpWLfWTH5JROEpINeK1w0jYCJpA,4652
|
|
6
|
+
web_intel_mcp/server.py,sha256=6vIA0dd3o3wRme9jCByN_TgDi2E6CLUHOaSAN_xH8jU,7528
|
|
7
|
+
web_intel_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=WmSvxxXOjcsfs8DUt4scvKEXcG0hKD6U6APjD-z3PLg,3548
|
|
8
|
+
web_intel_mcp-0.1.0.dist-info/METADATA,sha256=P9kEI3HEDqQHTJfLwAgzz_RFIW7Qlg2D2fmeMh2_Hdc,4139
|
|
9
|
+
web_intel_mcp-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
web_intel_mcp-0.1.0.dist-info/entry_points.txt,sha256=teSnQeGbxZI6B1NpmvhVQbbCYXn1ly8FCg5X7mkmnzU,60
|
|
11
|
+
web_intel_mcp-0.1.0.dist-info/top_level.txt,sha256=P_ZhqQZMOIQj20n0_mxemBCr-jV8c9NuQNHaItTTHHc,14
|
|
12
|
+
web_intel_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
|
|
2
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
3
|
+
|
|
4
|
+
Parameters
|
|
5
|
+
|
|
6
|
+
Licensor: ArceeDev (KeyFlint, https://keyflint.dev, legal@keyflint.dev)
|
|
7
|
+
Licensed Work: Web Intel MCP (web-intel-mcp), version 0.1.0 or later.
|
|
8
|
+
The Licensed Work is (c) 2026 ArceeDev.
|
|
9
|
+
Additional Use Grant: You may make production use of the Licensed Work:
|
|
10
|
+
(a) with a valid KeyFlint license for the Licensed Work
|
|
11
|
+
obtained from the Licensor (https://mcp.keyflint.dev),
|
|
12
|
+
with tool calls metered through the Licensor's
|
|
13
|
+
KeyFlint gateway; or
|
|
14
|
+
(b) for personal, educational, research or evaluation
|
|
15
|
+
purposes that are not commercial.
|
|
16
|
+
This grant does not cover removing, disabling or
|
|
17
|
+
bypassing the license verification or credit metering,
|
|
18
|
+
nor offering the Licensed Work, or a modified version of
|
|
19
|
+
it, to third parties on a hosted, embedded or resold
|
|
20
|
+
basis.
|
|
21
|
+
Change Date: 2030-11-05
|
|
22
|
+
Change License: Apache License, Version 2.0
|
|
23
|
+
|
|
24
|
+
For information about alternative licensing arrangements for the Licensed Work,
|
|
25
|
+
please contact the Licensor at legal@keyflint.dev or https://keyflint.dev.
|
|
26
|
+
|
|
27
|
+
Notice
|
|
28
|
+
|
|
29
|
+
Business Source License 1.1
|
|
30
|
+
|
|
31
|
+
Terms
|
|
32
|
+
|
|
33
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
34
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
35
|
+
Licensor may make an Additional Use Grant, above, permitting limited production use.
|
|
36
|
+
|
|
37
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
38
|
+
available distribution of a specific version of the Licensed Work under this
|
|
39
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
40
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
41
|
+
above terminate.
|
|
42
|
+
|
|
43
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
44
|
+
currently in effect as described in this License, you must purchase a
|
|
45
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
46
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
47
|
+
|
|
48
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
49
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
50
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
51
|
+
for each version of the Licensed Work released by Licensor.
|
|
52
|
+
|
|
53
|
+
You must conspicuously display this License on each original or modified copy
|
|
54
|
+
of the Licensed Work. If you receive the Licensed Work in original or
|
|
55
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
56
|
+
License apply to your use of that work.
|
|
57
|
+
|
|
58
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
59
|
+
terminate your rights under this License for the current and all other
|
|
60
|
+
versions of the Licensed Work.
|
|
61
|
+
|
|
62
|
+
This License does not grant you any right in any trademark or logo of
|
|
63
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
64
|
+
Licensor as expressly required by this License).
|
|
65
|
+
|
|
66
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
67
|
+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
68
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
69
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
70
|
+
TITLE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
web_intel_mcp
|