使用 Python 入门 Model Context Protocol(MCP)——保护你的应用程序

67 阅读15分钟

在很多情况下,把 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 编码。服务器解码后校验凭据是否有效。流程大致如下:

image.png

图 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 服务器“加一道门”,避免任意人滥用。需要两件事:

  1. 服务器中间件:检查 Authorization 头并校验凭据
  2. 客户端:每个请求都发送 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.WriteUser.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.ReadUser.WriteAdmin.ReadAdmin.Write;角色可为 UserAdmin 等。两者类似,但 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) :你希望对资源服务器申请到的访问权限

流程如下图所示:

image.png

当客户端已有有效令牌时,会直接使用它访问资源服务器;否则,就会引导用户完成认证与授权,从授权服务器获取访问令牌。拿到令牌后,客户端即可访问资源服务器。

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 的底层流程

更细一点地看授权码流程:

  1. 校验令牌或获取授权码:客户端检查是否已持有有效访问令牌;若没有,则请求授权服务器的 /authorize 端点获取授权码。用户在授权服务器登录并授权后,浏览器被重定向回客户端,并带上授权码。
  2. 用授权码换取访问令牌:客户端向授权服务器的 /token 端点发起 POST 请求,用授权码换取访问令牌
  3. 访问资源服务器:客户端以 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 服务器走向生产,确保其稳健与可扩展。