为网页工具保留来源与抓取时间
课程归属:MCP工具接入实战|第 17 节
当 MCP 客户端拿到网页正文后,如果结果只有一段 text,后续 Agent 就很难判断内容来自哪里、何时抓取,以及引用的 URL 是否经过重定向。本文在 mcp_lab/ 中实现一个可通过 stdio 调用的 fetch_page(url) 工具,把最终来源 URL、UTC 抓取时间、正文和截断状态一起返回,并用测试验证字段契约。本文聚焦来源与时间的可追溯性,不把示例包装成完整的生产级爬虫。
一、完成标准:正文之外,还要留下证据
网页正文是内容,来源与抓取时间是解释内容的元数据。三者应在同一次工具调用中产生,避免客户端另外猜测或用本机时间冒充抓取时间。
本文的验收结果:
- MCP 客户端可以发现
fetch_page并调用它。 - 成功结果至少包含
url、text、fetched_at、truncated。 url是经过重定向后实际响应的最终 URL;source数组保存同一来源。fetched_at是服务端收到成功响应后记录的 UTC 时间,使用 ISO 8601 格式。- 失败时仍遵守统一逻辑结果契约,不伪造来源或抓取时间。
- 对不可信 URL 做基础限制,并明确其安全边界。
1.1 本篇字段契约
| 字段 | 类型 | 成功时含义 | 失败时含义 |
|---|---|---|---|
status | 字符串 | ok | error |
data | 对象或 null | 含 url、text、fetched_at、truncated | null |
source | 字符串数组 | 最终响应 URL,例如 ["https://example.com/"] | 空数组 |
error | 对象或 null | null | 含 code、message |
这里的 status/data/source/error 是应用定义的逻辑结果字段,不是 MCP 协议消息的顶层字段。SDK 会按照工具返回值对应的结果机制序列化它们;客户端应读取工具结果中的结构化内容或文本内容,而不是假定 MCP 协议天然存在名为 source 的字段。
二、调用链路:时间由谁记录,来源取哪个 URL?
关键点有两个。第一,时间戳应由执行抓取的服务端记录,不能由客户端在拿到结果后补一个时间,否则网络延迟会混进时间语义。第二,来源 URL 应采用最终响应 URL;如果网页从入口地址跳转到规范地址,最终 URL 才是正文实际取自的位置。若业务需要完整跳转链,应另行记录经过校验的每一跳,不能只凭最终 URL 推断整个链路。
三、项目结构与版本基线
在 WSL2 Ubuntu 终端中执行以下命令。项目只处理公开网页,不需要模型 API Key,也不会把网页内容发送给第三方模型。
mcp_lab/
├── server.py
├── client.py
├── requirements.txt
├── .env.example
└── tests/
└── test_fetch_page.py
| 项目 | 本篇选择 | 说明 |
|---|---|---|
| Python | 3.11+ | 使用标准库实现 HTTP 请求与 HTML 清理 |
| MCP SDK | mcp==2.2.0 | 课程基线采用官方 Python SDK 稳定版 2.2.0 |
| 连接方式 | stdio | 客户端启动本地服务子进程,不开放监听端口 |
| HTML 处理 | html.parser | 标准库解析,移除常见非正文元素 |
| 时间标准 | UTC ISO 8601 | 例如 2026-10-22T07:00:00Z |
官方发行记录显示,SDK 2.2.0 于 2026-09-07 发布,2.x 为当前稳定主线,1.x 进入维护模式。安装约束是按课程基线锁定,不代表本文已在当前运行环境实际安装验证。参考:官方 SDK Releases。
3.1 安装依赖
在 WSL2 Ubuntu 终端中执行:
cd ~
mkdir -p mcp_lab/tests
cd mcp_lab
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
创建 requirements.txt:
mcp==2.2.0
安装:
python -m pip install -r requirements.txt
创建 .env.example:
# 本篇只抓取公开网页,不需要模型 API Key。
# 不要把账号密码、访问令牌或私有 URL 写入示例配置。
如果安装解析失败,先确认 Python 版本、当前虚拟环境与官方 SDK 文档要求,再检查网络或包索引设置;不要在未记录原因的情况下随意升级到另一个大版本。
四、实现网页正文提取与元数据记录
我们把网页抓取逻辑和 MCP 注册层分开。这样可以在不启动 MCP 服务的情况下测试 URL 校验和 HTML 清理,也便于未来由其他入口复用。示例只接受 HTTP/HTTPS、限制响应大小和请求时间,并对初始地址及重定向地址执行基础公网 IP 检查。
4.1 server.py
将下面代码完整保存为 server.py:
from __future__ import annotations
import ipaddress
import socket
from datetime import datetime, timezone
from html.parser import HTMLParser
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin, urlsplit
from urllib.request import (
HTTPRedirectHandler,
ProxyHandler,
Request,
build_opener,
)
from mcp.server import MCPServer
MAX_URL_LENGTH = 2048
MAX_RESPONSE_BYTES = 1_000_000
MAX_TEXT_CHARS = 12_000
REQUEST_TIMEOUT_SECONDS = 8
USER_AGENT = "MCP-WebTextDemo/1.0"
class FetchFailure(Exception):
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
def error_result(code: str, message: str) -> dict[str, Any]:
return {
"status": "error",
"data": None,
"source": [],
"error": {"code": code, "message": message},
}
def validate_public_url(url: str) -> str:
"""验证 URL 形态,并拒绝解析到非公网地址的主机。"""
if not isinstance(url, str) or not url or len(url) > MAX_URL_LENGTH:
raise FetchFailure("INVALID_URL", "URL 为空、类型错误或长度超限")
try:
parts = urlsplit(url)
scheme = parts.scheme.lower()
host = parts.hostname
port = parts.port
except ValueError:
raise FetchFailure("INVALID_URL", "URL 格式或端口无效") from None
if scheme not in {"http", "https"} or not host:
raise FetchFailure("INVALID_URL", "仅允许带主机名的 HTTP/HTTPS URL")
if parts.username is not None or parts.password is not None:
raise FetchFailure("INVALID_URL", "URL 不允许包含用户名或密码")
if parts.fragment and len(parts.fragment) > MAX_URL_LENGTH:
raise FetchFailure("INVALID_URL", "URL 片段长度超限")
if port is not None and port not in ({80} if scheme == "http" else {443}):
raise FetchFailure("INVALID_URL", "仅允许 HTTP 80 或 HTTPS 443 默认端口")
effective_port = port or (443 if scheme == "https" else 80)
try:
addresses = socket.getaddrinfo(
host, effective_port, type=socket.SOCK_STREAM
)
except OSError:
raise FetchFailure("URL_NOT_PUBLIC", "主机名无法解析") from None
if not addresses:
raise FetchFailure("URL_NOT_PUBLIC", "主机名没有可用 IP 地址")
for item in addresses:
ip_text = item[4][0].split("%", 1)[0]
try:
address = ipaddress.ip_address(ip_text)
except ValueError:
raise FetchFailure("URL_NOT_PUBLIC", "主机解析结果不是有效 IP") from None
if not address.is_global:
raise FetchFailure("URL_NOT_PUBLIC", "目标地址不是公网可路由 IP")
return url
class SafeRedirectHandler(HTTPRedirectHandler):
"""每次重定向前重新执行 URL 与公网地址校验。"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
absolute_url = urljoin(req.full_url, newurl)
validate_public_url(absolute_url)
return super().redirect_request(
req, fp, code, msg, headers, absolute_url
)
class VisibleTextParser(HTMLParser):
"""保留常见正文文本,忽略脚本、样式、导航等非正文节点。"""
HIDDEN_TAGS = {
"script", "style", "noscript", "svg", "nav", "footer",
"header", "form", "iframe", "canvas",
}
BREAK_TAGS = {
"p", "div", "br", "hr", "li", "ul", "ol", "h1", "h2",
"h3", "h4", "h5", "h6", "article", "section", "tr",
}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.hidden_depth = 0
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs) -> None:
if tag in self.HIDDEN_TAGS:
self.hidden_depth += 1
elif not self.hidden_depth and tag in self.BREAK_TAGS:
self.parts.append("\n")
def handle_endtag(self, tag: str) -> None:
if tag in self.HIDDEN_TAGS and self.hidden_depth:
self.hidden_depth -= 1
elif not self.hidden_depth and tag in self.BREAK_TAGS:
self.parts.append("\n")
def handle_data(self, data: str) -> None:
if not self.hidden_depth and data.strip():
self.parts.append(data.strip())
def text(self) -> str:
lines = [" ".join(part.split()) for part in self.parts]
return "\n".join(line for line in lines if line).strip()
def fetch_page_logic(url: str) -> dict[str, Any]:
try:
validate_public_url(url)
opener = build_opener(
SafeRedirectHandler(),
# 教学示例默认不继承环境代理,减少请求被隐式转发的可能。
# 如网络必须使用代理,应显式配置并审查代理信任边界。
ProxyHandler({}),
)
request = Request(
url,
headers={
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml",
},
method="GET",
)
with opener.open(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
final_url = response.geturl()
validate_public_url(final_url)
content_type = response.headers.get_content_type().lower()
if content_type not in {"text/html", "application/xhtml+xml"}:
return error_result(
"UNSUPPORTED_CONTENT_TYPE",
f"不支持的响应类型:{content_type}",
)
raw = response.read(MAX_RESPONSE_BYTES + 1)
if len(raw) > MAX_RESPONSE_BYTES:
return error_result(
"CONTENT_TOO_LARGE",
f"响应正文超过 {MAX_RESPONSE_BYTES} 字节限制",
)
charset = response.headers.get_content_charset() or "utf-8"
html_text = raw.decode(charset, errors="replace")
parser = VisibleTextParser()
parser.feed(html_text)
parser.close()
text = parser.text()
truncated = len(text) > MAX_TEXT_CHARS
if truncated:
text = text[:MAX_TEXT_CHARS]
# 时间含义:服务端完成读取响应并清理出正文后记录的 UTC 时间。
fetched_at = (
datetime.now(timezone.utc)
.isoformat(timespec="seconds")
.replace("+00:00", "Z")
)
return {
"status": "ok",
"data": {
"url": final_url,
"text": text,
"fetched_at": fetched_at,
"truncated": truncated,
},
"source": [final_url],
"error": None,
}
except FetchFailure as exc:
return error_result(exc.code, exc.message)
except HTTPError as exc:
return error_result("HTTP_ERROR", f"网页返回 HTTP 状态码 {exc.code}")
except (URLError, TimeoutError, socket.timeout, OSError) as exc:
# 不把内部异常细节、请求头或凭据直接回传给模型。
return error_result("FETCH_FAILED", f"网页请求失败:{type(exc).__name__}")
except Exception:
return error_result("FETCH_FAILED", "网页解析失败")
mcp = MCPServer("PublicPageTextDemo")
@mcp.tool()
def fetch_page(url: str) -> dict[str, Any]:
"""提取公开 HTML 网页正文,并返回最终来源 URL 与 UTC 抓取时间。"""
return fetch_page_logic(url)
if __name__ == "__main__":
# stdio 模式下不要自行向 stdout 打印调试信息,以免干扰协议消息。
mcp.run()
这段实现保留了哪些元数据?
data.url:最终响应 URL。入口 URL 如果发生重定向,这个字段会反映最终地址。data.fetched_at:UTC 时间,使用Z表示 UTC。它是服务端处理时刻,不是网页作者的发布时间,也不是 HTTPLast-Modified。data.truncated:只表示清理后的正文是否因字符上限被截断。若原始 HTTP 响应超过字节上限,则整个请求返回错误,而不是将不完整 HTML 当作成功结果。source:本例保存最终 URL,方便上层统一收集来源。后续做多跳重定向审计时,可以扩展为redirect_chain,但必须同步更新输出契约和测试。
4.2 时间戳的语义不能混用
| 时间字段 | 表示什么 | 能否替代 fetched_at |
|---|---|---|
fetched_at | 本次工具成功读取并处理网页的 UTC 时间 | 本篇字段 |
HTTP Date | HTTP 响应头声明的响应日期 | 不能,来自对端服务器 |
HTTP Last-Modified | 服务器提供的资源修改时间提示 | 不能,可能缺失或不准确 |
| 网页发布时间 | 页面内容中声明的发布时间 | 不能,需要单独解析并标注来源 |
如果网页内容会被缓存,fetched_at 记录的是本次工具读取到响应的时间,不保证源站刚刚生成了新内容。若未来加入缓存,还应增加 cached、cache_age_seconds 或缓存命中时间等字段,避免把缓存读取时间误解为源站更新时间。
五、通过 stdio 客户端发现并调用工具
MCP 的服务端暴露工具,客户端负责建立连接、发现工具并发起调用;是否由模型自动决定调用,是宿主或 Agent 的另一层职责。本例由 Python 客户端明确调用,不涉及模型 API。
5.1 client.py
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
from typing import Any
from mcp import Client, StdioServerParameters
BASE_DIR = Path(__file__).resolve().parent
def to_jsonable(value: Any) -> Any:
if hasattr(value, "model_dump"):
return value.model_dump(mode="json")
if hasattr(value, "__dict__"):
return value.__dict__
return str(value)
async def main(url: str) -> None:
server = StdioServerParameters(
command=sys.executable,
args=[str(BASE_DIR / "server.py")],
)
async with Client(server) as client:
tool_list = await client.list_tools()
names = [tool.name for tool in tool_list.tools]
print("发现工具:", names)
if "fetch_page" not in names:
raise RuntimeError("服务端未注册 fetch_page 工具")
result = await client.call_tool("fetch_page", {"url": url})
structured = getattr(result, "structured_content", None)
if structured is not None:
print(json.dumps(structured, ensure_ascii=False, indent=2))
else:
# SDK 或客户端可能以文本内容块承载结果;打印实际返回块供检查。
print(json.dumps(
[to_jsonable(item) for item in result.content],
ensure_ascii=False,
indent=2,
))
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit(
"用法:python client.py https://example.com/"
)
asyncio.run(main(sys.argv[1]))
运行命令:
python client.py https://example.com/
第一次执行时,客户端会启动 server.py 子进程,建立 stdio 连接,调用 list_tools(),然后调用 fetch_page。官方 SDK 的 stdio 客户端形式是 Client(StdioServerParameters(...)),在 async with 退出时会清理子进程。参见官方客户端传输文档。
下面是预期结构示例,不是本次实测输出。实际正文由目标网站返回,时间戳也会随执行时刻变化:
{
"status": "ok",
"data": {
"url": "https://example.com/",
"text": "Example Domain This domain is for use in illustrative examples...",
"fetched_at": "2026-10-22T07:00:00Z",
"truncated": false
},
"source": [
"https://example.com/"
],
"error": null
}
时间戳只是格式示例。验收时不要要求它和示例逐字相同,而要检查它是否能被 ISO 8601 解析、是否带 UTC 时区,以及它是否随新的成功调用更新。
六、异常处理:错误结果不应伪造来源
| 错误码 | 触发条件示例 | 处理建议 |
|---|---|---|
INVALID_URL | 非 HTTP(S)、URL 缺主机、包含凭据、非默认端口 | 修正输入 URL |
URL_NOT_PUBLIC | 主机无法解析或解析到非公网地址 | 拒绝访问,不尝试绕过限制 |
HTTP_ERROR | 服务端返回 4xx/5xx 等 HTTP 错误 | 检查地址、权限或站点状态 |
UNSUPPORTED_CONTENT_TYPE | 返回 PDF、图片、JSON 或其他非 HTML 类型 | 使用对应的解析工具,而不是强行按 HTML 处理 |
CONTENT_TOO_LARGE | 原始响应超过 1,000,000 字节 | 限制目标页面或另行设计流式处理 |
FETCH_FAILED | 超时、连接错误或解析异常 | 检查网络、TLS、站点限制与日志 |
失败时 data 为 null、source 为空数组,避免上层把错误页面或未成功抓取的 URL 当作可靠证据。不要把完整异常堆栈、Cookie、Authorization 头或 URL 中可能包含的敏感信息回传给模型。
七、自动化验证:测试契约,而不依赖外网
测试尽量不请求真实网站,避免因 DNS、网络、站点反爬策略或页面变化导致结果不稳定。本例验证 URL 拒绝策略、HTML 正文清理和逻辑结果契约。创建 tests/test_fetch_page.py:
import unittest
from server import (
FetchFailure,
VisibleTextParser,
error_result,
validate_public_url,
)
class FetchPageTests(unittest.TestCase):
def test_reject_non_http_scheme(self):
with self.assertRaises(FetchFailure) as ctx:
validate_public_url("file:///etc/passwd")
self.assertEqual(ctx.exception.code, "INVALID_URL")
def test_reject_url_credentials(self):
with self.assertRaises(FetchFailure) as ctx:
validate_public_url("https://user:pass@example.com/")
self.assertEqual(ctx.exception.code, "INVALID_URL")
def test_parser_removes_script_and_keeps_text(self):
parser = VisibleTextParser()
parser.feed(
"<html><body><h1>Hello</h1>"
"<script>secret()</script><p>Public text</p>"
"</body></html>"
)
parser.close()
text = parser.text()
self.assertIn("Hello", text)
self.assertIn("Public text", text)
self.assertNotIn("secret()", text)
def test_error_contract(self):
result = error_result("INVALID_URL", "bad url")
self.assertEqual(result["status"], "error")
self.assertIsNone(result["data"])
self.assertEqual(result["source"], [])
self.assertEqual(result["error"]["code"], "INVALID_URL")
if __name__ == "__main__":
unittest.main()
执行:
python -m unittest discover -s tests -v
**验证状态说明:**上述测试和运行命令是可复现方案;本文生成时未在真实环境安装 SDK、发起网页请求或执行测试,因此不宣称测试已通过。执行后应看到四个测试项的实际结果,再依据输出判断通过或失败。若要增加公网集成测试,应单独标注为网络测试,并避免将站点临时不可用误判为代码逻辑失败。
八、从教学示例到可部署工具:安全边界
本例用于教学和受控的本地调用,不等同于完整生产级网页采集服务。公网 IP 检查、超时和响应上限能降低部分风险,但不能单独解决所有 SSRF(服务端请求伪造)问题。
尤其要注意 DNS 重绑定与检查时序:代码先解析主机并检查 IP,但 HTTP 库建立连接时可能再次解析域名,两次解析之间存在时间窗口。因此生产环境应进一步采用出口网络策略或专门的安全抓取代理,限制可访问网段,并确保实际建立连接的 IP也受到约束;还要测试 IPv4、IPv6、重定向链、DNS 变化、代理配置和 TLS 行为。不要把这段示例当成抵御任意恶意输入的完整安全边界。
其他边界也需要明确:
- 网页内容不可信。 正文可能含提示注入、错误信息或诱导模型执行操作的文本。调用方应把它作为外部引用材料,而不是系统指令。
- 正文抽取不是浏览器渲染。 本例用标准库解析 HTML,不执行 JavaScript;由前端脚本生成的正文可能提取不到。
- 抓取时间不是内容版本号。 若要审计网页内容变化,应保存内容哈希、响应状态、ETag、Last-Modified 等可用元数据,并制定保留策略。
- 尊重访问规则。 只抓取有权访问的公开页面,遵守网站条款、合理频率与适用法律;不要尝试绕过登录、付费墙、验证码或访问控制。
- 隐私与凭据。 不要传入带会话令牌的 URL,也不要把内部业务地址、内网主机名或私有文档内容交给此工具。
九、验收清单
| 验收项 | 通过判据 | 不通过时检查 |
|---|---|---|
| 工具发现 | list_tools() 包含 fetch_page | 服务启动、导入错误、SDK 版本 |
| 成功结果 | status=ok 且含 url/text/fetched_at/truncated | 响应类型、解析流程、结果序列化 |
| 来源一致性 | source[0] 与 data.url 相同 | 是否错误地保存了入口 URL |
| 时间语义 | 时间可解析,时区为 UTC | 是否使用本机无时区时间 |
| 失败契约 | status=error、data=null、source=[] | 是否有异常路径绕过统一返回 |
| 安全边界 | 非法协议、凭据 URL 被拒绝 | 是否存在绕过校验的调用路径 |
本篇的核心交付不是“成功打印了一段网页文字”,而是每次成功提取都同时携带可核对的最终来源和明确语义的 UTC 抓取时间。后续第 18 节接入业务 HTTP 接口时,也可以沿用“结果内容与来源元数据一并返回”的设计原则,但应根据业务接口的认证、权限和审计要求重新设计,不能直接照搬网页抓取策略。
本文介绍如何在MCP协议下实现网页抓取工具,确保每次调用返回结构化结果,包含最终响应URL、UTC抓取时间、正文及截断状态。强调服务端记录时间戳、采用重定向后最终URL作为来源,并通过stdio客户端调用与自动化测试验证字段契约。内容聚焦元数据可追溯性、异常不伪造、安全边界(如SSRF防护)及生产就绪前的约束说明。
10

被折叠的 条评论
为什么被折叠?



