【MCP工具接入实战】为网页工具保留来源与抓取时间

本文介绍如何在MCP协议下实现网页抓取工具,确保每次调用返回结构化结果,包含最终响应URL、UTC抓取时间、正文及截断状态。强调服务端记录时间戳、采用重定向后最终URL作为来源,并通过stdio客户端调用与自动化测试验证字段契约。内容聚焦元数据可追溯性、异常不伪造、安全边界(如SSRF防护)及生产就绪前的约束说明。

为网页工具保留来源与抓取时间

课程归属: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字符串okerror
data对象或 null含 url、text、fetched_at、truncatednull
source字符串数组最终响应 URL,例如 ["https://example.com/"]空数组
error对象或 nullnull含 code、message

这里的 status/data/source/error 是应用定义的逻辑结果字段,不是 MCP 协议消息的顶层字段。SDK 会按照工具返回值对应的结果机制序列化它们;客户端应读取工具结果中的结构化内容或文本内容,而不是假定 MCP 协议天然存在名为 source 的字段。

二、调用链路:时间由谁记录,来源取哪个 URL?

MCP 客户端提交 URL

服务端校验 URL

请求公开网页

检查重定向与响应类型

读取并清理 HTML

记录 UTC 抓取时间

返回正文、最终 URL、时间与截断标记

校验失败:返回标准错误

请求失败:返回标准错误

关键点有两个。第一,时间戳应由执行抓取的服务端记录,不能由客户端在拿到结果后补一个时间,否则网络延迟会混进时间语义。第二,来源 URL 应采用最终响应 URL;如果网页从入口地址跳转到规范地址,最终 URL 才是正文实际取自的位置。若业务需要完整跳转链,应另行记录经过校验的每一跳,不能只凭最终 URL 推断整个链路。

三、项目结构与版本基线

在 WSL2 Ubuntu 终端中执行以下命令。项目只处理公开网页,不需要模型 API Key,也不会把网页内容发送给第三方模型。

mcp_lab/
├── server.py
├── client.py
├── requirements.txt
├── .env.example
└── tests/
    └── test_fetch_page.py
项目本篇选择说明
Python3.11+使用标准库实现 HTTP 请求与 HTML 清理
MCP SDKmcp==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。它是服务端处理时刻,不是网页作者的发布时间,也不是 HTTP Last-Modified。
  • data.truncated:只表示清理后的正文是否因字符上限被截断。若原始 HTTP 响应超过字节上限,则整个请求返回错误,而不是将不完整 HTML 当作成功结果。
  • source:本例保存最终 URL,方便上层统一收集来源。后续做多跳重定向审计时,可以扩展为 redirect_chain,但必须同步更新输出契约和测试。

4.2 时间戳的语义不能混用

时间字段表示什么能否替代 fetched_at
fetched_at本次工具成功读取并处理网页的 UTC 时间本篇字段
HTTP DateHTTP 响应头声明的响应日期不能,来自对端服务器
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 时区,以及它是否随新的成功调用更新。

六、异常处理:错误结果不应伪造来源

否

是

否

是

否

是

收到 URL

URL 格式和协议允许?

INVALID_URL

所有解析 IP 都是公网地址?

URL_NOT_PUBLIC

请求并逐跳校验重定向

响应可接受?

返回对应错误码

提取正文并记录 UTC 时间

status=ok,source 含最终 URL

错误码触发条件示例处理建议
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 解析" "网页工具服务端" "MCP 客户端" "公开网站" "DNS 解析" "网页工具服务端" "MCP 客户端" fetch_page(url) 解析主机地址 返回 IP 列表 检查协议、端口与公网 IP 发起限时 HTTP 请求 返回 HTML 或重定向 校验重定向并提取正文 正文、最终 URL、UTC 时间

尤其要注意 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 接口时,也可以沿用“结果内容与来源元数据一并返回”的设计原则,但应根据业务接口的认证、权限和审计要求重新设计,不能直接照搬网页抓取策略。

参考资料

  1. MCP Python SDK 官方仓库与发行记录
  2. MCP Python SDK:Client transports(stdio 与 Streamable HTTP)
  3. MCP Python SDK:Servers(Tools、Resources、Prompts)
  4. MCP Python SDK:Client
  5. Mermaid flowchart 语法
  6. Mermaid sequence diagram 语法
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值