MCP 工具开发实战:参数校验、结构化输出与错误处理

MCP 实战第 3 篇。沿用官方 Python SDK 2.3.0,把已经连通的缺陷查询能力完善为有明确输入、输出、分页和错误约定的工具。本文全部使用虚构数据,不连接真实缺陷平台。

上一篇已经用本地 stdio 跑通工具发现和调用。但"能调用"只是起点:模型传入字符串形式的数量怎么办,查询不到记录是否算失败,列表只返回两条时能不能说项目只有两条缺陷,上游数据缺字段又应该返回什么?

这一篇围绕这些问题继续完善团队研发助手。目标不是增加一批工具,而是让一个工具的职责与结果足够清楚,方便客户端可靠使用。

一、先写下业务约定

本篇工具名为 list_project_issues,只做只读缺陷查询。演示项目 demo-project 有三条 open、一条 closed 记录;empty-project 是允许查询的空项目;offline-project 用于模拟数据源不可用。其他名称按演示策略拒绝。

方面 本篇约定
查询 项目、状态、每页数量、偏移量
排序 按缺陷 ID 升序
输入 request 对象,拒绝其中的未知字段和错误类型
输出 明确字段的 IssuePage,而不是任意字典
空结果 成功返回空列表与相应计数
失败 is_error 为 true,并给出受控的业务错误信息
权限 仅固定演示策略,没有真实登录与用户鉴权

分页使用 offset,主要为了让小样例容易检查。它不是适用于所有实时数据集的分页方案;后文会说明数据变化时的限制。

环境继续使用 Python 3.11+、mcp==2.3.0,本篇同时固定 Pydantic 2.14.1。实际验证为 macOS、Python 3.13.3。SDK 的注册与结果转换依据固定标签源码核对,不把滚动文档自动套到其他版本。SDK 2.3.0 源码

二、参数校验要同时看类型、范围与对象边界

我们将参数放进一个 IssueQuery 模型。project 满足命名规则,status 只允许 open 或 closed,limit 为 1~20 的整数,offset 为 0~100 的整数。

为什么还要强调整数?在一些宽松转换中,数字字符串可能被转换,布尔值也容易与整数混淆。本篇采用严格类型,实际调用中 "2"、true 和越界数字都会被拒绝。Pydantic 严格模式

工具的调用参数长这样,注意外层有 request:

json 复制代码
{
  "request": {
    "project": "demo-project",
    "status": "open",
    "limit": 2,
    "offset": 0
  }
}

这里的 extra="forbid" 限制的是 IssueQuery 等自定义模型内部。例如 request 内出现 unrecognized 会失败。SDK 生成的外层函数参数对象还有自己的行为,不能因为内部模型禁止额外字段,就声称整个协议包的任意层都已采用同样策略。

校验也不等于鉴权。project 是调用者提供的条件,真实系统仍需根据可信身份在服务端决定允许查询的项目,不能凭模型填了一个项目名就放行。

三、结构化输出需要表达哪些事实

上一版返回 dict[str, Any],能够获得结构化结果,但内部字段还缺少明确约束。本篇改为 IssuePage,里面包含列表和查询条件,也包含分页元数据。

字段 含义
returned_count 当前实际返回多少条
matched_total 当前样例中满足筛选条件的总条数
offset、limit 本次请求的分页位置与数量
has_more 后面是否还有记录
next_offset 下一页偏移量;无后续时为 null
issues 经过字段校验的缺陷记录
source 明确标识 local-fixture

返回列表的长度与 total 不是同一个事实。第一页返回两条、matched_total 为三时,客户端可以说"本次返回两条,还有下一页",不能把两条包装成项目全部未关闭缺陷。

我们还加入跨字段校验:returned_count 必须等于列表长度,并符合当前偏移量、页大小和总量的关系;同页 ID 不重复、顺序稳定;每行项目和状态与查询一致;has_more 与 next_offset 彼此一致。

这种关系适合使用模型级校验器表达。Pydantic 模型校验器

这些自定义跨字段逻辑不会全部自动表达在 JSON Schema 中。公布 output_schema 不等于所有客户端都会执行同一套业务关系检查;运行时校验仍然需要保留。

它只能证明返回数据的结构和关系自洽。一个错误数据源完全可能返回"格式正确但事实错误"的记录,实际业务仍需验证数据来源、权限与查询实现。

四、完整模型代码

将以下代码保存为 mcp_issue_models.py:

python 复制代码
"""Article 03 contracts. Fixed SDK environment: mcp==2.3.0."""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator

Status = Literal["open", "closed"]


class Contract(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")


class IssueQuery(Contract):
    project: str = Field(pattern=r"^[a-z][a-z0-9-]{0,39}$")
    status: Status = "open"
    limit: int = Field(default=2, ge=1, le=20)
    offset: int = Field(default=0, ge=0, le=100)


class Issue(Contract):
    id: str = Field(pattern=r"^DEMO-[0-9]{3}$")
    project: str
    title: str = Field(min_length=1, max_length=200)
    status: Status


class IssuePage(Contract):
    source: Literal["local-fixture"] = "local-fixture"
    project: str
    status: Status
    limit: int = Field(ge=1, le=20)
    offset: int = Field(ge=0, le=100)
    returned_count: int = Field(ge=0)
    matched_total: int = Field(ge=0)
    has_more: bool
    next_offset: int | None
    issues: list[Issue]

    @model_validator(mode="after")
    def consistent_page(self):
        expected_count = min(self.limit, max(self.matched_total - self.offset, 0))
        if self.returned_count != len(self.issues) or self.returned_count != expected_count:
            raise ValueError("inconsistent_page_count")
        ids = [row.id for row in self.issues]
        if ids != sorted(ids) or len(set(ids)) != len(ids):
            raise ValueError("inconsistent_page_order")
        if any(row.project != self.project or row.status != self.status for row in self.issues):
            raise ValueError("inconsistent_page_scope")
        expected_more = self.offset + self.returned_count < self.matched_total
        expected_next = self.offset + self.returned_count if expected_more else None
        if self.has_more != expected_more or self.next_offset != expected_next:
            raise ValueError("inconsistent_pagination")
        return self

本篇限制标题长度,并没有验证标题是否充分描述了真实缺陷。类似地,ID 符合格式不代表它存在于某个真实平台。字段校验应说明自己能保证什么,不能把结构约束写成内容质量保证。

默认值会在省略 status、limit 和 offset 时使用。读者在客户端看到输入 schema 时,也应能理解这些值,而不是必须阅读服务端函数体才能猜测行为。

五、完整服务代码:查询、分页与受控错误

保存为 mcp_issue_server.py,与模型文件放在同一目录:

python 复制代码
"""Read-only fictional issue tool. No model, HTTP request or real user identity."""
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import ValidationError
from mcp_issue_models import Issue, IssuePage, IssueQuery

mcp = MCPServer("issue-contract-demo")


class ProjectUnavailable(Exception):
    pass


class SourceUnavailable(Exception):
    pass


ROWS = (
    {"id": "DEMO-001", "project": "demo-project", "title": "演示:按钮重复响应", "status": "open"},
    {"id": "DEMO-002", "project": "demo-project", "title": "演示:报告标题异常", "status": "open"},
    {"id": "DEMO-003", "project": "demo-project", "title": "演示:空列表提示缺失", "status": "closed"},
    {"id": "DEMO-004", "project": "demo-project", "title": "演示:筛选条件未保留", "status": "open"},
)


def fetch_demo_records(project):
    # A fixed demo policy, not authentication or real project authorization.
    if project not in {"demo-project", "empty-project", "offline-project"}:
        raise ProjectUnavailable()
    if project == "offline-project":
        raise SourceUnavailable()
    return [dict(row) for row in ROWS] if project == "demo-project" else []


def build_page(query):
    records = [Issue.model_validate(row) for row in fetch_demo_records(query.project)]
    if any(row.project != query.project for row in records):
        raise ValueError("invalid_source_scope")
    if len({row.id for row in records}) != len(records):
        raise ValueError("duplicate_source_id")
    matched = sorted((row for row in records if row.status == query.status), key=lambda row: row.id)
    page = matched[query.offset:query.offset + query.limit]
    more = query.offset + len(page) < len(matched)
    return IssuePage(project=query.project, status=query.status, limit=query.limit, offset=query.offset,
                     returned_count=len(page), matched_total=len(matched), has_more=more,
                     next_offset=query.offset + len(page) if more else None, issues=page)


@mcp.tool(
    title="查询演示项目缺陷",
    description="只查询虚构数据。支持状态筛选与offset分页,按ID排序;不修改缺陷,不访问真实平台。",
    structured_output=True,
)
def list_project_issues(request: IssueQuery) -> IssuePage:
    try:
        return build_page(request)
    except ProjectUnavailable:
        raise ToolError("PROJECT_UNAVAILABLE: 当前演示配置不可查询该项目。") from None
    except SourceUnavailable:
        raise ToolError("SOURCE_UNAVAILABLE: 演示数据源不可用;保留原查询条件,稍后再核验。") from None
    except (ValidationError, ValueError):
        raise ToolError("INVALID_SOURCE_DATA: 数据不满足本工具的返回约定。") from None
    except Exception:
        raise ToolError("INTERNAL_ERROR: 查询未完成,请由维护人员核查。") from None


if __name__ == "__main__":
    mcp.run(transport="stdio")

fetch_demo_records 是本篇的数据访问边界。它目前只读常量,offline-project 的异常也是人为设计的演示分支,没有实际请求外部 API。

build_page 先验证原始记录,再筛选、排序、截取与构造返回模型。原始数据含重复 ID 或不属于查询项目的记录时,不会直接交给调用方当作正常结果。

list_project_issues 负责把可以预期的失败转成受控信息。structured_output=True 表明这里明确需要结构化输出,返回类型 IssuePage 用于生成和校验输出约定。固定 SDK 的转换逻辑可参照 结果转换实现。

服务使用 stdio,stdout 仍应交给协议消息,不要加入普通启动横幅。它是本机受控教学进程,不包含 HTTP 部署、登录认证和多租户隔离。

六、错误信息怎么设计

本篇使用的 PROJECT_UNAVAILABLE、SOURCE_UNAVAILABLE、INVALID_SOURCE_DATA、INTERNAL_ERROR 都是自定义业务标签,放在 ToolError 的文本里。它们不是 MCP 规定的 JSON-RPC 数字错误码,也不是协议级结构化错误扩展。

情况 客户端应该如何理解
允许查询的项目确实为空 查询成功,列表为空
offset 超过总量 当前页为空,仍保留匹配总量
参数不满足输入模型 调用未通过参数校验
演示策略不允许该项目 不能查询,不透露更多项目细节
数据源不可用 本次未取得可靠查询结果
原始记录损坏 数据不符合约定,不能冒充正常列表
预期外异常 返回受控失败,由维护人员继续排查

不要把数据源异常变成 issues=[]。这样会让用户误以为查询成功、没有缺陷。空结果与失败必须保留不同证据。

也不要默认把原始异常字符串塞进 ToolError。真正接入外部平台之后,异常可能带地址、响应正文或业务内容。本篇预期业务错误使用固定文本,未知异常也不原样回传。

但这不等于所有日志已经脱敏。SDK 的参数校验和异常日志可能包含输入值或堆栈,本篇故障注入也会让它输出诊断信息。生产接入需要另行管理日志内容、访问权限与保留策略;本篇只验证指定的原始异常标记没有进入客户端错误内容。

固定 SDK 会将工具调用过程中的一些失败转换成工具错误结果,具体可查 工具执行实现。连接失败或协议级错误仍可能抛出异常,不能把所有异常都假设成普通 is_error 结果。

七、完整检查程序

保存为 mcp_issue_check.py:

python 复制代码
"""Real stdio checks plus in-process SDK fault injection; Python 3.11+."""
import asyncio
import copy
from importlib.metadata import version
import json
from pathlib import Path
import sys
from unittest.mock import patch
from mcp import Client, StdioServerParameters
from mcp.types import TextContent
from mcp_issue_models import IssuePage
import mcp_issue_server as server


async def verify():
    checks = []

    def require(condition, label):
        if not condition:
            raise RuntimeError("check_failed:" + label)
        checks.append(label)

    def error_text(result):
        return "\n".join(block.text for block in result.content if isinstance(block, TextContent))

    async def call(client, **query):
        return await client.call_tool("list_project_issues", {"request": query})

    def page(result):
        if result.is_error or result.structured_content is None:
            raise RuntimeError("expected_successful_structured_result")
        return IssuePage.model_validate(result.structured_content)

    path = Path(__file__).with_name("mcp_issue_server.py").resolve()
    params = StdioServerParameters(command=sys.executable, args=[str(path)], cwd=str(path.parent),
                                    env={"PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"})
    async with Client(params) as client:
        tools = (await client.list_tools()).tools
        require([tool.name for tool in tools] == ["list_project_issues"], "tool_discovery")
        require(tools[0].input_schema["properties"]["request"].get("$ref") is not None
                and tools[0].output_schema is not None, "nested_input_and_output_schema")
        first = page(await call(client, project="demo-project"))
        require(first.matched_total == 3 and first.returned_count == 2 and first.has_more
                and first.next_offset == 2, "first_page_counts")
        second = page(await call(client, project="demo-project", offset=first.next_offset))
        require([row.id for row in first.issues + second.issues] == ["DEMO-001", "DEMO-002", "DEMO-004"]
                and not second.has_more and second.next_offset is None, "second_page_no_duplicate")
        require(page(await call(client, project="demo-project", status="closed")).matched_total == 1, "closed_filter")
        empty = page(await call(client, project="empty-project"))
        require(empty.matched_total == 0 and empty.returned_count == 0 and not empty.has_more, "empty_is_success")
        require(page(await call(client, project="demo-project", offset=20)).returned_count == 0, "offset_beyond_total")
        for label, fields in [
            ("invalid_status", {"status": "all"}), ("limit_zero", {"limit": 0}),
            ("limit_too_large", {"limit": 21}), ("limit_bool", {"limit": True}),
            ("limit_string", {"limit": "2"}), ("offset_negative", {"offset": -1}),
            ("extra_inside_request", {"unrecognized": "x"}),
        ]:
            result = await call(client, project="demo-project", **fields)
            require(result.is_error is True, label)
        missing = await client.call_tool("list_project_issues", {})
        require(missing.is_error is True, "missing_request")
        for project, code in [("private-project", "PROJECT_UNAVAILABLE"), ("offline-project", "SOURCE_UNAVAILABLE")]:
            result = await call(client, project=project)
            require(result.is_error and code in error_text(result) and result.structured_content is None, code.lower())
        protocol = client.protocol_version
    stdio_count = len(checks)
    # Faults are injected in-process through the SDK, not into the stdio child.
    async with Client(server.mcp) as client:
        with patch.object(server, "fetch_demo_records", return_value=[{"id": "bad"}]):
            result = await call(client, project="demo-project")
            require(result.is_error and "INVALID_SOURCE_DATA" in error_text(result), "malformed_source")
        with patch.object(server, "fetch_demo_records", side_effect=RuntimeError("PRIVATE_MARKER")):
            result = await call(client, project="demo-project")
            require(result.is_error and "INTERNAL_ERROR" in error_text(result)
                    and "PRIVATE_MARKER" not in error_text(result), "unexpected_error_no_raw_text")
        with patch.object(server, "build_page", return_value={"wrong": "PRIVATE_MARKER"}):
            result = await call(client, project="demo-project")
            require(result.is_error and result.structured_content is None
                    and "PRIVATE_MARKER" not in error_text(result), "sdk_rejects_invalid_output")
        valid = server.build_page(server.IssueQuery(project="demo-project")).model_dump(mode="json")
        for label in ("output_count_mismatch", "output_scope_mismatch", "output_next_offset_mismatch"):
            corrupted = copy.deepcopy(valid)
            if label == "output_count_mismatch":
                corrupted["returned_count"] = 1
            elif label == "output_scope_mismatch":
                corrupted["issues"][0]["project"] = "other-project"
            else:
                corrupted["next_offset"] = 99
            with patch.object(server, "build_page", return_value=corrupted):
                result = await call(client, project="demo-project")
                require(result.is_error and result.structured_content is None, label)
    return {"sdk_version": version("mcp"), "protocol_version": protocol, "source": "local-fixture",
            "stdio_checks": stdio_count, "in_process_checks": len(checks) - stdio_count,
            "passed": len(checks), "checks": checks}


if __name__ == "__main__":
    if version("mcp") != "2.3.0":
        raise SystemExit("Install mcp==2.3.0")

    async def main():
        async with asyncio.timeout(30):
            return await verify()
    print(json.dumps(asyncio.run(main()), ensure_ascii=False, indent=2))

程序分成两部分。第一部分实际启动服务子进程,通过 stdio 完成发现与调用。第二部分将服务对象交给 SDK Client,在进程内注入坏数据与异常,观察 SDK 的处理结果。

这里的进程内故障检查没有启动第二套 stdio 连接,所以结果会分别列出 stdio_checks 与 in_process_checks。不能把合计数统称为"所有检查都走了子进程传输"。

输出模型故障注入绕过正常 build_page,直接给 SDK 一份错误字典。这是刻意测试最后一层结果转换能否拒绝不合约定的输出,不代表服务正常查询时会返回这个字典。

客户端先检查 is_error 与 structured_content,再使用自己的 IssuePage 解析结果。服务端与客户端复用模型便于教学和统一约定,但不构成两套独立实现的交叉认证,也不能单靠这个证明所有业务逻辑都正确。

八、Mac 与 Windows 运行步骤

本篇只需要以下四个文件:

text 复制代码
mcp-tools-lab/
  mcp-03-requirements.txt
  mcp_issue_models.py
  mcp_issue_server.py
  mcp_issue_check.py

依赖文件 mcp-03-requirements.txt 内容为:

text 复制代码
mcp==2.3.0
pydantic==2.14.1

推荐使用独立目录。Mac:

bash 复制代码
python3 -m venv .venv
.venv/bin/python -m pip install -r mcp-03-requirements.txt
.venv/bin/python -m pip check
.venv/bin/python mcp_issue_check.py

Windows PowerShell:

powershell 复制代码
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r mcp-03-requirements.txt
.\.venv\Scripts\python.exe -m pip check
.\.venv\Scripts\python.exe mcp_issue_check.py

Python 应为 3.11 或更新版本。Windows 未提供 py 启动器时,使用实际安装的解释器路径创建环境。三个 Python 文件放在同一目录,无需把上一篇旧代码混入;这次的自检对应这次的工具名称和 schema。

如果继续使用上一篇环境,先核对两个固定版本。直接依赖已锁定,其他间接依赖仍可能随解析时间与平台变化;不能把它叫作完全锁定的跨平台环境。

本次实际运行在 macOS、Python 3.13.3;Windows 命令提供适配指引,尚未实机执行。

九、看一页真实调用产生的演示结果

下面是通过本篇 stdio 客户端实际调用得到的结构化结果。通信是真实 MCP,记录仍然是本地虚构数据:

json 复制代码
{
  "source": "local-fixture",
  "project": "demo-project",
  "status": "open",
  "limit": 2,
  "offset": 0,
  "returned_count": 2,
  "matched_total": 3,
  "has_more": true,
  "next_offset": 2,
  "issues": [
    {
      "id": "DEMO-001",
      "project": "demo-project",
      "title": "演示:按钮重复响应",
      "status": "open"
    },
    {
      "id": "DEMO-002",
      "project": "demo-project",
      "title": "演示:报告标题异常",
      "status": "open"
    }
  ]
}

下一次查询使用相同项目、状态、limit,将 offset 设为 next_offset。本例第二页只剩 DEMO-004,因此 returned_count 为一、has_more 为 false、next_offset 为 null。

empty-project 返回 matched_total 为零的成功结果;demo-project 的 offset 设置为二十时,当前页没有记录,但 matched_total 仍为三。两者显示方式应有所区别。

当前记录固定不变,所以 offset 分页可以得到稳定结果。如果真实平台在翻页期间新增或删除记录,即使每页都按 ID 排序,offset 仍可能发生漂移,造成遗漏或重复。后续接入时,应按平台能力选择一致性快照、稳定游标或明确记录查询时间范围。

不要假装给结果加一个 next_offset 就实现了生产级的完整分页。尤其本文限制 offset 最大一百,只是小样例的显式边界,不能用于保证任意大项目都可遍历。

十、实际验证结果与验收范围

本次检查输出如下:

json 复制代码
{
  "sdk_version": "2.3.0",
  "protocol_version": "2026-07-28",
  "source": "local-fixture",
  "stdio_checks": 17,
  "in_process_checks": 6,
  "passed": 23,
  "checks": [
    "tool_discovery",
    "nested_input_and_output_schema",
    "first_page_counts",
    "second_page_no_duplicate",
    "closed_filter",
    "empty_is_success",
    "offset_beyond_total",
    "invalid_status",
    "limit_zero",
    "limit_too_large",
    "limit_bool",
    "limit_string",
    "offset_negative",
    "extra_inside_request",
    "missing_request",
    "project_unavailable",
    "source_unavailable",
    "malformed_source",
    "unexpected_error_no_raw_text",
    "sdk_rejects_invalid_output",
    "output_count_mismatch",
    "output_scope_mismatch",
    "output_next_offset_mismatch"
  ]
}

17 项 stdio 检查覆盖发现、schema、正常与空分页、非法请求及演示业务错误;6 项进程内检查覆盖坏源数据、未知异常的受控返回、SDK 输出拒绝,以及返回条数、记录归属、下一页位置的不一致。

因为故意输入无效参数和错误输出,stderr 会出现拒绝日志;最终成功结果与进程退出状态才表示这组测试整体通过。错误输出注入时出现 SDK 堆栈属于诊断证据,不是要求读者忽略真实业务中的异常。

发布包还附有通过实际 list_tools 获取的工具定义,便于核对 input_schema、output_schema 和模型引用。它是观测材料,不是需要手工导入客户端的配置文件,也不是作者另外手写的一份 schema。

没有连接真实项目、调用模型或执行写操作。固定演示策略也没有验证真实身份或真实跨项目权限;这些工作应在后面的接入与鉴权章节单独完成。

十一、工具可以进入下一步的条件

这次我们为一个工具补上了可检查的约定:参数错了会拒绝,结果为空仍能表达成功,分页保留总量与后续位置,数据损坏不会伪装成空列表,客户端能够区分结构化成功与工具错误。

如果要换成真实缺陷平台,优先替换 fetch_demo_records,并继续保留输入、数据范围、分页与输出检查。不能因为 API 成功返回 JSON,就绕过这里的业务校验。

下一篇将把工具接入实际 AI 客户端,检查工具能否被发现、选择和调用,以及最终回答是否忠实于工具返回的数据。本篇先把服务本身的合同稳定下来。

核对日期:2026-10-29。工具实现固定为 mcp 2.3.0、Pydantic 2.14.1;文中滚动文档用于解释相关机制,具体行为以本篇固定版本实测为准。

相关推荐
xrlfreedom13 小时前
MCP 面试高频考点:桌面客户端 stdio 连接失败,如何用 Resources、结构化输出与 OpenTelemetry 排查?
resources·opentelemetry·结构化输出·mcp
码哥字节15 小时前
AI 编码工具默认给 Agent 多大权限?三家差在这三处
mcp·claude code·ai编程工具
江屿风21 小时前
【Plain Language Large Model】【从麦当劳点餐到Redis接入:MCP如何让AI工具集成变简单】流食般投喂
人工智能·redis·笔记·语言模型·llm·mcp
AIGC大时代1 天前
MCP 三原语怎么划边界终裁:Tools做事、Resources 供上下文、Prompts 定工作流的能用/限场景/不能用验收
tools·resources·prompts·mcp·边界终裁
情 九1 天前
MCP 开发环境搭建:Mac 与 Windows 跑通 Python 服务和 stdio 客户端
windows·python·macos·mcp
跨境Jacky1 天前
亚马逊选品排名监控改造记:从手抄表格到MCP自动监控
跨境电商·mcp·sorftime
keren22 天前
SceneDock:Windows 场景化工作台——一键启动工作链,还能被 AI Agent 调用(CLI / MCP)
windows·开发工具·效率工具·mcp
光依旧2 天前
多实例治理一致性深潜:Redis与本地缓存降级(MCP实战手记·篇12 延伸)
redis·caffeine·高可用·缓存一致性·mcp·多实例