langchain-agentx-python 2.3.5__py3-none-any.whl → 2.3.6__py3-none-any.whl

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.
@@ -11,7 +11,7 @@ from langchain_agentx import create_loop_agent
11
11
  ```
12
12
  """
13
13
 
14
- __version__ = "2.3.5"
14
+ __version__ = "2.3.6"
15
15
 
16
16
  from .mcp.call import McpToolCaller
17
17
  from .mcp.client_manager import McpClientManager, McpConnectedClient
@@ -1,5 +1,30 @@
1
1
  """mcp.auth — OAuth 协议面(无浏览器)。"""
2
2
 
3
- from langchain_agentx.mcp.auth.oauth_session import McpOAuthSession, McpTokenStore
3
+ from langchain_agentx.mcp.auth.authorization_flow import (
4
+ McpOAuthAuthorizationFlow,
5
+ McpOAuthStartResult,
6
+ )
7
+ from langchain_agentx.mcp.auth.errors import McpOAuthError, McpOAuthErrorReason
8
+ from langchain_agentx.mcp.auth.oauth_session import (
9
+ McpOAuthSession,
10
+ McpOAuthTokenRecord,
11
+ McpTokenStore,
12
+ )
13
+ from langchain_agentx.mcp.auth.pkce import McpPkcePair
14
+ from langchain_agentx.mcp.auth.token_store import (
15
+ McpFileTokenStore,
16
+ default_token_store_path,
17
+ )
4
18
 
5
- __all__ = ["McpOAuthSession", "McpTokenStore"]
19
+ __all__ = [
20
+ "McpOAuthAuthorizationFlow",
21
+ "McpOAuthError",
22
+ "McpOAuthErrorReason",
23
+ "McpOAuthSession",
24
+ "McpOAuthStartResult",
25
+ "McpOAuthTokenRecord",
26
+ "McpPkcePair",
27
+ "McpTokenStore",
28
+ "McpFileTokenStore",
29
+ "default_token_store_path",
30
+ ]
@@ -0,0 +1,271 @@
1
+ """OAuth 授权码 + PKCE 主编排。
2
+
3
+ 职责:
4
+ ``start``:endpoint 发现 + PKCE + state → authorize URL;
5
+ ``complete``:callback 校验(error/state/code)→ code 交换 → 写 Store。
6
+
7
+ 链路位置:
8
+ 对标 CC ``performMCPOAuthFlow`` 的协议切片(无浏览器 / 无本地端口监听 / 无 XAA)。
9
+ ``McpOAuthSession`` 门面调用;宿主负责打开浏览器与收 callback。
10
+
11
+ 当前裁剪:
12
+ 无动态 client registration(client_id 由调用方给)。
13
+ refresh 仅一次、无循环重试(Manager 侧控制);pending 按 server 名单槽,
14
+ 二次 ``start`` 覆盖(旧 state 失效)。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import secrets
21
+ import time
22
+ from collections.abc import Callable, Mapping
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+ from urllib.parse import parse_qs, urlencode, urlsplit
26
+
27
+ import httpx
28
+
29
+ from langchain_agentx.mcp.auth.discovery import McpOAuthDiscovery, McpOAuthEndpoints
30
+ from langchain_agentx.mcp.auth.errors import McpOAuthError
31
+ from langchain_agentx.mcp.auth.pkce import McpPkcePair
32
+ from langchain_agentx.mcp.auth.token_store import (
33
+ McpOAuthTokenRecord,
34
+ McpTokenStoreProtocol,
35
+ build_server_key,
36
+ )
37
+ from langchain_agentx.protocol.mcp.types import McpServerSpec
38
+
39
+ _ENV_CALLBACK_PORT = "MCP_OAUTH_CALLBACK_PORT"
40
+ _DEFAULT_CALLBACK_PORT = 3118 # CC REDIRECT_PORT_FALLBACK
41
+ _REDIRECT_PATH = "/callback"
42
+
43
+
44
+ def default_callback_redirect_uri() -> str:
45
+ """冻结形态:``http://localhost:{port}/callback``(env 可覆盖端口)。"""
46
+ port = int(os.environ.get(_ENV_CALLBACK_PORT, "") or 0) or _DEFAULT_CALLBACK_PORT
47
+ return f"http://localhost:{port}{_REDIRECT_PATH}"
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class McpOAuthStartResult:
52
+ """``start_authorization`` 产物;宿主拿 URL 去开浏览器。"""
53
+
54
+ authorization_url: str
55
+ state: str
56
+ redirect_uri: str
57
+ server_key: str
58
+
59
+
60
+ @dataclass
61
+ class _PendingFlow:
62
+ server_name: str
63
+ server_key: str
64
+ endpoints: McpOAuthEndpoints
65
+ pkce: McpPkcePair
66
+ state: str
67
+ redirect_uri: str
68
+ client_id: str | None
69
+ scope: str | None
70
+
71
+
72
+ class McpOAuthTokenExchanger:
73
+ """POST token endpoint(form → JSON)。测试注入同形 fake。"""
74
+
75
+ def __init__(self, *, timeout: float = 30.0) -> None:
76
+ self._timeout = timeout
77
+
78
+ async def exchange(self, endpoint: str, form: Mapping[str, str]) -> Mapping[str, Any]:
79
+ async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client:
80
+ response = await client.post(
81
+ endpoint, data=dict(form), headers={"Accept": "application/json"}
82
+ )
83
+ response.raise_for_status()
84
+ payload = response.json()
85
+ if not isinstance(payload, Mapping):
86
+ raise ValueError("token endpoint returned non-object JSON")
87
+ return payload
88
+
89
+
90
+ class McpOAuthAuthorizationFlow:
91
+ """一个 server 至多一个 pending flow;完成/拒绝/交换失败后清槽。"""
92
+
93
+ def __init__(
94
+ self,
95
+ store: McpTokenStoreProtocol,
96
+ *,
97
+ discovery: McpOAuthDiscovery | None = None,
98
+ exchanger: McpOAuthTokenExchanger | None = None,
99
+ time_fn: Callable[[], float] = time.time,
100
+ ) -> None:
101
+ self._store = store
102
+ self._discovery = discovery or McpOAuthDiscovery()
103
+ self._exchanger = exchanger or McpOAuthTokenExchanger()
104
+ self._time_fn = time_fn
105
+ self._pending: dict[str, _PendingFlow] = {}
106
+
107
+ @property
108
+ def store(self) -> McpTokenStoreProtocol:
109
+ return self._store
110
+
111
+ async def start(
112
+ self,
113
+ spec: McpServerSpec,
114
+ *,
115
+ redirect_uri: str | None = None,
116
+ client_id: str | None = None,
117
+ scope: str | None = None,
118
+ ) -> McpOAuthStartResult:
119
+ _ensure_remote(spec)
120
+ endpoints = await self._discovery.resolve(spec)
121
+ pkce = McpPkcePair.generate()
122
+ state = secrets.token_urlsafe(24)
123
+ resolved_redirect_uri = redirect_uri or default_callback_redirect_uri()
124
+ query: dict[str, str] = {
125
+ "response_type": "code",
126
+ "redirect_uri": resolved_redirect_uri,
127
+ "state": state,
128
+ "code_challenge": pkce.code_challenge,
129
+ "code_challenge_method": pkce.code_challenge_method,
130
+ }
131
+ if client_id:
132
+ query["client_id"] = client_id
133
+ if scope:
134
+ query["scope"] = scope
135
+ server_key = build_server_key(spec.name, spec.url or "")
136
+ self._pending[spec.name] = _PendingFlow(
137
+ server_name=spec.name,
138
+ server_key=server_key,
139
+ endpoints=endpoints,
140
+ pkce=pkce,
141
+ state=state,
142
+ redirect_uri=resolved_redirect_uri,
143
+ client_id=client_id,
144
+ scope=scope,
145
+ )
146
+ return McpOAuthStartResult(
147
+ authorization_url=f"{endpoints.authorization_endpoint}?{urlencode(query)}",
148
+ state=state,
149
+ redirect_uri=resolved_redirect_uri,
150
+ server_key=server_key,
151
+ )
152
+
153
+ async def complete(self, server_name: str, callback_url: str) -> McpOAuthTokenRecord:
154
+ pending = self._pending.get(server_name)
155
+ if pending is None:
156
+ raise McpOAuthError(
157
+ "no_pending_flow", f"server {server_name!r} has no pending authorization flow"
158
+ )
159
+ params = {
160
+ key: values[-1] for key, values in parse_qs(urlsplit(callback_url).query).items()
161
+ }
162
+ error = params.get("error")
163
+ if error:
164
+ self._pending.pop(server_name, None)
165
+ raise McpOAuthError("provider_denied", f"authorization server returned error={error}")
166
+ if params.get("state") != pending.state:
167
+ # state 不匹配保留 pending:正确的 callback 仍可到达。
168
+ raise McpOAuthError(
169
+ "state_mismatch", f"callback state does not match pending flow for {server_name!r}"
170
+ )
171
+ code = params.get("code")
172
+ if not code:
173
+ # 无 error 也无 code 的畸形回跳:AS 未授予,按拒绝收口。
174
+ raise McpOAuthError("provider_denied", "callback carries neither code nor error")
175
+ try:
176
+ record = await self._exchange_code(pending, code)
177
+ except McpOAuthError:
178
+ self._pending.pop(server_name, None)
179
+ raise
180
+ self._store.set_record(pending.server_key, record, server_name=pending.server_name)
181
+ self._pending.pop(server_name, None)
182
+ return record
183
+
184
+ async def _exchange_code(self, pending: _PendingFlow, code: str) -> McpOAuthTokenRecord:
185
+ form: dict[str, str] = {
186
+ "grant_type": "authorization_code",
187
+ "code": code,
188
+ "redirect_uri": pending.redirect_uri,
189
+ "code_verifier": pending.pkce.code_verifier,
190
+ }
191
+ if pending.client_id:
192
+ form["client_id"] = pending.client_id
193
+ try:
194
+ payload = await self._exchanger.exchange(pending.endpoints.token_endpoint, form)
195
+ except Exception as exc:
196
+ raise McpOAuthError("token_exchange_failed", f"token exchange failed: {exc}") from exc
197
+ return _record_from_payload(payload, self._time_fn(), client_id=pending.client_id)
198
+
199
+ async def refresh(self, spec: McpServerSpec) -> McpOAuthTokenRecord:
200
+ """401 后的一次静默 refresh;成功写回 Store。
201
+
202
+ 响应缺 ``refresh_token`` 时沿用旧值(对齐 CC 换票语义);
203
+ 无记录 / 无 refresh_token / 交换失败统一 ``token_exchange_failed``,
204
+ 由 ``McpOAuthSession.refresh_authorization`` 收口清凭据。
205
+ """
206
+ server_key = build_server_key(spec.name, spec.url or "")
207
+ previous = self._store.get_record(server_key)
208
+ if previous is None or not previous.refresh_token:
209
+ raise McpOAuthError(
210
+ "token_exchange_failed", f"server {spec.name!r} has no refresh_token"
211
+ )
212
+ endpoints = await self._discovery.resolve(spec)
213
+ form: dict[str, str] = {
214
+ "grant_type": "refresh_token",
215
+ "refresh_token": previous.refresh_token,
216
+ }
217
+ if previous.client_id:
218
+ form["client_id"] = previous.client_id
219
+ try:
220
+ payload = await self._exchanger.exchange(endpoints.token_endpoint, form)
221
+ except Exception as exc:
222
+ raise McpOAuthError("token_exchange_failed", f"refresh failed: {exc}") from exc
223
+ record = _record_from_payload(
224
+ payload, self._time_fn(), client_id=previous.client_id, previous=previous
225
+ )
226
+ self._store.set_record(server_key, record, server_name=spec.name)
227
+ return record
228
+
229
+
230
+ def _ensure_remote(spec: McpServerSpec) -> None:
231
+ if spec.transport not in ("sse", "http") or not spec.url:
232
+ raise McpOAuthError(
233
+ "not_remote", f"{spec.name}: OAuth 仅适用于带 url 的 sse/http 远程 server"
234
+ )
235
+
236
+
237
+ def _record_from_payload(
238
+ payload: Mapping[str, Any],
239
+ now: float,
240
+ *,
241
+ client_id: str | None,
242
+ previous: McpOAuthTokenRecord | None = None,
243
+ ) -> McpOAuthTokenRecord:
244
+ """token 响应 → 记录;``previous`` 提供缺省(refresh 沿用旧 refresh_token/scope)。"""
245
+ access_token = payload.get("access_token")
246
+ if not isinstance(access_token, str) or not access_token:
247
+ raise McpOAuthError("token_exchange_failed", "token response missing access_token")
248
+ refresh_token = _optional_str(payload.get("refresh_token"))
249
+ if refresh_token is None and previous is not None:
250
+ refresh_token = previous.refresh_token
251
+ scope = _optional_str(payload.get("scope"))
252
+ if scope is None and previous is not None:
253
+ scope = previous.scope
254
+ token_type = _optional_str(payload.get("token_type"))
255
+ if token_type is None:
256
+ token_type = previous.token_type if previous is not None else "Bearer"
257
+ expires_in = payload.get("expires_in")
258
+ return McpOAuthTokenRecord(
259
+ access_token=access_token,
260
+ refresh_token=refresh_token,
261
+ expires_at=(
262
+ now + float(expires_in) if isinstance(expires_in, (int, float)) else None
263
+ ),
264
+ token_type=token_type,
265
+ scope=scope,
266
+ client_id=client_id,
267
+ )
268
+
269
+
270
+ def _optional_str(value: object) -> str | None:
271
+ return value if isinstance(value, str) and value else None
@@ -0,0 +1,134 @@
1
+ """OAuth endpoint 发现。
2
+
3
+ 职责:
4
+ 解析 authorization / token endpoint:显式配置 → RFC 9728 → RFC 8414(path-aware)
5
+ → 约定路径兜底。
6
+
7
+ 链路位置:
8
+ ``McpOAuthAuthorizationFlow.start`` 调用;对齐 CC ``fetchAuthServerMetadata``
9
+ 的裁剪版(无 configuredMetadataUrl / 无严格 schema 校验)。
10
+
11
+ 当前裁剪:
12
+ 取 ``authorization_endpoint`` / ``token_endpoint`` 两字段即用;
13
+ 网络失败不抛(落兜底);已取得的 metadata 残缺才 ``discovery_failed``。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass
20
+ from typing import Any, Protocol
21
+ from urllib.parse import urlsplit, urlunsplit
22
+
23
+ import httpx
24
+
25
+ from langchain_agentx.mcp.auth.errors import McpOAuthError
26
+ from langchain_agentx.protocol.mcp.types import McpServerSpec
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class McpOAuthEndpoints:
31
+ """授权码流两端点。"""
32
+
33
+ authorization_endpoint: str
34
+ token_endpoint: str
35
+
36
+
37
+ class McpOAuthJsonFetcher(Protocol):
38
+ """GET JSON;404/无文档返回 None,网络/HTTP 错误抛异常。"""
39
+
40
+ async def get_json(self, url: str) -> Mapping[str, Any] | None: ...
41
+
42
+
43
+ class McpOAuthHttpJsonFetcher:
44
+ """httpx 默认实现。"""
45
+
46
+ def __init__(self, *, timeout: float = 10.0) -> None:
47
+ self._timeout = timeout
48
+
49
+ async def get_json(self, url: str) -> Mapping[str, Any] | None:
50
+ async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client:
51
+ response = await client.get(url, headers={"Accept": "application/json"})
52
+ if response.status_code == 404:
53
+ return None
54
+ response.raise_for_status()
55
+ payload = response.json()
56
+ if not isinstance(payload, Mapping):
57
+ raise ValueError(f"{url} returned non-object JSON")
58
+ return payload
59
+
60
+
61
+ class McpOAuthDiscovery:
62
+ """endpoint 解析;注入 endpoints 可整体跳过网络(测试/已知配置)。"""
63
+
64
+ def __init__(
65
+ self,
66
+ fetch_json: McpOAuthJsonFetcher | None = None,
67
+ *,
68
+ endpoints: McpOAuthEndpoints | None = None,
69
+ ) -> None:
70
+ self._fetch_json = fetch_json or McpOAuthHttpJsonFetcher()
71
+ self._endpoints = endpoints
72
+
73
+ async def resolve(self, spec: McpServerSpec) -> McpOAuthEndpoints:
74
+ if self._endpoints is not None:
75
+ return self._endpoints
76
+ base = _base_url(spec.url or "")
77
+ try:
78
+ endpoints = await self._resolve_from_metadata(base)
79
+ if endpoints is not None:
80
+ return endpoints
81
+ except McpOAuthError:
82
+ raise
83
+ except Exception:
84
+ pass
85
+ return _conventional_endpoints(base)
86
+
87
+ async def _resolve_from_metadata(self, base: str) -> McpOAuthEndpoints | None:
88
+ protected = await self._fetch_json.get_json(_well_known(base, "oauth-protected-resource"))
89
+ if protected is not None:
90
+ servers = protected.get("authorization_servers")
91
+ if isinstance(servers, list) and servers and isinstance(servers[0], str):
92
+ as_base = _base_url(servers[0])
93
+ metadata = await self._fetch_json.get_json(
94
+ _well_known(as_base, "oauth-authorization-server")
95
+ )
96
+ if metadata is not None:
97
+ return _endpoints_from_metadata(metadata)
98
+ metadata = await self._fetch_json.get_json(_well_known(base, "oauth-authorization-server"))
99
+ if metadata is not None:
100
+ return _endpoints_from_metadata(metadata)
101
+ return None
102
+
103
+
104
+ def _base_url(url: str) -> str:
105
+ return url.strip().rstrip("/")
106
+
107
+
108
+ def _well_known(base: str, suffix: str) -> str:
109
+ parts = urlsplit(base)
110
+ path = parts.path.strip("/")
111
+ well_known = f"/.well-known/{suffix}" + (f"/{path}" if path else "")
112
+ return urlunsplit((parts.scheme, parts.netloc, well_known, "", ""))
113
+
114
+
115
+ def _endpoints_from_metadata(metadata: Mapping[str, Any]) -> McpOAuthEndpoints:
116
+ authorization = metadata.get("authorization_endpoint")
117
+ token = metadata.get("token_endpoint")
118
+ if not isinstance(authorization, str) or not authorization:
119
+ raise McpOAuthError(
120
+ "discovery_failed", "authorization server metadata lacks authorization_endpoint"
121
+ )
122
+ if not isinstance(token, str) or not token:
123
+ raise McpOAuthError(
124
+ "discovery_failed", "authorization server metadata lacks token_endpoint"
125
+ )
126
+ return McpOAuthEndpoints(authorization_endpoint=authorization, token_endpoint=token)
127
+
128
+
129
+ def _conventional_endpoints(base: str) -> McpOAuthEndpoints:
130
+ """legacy 兜底:沿 P2a stub 的约定路径。"""
131
+ return McpOAuthEndpoints(
132
+ authorization_endpoint=f"{base}/oauth/authorize",
133
+ token_endpoint=f"{base}/oauth/token",
134
+ )
@@ -0,0 +1,34 @@
1
+ """MCP OAuth 错误类型。
2
+
3
+ 职责:
4
+ 授权流失败原因枚举,禁止调用方靠裸 Exception 字符串分叉。
5
+
6
+ 链路位置:
7
+ authorization_flow / oauth_session 抛出;宿主映射 UI。
8
+
9
+ 当前裁剪:
10
+ Phase 1 不含 refresh 专用 reason(Phase 2 可扩)。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Literal
16
+
17
+ McpOAuthErrorReason = Literal[
18
+ "not_remote",
19
+ "state_mismatch",
20
+ "provider_denied",
21
+ "token_exchange_failed",
22
+ "discovery_failed",
23
+ "no_pending_flow",
24
+ "cancelled",
25
+ ]
26
+
27
+
28
+ class McpOAuthError(Exception):
29
+ """第三方 MCP OAuth 协议失败。"""
30
+
31
+ def __init__(self, reason: McpOAuthErrorReason, message: str) -> None:
32
+ self.reason: McpOAuthErrorReason = reason
33
+ self.message = message
34
+ super().__init__(f"{reason}: {message}")
@@ -1,57 +1,107 @@
1
- """OAuth 协议状态(无浏览器)。
1
+ """OAuth 协议门面(无浏览器)。
2
2
 
3
3
  职责:
4
- 授权 URL、callback/token 登记。宿主打开浏览器;SDK 只持 token。
4
+ ``needs_auth`` 后宿主经 ``start_authorization`` / ``complete_authorization`` /
5
+ ``logout`` 驱动授权码 + PKCE;兼容 P2a ``submit_token`` / ``authorized_headers``。
5
6
 
6
7
  链路位置:
7
- 对标 CC ``performMCPOAuthFlow`` 的协议切片(忽略 xaa / WebView)。
8
- Manager connect 无 token 时 ``needs_auth``。
8
+ Manager connect 无 token 时 ``needs_auth``(经 ``get_token`` /
9
+ ``authorized_headers`` 消费);打开浏览器 / 收 callback 在宿主。
9
10
 
10
11
  当前裁剪:
11
- 内存 token;无 PKCE/IdP 实现。
12
+ Memory store 默认(生产接 ``McpFileTokenStore``);refresh 单次、由 Manager
13
+ 在 401 时触发。
12
14
  """
13
15
 
14
16
  from __future__ import annotations
15
17
 
18
+ from langchain_agentx.mcp.auth.authorization_flow import (
19
+ McpOAuthAuthorizationFlow,
20
+ McpOAuthStartResult,
21
+ )
22
+ from langchain_agentx.mcp.auth.errors import McpOAuthError
23
+ from langchain_agentx.mcp.auth.token_store import (
24
+ McpOAuthTokenRecord,
25
+ McpTokenStore,
26
+ McpTokenStoreProtocol,
27
+ build_server_key,
28
+ )
16
29
  from langchain_agentx.protocol.mcp.types import McpServerSpec
17
30
 
18
-
19
- class McpTokenStore:
20
- """进程内 token。测试注入 mock。"""
21
-
22
- def __init__(self) -> None:
23
- self._tokens: dict[str, str] = {}
24
-
25
- def get_token(self, server_name: str) -> str | None:
26
- return self._tokens.get(server_name)
27
-
28
- def set_token(self, server_name: str, access_token: str) -> None:
29
- self._tokens[server_name] = access_token
31
+ __all__ = [
32
+ "McpOAuthSession",
33
+ "McpOAuthStartResult",
34
+ "McpOAuthTokenRecord",
35
+ "McpTokenStore",
36
+ ]
30
37
 
31
38
 
32
39
  class McpOAuthSession:
33
- """needs_auth → 宿主填 token → 再 connect。"""
34
-
35
- def __init__(self, store: McpTokenStore | None = None) -> None:
36
- self._store = store or McpTokenStore()
40
+ """needs_auth → 宿主 PKCE(或 submit_token)→ 再 connect。"""
41
+
42
+ def __init__(
43
+ self,
44
+ store: McpTokenStoreProtocol | None = None,
45
+ *,
46
+ flow: McpOAuthAuthorizationFlow | None = None,
47
+ ) -> None:
48
+ if flow is not None and store is None:
49
+ store = flow.store
50
+ self._store: McpTokenStoreProtocol = store or McpTokenStore()
51
+ self._flow = flow or McpOAuthAuthorizationFlow(self._store)
37
52
 
38
53
  @property
39
- def store(self) -> McpTokenStore:
54
+ def store(self) -> McpTokenStoreProtocol:
40
55
  return self._store
41
56
 
57
+ @property
58
+ def flow(self) -> McpOAuthAuthorizationFlow:
59
+ return self._flow
60
+
42
61
  def get_token(self, server_name: str) -> str | None:
43
62
  return self._store.get_token(server_name)
44
63
 
45
- def build_authorization_url(self, spec: McpServerSpec) -> str:
46
- base = (spec.url or "").rstrip("/")
47
- return f"{base}/oauth/authorize"
48
-
49
64
  def submit_token(self, server_name: str, access_token: str) -> None:
50
65
  self._store.set_token(server_name, access_token)
51
66
 
52
67
  def authorized_headers(self, spec: McpServerSpec) -> dict[str, str]:
53
68
  headers = dict(spec.headers or {})
54
- token = self.get_token(spec.name)
69
+ token = self._token_for_spec(spec)
55
70
  if token:
56
71
  headers["Authorization"] = f"Bearer {token}"
57
72
  return headers
73
+
74
+ def _token_for_spec(self, spec: McpServerSpec) -> str | None:
75
+ """同 name 多 url 时按 server_key 取(不串票);无记录回退 by-name(P2a)。"""
76
+ if spec.url:
77
+ record = self._store.get_record(build_server_key(spec.name, spec.url))
78
+ if record is not None:
79
+ return record.access_token
80
+ return self.get_token(spec.name)
81
+
82
+ async def refresh_authorization(self, spec: McpServerSpec) -> bool:
83
+ """401 后的一次静默 refresh;任何失败清该 server 凭据并返回 False。"""
84
+ try:
85
+ await self._flow.refresh(spec)
86
+ return True
87
+ except McpOAuthError:
88
+ self._store.clear(spec.name)
89
+ return False
90
+
91
+ async def start_authorization(
92
+ self,
93
+ spec: McpServerSpec,
94
+ *,
95
+ redirect_uri: str | None = None,
96
+ client_id: str | None = None,
97
+ scope: str | None = None,
98
+ ) -> McpOAuthStartResult:
99
+ return await self._flow.start(
100
+ spec, redirect_uri=redirect_uri, client_id=client_id, scope=scope
101
+ )
102
+
103
+ async def complete_authorization(self, server_name: str, callback_url: str) -> None:
104
+ await self._flow.complete(server_name, callback_url)
105
+
106
+ def logout(self, server_name: str) -> None:
107
+ self._store.clear(server_name)
@@ -0,0 +1,38 @@
1
+ """PKCE (S256) 值对象。
2
+
3
+ 职责:
4
+ 生成 code_verifier / code_challenge,供授权码流使用。
5
+
6
+ 链路位置:
7
+ McpOAuthAuthorizationFlow.start 调用。
8
+
9
+ 当前裁剪:
10
+ 仅 S256;无 plain。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import hashlib
17
+ import secrets
18
+ from dataclasses import dataclass
19
+
20
+
21
+ def _b64url_no_pad(raw: bytes) -> str:
22
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class McpPkcePair:
27
+ """RFC 7636 S256 PKCE 对。"""
28
+
29
+ code_verifier: str
30
+ code_challenge: str
31
+ code_challenge_method: str = "S256"
32
+
33
+ @classmethod
34
+ def generate(cls) -> McpPkcePair:
35
+ verifier = _b64url_no_pad(secrets.token_bytes(32))
36
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
37
+ challenge = _b64url_no_pad(digest)
38
+ return cls(code_verifier=verifier, code_challenge=challenge)