mcp-test-authz 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-test-authz might be problematic. Click here for more details.

@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-test-authz
3
+ Version: 0.1.0
4
+ Summary: Authz probe MCP server (stdio) covering L2/L3 auth modes
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: mcp>=2.0.0
@@ -0,0 +1,332 @@
1
+ """本地演示用授权服务器(Authorization Server, AS)—— 仅供本地联调
2
+
3
+ 用途
4
+ ----
5
+ 让「鉴权探针」示例在本机完整跑通。本脚本同时支持两种 grant,覆盖 L3 的两种 OAuth 形态:
6
+
7
+ - `client_credentials`:服务身份令牌(对应 L3 的 client_credentials 模式);
8
+ - `urn:ietf:params:oauth:grant-type:token-exchange`(RFC 8693):用 `subject_token`
9
+ (终端用户访问令牌)换取**委派令牌**——`sub` 取自用户、`act` 记录本 MCP、`aud` 绑定上游。
10
+
11
+ 此外提供 `POST /demo/user-token`,便于本地演示时快速签一枚**用户 subject token**
12
+ (其 `aud` 指向「AgentArts 网关」标识,**不等于**上游资源——正是这一点让「透传用户令牌」
13
+ 到上游会被 401 拒绝,说明必须做 token-exchange 而非透传)。
14
+
15
+ 生产环境请改用 Keycloak / 企业 IdP 等成熟授权服务器,不要使用本脚本。
16
+
17
+ 实现
18
+ ----
19
+ 仅依赖 Python 标准库(http.server),无需安装任何包。签发 HS256 JWT。
20
+
21
+ 启动
22
+ ----
23
+ python dev_auth_server.py --secret dev-authz-secret \
24
+ --issuer https://auth.example.com \
25
+ --resource https://upstream.demo.example.com \
26
+ --gateway-audience https://agentarts.example.com \
27
+ --actor service:authz-mcp
28
+
29
+ 端点
30
+ ----
31
+ - GET /.well-known/oauth-authorization-server 授权服务器元数据(RFC 8414)
32
+ - POST /token 令牌端点(client_credentials / token-exchange)
33
+ - POST /demo/user-token 演示:签发用户 subject token(body: {"sub":"alice"})
34
+ - POST /introspect 令牌内省(演示用,返回是否有效)
35
+ - GET / 说明页
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import base64
42
+ import hashlib
43
+ import hmac
44
+ import json
45
+ import time
46
+ import urllib.parse
47
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
48
+
49
+ # RFC 8693 常量
50
+ TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
51
+ ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
52
+
53
+ # --------------------------------------------------------------------------- #
54
+ # 配置(可被命令行参数覆盖)
55
+ # --------------------------------------------------------------------------- #
56
+ CONFIG = {
57
+ "secret": "dev-authz-secret",
58
+ "issuer": "https://auth.example.com",
59
+ "resource": "https://upstream.demo.example.com", # 上游资源标识(委派/服务令牌的 aud)
60
+ "gateway_audience": "https://agentarts.example.com", # AgentArts 网关标识(用户令牌的 aud)
61
+ "actor": "service:authz-mcp", # 本 MCP 的服务身份(委派令牌的 act)
62
+ "client_id": "authz-mcp-client",
63
+ "client_secret": "authz-mcp-secret",
64
+ "scopes": ["demo:read", "demo:write"],
65
+ "token_ttl": 3600,
66
+ "user_token_ttl": 1800,
67
+ }
68
+
69
+
70
+ # --------------------------------------------------------------------------- #
71
+ # JWT(HS256) 工具
72
+ # --------------------------------------------------------------------------- #
73
+ def _b64url(data: bytes) -> str:
74
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
75
+
76
+
77
+ def _b64url_decode(seg: str) -> bytes:
78
+ seg += "=" * (-len(seg) % 4)
79
+ return base64.urlsafe_b64decode(seg.encode("ascii"))
80
+
81
+
82
+ def sign_jwt(claims: dict) -> str:
83
+ header = {"alg": "HS256", "typ": "JWT"}
84
+ seg = ".".join(
85
+ _b64url(json.dumps(part, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
86
+ for part in (header, claims)
87
+ )
88
+ sig = hmac.new(CONFIG["secret"].encode("utf-8"), seg.encode("ascii"), hashlib.sha256).digest()
89
+ return f"{seg}.{_b64url(sig)}"
90
+
91
+
92
+ def issue_service_token(scope: str, client_id: str) -> dict:
93
+ """client_credentials:服务身份令牌(aud=上游资源,无 act)。"""
94
+ now = int(time.time())
95
+ claims = {
96
+ "iss": CONFIG["issuer"],
97
+ "sub": CONFIG["actor"], # 服务身份就是主体
98
+ "aud": CONFIG["resource"],
99
+ "client_id": client_id,
100
+ "scope": scope,
101
+ "iat": now,
102
+ "exp": now + CONFIG["token_ttl"],
103
+ }
104
+ return {
105
+ "access_token": sign_jwt(claims),
106
+ "token_type": "Bearer",
107
+ "expires_in": CONFIG["token_ttl"],
108
+ "scope": scope,
109
+ }
110
+
111
+
112
+ def issue_user_token(sub: str) -> dict:
113
+ """演示:签发一枚**用户 subject token**,其 aud 指向 AgentArts 网关(非上游资源)。"""
114
+ now = int(time.time())
115
+ scope = "openid profile"
116
+ claims = {
117
+ "iss": CONFIG["issuer"],
118
+ "sub": sub,
119
+ "aud": CONFIG["gateway_audience"], # 受众是网关,不是上游
120
+ "client_id": "agentarts-gateway",
121
+ "scope": scope,
122
+ "iat": now,
123
+ "exp": now + CONFIG["user_token_ttl"],
124
+ }
125
+ return {
126
+ "access_token": sign_jwt(claims),
127
+ "token_type": "Bearer",
128
+ "expires_in": CONFIG["user_token_ttl"],
129
+ "scope": scope,
130
+ }
131
+
132
+
133
+ def issue_delegated_token(sub: str, scope: str, audience: str) -> dict:
134
+ """token-exchange:委派令牌 —— sub=用户、act=本 MCP、aud=上游资源。"""
135
+ now = int(time.time())
136
+ claims = {
137
+ "iss": CONFIG["issuer"],
138
+ "sub": sub, # 终端用户身份
139
+ "act": CONFIG["actor"], # 代理方:本 MCP 服务身份(代理链)
140
+ "aud": audience, # 受众:上游资源
141
+ "client_id": CONFIG["client_id"],
142
+ "scope": scope,
143
+ "iat": now,
144
+ "exp": now + CONFIG["token_ttl"],
145
+ }
146
+ return {
147
+ "access_token": sign_jwt(claims),
148
+ "issued_token_type": ACCESS_TOKEN_TYPE,
149
+ "token_type": "Bearer",
150
+ "expires_in": CONFIG["token_ttl"],
151
+ "scope": scope,
152
+ }
153
+
154
+
155
+ def verify_subject_token(token: str) -> tuple[dict | None, str]:
156
+ """校验入站 subject_token:签名 + iss + exp。返回 (claims, error)。"""
157
+ if not token:
158
+ return None, "缺少 subject_token"
159
+ try:
160
+ head_b64, payload_b64, sig_b64 = token.split(".")
161
+ except ValueError:
162
+ return None, "subject_token 格式错误"
163
+
164
+ signing_input = f"{head_b64}.{payload_b64}".encode("ascii")
165
+ expected = hmac.new(CONFIG["secret"].encode("utf-8"), signing_input, hashlib.sha256).digest()
166
+ try:
167
+ given = _b64url_decode(sig_b64)
168
+ except Exception: # noqa: BLE001
169
+ return None, "subject_token 签名解码失败"
170
+ if not hmac.compare_digest(expected, given):
171
+ return None, "subject_token 签名校验失败"
172
+
173
+ try:
174
+ claims = json.loads(_b64url_decode(payload_b64))
175
+ except Exception: # noqa: BLE001
176
+ return None, "subject_token 载荷解析失败"
177
+
178
+ if claims.get("iss") != CONFIG["issuer"]:
179
+ return None, "subject_token 签发者(iss)不受信任"
180
+ if int(claims.get("exp", 0)) < int(time.time()):
181
+ return None, "subject_token 已过期"
182
+ return claims, ""
183
+
184
+
185
+ # --------------------------------------------------------------------------- #
186
+ # HTTP 处理
187
+ # --------------------------------------------------------------------------- #
188
+ class Handler(BaseHTTPRequestHandler):
189
+ server_version = "DevAuthServer/1.0"
190
+
191
+ def log_message(self, fmt: str, *args) -> None: # noqa: A003
192
+ print("[dev-as] " + fmt % args)
193
+
194
+ def _json(self, status: int, payload: dict) -> None:
195
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
196
+ self.send_response(status)
197
+ self.send_header("Content-Type", "application/json; charset=utf-8")
198
+ self.send_header("Content-Length", str(len(body)))
199
+ self.send_header("Cache-Control", "no-store")
200
+ self.end_headers()
201
+ self.wfile.write(body)
202
+
203
+ def _read_json(self) -> dict:
204
+ length = int(self.headers.get("Content-Length", "0") or 0)
205
+ if not length:
206
+ return {}
207
+ try:
208
+ return json.loads(self.rfile.read(length).decode("utf-8"))
209
+ except Exception: # noqa: BLE001
210
+ return {}
211
+
212
+ def do_GET(self) -> None: # noqa: N802
213
+ parsed = urllib.parse.urlparse(self.path)
214
+ if parsed.path == "/.well-known/oauth-authorization-server":
215
+ self._json(200, {
216
+ "issuer": CONFIG["issuer"],
217
+ "token_endpoint": f"{CONFIG['issuer']}/token",
218
+ "introspection_endpoint": f"{CONFIG['issuer']}/introspect",
219
+ "grant_types_supported": ["client_credentials", TOKEN_EXCHANGE_GRANT],
220
+ "token_endpoint_auth_methods_supported": ["client_secret_post"],
221
+ "scopes_supported": CONFIG["scopes"],
222
+ })
223
+ return
224
+ self._json(200, {
225
+ "service": "Dev Authorization Server (仅供本地演示)",
226
+ "issuer": CONFIG["issuer"],
227
+ "resource": CONFIG["resource"],
228
+ "gateway_audience": CONFIG["gateway_audience"],
229
+ "actor": CONFIG["actor"],
230
+ "demo_client": {"client_id": CONFIG["client_id"]},
231
+ "endpoints": [
232
+ "/.well-known/oauth-authorization-server", "/token", "/demo/user-token", "/introspect",
233
+ ],
234
+ })
235
+
236
+ def do_POST(self) -> None: # noqa: N802
237
+ parsed = urllib.parse.urlparse(self.path)
238
+
239
+ if parsed.path == "/demo/user-token":
240
+ body = self._read_json()
241
+ sub = str(body.get("sub") or "alice")
242
+ self._json(200, issue_user_token(sub))
243
+ return
244
+
245
+ length = int(self.headers.get("Content-Length", "0") or 0)
246
+ form = dict(urllib.parse.parse_qsl(self.rfile.read(length).decode("utf-8")))
247
+
248
+ if parsed.path == "/token":
249
+ self._handle_token(form)
250
+ return
251
+ if parsed.path == "/introspect":
252
+ self._json(200, {"active": bool(form.get("token"))})
253
+ return
254
+ self._json(404, {"error": "not_found"})
255
+
256
+ def _check_client(self, form: dict) -> bool:
257
+ cid = form.get("client_id", "")
258
+ csec = form.get("client_secret", "")
259
+ # 常量时间比较,避免时序侧信道
260
+ return hmac.compare_digest(cid, CONFIG["client_id"]) and hmac.compare_digest(
261
+ csec, CONFIG["client_secret"]
262
+ )
263
+
264
+ def _handle_token(self, form: dict) -> None:
265
+ grant = form.get("grant_type")
266
+
267
+ if grant == "client_credentials":
268
+ if not self._check_client(form):
269
+ self._json(401, {"error": "invalid_client"})
270
+ return
271
+ scope = form.get("scope") or " ".join(CONFIG["scopes"])
272
+ self._json(200, issue_service_token(scope, form.get("client_id", "")))
273
+ return
274
+
275
+ if grant == TOKEN_EXCHANGE_GRANT:
276
+ if not self._check_client(form):
277
+ self._json(401, {"error": "invalid_client"})
278
+ return
279
+ subject_token = form.get("subject_token", "")
280
+ subject_type = form.get("subject_token_type") or ACCESS_TOKEN_TYPE
281
+ if subject_type != ACCESS_TOKEN_TYPE:
282
+ self._json(400, {"error": "invalid_request",
283
+ "error_description": f"不支持的 subject_token_type:{subject_type}"})
284
+ return
285
+ claims, err = verify_subject_token(subject_token)
286
+ if err:
287
+ self._json(400, {"error": "invalid_grant", "error_description": err})
288
+ return
289
+ audience = form.get("audience") or form.get("resource") or CONFIG["resource"]
290
+ scope = form.get("scope") or " ".join(CONFIG["scopes"])
291
+ sub = str(claims.get("sub") or "unknown")
292
+ self._json(200, issue_delegated_token(sub, scope, audience))
293
+ return
294
+
295
+ self._json(400, {"error": "unsupported_grant_type",
296
+ "error_description": "本演示授权服务器支持 client_credentials 与 token-exchange"})
297
+
298
+
299
+ def main() -> None:
300
+ parser = argparse.ArgumentParser(
301
+ description="本地演示用 OAuth 2.1 授权服务器(client_credentials + token-exchange)"
302
+ )
303
+ parser.add_argument("--host", default="127.0.0.1")
304
+ parser.add_argument("--port", type=int, default=9000)
305
+ parser.add_argument("--secret", default=CONFIG["secret"])
306
+ parser.add_argument("--issuer", default=CONFIG["issuer"])
307
+ parser.add_argument("--resource", default=CONFIG["resource"])
308
+ parser.add_argument("--gateway-audience", default=CONFIG["gateway_audience"])
309
+ parser.add_argument("--actor", default=CONFIG["actor"])
310
+ parser.add_argument("--client-id", default=CONFIG["client_id"])
311
+ parser.add_argument("--client-secret", default=CONFIG["client_secret"])
312
+ args = parser.parse_args()
313
+
314
+ CONFIG.update({
315
+ "secret": args.secret, "issuer": args.issuer.rstrip("/"), "resource": args.resource,
316
+ "gateway_audience": args.gateway_audience, "actor": args.actor,
317
+ "client_id": args.client_id, "client_secret": args.client_secret,
318
+ })
319
+
320
+ server = ThreadingHTTPServer((args.host, args.port), Handler)
321
+ print(f"[dev-as] 监听 http://{args.host}:{args.port} issuer={CONFIG['issuer']}")
322
+ print(f"[dev-as] resource(上游)={CONFIG['resource']} gateway={CONFIG['gateway_audience']} actor={CONFIG['actor']}")
323
+ print("[dev-as] 支持 client_credentials 与 token-exchange;/demo/user-token 可签用户 subject token")
324
+ print("[dev-as] 仅供本地演示,生产请使用 Keycloak 等成熟授权服务器")
325
+ try:
326
+ server.serve_forever()
327
+ except KeyboardInterrupt:
328
+ server.shutdown()
329
+
330
+
331
+ if __name__ == "__main__":
332
+ main()
@@ -0,0 +1,176 @@
1
+ """受保护的上游 API(资源服务器 / RS)—— 仅供本地联调
2
+
3
+ 定位
4
+ ----
5
+ 它是 L3 的终点站:同时接受两种凭证,用于覆盖 UPSTREAM_AUTH_MODE 的三种模式:
6
+
7
+ - **静态 Key**(`X-Api-Key: <key>`) —— 对应 `static` 模式;
8
+ - **Bearer JWT**(HS256 验签) —— 对应 `client_credentials` / `token_exchange` 模式。
9
+
10
+ 对 Bearer JWT,会校验 签名 + iss + aud + exp + scope,并把 `sub`(主体)与 `act`(代理方)
11
+ 原样回显——这样调用方一眼就能看出「是谁在代表谁访问上游」。
12
+
13
+ 关键对照:**直接透传用户令牌会被 401 拒绝**
14
+ ----------------------------------------
15
+ 用户 subject token 的 `aud` 是 **AgentArts 网关标识**,并不等于本上游资源。若把用户令牌原样
16
+ 透传过来,下面的 `aud` 校验会把它挡在门外——这就是「受众绑定(RFC 8707)」约束的具体体现。
17
+
18
+ 实现
19
+ ----
20
+ 仅依赖 Python 标准库(http.server),无需安装任何包。
21
+
22
+ 启动
23
+ ----
24
+ python dev_upstream_api.py --secret dev-authz-secret \
25
+ --issuer https://auth.example.com \
26
+ --audience https://upstream.demo.example.com \
27
+ --static-key dev-static-key
28
+
29
+ 端点
30
+ ----
31
+ - GET /echo 回显鉴权结果(via / sub / act / scope / iss / aud)
32
+ - GET / 说明页
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import base64
39
+ import hashlib
40
+ import hmac
41
+ import json
42
+ import time
43
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
44
+
45
+ CONFIG = {
46
+ "secret": "dev-authz-secret",
47
+ "issuer": "https://auth.example.com",
48
+ "audience": "https://upstream.demo.example.com",
49
+ "static_key": "dev-static-key",
50
+ "required_scope": "demo:read",
51
+ }
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # HS256 JWT 校验(标准库)
56
+ # --------------------------------------------------------------------------- #
57
+ def _b64url_decode(seg: str) -> bytes:
58
+ seg += "=" * (-len(seg) % 4)
59
+ return base64.urlsafe_b64decode(seg.encode("ascii"))
60
+
61
+
62
+ def verify_bearer(authorization: str | None) -> tuple[dict | None, str]:
63
+ """校验 Authorization: Bearer <JWT>,返回 (claims, error)。"""
64
+ if not authorization or not authorization.startswith("Bearer "):
65
+ return None, "缺少 Bearer 令牌"
66
+ token = authorization[len("Bearer "):].strip()
67
+ try:
68
+ head_b64, payload_b64, sig_b64 = token.split(".")
69
+ except ValueError:
70
+ return None, "令牌格式错误"
71
+
72
+ signing_input = f"{head_b64}.{payload_b64}".encode("ascii")
73
+ expected = hmac.new(CONFIG["secret"].encode("utf-8"), signing_input, hashlib.sha256).digest()
74
+ try:
75
+ given = _b64url_decode(sig_b64)
76
+ except Exception: # noqa: BLE001
77
+ return None, "签名解码失败"
78
+ if not hmac.compare_digest(expected, given):
79
+ return None, "签名校验失败"
80
+
81
+ try:
82
+ claims = json.loads(_b64url_decode(payload_b64))
83
+ except Exception: # noqa: BLE001
84
+ return None, "载荷解析失败"
85
+
86
+ if claims.get("iss") != CONFIG["issuer"]:
87
+ return None, "签发者(iss)不受信任"
88
+ if claims.get("aud") != CONFIG["audience"]:
89
+ return None, "令牌受众(aud)与本资源不匹配(禁止把用户 token 直接透传到上游)"
90
+ if int(claims.get("exp", 0)) < int(time.time()):
91
+ return None, "令牌已过期"
92
+ if CONFIG["required_scope"] and CONFIG["required_scope"] not in set(str(claims.get("scope", "")).split()):
93
+ return None, f"缺少 scope: {CONFIG['required_scope']}"
94
+ return claims, ""
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # HTTP 处理
99
+ # --------------------------------------------------------------------------- #
100
+ class Handler(BaseHTTPRequestHandler):
101
+ server_version = "DevUpstreamAPI/1.0"
102
+
103
+ def log_message(self, fmt: str, *args) -> None: # noqa: A003
104
+ print("[dev-upstream] " + fmt % args)
105
+
106
+ def _json(self, status: int, payload: dict, www_authenticate: str | None = None) -> None:
107
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
108
+ self.send_response(status)
109
+ self.send_header("Content-Type", "application/json; charset=utf-8")
110
+ self.send_header("Content-Length", str(len(body)))
111
+ if www_authenticate:
112
+ self.send_header("WWW-Authenticate", www_authenticate)
113
+ self.end_headers()
114
+ self.wfile.write(body)
115
+
116
+ def _authenticate(self) -> dict | None:
117
+ """返回鉴权结果字典;失败时已写出响应并返回 None。"""
118
+ api_key = self.headers.get("X-Api-Key")
119
+ if api_key is not None:
120
+ if hmac.compare_digest(api_key, CONFIG["static_key"]):
121
+ return {"via": "static_key"}
122
+ self._json(401, {"error": "invalid_api_key", "error_description": "静态 Key 不正确"})
123
+ return None
124
+
125
+ claims, err = verify_bearer(self.headers.get("Authorization"))
126
+ if err:
127
+ self._json(401, {"error": "invalid_token", "error_description": err},
128
+ www_authenticate=f'Bearer resource_metadata="{CONFIG["audience"]}"')
129
+ return None
130
+ return {
131
+ "via": "jwt",
132
+ "sub": claims.get("sub"),
133
+ "act": claims.get("act"),
134
+ "scope": claims.get("scope"),
135
+ "iss": claims.get("iss"),
136
+ "aud": claims.get("aud"),
137
+ "exp": claims.get("exp"),
138
+ }
139
+
140
+ def do_GET(self) -> None: # noqa: N802
141
+ if self.path.rstrip("/") in ("/echo", ""):
142
+ auth = self._authenticate()
143
+ if auth is None:
144
+ return
145
+ self._json(200, {"ok": True, "path": "/echo", "resource": CONFIG["audience"], **auth})
146
+ return
147
+ self._json(404, {"error": "not_found"})
148
+
149
+
150
+ def main() -> None:
151
+ parser = argparse.ArgumentParser(description="受保护的上游 API(本地演示 / L3 终点)")
152
+ parser.add_argument("--host", default="127.0.0.1")
153
+ parser.add_argument("--port", type=int, default=9100)
154
+ parser.add_argument("--secret", default=CONFIG["secret"])
155
+ parser.add_argument("--issuer", default=CONFIG["issuer"])
156
+ parser.add_argument("--audience", default=CONFIG["audience"])
157
+ parser.add_argument("--static-key", default=CONFIG["static_key"])
158
+ parser.add_argument("--required-scope", default=CONFIG["required_scope"])
159
+ args = parser.parse_args()
160
+
161
+ CONFIG.update({
162
+ "secret": args.secret, "issuer": args.issuer.rstrip("/"), "audience": args.audience,
163
+ "static_key": args.static_key, "required_scope": args.required_scope,
164
+ })
165
+
166
+ server = ThreadingHTTPServer((args.host, args.port), Handler)
167
+ print(f"[dev-upstream] 监听 http://{args.host}:{args.port} aud={CONFIG['audience']}")
168
+ print("[dev-upstream] 接受静态 Key(X-Api-Key) 或 aud 匹配、带 scope 的 Bearer 令牌;回显 sub/act")
169
+ try:
170
+ server.serve_forever()
171
+ except KeyboardInterrupt:
172
+ server.shutdown()
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
@@ -0,0 +1,79 @@
1
+ """离线签发示例令牌(仅标准库)—— 用于本地验证 Stdio 鉴权探针。
2
+
3
+ 用途:生成 HS256 JWT,填入 AgentArts Stdio MCP 配置的 `env`:
4
+ MCP_INBOUND_TOKEN = <本脚本输出的令牌>
5
+ MCP_AUTH_JWT_SECRET = <与 --secret 相同的密钥>
6
+
7
+ 示例
8
+ ----
9
+ # 用户 subject token(aud=AgentArts 网关,用于验证 L2=jwt;也可作为 token_exchange 的 subject)
10
+ python issue_token.py --secret demo-secret --scope "demo:read demo:write" --aud https://agentarts.example.com
11
+
12
+ # 直接打印可粘贴的 env JSON
13
+ python issue_token.py --secret demo-secret --aud https://agentarts.example.com --env-json
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import importlib.util
20
+ import json
21
+ import time
22
+ from pathlib import Path
23
+
24
+ # 直接按文件路径加载 _jwt,避免触发包 __init__(进而 import mcp)
25
+ _JWT_PATH = Path(__file__).resolve().parent / "src" / "mcp_authz" / "_jwt.py"
26
+ _spec = importlib.util.spec_from_file_location("_authz_jwt", _JWT_PATH)
27
+ _jwt = importlib.util.module_from_spec(_spec)
28
+ _spec.loader.exec_module(_jwt)
29
+
30
+
31
+ def build_token(secret: str, *, issuer: str, audience: str, sub: str, client_id: str,
32
+ scope: str, actor: str, ttl: int) -> str:
33
+ now = int(time.time())
34
+ claims = {
35
+ "iss": issuer,
36
+ "sub": sub,
37
+ "client_id": client_id,
38
+ "scope": scope,
39
+ "iat": now,
40
+ "exp": now + ttl,
41
+ }
42
+ if audience:
43
+ claims["aud"] = audience
44
+ if actor:
45
+ claims["act"] = actor
46
+ return _jwt.encode(claims, secret)
47
+
48
+
49
+ def main() -> None:
50
+ ap = argparse.ArgumentParser(description="离线签发鉴权探针令牌(HS256)")
51
+ ap.add_argument("--secret", required=True, help="签名密钥(须与运行时 MCP_AUTH_JWT_SECRET 一致)")
52
+ ap.add_argument("--issuer", default="https://auth.example.com", help="签发者 iss")
53
+ ap.add_argument("--aud", default="https://agentarts.example.com",
54
+ help="受众 aud(用户令牌通常为 AgentArts 网关标识)")
55
+ ap.add_argument("--sub", default="user-alice", help="主体标识")
56
+ ap.add_argument("--client-id", default="agentarts-gateway", help="客户端标识")
57
+ ap.add_argument("--scope", default="demo:read demo:write", help='作用域,如 "demo:read demo:write"')
58
+ ap.add_argument("--actor", default="", help="委托方 act(token_exchange 委派令牌才需要)")
59
+ ap.add_argument("--ttl", type=int, default=3600, help="有效期(秒),默认 3600")
60
+ ap.add_argument("--env-json", action="store_true", help="以 JSON 打印可粘贴的 env 片段")
61
+ args = ap.parse_args()
62
+
63
+ token = build_token(
64
+ args.secret, issuer=args.issuer, audience=args.aud, sub=args.sub,
65
+ client_id=args.client_id, scope=args.scope, actor=args.actor, ttl=args.ttl,
66
+ )
67
+
68
+ if args.env_json:
69
+ print(json.dumps(
70
+ {"MCP_INBOUND_TOKEN": token, "MCP_AUTH_JWT_SECRET": args.secret,
71
+ "MCP_AUTH_ISSUER": args.issuer, "MCP_AUTH_AUDIENCE": args.aud},
72
+ ensure_ascii=False, indent=2,
73
+ ))
74
+ else:
75
+ print(token)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-test-authz"
7
+ version = "0.1.0"
8
+ description = "Authz probe MCP server (stdio) covering L2/L3 auth modes"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ dependencies = [
12
+ "mcp>=2.0.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ mcp-test-authz = "mcp_authz.server:main"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/mcp_authz"]
@@ -0,0 +1,3 @@
1
+ from .server import mcp
2
+
3
+ __all__ = ["mcp"]
@@ -0,0 +1,81 @@
1
+ """极简 HS256 JWT 编解码(仅标准库)。
2
+
3
+ 本模块只做一件事:把「离线签发」与「本地校验」令牌所需的 HMAC-SHA256
4
+ 逻辑收敛到一处,且**不依赖 mcp / 任何第三方库**,便于单测与复用。
5
+
6
+ 生产建议:用 PyJWT,或改用非对称签名(RS256/ES256),资源侧只持有公钥。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import hmac
14
+ import json
15
+ import time
16
+
17
+
18
+ class JWTError(ValueError):
19
+ """令牌编解码或校验失败。"""
20
+
21
+
22
+ def _b64url_encode(raw: bytes) -> str:
23
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
24
+
25
+
26
+ def _b64url_decode(seg: str) -> bytes:
27
+ pad = "=" * (-len(seg) % 4)
28
+ return base64.urlsafe_b64decode(seg + pad)
29
+
30
+
31
+ def _sign(signing_input: bytes, secret: str) -> str:
32
+ digest = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
33
+ return _b64url_encode(digest)
34
+
35
+
36
+ def encode(claims: dict, secret: str) -> str:
37
+ """用 HS256 把声明签发成 JWT。"""
38
+ header = {"alg": "HS256", "typ": "JWT"}
39
+ h = _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
40
+ p = _b64url_encode(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
41
+ return f"{h}.{p}.{_sign(f'{h}.{p}'.encode('ascii'), secret)}"
42
+
43
+
44
+ def decode(
45
+ token: str,
46
+ secret: str,
47
+ *,
48
+ issuer: str | None = None,
49
+ audience: str | None = None,
50
+ leeway: int = 0,
51
+ ) -> dict:
52
+ """校验 JWT 并返回声明;任一校验不通过都抛 JWTError。"""
53
+ parts = (token or "").split(".")
54
+ if len(parts) != 3:
55
+ raise JWTError("令牌格式不合法(应为 header.payload.signature)")
56
+ header_seg, payload_seg, sig_seg = parts
57
+ try:
58
+ header = json.loads(_b64url_decode(header_seg))
59
+ claims = json.loads(_b64url_decode(payload_seg))
60
+ except Exception as exc: # noqa: BLE001 - 解码失败统一转成 JWTError
61
+ raise JWTError(f"令牌解码失败:{exc}") from exc
62
+ if header.get("alg") != "HS256":
63
+ raise JWTError(f"不支持的签名算法:{header.get('alg')}")
64
+ if not hmac.compare_digest(_sign(f"{header_seg}.{payload_seg}".encode("ascii"), secret), sig_seg):
65
+ raise JWTError("令牌签名校验失败")
66
+
67
+ now = int(time.time())
68
+ exp = claims.get("exp")
69
+ if exp is not None and now > int(exp) + leeway:
70
+ raise JWTError("令牌已过期")
71
+ nbf = claims.get("nbf")
72
+ if nbf is not None and now + leeway < int(nbf):
73
+ raise JWTError("令牌尚未生效(nbf)")
74
+ if issuer is not None and claims.get("iss") != issuer:
75
+ raise JWTError(f"签发者不匹配:期望 {issuer},实际 {claims.get('iss')}")
76
+ if audience is not None:
77
+ aud = claims.get("aud")
78
+ aud_list = aud if isinstance(aud, list) else [aud]
79
+ if audience not in aud_list:
80
+ raise JWTError(f"受众不匹配:令牌 aud={aud},期望包含 {audience}")
81
+ return claims
@@ -0,0 +1,391 @@
1
+ """鉴权探针(Stdio / UVX)—— 与 HTTP 版同构,覆盖 L1 / L2 / L3 三层鉴权
2
+
3
+ 定位(与 HTTP 版的差异)
4
+ ------------------------
5
+ HTTP 版通过**请求头**承载 L2 凭证,并按请求做中间件校验;Stdio 版没有入站 HTTP 传输
6
+ (平台用 `uvx` 拉起本进程,经 stdin/stdout 通信),因此:
7
+
8
+ - **L2 令牌经环境变量注入**:平台在 MCP 配置的 `env` 里下发令牌(`MCP_INBOUND_TOKEN`),
9
+ 本服务在进程内本地校验(HS256 + iss/aud),并按模式 `MCP_AUTH_MODE` 分支;
10
+ - **没有 `Authorization` 头冲突**:Stdio 不与平台走 HTTP,天然规避 1.4 所述的头名冲突;
11
+ - **L3 完全同构**:本服务仍按 `UPSTREAM_AUTH_MODE` 用**自己的**凭证访问上游。
12
+
13
+ 工具与 HTTP 版保持一致,便于对照测试:
14
+ whoami(回显 L2 身份) / auth_matrix(回显生效配置) / call_upstream(验证 L3 凭证)。
15
+
16
+ 环境变量(AgentArts Stdio 模式在配置的 `env` 中注入,不要写进代码/镜像)
17
+ ------------------------------------------------------------------------
18
+ L2:
19
+ - `MCP_AUTH_MODE` : none | apikey | jwt,默认 none
20
+ - `MCP_INBOUND_TOKEN` : 注入的入站凭证(jwt 模式为访问令牌;apikey 模式为被校验值)
21
+ - `MCP_INBOUND_APIKEY` : apikey 模式下的期望 Key(与上面比对)
22
+ - `MCP_AUTH_JWT_SECRET`: jwt 模式下的 HS256 验签密钥,缺失则 fail-closed
23
+ - `MCP_AUTH_ISSUER` : jwt 模式下期望的签发者 iss(可选,配置后强制校验)
24
+ - `MCP_AUTH_AUDIENCE` : jwt 模式下期望的受众 aud(可选,配置后强制校验)
25
+ L3:
26
+ - `UPSTREAM_AUTH_MODE` : static | client_credentials | token_exchange,默认 static
27
+ - `UPSTREAM_URL` : 上游基址,默认 http://127.0.0.1:9100
28
+ - `UPSTREAM_API_KEY` : static 模式下本服务访问上游的静态 Key
29
+ - `AS_TOKEN_URL` : 授权服务器令牌端点,默认 http://127.0.0.1:9000/token
30
+ - `OAUTH_CLIENT_ID` : 本服务的 OAuth client_id
31
+ - `OAUTH_CLIENT_SECRET`: 本服务的 OAuth client_secret
32
+ - `OAUTH_SCOPE` : 申请的 scope,默认 "demo:read demo:write"
33
+ - `OAUTH_ACTOR` : token_exchange 委派令牌中的 act(本服务身份)
34
+ - `OAUTH_AUDIENCE` : token_exchange 的 audience(上游资源标识)
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import functools
40
+ import hmac
41
+ import json
42
+ import logging
43
+ import os
44
+ import threading
45
+ import time
46
+ import urllib.error
47
+ import urllib.parse
48
+ import urllib.request
49
+
50
+ from mcp.server import MCPServer
51
+ from mcp.server.mcpserver.exceptions import ToolError
52
+
53
+ from . import _jwt
54
+
55
+ logging.basicConfig(level=logging.INFO)
56
+ logger = logging.getLogger("authz-mcp-stdio")
57
+
58
+ # 创建 MCP 服务器实例 (v2: MCPServer)
59
+ mcp = MCPServer("authz-mcp-server")
60
+
61
+ # --------------------------------------------------------------------------- #
62
+ # 配置
63
+ # --------------------------------------------------------------------------- #
64
+ MCP_AUTH_MODE_ENV = "MCP_AUTH_MODE"
65
+ MCP_INBOUND_TOKEN_ENV = "MCP_INBOUND_TOKEN"
66
+ MCP_INBOUND_APIKEY_ENV = "MCP_INBOUND_APIKEY"
67
+ MCP_AUTH_JWT_SECRET_ENV = "MCP_AUTH_JWT_SECRET"
68
+ MCP_AUTH_ISSUER_ENV = "MCP_AUTH_ISSUER"
69
+ MCP_AUTH_AUDIENCE_ENV = "MCP_AUTH_AUDIENCE"
70
+
71
+ UPSTREAM_AUTH_MODE_ENV = "UPSTREAM_AUTH_MODE"
72
+ UPSTREAM_URL_ENV = "UPSTREAM_URL"
73
+ UPSTREAM_API_KEY_ENV = "UPSTREAM_API_KEY"
74
+ AS_TOKEN_URL_ENV = "AS_TOKEN_URL"
75
+ OAUTH_CLIENT_ID_ENV = "OAUTH_CLIENT_ID"
76
+ OAUTH_CLIENT_SECRET_ENV = "OAUTH_CLIENT_SECRET"
77
+ OAUTH_SCOPE_ENV = "OAUTH_SCOPE"
78
+ OAUTH_ACTOR_ENV = "OAUTH_ACTOR"
79
+ OAUTH_AUDIENCE_ENV = "OAUTH_AUDIENCE"
80
+
81
+ DEFAULT_MCP_AUTH_MODE = "none"
82
+ DEFAULT_UPSTREAM_AUTH_MODE = "static"
83
+ DEFAULT_UPSTREAM_URL = "http://127.0.0.1:9100"
84
+ DEFAULT_AS_TOKEN_URL = "http://127.0.0.1:9000/token"
85
+ DEFAULT_CLIENT_ID = "authz-mcp-client"
86
+ DEFAULT_CLIENT_SECRET = "authz-mcp-secret"
87
+ DEFAULT_SCOPE = "demo:read demo:write"
88
+ DEFAULT_ACTOR = "service:authz-mcp"
89
+ DEFAULT_AUDIENCE = "https://upstream.demo.example.com"
90
+
91
+ # RFC 8693 常量
92
+ TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
93
+ ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
94
+
95
+
96
+ def _env(name: str, default: str = "") -> str:
97
+ return os.getenv(name, default).strip()
98
+
99
+
100
+ def _mcp_auth_mode() -> str:
101
+ return _env(MCP_AUTH_MODE_ENV, DEFAULT_MCP_AUTH_MODE).lower()
102
+
103
+
104
+ def _upstream_auth_mode() -> str:
105
+ return _env(UPSTREAM_AUTH_MODE_ENV, DEFAULT_UPSTREAM_AUTH_MODE).lower()
106
+
107
+
108
+ def _upstream_url() -> str:
109
+ return _env(UPSTREAM_URL_ENV, DEFAULT_UPSTREAM_URL)
110
+
111
+
112
+ def _token_url() -> str:
113
+ return _env(AS_TOKEN_URL_ENV, DEFAULT_AS_TOKEN_URL)
114
+
115
+
116
+ def _client_id() -> str:
117
+ return _env(OAUTH_CLIENT_ID_ENV, DEFAULT_CLIENT_ID)
118
+
119
+
120
+ def _client_secret() -> str:
121
+ return _env(OAUTH_CLIENT_SECRET_ENV, DEFAULT_CLIENT_SECRET)
122
+
123
+
124
+ def _scope() -> str:
125
+ return _env(OAUTH_SCOPE_ENV, DEFAULT_SCOPE)
126
+
127
+
128
+ def _actor() -> str:
129
+ return _env(OAUTH_ACTOR_ENV, DEFAULT_ACTOR)
130
+
131
+
132
+ def _audience() -> str:
133
+ return _env(OAUTH_AUDIENCE_ENV, DEFAULT_AUDIENCE)
134
+
135
+
136
+ def _mask(token: str | None) -> str:
137
+ if not token:
138
+ return ""
139
+ if len(token) <= 16:
140
+ return "*" * len(token)
141
+ return f"{token[:8]}...{token[-6:]}(已脱敏)"
142
+
143
+
144
+ def _surface_errors(fn):
145
+ """兜底装饰器:把工具内未预期的异常转成 ToolError,让失败原因对模型可见。"""
146
+
147
+ @functools.wraps(fn)
148
+ def wrapper(*args, **kwargs):
149
+ try:
150
+ return fn(*args, **kwargs)
151
+ except ToolError:
152
+ raise
153
+ except Exception as exc: # noqa: BLE001 - 故意兜底,确保错误可见
154
+ logger.exception("工具 %s 执行异常", fn.__name__)
155
+ raise ToolError(f"{fn.__name__} 执行失败:{type(exc).__name__}: {exc}") from exc
156
+
157
+ return wrapper
158
+
159
+
160
+ # --------------------------------------------------------------------------- #
161
+ # L2:进程内本地校验(none / apikey / jwt)
162
+ # --------------------------------------------------------------------------- #
163
+ def _l2_inbound() -> dict:
164
+ """按 `MCP_AUTH_MODE` 校验 env 注入的入站凭证,返回上下文;失败一律抛 ToolError。"""
165
+ mode = _mcp_auth_mode()
166
+ if mode == "none":
167
+ return {"mode": "none"}
168
+
169
+ if mode == "apikey":
170
+ expected = _env(MCP_INBOUND_APIKEY_ENV)
171
+ if not expected:
172
+ raise ToolError(f"MCP_AUTH_MODE=apikey 但未配置 {MCP_INBOUND_APIKEY_ENV}(fail-closed)")
173
+ presented = _env(MCP_INBOUND_TOKEN_ENV)
174
+ if not hmac.compare_digest(presented, expected):
175
+ raise ToolError(f"入站 API Key 缺失或不正确:请在 env 注入 {MCP_INBOUND_TOKEN_ENV}")
176
+ return {"mode": "apikey", "credential": presented}
177
+
178
+ if mode == "jwt":
179
+ token = _env(MCP_INBOUND_TOKEN_ENV)
180
+ if not token:
181
+ raise ToolError(f"鉴权失败:未配置 {MCP_INBOUND_TOKEN_ENV}(入站访问令牌),请在 env 注入。")
182
+ secret = _env(MCP_AUTH_JWT_SECRET_ENV)
183
+ if not secret:
184
+ raise ToolError(f"鉴权失败:未配置 {MCP_AUTH_JWT_SECRET_ENV}(验签密钥),fail-closed。")
185
+ try:
186
+ claims = _jwt.decode(
187
+ token, secret,
188
+ issuer=_env(MCP_AUTH_ISSUER_ENV) or None,
189
+ audience=_env(MCP_AUTH_AUDIENCE_ENV) or None,
190
+ )
191
+ except _jwt.JWTError as exc:
192
+ raise ToolError(f"令牌校验失败:{exc}") from exc
193
+ return {"mode": "jwt", "credential": token, "token": token, "claims": claims}
194
+
195
+ raise ToolError(f"未知的 MCP_AUTH_MODE:{mode}")
196
+
197
+
198
+ def _subject_token() -> str:
199
+ """取得 L2 注入的用户令牌(供 token_exchange 使用)。"""
200
+ return _env(MCP_INBOUND_TOKEN_ENV)
201
+
202
+
203
+ # --------------------------------------------------------------------------- #
204
+ # L3:取上游凭证 —— static / client_credentials / token_exchange 三种模式
205
+ # --------------------------------------------------------------------------- #
206
+ _TOKEN_LOCK = threading.Lock()
207
+ _TOKEN_CACHE: dict = {"token": None, "exp": 0.0, "subject": None}
208
+
209
+
210
+ def _as_post(form: dict) -> dict:
211
+ data = urllib.parse.urlencode(form).encode("utf-8")
212
+ req = urllib.request.Request(
213
+ _token_url(), data=data, method="POST",
214
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
215
+ )
216
+ try:
217
+ with urllib.request.urlopen(req, timeout=10) as resp:
218
+ return json.loads(resp.read().decode("utf-8"))
219
+ except urllib.error.HTTPError as exc:
220
+ detail = exc.read().decode("utf-8", "ignore")
221
+ raise ToolError(f"向授权服务器取令牌失败:HTTP {exc.code} {detail}") from exc
222
+ except ToolError:
223
+ raise
224
+ except Exception as exc: # noqa: BLE001
225
+ raise ToolError(f"向授权服务器取令牌失败:{type(exc).__name__}: {exc}") from exc
226
+
227
+
228
+ def _fetch_service_token(force: bool = False) -> str:
229
+ """client_credentials:用本服务自己的 client_id/secret 取令牌(带缓存/自动刷新)。"""
230
+ with _TOKEN_LOCK:
231
+ now = time.time()
232
+ if not force and _TOKEN_CACHE["token"] and _TOKEN_CACHE["exp"] - 30 > now:
233
+ return _TOKEN_CACHE["token"]
234
+ payload = _as_post({
235
+ "grant_type": "client_credentials",
236
+ "client_id": _client_id(),
237
+ "client_secret": _client_secret(),
238
+ "scope": _scope(),
239
+ })
240
+ token = payload.get("access_token")
241
+ if not token:
242
+ raise ToolError(f"授权服务器未返回 access_token:{payload}")
243
+ _TOKEN_CACHE.update({"token": token, "exp": now + float(payload.get("expires_in", 3600))})
244
+ logger.info("已取得服务令牌:client_id=%s scope=%s", _client_id(), payload.get("scope"))
245
+ return token
246
+
247
+
248
+ def _exchange_token(subject_token: str, force: bool = False) -> str:
249
+ """token_exchange(RFC 8693):用入站用户令牌换取委派令牌(带缓存/自动刷新)。"""
250
+ with _TOKEN_LOCK:
251
+ now = time.time()
252
+ if (
253
+ not force
254
+ and _TOKEN_CACHE["token"]
255
+ and _TOKEN_CACHE["subject"] == subject_token
256
+ and _TOKEN_CACHE["exp"] - 30 > now
257
+ ):
258
+ return _TOKEN_CACHE["token"]
259
+ payload = _as_post({
260
+ "grant_type": TOKEN_EXCHANGE_GRANT,
261
+ "client_id": _client_id(),
262
+ "client_secret": _client_secret(),
263
+ "subject_token": subject_token,
264
+ "subject_token_type": ACCESS_TOKEN_TYPE,
265
+ "audience": _audience(),
266
+ "scope": _scope(),
267
+ })
268
+ token = payload.get("access_token")
269
+ if not token:
270
+ raise ToolError(f"授权服务器未返回 access_token:{payload}")
271
+ _TOKEN_CACHE.update({
272
+ "token": token, "exp": now + float(payload.get("expires_in", 3600)), "subject": subject_token,
273
+ })
274
+ return token
275
+
276
+
277
+ def _l3_headers() -> tuple[dict, str]:
278
+ """按 `UPSTREAM_AUTH_MODE` 生成访问上游所需请求头,返回 (headers, 使用的模式)。"""
279
+ mode = _upstream_auth_mode()
280
+ if mode == "static":
281
+ key = _env(UPSTREAM_API_KEY_ENV)
282
+ if not key:
283
+ raise ToolError("UPSTREAM_AUTH_MODE=static 需要配置 UPSTREAM_API_KEY")
284
+ return {"X-Api-Key": key}, "static"
285
+ if mode == "client_credentials":
286
+ return {"Authorization": f"Bearer {_fetch_service_token()}"}, "client_credentials"
287
+ if mode == "token_exchange":
288
+ subject = _subject_token()
289
+ if not subject or _mcp_auth_mode() != "jwt":
290
+ raise ToolError(
291
+ "UPSTREAM_AUTH_MODE=token_exchange 需要 L2 提供用户令牌;"
292
+ "请将 MCP_AUTH_MODE 设为 jwt 并注入 MCP_INBOUND_TOKEN。"
293
+ )
294
+ return {"Authorization": f"Bearer {_exchange_token(subject)}"}, "token_exchange"
295
+ raise ToolError(f"未知的 UPSTREAM_AUTH_MODE:{mode}")
296
+
297
+
298
+ # --------------------------------------------------------------------------- #
299
+ # 工具(与 HTTP 版同名)
300
+ # --------------------------------------------------------------------------- #
301
+ @mcp.tool()
302
+ @_surface_errors
303
+ def whoami() -> dict:
304
+ """回显本次调用的 L2 身份信息(模式 / 令牌声明)。"""
305
+ inbound = _l2_inbound()
306
+ claims = inbound.get("claims") or {}
307
+ credential = inbound.get("credential", "")
308
+ return {
309
+ "l2_mode": inbound.get("mode", _mcp_auth_mode()),
310
+ "l2_source": MCP_INBOUND_TOKEN_ENV,
311
+ "credential_present": bool(credential),
312
+ "credential_preview": _mask(credential),
313
+ "inbound_claims": {
314
+ "sub": claims.get("sub"),
315
+ "iss": claims.get("iss"),
316
+ "aud": claims.get("aud"),
317
+ "client_id": claims.get("client_id"),
318
+ "scope": claims.get("scope"),
319
+ "act": claims.get("act"),
320
+ "exp": claims.get("exp"),
321
+ },
322
+ }
323
+
324
+
325
+ @mcp.tool()
326
+ @_surface_errors
327
+ def auth_matrix() -> dict:
328
+ """回显本服务当前生效的鉴权配置(L2 / L3 全量),用于快速核对 env 是否注入正确。"""
329
+ return {
330
+ "l2": {
331
+ "mode": _mcp_auth_mode(),
332
+ "token_env": MCP_INBOUND_TOKEN_ENV,
333
+ "token_configured": bool(_env(MCP_INBOUND_TOKEN_ENV)),
334
+ "apikey_configured": bool(_env(MCP_INBOUND_APIKEY_ENV)),
335
+ "jwt_secret_configured": bool(_env(MCP_AUTH_JWT_SECRET_ENV)),
336
+ "issuer": _env(MCP_AUTH_ISSUER_ENV) or None,
337
+ "audience": _env(MCP_AUTH_AUDIENCE_ENV) or None,
338
+ },
339
+ "l3": {
340
+ "mode": _upstream_auth_mode(),
341
+ "upstream_url": _upstream_url(),
342
+ "static_key_configured": bool(_env(UPSTREAM_API_KEY_ENV)),
343
+ "as_token_url": _token_url(),
344
+ "client_id": _client_id(),
345
+ "scope": _scope(),
346
+ "actor": _actor(),
347
+ "audience": _audience(),
348
+ },
349
+ "notes": {
350
+ "token_passthrough": False,
351
+ "l2_transport": "stdio: 平台在 env 注入令牌,进程内本地校验(无 HTTP 头冲突)",
352
+ "l1": "运行时端点鉴权由 AgentArts 网关负责,与本服务代码无关",
353
+ },
354
+ }
355
+
356
+
357
+ @mcp.tool()
358
+ @_surface_errors
359
+ def call_upstream(path: str = "/echo") -> dict:
360
+ """按 L3 模式取得凭证并调用上游,回显上游的鉴权结果(sub / act / scope / via)。
361
+
362
+ token_exchange 模式需搭配 L2 的 jwt 模式使用。
363
+
364
+ Args:
365
+ path: 上游路径,默认 /echo。
366
+ """
367
+ headers, via = _l3_headers()
368
+ req = urllib.request.Request(
369
+ _upstream_url().rstrip("/") + path, method="GET",
370
+ headers={**headers, "Accept": "application/json"},
371
+ )
372
+ try:
373
+ with urllib.request.urlopen(req, timeout=15) as resp:
374
+ payload = json.loads(resp.read().decode("utf-8"))
375
+ except urllib.error.HTTPError as exc:
376
+ detail = exc.read().decode("utf-8", "ignore")
377
+ raise ToolError(f"调用上游失败:HTTP {exc.code} {detail}") from exc
378
+ except ToolError:
379
+ raise
380
+ except Exception as exc: # noqa: BLE001
381
+ raise ToolError(f"调用上游失败:{type(exc).__name__}: {exc}") from exc
382
+ return {"l3_mode": via, "upstream_url": _upstream_url(), "upstream": payload}
383
+
384
+
385
+ def main() -> None:
386
+ """UVX 入口点 — 以 stdio 模式启动 MCP 服务"""
387
+ mcp.run(transport="stdio")
388
+
389
+
390
+ if __name__ == "__main__":
391
+ main()