在很多情况下,把 Web 应用上线到生产环境之前的前提就是先把它保护好。 也确实有不需要的场景,但大多数时候,你都希望确保应用是安全的,且只有授权用户才能访问其中的某些部分。
下面按场景给出可考虑的安全措施:
| 场景 | 敏感级别 | 安全措施 | 理由 |
|---|---|---|---|
| 任何人都可访问的公共数据 | 暴露风险极低 | 基础安全或无需安全 | 若想防止机器人等过度调用 API,可让用户注册并分配 API Key |
| 既有公共数据又有受保护数据 | 中等暴露风险 | 使用 HTTPS 与 API Key 的基础安全 | 在允许访问非敏感公共数据的同时保护敏感数据 |
| 敏感个人数据(如健康记录、金融信息) | 暴露风险高 | 进阶安全:HTTPS、OAuth 2.0/2.1、加密、RBAC | 遵循 GDPR、HIPAA 等合规要求,确保数据隐私与完整性 |
表 11.1 —— 安全措施与场景
基于以上,再看 Web 应用中常见的安全措施。
在本章你将学到:
- 为应用添加基本认证(Basic Auth)
- 使用**JSON Web Token(JWT)**保护应用
- 使用 OAuth2 进一步强化安全态势
本章涵盖:
- 基本认证
- 通过 JWT 加固安全
- JWT 的工作原理
- 创建 JWT
- 将 JWT 集成进中间件(以及 MCP 服务器)
- OAuth2
基本认证(Basic Authentication)
Basic Auth 是保护应用最简单的方式:每个请求都携带用户名与密码。它并非最安全的手段——那为何还用?因为在某些情况下,“有点安全总比没有强”。例如你的 API 总体向公众开放,但想限制某些端点访问时,Basic Auth 就能派上用场。
工作原理:客户端在每个请求里发送 Authorization 头,值为 Basic 加一个空格,再加上 username:password 的 Base64 编码。服务器解码后校验凭据是否有效。流程大致如下:
图 11.1 —— 基本认证流程
有时 Basic Auth 不是“用户名+密码”,而是一个API Key。发送方式一致,只是用一个字符串识别客户端,服务器校验该 Key 是否有效。
客户端示例(代码) :
Python(发送 API Key)
# send api key
import requests
import base64
api_key = 'your_api_key'
encoded_api_key = base64.b64encode(api_key.encode()).decode()
headers = {'Authorization': f'Basic {encoded_api_key}'}
response = requests.get('https://api.example.com/endpoint',
headers=headers)
print(response.json())
JavaScript(发送 API Key)
// send api key
const apiKey = 'your_api_key';
const encodedApiKey = btoa(apiKey);
const headers = new Headers();
headers.append('Authorization', `Basic ${encodedApiKey}`);
fetch('https://api.example.com/endpoint', { headers })
.then(response => response.json())
.then(data => console.log(data));
在我们的 MCP 服务器中使用基本认证
我们同样可以用这种方式给 MCP 服务器“加一道门”,避免任意人滥用。需要两件事:
- 服务器中间件:检查
Authorization头并校验凭据 - 客户端:每个请求都发送
Authorization头
实现 MCP 服务器并以 Web 应用方式启动
先实现 MCP 服务器并以 Web 方式启动(这样就能处理 MCP 的请求/响应),随后再为服务器加上中间件安全层。这里用 Starlette。不过要拿到该服务器以便添加中间件,你需要自己控制启动过程。
创建 MCP 服务器实例:
app = FastMCP(
name="MCP Resource Server",
instructions="Resource Server that validates tokens
via Authorization Server introspection",
host=settings["host"],
port=settings["port"],
debug=True,
)
创建使用 Streamable HTTP 传输的 Starlette 应用:
starlette_app = app.streamable_http_app()
使用 Uvicorn 启动:
async def run(starlette_app):
import uvicorn
config = uvicorn.Config(
starlette_app,
host=app.settings.host,
port=app.settings.port,
log_level=app.settings.log_level.lower(),
)
server = uvicorn.Server(config)
await server.serve()
run(starlette_app)
编写中间件
中间件要做的事:检查 Authorization,校验凭据,校验通过则放行,否则返回错误。
在 Starlette 中通过继承 BaseHTTPMiddleware 来创建中间件。它会拿到请求对象以及 call_next,通过调用 call_next(request) 进入后续中间件或实际处理逻辑;不通过就直接返回错误响应。
def valid_token(token: str) -> bool:
# remove the "Bearer " prefix
if token.startswith("Bearer "):
token = token[7:]
return token == "secret-token"
return False
class CustomHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
has_header = request.headers.get("Authorization")
if not has_header:
print("-> Missing Authorization header!")
return Response(status_code=401, content="Unauthorized")
if not valid_token(has_header):
print("-> Invalid token!")
return Response(status_code=403, content="Forbidden")
print("Valid token, proceeding...")
print(f"-> Received {request.method} {request.url}")
response = await call_next(request)
response.headers['Custom'] = 'Example'
return response
逻辑说明:
- 若无
Authorization,返回 401 Unauthorized - 若 Token 无效,返回 403 Forbidden
- 若 Token 有效,放行并可在响应上附加自定义头等
把中间件加到 Starlette 应用:
middleware = [ Middleware(CustomHeaderMiddleware, header_value='Customized')]
async def main():
print("Running MCP Resource Server...")
starlette_app = await setup(app)
print("Adding custom middleware...")
starlette_app.add_middleware(CustomHeaderMiddleware)
测试中间件
用与之前相似的客户端代码测试,但这次需要带上有效的 Authorization 头。
普通 HTTP 测试:
import requests
import base64
def get_auth_token():
api_key = "my_api_key"
token = base64.b64encode(api_key.encode()).decode()
return f"Bearer {token}"
headers = {'Authorization': get_auth_token()}
response = requests.get('http://127.0.0.1:8000/protected', headers=headers)
print(response.status_code)
print(response.text)
MCP 客户端测试(传自定义请求头):
这里用 MCP SDK 的 streamablehttp_client 创建客户端,并通过 headers 参数传入 Authorization:
token = "secret-token"
async with streamablehttp_client(
url = f"http://localhost:{port}/mcp",
headers = {"Authorization": f"Bearer {token}"}
) as (
read_stream,
write_stream,
session_callback,
):
async with ClientSession(
read_stream,
write_stream
) as session:
await session.initialize()
# TODO: 在此处执行你需要的客户端操作,如列工具、调用工具等
接下来我们将介绍 JWT。
用 JWT 加固安全
相比基本认证,JWT 的优势是什么?用 JWT 你可以对客户端权限做更细粒度的控制。你可以在令牌里放入 “声明(claims)” 指定客户端被允许执行的操作。比如只允许读取用户数据,而不允许写入,载荷可能如下:
{
"sub": "1234567890",
"name": "User Userson",
"admin": true,
"iat": 1516239022,
"exp": 1516242622,
"scopes": ["User.Read"]
}
服务端据此检查令牌,判断客户端是否具备执行所请求动作所需的 scope。除此之外还有很多好处:
- 无状态(Stateless) :服务端无需存储会话信息;令牌自包含认证所需信息。
- 可扩展性(Scalability) :由于不存会话,横向扩展更容易。
- 安全性(Security) :令牌可签名/加密,确保完整性与机密性。
- 灵活性(Flexibility) :可包含任意自定义声明,覆盖广泛用例。
- 互操作性(Interoperability) :JWT 是广泛采用的标准,易于与其它系统集成。
这些听起来都很好——下面介绍如何把基本认证升级为 JWT。
JWT 的工作原理
JWT 是一种紧凑、URL 安全的方式,用于在双方之间传递声明。JWT 的声明以 JSON 对象编码,由 三部分 通过点号(.)分隔:
-
Header(头部) :通常包含两部分:令牌类型(
JWT)和签名算法(如 HMAC SHA256 或 RSA)。示例:{ "alg": "HS256", "typ": "JWT" }这里算法为 HMAC SHA256,类型为
JWT,服务端据此验证令牌。 -
Payload(载荷) :包含声明(关于实体〔通常是用户〕的陈述与额外数据)。声明分为注册声明、公共声明、私有声明。示例:
{ "sub": "1234567890", "name": "User Userson", "admin": false, "iat": 1516239022, "exp": 1516242622, "scopes": ["User.Write", "User.Write"] }表示用户 ID 为
1234567890、姓名为User Userson、非管理员,并包含User.Write与User.Read的 scopes(示例中写了两次User.Write,应包含读写各一项)。iat为签发时间,exp为过期时间。 -
Signature(签名) :用于验证 JWT 的发送方身份,以及确保消息未被篡改。
创建 JWT
怎么创建 JWT?使用库就行,示例(Python):
# pip install PyJWT
import jwt
import datetime
def create_jwt():
header = {
"alg": "HS256",
"typ": "JWT"
}
payload = {
"sub": "1234567890", # 主体(用户 ID)
"name": "User Userson", # 自定义声明
"admin": True, # 自定义声明
"iat": datetime.datetime.utcnow(), # 签发时间
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1), # 过期时间
"scopes": ["Admin.Write", "User.Read"] # 权限/范围
}
secret = "your-256-bit-secret"
token = jwt.encode(payload, secret, algorithm="HS256", headers=header)
return token
jwt.encode 负责编码头部与载荷并完成签名。token 是 Base64 编码的三段式字符串(头部.载荷.签名),可作为 Authorization 头里的 Bearer 令牌使用。解码后可看到头与载荷的 JSON。
验证 JWT
验证要确保令牌有效、未过期、签名正确。示例(Python):
import jwt
def validate_jwt(token: str) -> bool:
secret = "your-256-bit-secret"
try:
decoded = jwt.decode(token, secret, algorithms=["HS256"])
print("Decoded claims:")
for key, value in decoded.items():
print(f" {key}: {value}")
return True
except jwt.ExpiredSignatureError:
print("Token has expired")
return False
except jwt.InvalidTokenError:
print("Invalid token")
return False
上述只是“结构性检查”的起点。还应考虑的校验包括:
iss(Issuer,签发者) :确认令牌由可信的授权方签发(如你的认证服务器)。aud(Audience,受众) :确认令牌面向你的应用(例如你的 MCP 服务器 URL)。nbf(Not Before) :确认令牌在指定时间之前不可用。- Scopes / Roles:确认客户端具备执行操作所需的权限。示例的 scopes:
User.Read、User.Write、Admin.Read、Admin.Write;角色可为User、Admin等。两者类似,但 scope 通常比 role 更细粒度。
在中间件(以及 MCP 服务器)中集成 JWT
到目前为止,你已经看到如何在中间件中执行基本认证并校验凭证是否有效。现在我们来看看如何集成 JWT 校验。我们的计划如下:
- 创建一个用于测试的 JWT。我们将用一个工具脚本来生成。在真实场景中,你会从身份提供方(IDP),例如 Auth0、Keycloak 或 Entra ID 获取令牌。
- 更新中间件以验证 JWT。
- 更新客户端,把 JWT 放在
Authorization请求头里发送。
创建用于测试的 JWT
下面是我们用来创建测试用 JWT 的工具代码,其中包含生成与校验 JWT 的函数:
import jwt
import datetime
def create_jwt():
header = {
"alg": "HS256",
"typ": "JWT"
}
payload = {
"sub": "1234567890", # Subject (user ID)
"name": "User Userson", # Custom claim
"admin": True, # Custom claim
"iat": datetime.datetime.utcnow(), # Issued at
"exp": datetime.datetime.utcnow() +
datetime.timedelta(hours=1), # Expiry
"scopes": ["Admin.Write", "User.Read"] # Custom claim for
# scopes/permissions
}
token = jwt.encode(payload, "your-256-bit-secret",
algorithm="HS256", headers=header)
with open(".env", "w") as f:
f.write(f"JWT_TOKEN={token}\n")
def validate_jwt(token: str) -> str | None:
secret = "your-256-bit-secret"
try:
decoded = jwt.decode(token, secret, algorithms=["HS256"])
return decoded
except jwt.ExpiredSignatureError:
print("Token has expired")
return None
except jwt.InvalidTokenError:
print("Invalid token")
return None
if __name__ == "__main__":
create_jwt()
在这段代码中,我们用头部、载荷和签名生成了一个 JWT。create_jwt 函数会生成令牌并写入 .env 文件。注意 validate_jwt 函数会验证令牌并在令牌有效时返回解码后的声明。
更新客户端以发送 JWT
接下来是客户端。它需要从 .env 文件中加载令牌,并把它放入 Authorization 请求头中:
import os
from dotenv import load_dotenv
load_dotenv()
def get_auth_token():
token = os.getenv("JWT_TOKEN")
return f"Bearer {token}"
headers = {'Authorization': get_auth_token()}
# 省略:创建并连接 MCP 客户端
更新服务器中间件以验证 JWT
最后,我们需要更新服务器中间件来验证 JWT。示例:
from your_jwt_utility import validate_jwt # 导入 validate_jwt 函数
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
class JWTMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
token = request.headers.get("Authorization")
if not token or not token.startswith("Bearer "):
return Response("Unauthorized", status_code=401)
jwt_token = token.split(" ")[1]
decoded = validate_jwt(jwt_token)
if not decoded:
return Response("Unauthorized", status_code=401)
# TODO:检查用户是否存在、校验 scopes 等
# 可选:把用户信息附加到 request.state
request.state.user = decoded
response = await call_next(request)
return response
现在,中间件会检查 Authorization 头,验证 JWT,并决定是放行请求还是返回 401 Unauthorized。我们也留了一个 TODO:例如检查用户是否存在、校验 scopes 等等。
OAuth2
把安全机制从 Basic 认证升级到 JWT 后,我们的安全姿态确实更强了,但仍有提升空间。OAuth2 是被广泛采用的授权框架,它为保护你的应用提供了更健壮、更灵活的方式。
它允许你在不共享凭据的情况下,将对资源的访问“委托”出去。具体来说,访问受保护资源时通常有三方参与:
- 资源服务器(Resource server) :承载受保护资源的服务器,本书场景中即 MCP 服务器。
- 客户端(Client) :想要访问受保护资源的应用,本书场景中即 MCP 客户端。
- 授权服务器(Authorization server) :在成功验证资源所有者并获得授权后,向客户端签发访问令牌的服务器。
关于“委托”的部分:资源所有者(通常是用户)通过授予客户端访问令牌把访问权限委托给客户端。之后,客户端便可代表资源所有者,使用该访问令牌访问受保护资源。这样客户端无需知道资源所有者的账号密码,而资源所有者也可以随时使访问令牌失效来撤销授权。显然,这比 Basic 认证更好。需要说明的是,JWT 常被用作 OAuth2 中访问令牌的表示形式。因此,OAuth2 的真正提升在于:它提供了一整套令牌管理框架(签发、校验、吊销等)。
OAuth2.1 授权码流程
MCP SDK 支持的是 OAuth2.1。更准确地说,SDK 提供了一个中间件,你可以在其中指定:
- 授权服务器:用于签发和校验令牌
- 资源服务器:你的数据所在处,通常就是 MCP 服务器
- 请求的作用域(scopes) :你希望对资源服务器申请到的访问权限
流程如下图所示:
当客户端已有有效令牌时,会直接使用它访问资源服务器;否则,就会引导用户完成认证与授权,从授权服务器获取访问令牌。拿到令牌后,客户端即可访问资源服务器。
MCP SDK 为我们做了什么?它提供了一个易用的 OAuth2.1 中间件。你只需提供如下配置:
auth = AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"), # 授权服务器 URL
resource_server_url=AnyHttpUrl("http://localhost:3001"), # 本资源服务器 URL
required_scopes=["user"], # 需要的作用域
)
字段说明:
- issuer_url:授权服务器的地址(真实环境中是你的 IdP,如 Auth0、Keycloak、Entra ID 等)。
- resource_server_url:资源服务器地址(本例即 MCP 服务器),用于校验令牌是否颁发给本资源服务器。
- required_scopes:访问资源服务器所需的作用域列表。
该中间件会完成令牌校验、scope 检查与受众匹配,并处理 OAuth2 流程(必要时重定向用户去授权服务器获取令牌)。
OAuth2.1 的底层流程
更细一点地看授权码流程:
- 校验令牌或获取授权码:客户端检查是否已持有有效访问令牌;若没有,则请求授权服务器的
/authorize端点获取授权码。用户在授权服务器登录并授权后,浏览器被重定向回客户端,并带上授权码。 - 用授权码换取访问令牌:客户端向授权服务器的
/token端点发起 POST 请求,用授权码换取访问令牌。 - 访问资源服务器:客户端以 Bearer Token 的形式,将访问令牌放入
Authorization请求头中,调用资源服务器的目标接口。资源服务器校验令牌、检查 scope 并确认令牌受众是自己,一切通过后才放行请求。
下面是一个简化的中间件流程示例(仅帮助理解):
# Step 1: 模拟浏览器重定向到 /authorize
authorize_url = f"{AUTH_SERVER}/authorize?client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&state={STATE}&code_challenge={CODE_CHALLENGE}&code_challenge_method=plain"
print(f"Requesting authorization: {authorize_url}")
response = requests.get(authorize_url, allow_redirects=False)
# Step 2: 从重定向地址中提取授权码
redirect_location = response.headers.get("Location")
if not redirect_location:
print("Authorization server did not redirect. Is it running?")
exit(1)
parsed_url = urlparse(redirect_location)
query_params = parse_qs(parsed_url.query)
auth_code = query_params.get("code", [None])[0]
print(f"Received authorization code: {auth_code}")
# Step 3: 用授权码交换访问令牌
token_response = requests.post(f"{AUTH_SERVER}/token", data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"code_verifier": CODE_VERIFIER
})
token_data = token_response.json()
access_token = token_data.get("access_token")
print(f"Access token: {access_token}")
# Step 4: 携带令牌访问资源服务器
resource_response = requests.get(f"{RESOURCE_SERVER}/userinfo", headers={
"Authorization": f"Bearer {access_token}"
})
print("User info response:")
print(resource_response.json())
步骤解读:
- 步骤 1:构造授权 URL,请求授权服务器的
/authorize。用户在该页面完成登录与授权,响应中包含带授权码的重定向 URL。 - 步骤 2:从重定向 URL 中解析出授权码。
- 步骤 3:向授权服务器的
/token发起 POST 请求,用授权码换取访问令牌。 - 步骤 4:把访问令牌放入
Authorization: Bearer ...,调用资源服务器 API;资源服务器验证通过后返回资源。
MCP SDK 在此处有一个可参考的示例实现:simple-auth(链接见官方仓库)。
关于使用 OAuth2.1 的一些收尾建议
在生产环境中,你通常只需编写客户端与资源服务器两部分代码;授权服务器应由独立组件/服务提供(如 Azure 的 Entra ID、AWS 的 Amazon Cognito 或 Auth0 等)。
你需要决定把“授权组件”放在什么位置前面来做保护:
- Web 服务器之前:用常规 Web 中间件与 IdP 对接(Entra ID / Cognito / Auth0 等)。
- MCP 服务器之前:优先考虑 MCP SDK 的认证组件,也可叠加通用中间件。
- 网关(反向代理)层:如 Azure API Management、AWS API Gateway 等。它们能处理认证,简化架构,并提供与 AI 相关的增值特性(内容安全、语义缓存)以及韧性与扩展能力。
另外,若要做到按工具(Tool)授权,你可能需要在调用 Tool 前增加权限检查逻辑,确保用户拥有相应的 scopes/permissions。不同工具可能需要不同的 scopes。MCP SDK 目前不内置这一点,但实现起来很简单。例如(伪代码):
PermissionToolMapping = {
"User.Read": ["GetUser", "ListUsers"],
"User.Write": ["CreateUser", "UpdateUser", "DeleteUser"],
"Admin.Read": ["GetAdmin", "ListAdmins"]
}
def has_permission(token: str, tool: str) -> bool:
decoded = validate_jwt(token)
if not decoded or "scopes" not in decoded:
return False
user_scopes = decoded["scopes"]
required_scopes = [scope for scope, tools in PermissionToolMapping.items() if tool in tools]
return any(scope in user_scopes for scope in required_scopes)
@server.call_tool
def call_tool(name: str, arguments: dict[str, Any], request: types.CallToolRequest) -> types.CallToolResponse:
if has_permission(token=request.headers["token"], tool=name):
# 继续调用对应工具
...
else:
raise Exception("Forbidden")
小结
本章介绍了如何提升 MCP 服务器与客户端的安全性:从 Basic 认证,到 JWT,再到 OAuth2.1 的完整令牌管理框架。请记住:安全是一个持续过程。务必跟进最佳实践与最新技术,保护你的应用与数据。下一章我们将讨论如何让 MCP 服务器走向生产,确保其稳健与可扩展。