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.
- langchain_agentx/__init__.py +1 -1
- langchain_agentx/mcp/auth/__init__.py +27 -2
- langchain_agentx/mcp/auth/authorization_flow.py +271 -0
- langchain_agentx/mcp/auth/discovery.py +134 -0
- langchain_agentx/mcp/auth/errors.py +34 -0
- langchain_agentx/mcp/auth/oauth_session.py +77 -27
- langchain_agentx/mcp/auth/pkce.py +38 -0
- langchain_agentx/mcp/auth/token_store.py +264 -0
- langchain_agentx/mcp/client_manager.py +55 -10
- langchain_agentx/mcp/server/__init__.py +42 -0
- langchain_agentx/mcp/server/stdio_transport_host.py +91 -0
- langchain_agentx/mcp/server/tool_call_bridge.py +150 -0
- langchain_agentx/mcp/server/tool_schema_projector.py +71 -0
- langchain_agentx/mcp/server/tool_server_runtime.py +101 -0
- {langchain_agentx_python-2.3.5.dist-info → langchain_agentx_python-2.3.6.dist-info}/METADATA +1 -1
- {langchain_agentx_python-2.3.5.dist-info → langchain_agentx_python-2.3.6.dist-info}/RECORD +19 -9
- {langchain_agentx_python-2.3.5.dist-info → langchain_agentx_python-2.3.6.dist-info}/LICENSE +0 -0
- {langchain_agentx_python-2.3.5.dist-info → langchain_agentx_python-2.3.6.dist-info}/WHEEL +0 -0
- {langchain_agentx_python-2.3.5.dist-info → langchain_agentx_python-2.3.6.dist-info}/top_level.txt +0 -0
langchain_agentx/__init__.py
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
"""mcp.auth — OAuth 协议面(无浏览器)。"""
|
|
2
2
|
|
|
3
|
-
from langchain_agentx.mcp.auth.
|
|
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__ = [
|
|
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
|
-
|
|
4
|
+
``needs_auth`` 后宿主经 ``start_authorization`` / ``complete_authorization`` /
|
|
5
|
+
``logout`` 驱动授权码 + PKCE;兼容 P2a ``submit_token`` / ``authorized_headers``。
|
|
5
6
|
|
|
6
7
|
链路位置:
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
Manager connect 无 token 时 ``needs_auth``(经 ``get_token`` /
|
|
9
|
+
``authorized_headers`` 消费);打开浏览器 / 收 callback 在宿主。
|
|
9
10
|
|
|
10
11
|
当前裁剪:
|
|
11
|
-
|
|
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
|
-
|
|
20
|
-
""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 →
|
|
34
|
-
|
|
35
|
-
def __init__(
|
|
36
|
-
self
|
|
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) ->
|
|
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.
|
|
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)
|