Guide · 使用教程
FastMCP Python 教程:只读工具、参数校验与访问控制测试
固定 FastMCP 4.1.1,用合成文档和原生客户端测试参数、身份、scope、租户隔离与本地 JWT verifier,说明 HTTP 和生产鉴权边界。
先把一个只读接口的权限边界测清楚,再把真实数据接给 AI。本文使用 Python 3.11、FastMCP 4.1.1、MCP SDK 2.3.0,建立一个只允许读取当前成员所属租户文档的工具,并用原生 FastMCP 客户端检查成功和拒绝路径。
资料与运行核对日期:2026-10-20。数据全部合成,不需要模型 API。本地测试分别验证工具分发和 JWTVerifier;没有启动公网服务、接入真实身份提供方或完成远程 OAuth 流程。
先明确四个边界
| 边界 | 本例如何处理 | 不能由它代替的事情 |
|---|---|---|
| 工具提示 | 标注 readOnlyHint、非破坏、幂等 | 不会限制数据库账号或阻止函数写入 |
| 参数 | 固定文档 ID 格式,拒绝额外参数和错误类型 | 参数合法不代表可以访问该文档 |
| 身份 | 业务函数读取服务端验证后的访问令牌 | 不能信任模型提交的租户或用户 ID |
| 数据授权 | 身份映射到租户,再按租户与 ID 查询 | 工具可见不代表每行数据都可读 |
FastMCP 可以根据类型与 Pydantic 约束建立参数定义。annotation 是客户端的提示,安全规则仍要由代码和底层数据权限执行。工具文档
HTTP 的令牌验证与 stdio 的本地进程权限是不同机制。本文在函数内部也检查身份和 scope,使缺少身份时直接拒绝;不要把原生内存调用的测试替身接到生产认证入口。授权说明
1. 建立隔离环境
在新的空目录中保存后面的两个文件。下面固定直接使用的主要版本;本次本机验证使用 cryptography 48.0.1 的可用 wheel,其他平台应按兼容性和自己的依赖策略生成完整锁文件。
python3.11 -m venv .venv
.venv/bin/python -m pip install --only-binary=cryptography \
'fastmcp==4.1.1' 'mcp==2.3.0' 'pydantic==2.14.1' \
'PyJWT==2.15.1' 'cryptography==48.0.1'
当前 FastMCP 文档跟随主分支,升级后应重新运行本例,避免把文档中的新接口和旧包混用。SDK v2 对象使用 snake_case,测试读取 input_schema 与 read_only_hint;ToolAnnotations 的构造参数按本版本支持的 wire alias 填写。安装与版本说明、4.x 迁移说明
2. 定义只读文档工具
保存为 readonly_server.py。服务只有 get_document 一个工具,输入只包含 doc_id。成员关系位于服务端,返回只包含文档 ID 与标题;其他租户记录和不存在的记录返回同一种业务错误。
代码中的 __main__ 入口要求配置可信身份提供方的 JWKS、issuer 和 audience,并只监听本机。测试不会执行这个入口,运行生产服务前应接入真实成员目录与数据库查询。
from dataclasses import dataclass
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AuthProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.dependencies import get_access_token
from mcp.types import ToolAnnotations
from pydantic import Field
@dataclass(frozen=True)
class Document:
tenant: str
doc_id: str
title: str
# Synthetic data only. In production, apply tenant + ID in the database query.
DOCUMENTS = (
Document("tenant-a", "a-001", "A team onboarding"),
Document("tenant-b", "b-001", "B private handbook"),
)
# Trusted server-side mapping; never accept the tenant from tool arguments.
MEMBERSHIPS = {"alice": "tenant-a", "bob": "tenant-b"}
def current_tenant() -> str:
token = get_access_token()
if token is None or "docs:read" not in token.scopes:
raise ToolError("Access denied")
subject = token.claims.get("sub")
if not isinstance(subject, str) or subject not in MEMBERSHIPS:
raise ToolError("Access denied")
return MEMBERSHIPS[subject]
def build_server(verifier: AuthProvider) -> FastMCP:
server = FastMCP(
"Read-only document demo",
auth=verifier,
strict_input_validation=True,
mask_error_details=True,
)
@server.tool(
annotations=ToolAnnotations(
readOnlyHint=True,
destructiveHint=False,
idempotentHint=True,
openWorldHint=False,
)
)
def get_document(
doc_id: Annotated[str, Field(
min_length=5, max_length=5, pattern=r"^[ab]-[0-9]{3}$"
)],
) -> dict[str, str]:
"""Read one document that belongs to the authenticated member."""
tenant = current_tenant()
doc = next((d for d in DOCUMENTS
if d.tenant == tenant and d.doc_id == doc_id), None)
if doc is None:
# Do not distinguish a foreign ID from a nonexistent ID.
raise ToolError("Document unavailable")
return {"doc_id": doc.doc_id, "title": doc.title}
return server
if __name__ == "__main__":
import os
# Required configuration: a trusted IdP, not a URL supplied by the model.
verifier = JWTVerifier(
jwks_uri=os.environ["MCP_JWKS_URI"],
issuer=os.environ["MCP_ISSUER"],
audience=os.environ["MCP_AUDIENCE"],
algorithm="RS256",
)
build_server(verifier).run(transport="http", host="127.0.0.1", port=8000)
注意 tenant_id 没有出现在工具参数中。模型即使生成一个看似有效的其他租户 ID,也只能得到当前身份可访问的记录;没有身份、scope 或成员关系时,函数拒绝执行查询。
本例的固定内存表用于说明约束。真实数据库应把租户条件与记录 ID 一起放入参数化查询,并使用只读、最小权限账号;不能先读取全库后再把所有结果交给模型筛选。多身份提供方部署还应把成员键扩展为可信 issuer 与 subject 的组合,避免不同发行方使用相同 sub 时冲突。
3. 用原生客户端验收成功和失败
保存为 test_readonly.py。测试通过 Client(server) 执行实际 FastMCP 工具发现、参数分发和结果解析;仅把已经验证后的身份读取函数替换为合成令牌。这一替换不走 HTTP,不能证明真实 Bearer 头或 OAuth 接入已经工作。官方测试方式
第九组另外调用原生 JWTVerifier.verify_token:用临时随机密钥检查有效签名,并拒绝其他密钥、错误 issuer、错误 audience 和过期令牌。它测试本地 verifier 的这些输入,不等于测试生产 RS256、JWKS 获取、轮换、撤销或代理链。令牌验证文档
import secrets
import time
import unittest
from unittest.mock import patch
import jwt
from fastmcp import Client
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken
from fastmcp.server.auth.providers.jwt import JWTVerifier
import readonly_server as app
class ReadOnlyTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
# Ephemeral local test secret. Never reuse as a deployment credential.
self.secret = secrets.token_urlsafe(48)
self.verifier = JWTVerifier(
public_key=self.secret, algorithm="HS256",
issuer="test-issuer", audience="test-mcp",
)
self.server = app.build_server(self.verifier)
def identity(self, subject="alice", scopes=None):
return AccessToken(
token="synthetic-test-only", client_id="test-client",
scopes=["docs:read"] if scopes is None else scopes,
claims={"sub": subject},
)
async def call(self, arguments, identity):
# This substitutes verified identity ONLY. It does not test HTTP auth.
with patch.object(app, "get_access_token", return_value=identity):
async with Client(self.server) as client:
return await client.call_tool("get_document", arguments)
async def denied(self, arguments, identity):
with self.assertRaises(ToolError) as caught:
await self.call(arguments, identity)
return str(caught.exception)
async def test_schema_and_readonly_hint(self):
async with Client(self.server) as client:
tools = await client.list_tools()
self.assertEqual([t.name for t in tools], ["get_document"])
self.assertEqual(set(tools[0].input_schema["properties"]), {"doc_id"})
self.assertTrue(tools[0].annotations.read_only_hint)
self.assertFalse(tools[0].annotations.destructive_hint)
async def test_own_document(self):
result = await self.call({"doc_id": "a-001"}, self.identity())
self.assertEqual(result.data,
{"doc_id": "a-001", "title": "A team onboarding"})
async def test_cross_tenant_and_missing_share_error(self):
foreign = await self.denied({"doc_id": "b-001"}, self.identity())
missing = await self.denied({"doc_id": "a-999"}, self.identity())
self.assertEqual(foreign, missing)
self.assertNotIn("B private handbook", foreign)
async def test_missing_identity(self):
await self.denied({"doc_id": "a-001"}, None)
async def test_missing_scope(self):
await self.denied({"doc_id": "a-001"}, self.identity(scopes=[]))
async def test_unknown_member(self):
await self.denied({"doc_id": "a-001"}, self.identity(subject="eve"))
async def test_invalid_inputs_and_tenant_injection(self):
for args in [{}, {"doc_id": 1}, {"doc_id": "a-001-extra"},
{"doc_id": "../../x"}, {"doc_id": "c-001"},
{"doc_id": "a-001", "tenant_id": "tenant-b"}]:
with self.subTest(args=args):
await self.denied(args, self.identity())
async def test_repeat_calls_leave_fixture_unchanged(self):
before = tuple(app.DOCUMENTS)
first = await self.call({"doc_id": "a-001"}, self.identity())
second = await self.call({"doc_id": "a-001"}, self.identity())
self.assertEqual(first.data, second.data)
self.assertEqual(tuple(app.DOCUMENTS), before)
async def test_native_jwt_verifier(self):
now = int(time.time())
claims = {"sub": "alice", "iss": "test-issuer", "aud": "test-mcp",
"exp": now + 300, "scope": "docs:read"}
signed = jwt.encode(claims, self.secret, algorithm="HS256")
verified = await self.verifier.verify_token(signed)
self.assertIsNotNone(verified)
self.assertEqual(verified.claims["sub"], "alice")
self.assertIn("docs:read", verified.scopes)
invalid = [jwt.encode(claims, secrets.token_urlsafe(48), algorithm="HS256")]
for change in [{"iss": "other"}, {"aud": "other"}, {"exp": now - 300}]:
invalid.append(jwt.encode(claims | change, self.secret, algorithm="HS256"))
for value in invalid:
with self.subTest(kind="invalid token"):
self.assertIsNone(await self.verifier.verify_token(value))
if __name__ == "__main__":
unittest.main(verbosity=2)
4. 运行与解释结果
.venv/bin/python -m unittest -v test_readonly
本次实际运行结果为:
Ran 9 tests
OK
这九组覆盖工具参数与只读提示、自有记录、跨租户与不存在记录的一致拒绝、无身份、无 scope、未知成员、六类非法输入、重复调用不改变合成数据,以及本地 JWT verifier。测试期间阻断 socket 外连,没有使用付费模型或访问真实业务数据。拒绝样例出现工具错误或令牌拒绝日志是预期结果,应以测试断言和最终退出状态判断。
只有测试验证的这些场景可以称为通过。不可从中推导服务已抵御所有提示注入、信息推断、并发问题或生产越权,也不能把内存测试速度当作网络服务吞吐。
5. 接入真实 HTTP 前补齐验证
- 从服务端配置可信身份系统,验证 issuer、audience、签名算法与令牌有效期。JWKS 地址不接受模型或外部请求动态指定。
- 验证实际 HTTP 请求在缺少、过期和错误凭据时被拒绝,确认反向代理没有跳过或覆盖认证链。
- 从成员目录取得租户关系,并测试成员撤销、多角色、停用账户与不同 issuer 的相同 subject。
- 验证真实数据库只读权限、行过滤、超时、结果大小和连接回收。给审计记录设置最少必要字段,避免保存令牌与文档全文。
- 在目标 AI 宿主中验收工具发现、取消、超时和错误展示。需要交互 UI 时,前端状态和服务端权限分别测试。
JWTVerifier 的令牌验证模式适合已有发行渠道的资源服务,不会替你完成用户登录和全部 OAuth 发现流程。面向需要标准授权发现的客户端,应按官方 Remote OAuth 等接入文档选择模式。Token Verification
费用与升级清单
FastMCP 当前 Apache-2.0,软件许可费 $0。本教程不调用模型;真实服务的主机、数据库、身份系统、监控、下游 API 和维护费用仍需预算。当前许可
每次升级先保存旧版工具 schema、运行依赖和测试结果,再检查 snake_case 字段、认证上下文和错误响应是否变化。最后用真实客户端与身份系统回归。不要因内存测试通过就直接扩大到写入、发送消息或删除数据等操作。
继续阅读:FastMCP 工具页、5 款服务端开发工具、FastMCP vs 官方 Python SDK、FastMCP vs mcp-use。
常见问题
- readOnlyHint 会阻止函数写入吗?
- 不会。它是客户端提示;只读应通过函数行为、数据权限、只读账号和必要的审计共同落实。
- 为什么不让模型传入 tenant_id?
- 租户来自服务端已验证身份对应的成员关系。允许模型自由指定租户会把业务权限交给不可信输入。
- 测试替换身份函数是否证明认证安全?
- 不能。替身只帮助验证业务授权与工具分发;另一个测试检查本地 JWT verifier,二者都不等于完整 HTTP 或 OAuth 验证。
- 本例会调用付费模型或真实数据库吗?
- 不会。全部使用合成数据和临时随机测试密钥,最终测试阻断网络,没有模型用量。
- 九组测试覆盖了哪些 JWT 场景?
- 本地 HS256 verifier 接受有效令牌,拒绝其他签名密钥、错误 issuer、错误 audience 和过期令牌;不覆盖生产 RS256、JWKS 获取轮换或真实身份系统。
- 生产部署前还需要什么?
- 真实 HTTP 凭据与代理链、身份与成员撤销、数据库行过滤及只读权限、限额与日志、目标宿主和版本升级回归都要验证。