python-sdk 中的身份断言:用 SEP-990 ID-JAG 实现企业托管的 MCP 授权

python-sdk 中的身份断言:用 SEP-990 ID-JAG 实现企业托管的 MCP 授权 人工智能MCP 服务MCP Clients【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址https://gitcode.com/gh_mirrors/pythonsd/python-sdk点击查看免费下载在企业环境中OAuth 的默认玩法先问 MCP 服务器“你信任哪个授权服务器”再让用户登录或使用预共享密钥往往行不通安全团队希望由企业既有的身份提供者IdP如 Okta、Microsoft Entra ID 或自建系统来决定“谁可以访问哪个 MCP 服务器”而不是逐服务器地由终端用户或开发者拍板。本指南以官方 Python SDK 的 Identity Assertion身份断言实现为骨架完整讲解 SEP-990 Enterprise-Managed Authorization 扩展的两端客户端如何把 IdP 签发的短时 JWT即 ID-JAG换成普通访问令牌以及服务端如何作为授权服务器校验并接受这一交换。读完你将掌握IdentityAssertionOAuthProvider的构造参数、assertion_provider回调契约、exchange_identity_assertion钩子与完整校验规则并能运行仓库中开箱即用的端到端示例。为什么需要身份断言把授权决定搬到 IdP普通 OAuth 客户端参见 Clientes OAuth / OAuth clients的流程是向 MCP 服务器询问“你信任哪个授权服务器”然后跟随答案指向的方向之后要么真人登录、要么用预先共享的密钥代替。企业场景不希望这两种方式逐服务器地发生——员工今早已经登录过公司 IdP安全团队只希望在这一个地方决定谁能访问什么。SEP-990Enterprise-Managed Authorization 扩展把决定权搬到了 IdPIdP 签署一个短时 JWT——Identity Assertion JWT Authorization GrantID-JAG即一份“这个用户通过这个客户端可以访问这个 MCP 服务器”的声明。客户端把它换成普通访问令牌。整个过程没有浏览器、没有同意页、没有动态注册。关键边界在于MCP 服务器本身完全不变。它依然是 Autorización / Authorization 中描述的受保护资源服务器只负责校验送来的令牌。本文介绍的内容全部发生在“令牌是如何拿到手的”这一层。两次令牌请求分清两个权威方要理解本页最关键的是分清两个不同的权威方企业 IdP你所在组织的身份提供者它知道员工是谁、承载策略并签发 ID-JAG。SDK 从不与它对话。MCP 授权服务器与 Autorización / Authorization 中的角色相同——MCP 服务器元数据中点名的签发者铸造该 MCP 服务器接受的所有令牌。在普通 OAuth 流程里这两个角色通常是同一个盒子在这里它们是两个整个授权交换的本质就是“第二个同意信任第一个”。客户端向两者各发一次令牌请求向企业 IdP客户端把用户已登录的 OpenID Connect ID token 换成一个 ID-JAG。这是一次 RFC 8693 令牌交换完全是你 IdP 自己的 APISDK 不做这件事——由你在一个异步回调里完成。策略决定也发生在这里IdP 拒绝时永远不会签发 ID-JAG客户端也就没有东西可以递交。向 MCP 授权服务器客户端按 RFC 7523 的jwt-bearer授权类型grant_typeurn:ietf:params:oauth:grant-type:jwt-bearer以 ID-JAG 作为assertion递交 ID-JAG换回访问令牌。这是 SDK 发出的请求而“接受它”正是本页给授权服务器增加的全部内容。下文所有内容都是围绕第二个请求展开的发送它的客户端以及应答它的授权服务器。客户端侧IdentityAssertionOAuthProviderIdentityAssertionOAuthProvider位于mcp.client.auth.extensions.identity_assertion。与 Clientes OAuth / OAuth clients 中的每个提供者一样它是一个httpx2.Auth构造一个实例、挂到auth上、把httpx2.AsyncClient交给传输层即可。文档教程的完整可运行示例见 docs_src/identity_assertion/tutorial001.pyimport time import uuid import httpx2 import jwt from mcp import Client from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientInformationFull, OAuthToken IDP_SIGNING_KEY the-enterprise-idp-signing-key-for-this-demo class InMemoryTokenStorage: def __init__(self) - None: self.tokens: OAuthToken | None None self.client_info: OAuthClientInformationFull | None None async def get_tokens(self) - OAuthToken | None: return self.tokens async def set_tokens(self, tokens: OAuthToken) - None: self.tokens tokens async def get_client_info(self) - OAuthClientInformationFull | None: return self.client_info async def set_client_info(self, client_info: OAuthClientInformationFull) - None: self.client_info client_info def idp_issue_id_jag(subject: str, audience: str, resource: str) - str: now int(time.time()) claims { iss: https://idp.example.com, sub: subject, aud: audience, client_id: finance-agent, resource: resource, scope: notes:read, jti: str(uuid.uuid4()), iat: now, exp: now 300, } return jwt.encode(claims, IDP_SIGNING_KEY, algorithmHS256, headers{typ: oauth-id-jagjwt}) async def fetch_id_jag(audience: str, resource: str) - str: return idp_issue_id_jag(aliceexample.com, audience, resource) oauth IdentityAssertionOAuthProvider( server_urlhttp://localhost:8001/mcp, storageInMemoryTokenStorage(), client_idfinance-agent, client_secretfinance-agent-secret, issuerhttps://auth.example.com/, assertion_providerfetch_id_jag, scopenotes:read, ) async def main() - None: async with httpx2.AsyncClient(authoauth) as http_client: transport streamable_http_client(http://localhost:8001/mcp, http_clienthttp_client) async with Client(transport) as client: result await client.list_tools() print([tool.name for tool in result.tools])建议从下往上阅读这段代码main()就是标准 OAuth 客户端的main()见 Clientes OAuth / OAuth clients一行不改——这正是设计意图一旦提供者存在下游任何代码都不需要知道令牌来自哪种授权类型。提供者接收其他提供者无法“发现”的信息别人预先注册在授权服务器上的client_id和client_secret、该授权服务器的issuer以及assertion_provider——一个按需返回新 ID-JAG 的异步回调。storage是同一个TokenStorage协议。这里只会调用两个令牌方法因为没有动态注册所以也没有client_info需要记住。assertion_provider 回调你唯一要写的代码fetch_id_jag(audience, resource)是唯一需要你写的代码。它每次令牌交换被 await 一次绝不在构造时调用并且只在授权服务器的元数据被获取并校验之后才调用——这样配置错误的 issuer 永远不会泄漏断言。它的两个参数正是 ID-JAG 铸造时必须携带的两个 claimaudience授权服务器的 issuer即 ID-JAG 的audresourceMCP 服务器的规范标识符即 ID-JAG 的resource。第三个参数你本来就握着ID-JAG 的client_idclaim 必须与你给提供者的client_id一致否则授权服务器会拒绝这次交换。示例里fetch_id_jag上方的idp_issue_id_jag不是你的代码——它只是在本进程中代替身份提供者、现场签署断言让文件完整可运行并让你能读到 ID-JAG 携带的每一个 claim。真实的fetch_id_jag做的是前一节的第一次令牌请求针对你的 IdP 的一次 RFC 8693 令牌交换其定义来自 SEP-990 所定型的 Identity Assertion JWT Authorization Grant 草案。用户已登录的 ID token 作为subject_token传入requested_token_type是 ID-JAG 自己的 URNurn:ietf:params:oauth:token-type:id-jagaudience和resource原样透传响应带回 ID-JAG。以这些名字去查你 IdP 的文档就能找到对应的交换端点。提示每次交换都会请求一个全新的 ID-JAG这正是设计意图——它是一次性、存活几分钟的授权本页的授权服务器拒绝接受同一个两次。不要缓存它。真正被复用的是它换来的访问令牌。issuer 是配置不是发现这里发生了角色反转。OAuthClientProvider会问资源服务器用哪个授权服务器并跟随答案本提供者拒绝这么做issuer是必填的RFC 8414 元数据从该 issuer 自己的 well-known 路径获取令牌端点必须位于该 issuer 的同源origin下资源服务器永远不会被询问任何东西。扩展本身并不强制这一点——这是 SDK 一个刻意的、更严格的选择。这个客户端随身携带两样值得偷的东西预先注册的密钥以及绑定 audience 的断言。如果客户端被一个被攻破的 MCP 服务器指到攻击者的授权服务器两样都会被投递过去。在构造时固定 issuer就把这段对话整个删掉了。警告配置的issuer与元数据文档的issuer字段按 RFC 8414 §3.3 做简单字符串比较逐字符比较、包含末尾斜杠、不做任何归一化。不要猜。从你的授权服务器抓取/.well-known/oauth-authorization-server直接复制返回的issuer值。本页授权服务器的取值是https://auth.example.com/带斜杠因为它的 issuer 是从 pydantic URL 对象构造出来的。任何不一致都会在发送任何凭据或断言之前于OAuthFlowError: Authorization server metadata issuer mismatch处终止流程。在源码中这一比较由 src/mcp/client/auth/extensions/identity_assertion.py 的_auth_flow完成它通过build_oauth_authorization_server_metadata_discovery_urls(self._issuer, self._issuer)构造发现 URL两个参数都是 issuer说明连辅助函数的旧版回退也解析到该 issuer 本身再调用validate_metadata_issuer(asm, self._issuer)做校验随后用_origin(token_endpoint) ! _origin(self._issuer)的 same-origin 比较规范化端口_DEFAULT_PORTS {https: 443, http: 80}确保令牌端点与 issuer 同源否则抛出OAuthFlowError。机密客户端client_secret 是强制的client_secret必填——缺少它构造函数直接抛ValueError。SEP-990 之下的 IETF profile 把这种授权保留给机密客户端confidential clientSEP-990 要求客户端必须认证而本 SDK 通过强制共享密钥同时落实了这两点。token_endpoint_auth_method决定密钥走哪条路client_secret_post默认值放在表单体中或client_secret_basicHTTP Basic 头。profile 也允许private_key_jwt但本提供者不支持。源码层面src/mcp/client/auth/extensions/identity_assertion.py 的构造函数以两段显式检查落实这一约束if not client_secret: raise ValueError(client_secret is required: SEP-990 mandates a confidential client) if not issuer: raise ValueError(issuer is required: the authorization server is configuration, not discovery)而_build_token_request展示了两种认证方式的底层实现client_secret_basic时按 RFC 6749 §2.3.1 先对client_id与client_secret分别做百分号编码再用冒号连接、base64 后放入Authorization: Basic ...头默认的client_secret_post则直接把client_secret放进表单体。提示client_secret应从环境变量或密钥管理器读取永远不要放进版本控制。提供者替你做了什么第一次请求不带认证发出服务器返回的401启动整个流程发现Discovery从配置的 issuer 的 RFC 8414 well-known 路径抓取授权服务器元数据校验文档issuer匹配并确认令牌端点位于 issuer 同源。断言The assertionawait 你的assertion_provider。交换Exchange向令牌端点 POSTjwt-bearer授权保存OAuthToken并以Authorization: Bearer ...重放你最初的请求。如果403响应的WWW-Authenticate指明insufficient_scope则用你的scope与挑战声明的 scope 的并集重新执行步骤 2 和 3scope永远只是请求本页的授权服务器只授予 ID-JAG 里声明的内容不多不少。整个过程没有任何刷新令牌访问令牌过期后下一个401会铸造新的 ID-JAG 再换一次——这正是 IdP 握着的杠杆。失败时与 Clientes OAuth / OAuth clients 其余部分一样只有两个异常发现与校验失败抛OAuthFlowError令牌端点拒绝时抛其子类OAuthTokenError。源码中令牌交换失败路径的异常即构造为OAuthTokenError(fToken exchange failed ({token_response.status_code})...)。服务端侧把 SDK 变成授权服务器大多数情况下到这里就够了MCP 授权服务器是别人的产品“接受 ID-JAG”只是它的一项配置开关SEP-990 属于 SDK 的那一半就是上面的客户端。但 SDK 也可以就是授权服务器create_auth_routes把授权服务器路由作为列表返回任何 Starlette 应用都能挂载——仓库里examples/servers/simple-auth/就是这样运行一个的。SEP-990 在这套表面surface上增加了一个开关和一个方法。完整可运行的示例见 docs_src/identity_assertion/tutorial002.pyimport secrets import time import jwt from pydantic import AnyHttpUrl from starlette.applications import Starlette from mcp.server.auth.provider import ( AccessToken, AuthorizationCode, AuthorizationParams, AuthorizeError, IdentityAssertionParams, OAuthAuthorizationServerProvider, RefreshToken, TokenError, ) from mcp.server.auth.routes import create_auth_routes from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken ISSUER https://auth.example.com/ MCP_SERVER http://localhost:8001/mcp IDP_ISSUER https://idp.example.com IDP_SIGNING_KEY the-enterprise-idp-signing-key-for-this-demo REGISTERED_CLIENTS { finance-agent: OAuthClientInformationFull( client_idfinance-agent, client_secretfinance-agent-secret, redirect_urisNone, grant_types[JWT_BEARER_GRANT_TYPE], token_endpoint_auth_methodclient_secret_post, ) } class EnterpriseAuthorizationServer(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): def __init__(self) - None: self.access_tokens: dict[str, AccessToken] {} self.seen_jtis: set[str] set() async def get_client(self, client_id: str) - OAuthClientInformationFull | None: return REGISTERED_CLIENTS.get(client_id) async def load_access_token(self, token: str) - AccessToken | None: return self.access_tokens.get(token) async def exchange_identity_assertion( self, client: OAuthClientInformationFull, params: IdentityAssertionParams ) - OAuthToken: try: header jwt.get_unverified_header(params.assertion) claims jwt.decode( params.assertion, IDP_SIGNING_KEY, algorithms[HS256], issuerIDP_ISSUER, audienceISSUER, options{require: [iss, sub, aud, exp, iat, jti, client_id, resource, scope]}, ) except jwt.InvalidTokenError as error: raise TokenError(invalid_grant, the assertion did not verify) from error if header.get(typ) ! oauth-id-jagjwt: raise TokenError(invalid_grant, the assertion is not an ID-JAG) if claims[client_id] ! client.client_id: raise TokenError(invalid_grant, the assertion was issued to a different client) if claims[resource] ! MCP_SERVER: raise TokenError(invalid_target, the assertion is for a resource this server does not serve) if claims[jti] in self.seen_jtis: raise TokenError(invalid_grant, the assertion has already been used) self.seen_jtis.add(claims[jti]) scopes claims[scope].split() access_token fmcp_{secrets.token_hex(16)} self.access_tokens[access_token] AccessToken( tokenaccess_token, client_idclaims[client_id], scopesscopes, expires_atint(time.time()) 300, resourceclaims[resource], subjectclaims[sub], ) return OAuthToken(access_tokenaccess_token, token_typeBearer, expires_in300, scope .join(scopes)) async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) - str: raise AuthorizeError(unauthorized_client, this authorization server only accepts ID-JAGs) async def load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) - None: return None async def exchange_authorization_code( self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode ) - OAuthToken: raise TokenError(invalid_grant, this authorization server only accepts ID-JAGs) async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) - None: return None async def exchange_refresh_token( self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str] ) - OAuthToken: raise TokenError(invalid_grant, this authorization server only accepts ID-JAGs) provider EnterpriseAuthorizationServer() auth_app Starlette( routescreate_auth_routes(provider, issuer_urlAnyHttpUrl(ISSUER), identity_assertion_enabledTrue) )逐点解读这段代码identity_assertion_enabledTrue控制一切。关闭时默认值即使你实现了钩子/token也会以unsupported_grant_type应答这种授权且元数据完全不提及它打开后元数据增加jwt-bearer授权类型并在authorization_grant_profiles_supported中列出urn:ietf:params:oauth:grant-profile:id-jag——这是扩展用来广播兼容性的字段。本 SDK 的客户端从不读它它是为单一 issuer 预置的直接发问即可。源码依据见 src/mcp/server/auth/routes.py 的build_metadata只有supports_identity_assertionTrue时才追加JWT_BEARER_GRANT_TYPE并设置authorization_grant_profiles_supported [ID_JAG_GRANT_PROFILE]。exchange_identity_assertion是钩子。在它执行之前SDK 已经完成了认证客户端、拒绝公开客户端、拒绝注册信息中未列出该授权的客户端。你会收到一个IdentityAssertionParams原始assertion、请求的scopes与resource返回一个普通的OAuthToken。IdentityAssertionParams的定义与注释见 src/mcp/server/auth/provider.pyassertion是 RFC 7523 §2.1 中以 JWTID-JAG形式呈现的授权scopes与resource均来自令牌请求。服务端的门禁逻辑在 src/mcp/server/auth/handlers/token.py 的JwtBearerRequest分支中开关关闭返回unsupported_grant_typeclient_info.client_secret为空公开客户端时返回unauthorized_client注释明确指出“绝不能把未经认证的客户端放进 provider 钩子”随后才组装IdentityAssertionParams并调用钩子。动态客户端注册无条件拒绝这种授权所以这里的get_client服务的是手工预置的客户端——一个 ID-JAG 客户端无法把自己注册进存在。见 src/mcp/server/auth/handlers/register.py当grant_types中出现JWT_BEARER_GRANT_TYPE时直接拒绝。类的一半是各种拒绝。OAuthAuthorizationServerProvider是完整的授权服务器所以它也要求实现授权码流程一个同时给用户登录的服务器会真正实现这些而本例只有一扇门authorize、exchange_authorization_code、exchange_refresh_token全部以明确的错误拒绝。警告SDK从不解码断言——只有你的部署知道它信任哪个 IdP、那个 IdP 发布哪些密钥所以exchange_identity_assertion里的每一步都是承重墙。请务必按 RFC 7523 §3 用 IdP 发布的密钥其 JWKS这里的共享密钥只是 demo 用的校验签名以及iss、exp要求 JWT 头的typ为oauth-id-jagjwt——这是 profile 防止其他 JWT 被当作授权重放的屏障要求aud是你自己的 issuer要求 ID-JAG 的client_idclaim 等于 handler 认证的那个客户端要求其resourceclaim 指向你真正服务的资源记录jti直到断言exp保证每个断言只被接受一次已授予的 scopes 以及最重要的、所发令牌的resource一律取自校验过的 ID-JAG绝不取自请求——params.resource只是客户端随手填的。 完整处理规则见 Enterprise-Managed Authorization 规范SEP-990。拒绝一条无效断言时使用TokenError(invalid_grant, ...)。本流程的另一个错误码是invalid_targetID-JAG 点名了一个你不服务的资源时用它拒绝——这正是阻止本服务器为别人的服务器铸造令牌的机制。已授予的 scopes 来自 ID-JAG 的scopeclaim没有它的断言同样被拒你的实现也可以改为映射用户组。注意返回的OAuthToken不带刷新令牌。IdP 通过决定是否签发下一个 ID-JAG来决定这个用户能保持访问多久这里铸造一个刷新令牌等于悄悄把决定权交还回去。补充仍然通过auth_server_provider内嵌授权服务器的旧式服务器可以通过AuthSettings(identity_assertion_enabledTrue)到达同一份代码。Autorización / Authorization 解释了为什么新服务器不应从那种方式起步该开关定义于 src/mcp/server/auth/settings.py。把两端接起来整个授权就是一个 POST /token把本页两个文件连起来整个授权就是一次POST /tokengrant_typeurn:ietf:params:oauth:grant-type:jwt-bearer assertioneyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ... client_idfinance-agent resourcehttp://localhost:8001/mcp scopenotes:read client_secretfinance-agent-secret HTTP/1.1 200 OK {access_token: mcp_..., token_type: Bearer, expires_in: 300, scope: notes:read}没有/authorize、没有/register、没有受保护资源元数据的抓取。线上传输的请求只有引出401的那一次、well-known 抓取、这次交换然后是带着 bearer 的普通 MCP 流量。而且你的校验器从 ID-JAG 里读出的sub正是工具内部get_access_token().subject报告的值——用户身份从 IdP 一路贯通到 MCP 工具调用。试运行仓库里的端到端示例examples/stories/identity_assertion/目录README.md就是本页内容的真实运行版同一个exchange_identity_assertion校验器、一个由令牌把关的 MCP 服务器、一个替身 IdP以及客户端全部装进一个自校验程序。该示例的部署形态是让授权服务器与受 bearer 保护的 MCP 服务器共宿主在同一个 app上并用进程内签名器代替 IdP最终断言“IdP 点名的用户正是工具看到的用户”。运行方式# HTTP、自托管客户端自行拉起共宿主的 AS MCP app递交 ID-JAG # 并断言 whoami 工具报告的正是 IdP 的 subject。 # 自托管使用该 story 固定的 :8000 端口issuer/PRM 元数据已将其固化请确保 :8000 空闲。 uv run python -m stories.identity_assertion.client --http # 同样但针对 lowlevel-API 服务端变体 uv run python -m stories.identity_assertion.client --http --server server_lowlevel # 针对你自己启动的服务器真实 uvicorn跑在 :8000 uv run python -m stories.identity_assertion.server --port 8000 SERVER_PID$! uv run python -m stories.identity_assertion.client --http http://127.0.0.1:8000/mcp由于Client(url)没有auth直通参数两个 runner 都把模块导出的build_auth一个IdentityAssertionOAuthProvider挂到传输层底下的httpx2.AsyncClient上再把已经路由好的目标交给main。小结SEP-990 让企业身份提供者而不是终端用户决定一个客户端能访问哪些 MCP 服务器。IdP 把该决定签进一个ID-JAG。获取 ID-JAG 是针对你的 IdP的 RFC 8693 令牌交换SDK 不做这件事把它呈交给 MCP 授权服务器则是 RFC 7523 的jwt-bearer授权SDK 覆盖这一交换的两端。IdentityAssertionOAuthProvider是又一个httpx2.Auth预先注册的机密客户端、固定的issuer、一个assertion_provider(audience, resource)回调。没有浏览器、没有注册、没有刷新令牌。授权服务器绝不会从资源服务器“被发现”。把issuer配置成与它元数据文档提供的字符串完全一致比较是逐字符的。服务端侧identity_assertion_enabledTrue加上exchange_identity_assertion。SDK 负责认证客户端并给授权把关校验 ID-JAG 完全是你的职责所发令牌绑定到 ID-JAG 的resource而不是请求里的。本页从未触碰的一方是 MCP 服务器本身——它对你刚铸造的令牌所做的事早在 Autorización / Authorization 中就已经在做了。赞分享人工智能MCP 服务MCP Clients【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址https://gitcode.com/gh_mirrors/pythonsd/python-sdk点击查看免费下载相关推荐python-sdk 身份断言ID-JAG实战指南基于 SEP-990 企业托管授权的 MCP 客户端与服务端接入python sdk 身份断言ID JAG实战指南基于 SEP 990 企业托管授权的 MCP 客户端与服务端接入 本文以 python sdk 官方文档人工智能MCP 服务MCP ClientsMCP Python SDK 的 Identity AssertionSEP-990 企业托管授权从客户端 ID-JAG 流程到授权服务器完整实现MCP Python SDK 的 Identity AssertionSEP 990 企业托管授权从客户端 ID JAG 流程到授权服务器完整实现 本篇技人工智能MCP 服务MCP Clients3分钟掌握RevokeMsgPatcher微信QQ防撤回神器终极指南3分钟掌握RevokeMsgPatcher微信QQ防撤回神器终极指南 你是否曾经因为错过了重要的微信消息而懊恼或者因为QQ聊天记录被撤回而错过了关键信息今人工智能MCP 服务MCP Clients上一篇res-downloader 完整新手指南快速存下视频号视频、抖音音乐与网页图片下一篇Mac效率神器Loop窗口管理终极攻略创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考