fast-agent-mcp 0.3.4__py3-none-any.whl → 0.3.5__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.
Potentially problematic release.
This version of fast-agent-mcp might be problematic. Click here for more details.
- fast_agent/cli/__main__.py +8 -5
- fast_agent/cli/commands/auth.py +370 -0
- fast_agent/cli/commands/check_config.py +54 -3
- fast_agent/cli/commands/quickstart.py +3 -1
- fast_agent/cli/commands/server_helpers.py +10 -2
- fast_agent/cli/commands/setup.py +3 -2
- fast_agent/cli/constants.py +1 -1
- fast_agent/cli/main.py +3 -1
- fast_agent/config.py +59 -8
- fast_agent/mcp/mcp_connection_manager.py +21 -3
- fast_agent/mcp/oauth_client.py +481 -0
- fast_agent/resources/setup/fastagent.config.yaml +1 -2
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.5.dist-info}/METADATA +39 -2
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.5.dist-info}/RECORD +17 -15
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.5.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.5.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.5.dist-info}/licenses/LICENSE +0 -0
fast_agent/config.py
CHANGED
|
@@ -9,14 +9,29 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any, Dict, List, Literal, Optional, Tuple
|
|
10
10
|
|
|
11
11
|
from mcp import Implementation
|
|
12
|
-
from pydantic import BaseModel, ConfigDict, field_validator
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
13
13
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class MCPServerAuthSettings(BaseModel):
|
|
17
|
-
"""Represents authentication configuration for a server.
|
|
17
|
+
"""Represents authentication configuration for a server.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Minimal OAuth v2.1 support with sensible defaults.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Enable OAuth for SSE/HTTP transports. If None is provided for the auth block,
|
|
23
|
+
# the system will assume OAuth is enabled by default.
|
|
24
|
+
oauth: bool = True
|
|
25
|
+
|
|
26
|
+
# Local callback server configuration
|
|
27
|
+
redirect_port: int = 3030
|
|
28
|
+
redirect_path: str = "/callback"
|
|
29
|
+
|
|
30
|
+
# Optional scope override. If set to a list, values are space-joined.
|
|
31
|
+
scope: str | list[str] | None = None
|
|
32
|
+
|
|
33
|
+
# Token persistence: use OS keychain via 'keyring' by default; fallback to 'memory'.
|
|
34
|
+
persist: Literal["keyring", "memory"] = "keyring"
|
|
20
35
|
|
|
21
36
|
model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
|
|
22
37
|
|
|
@@ -109,6 +124,42 @@ class MCPServerSettings(BaseModel):
|
|
|
109
124
|
|
|
110
125
|
implementation: Implementation | None = None
|
|
111
126
|
|
|
127
|
+
@model_validator(mode="before")
|
|
128
|
+
@classmethod
|
|
129
|
+
def validate_transport_inference(cls, values):
|
|
130
|
+
"""Automatically infer transport type based on url/command presence."""
|
|
131
|
+
import warnings
|
|
132
|
+
|
|
133
|
+
if isinstance(values, dict):
|
|
134
|
+
# Check if transport was explicitly provided in the input
|
|
135
|
+
transport_explicit = "transport" in values
|
|
136
|
+
url = values.get("url")
|
|
137
|
+
command = values.get("command")
|
|
138
|
+
|
|
139
|
+
# Only infer if transport was not explicitly set
|
|
140
|
+
if not transport_explicit:
|
|
141
|
+
# Check if we have both url and command specified
|
|
142
|
+
has_url = url is not None and str(url).strip()
|
|
143
|
+
has_command = command is not None and str(command).strip()
|
|
144
|
+
|
|
145
|
+
if has_url and has_command:
|
|
146
|
+
warnings.warn(
|
|
147
|
+
f"MCP Server config has both 'url' ({url}) and 'command' ({command}) specified. "
|
|
148
|
+
"Preferring HTTP transport and ignoring command.",
|
|
149
|
+
UserWarning,
|
|
150
|
+
stacklevel=4,
|
|
151
|
+
)
|
|
152
|
+
values["transport"] = "http"
|
|
153
|
+
values["command"] = None # Clear command to avoid confusion
|
|
154
|
+
elif has_url and not has_command:
|
|
155
|
+
values["transport"] = "http"
|
|
156
|
+
elif has_command and not has_url:
|
|
157
|
+
# Keep default "stdio" for command-based servers
|
|
158
|
+
values["transport"] = "stdio"
|
|
159
|
+
# If neither url nor command is specified, keep default "stdio"
|
|
160
|
+
|
|
161
|
+
return values
|
|
162
|
+
|
|
112
163
|
|
|
113
164
|
class MCPSettings(BaseModel):
|
|
114
165
|
"""Configuration for all MCP servers."""
|
|
@@ -260,8 +311,8 @@ class TensorZeroSettings(BaseModel):
|
|
|
260
311
|
Settings for using TensorZero via its OpenAI-compatible API.
|
|
261
312
|
"""
|
|
262
313
|
|
|
263
|
-
base_url:
|
|
264
|
-
api_key:
|
|
314
|
+
base_url: str | None = None
|
|
315
|
+
api_key: str | None = None
|
|
265
316
|
model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
|
|
266
317
|
|
|
267
318
|
|
|
@@ -287,7 +338,7 @@ class HuggingFaceSettings(BaseModel):
|
|
|
287
338
|
Settings for HuggingFace authentication (used for MCP connections).
|
|
288
339
|
"""
|
|
289
340
|
|
|
290
|
-
api_key:
|
|
341
|
+
api_key: str | None = None
|
|
291
342
|
model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
|
|
292
343
|
|
|
293
344
|
|
|
@@ -408,7 +459,7 @@ class Settings(BaseSettings):
|
|
|
408
459
|
execution_engine: Literal["asyncio"] = "asyncio"
|
|
409
460
|
"""Execution engine for the fast-agent application"""
|
|
410
461
|
|
|
411
|
-
default_model: str | None = "
|
|
462
|
+
default_model: str | None = "gpt-5-mini.low"
|
|
412
463
|
"""
|
|
413
464
|
Default model for agents. Format is provider.model_name.<reasoning_effort>, for example openai.o3-mini.low
|
|
414
465
|
Aliases are provided for common models e.g. sonnet, haiku, gpt-4.1, o3-mini etc.
|
|
@@ -459,7 +510,7 @@ class Settings(BaseSettings):
|
|
|
459
510
|
groq: GroqSettings | None = None
|
|
460
511
|
"""Settings for using the Groq provider in the fast-agent application"""
|
|
461
512
|
|
|
462
|
-
logger: LoggerSettings
|
|
513
|
+
logger: LoggerSettings = LoggerSettings()
|
|
463
514
|
"""Logger settings for the fast-agent application"""
|
|
464
515
|
|
|
465
516
|
# MCP UI integration mode for handling ui:// embedded resources from MCP tool results
|
|
@@ -33,6 +33,7 @@ from fast_agent.core.logging.logger import get_logger
|
|
|
33
33
|
from fast_agent.event_progress import ProgressAction
|
|
34
34
|
from fast_agent.mcp.logger_textio import get_stderr_handler
|
|
35
35
|
from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
|
|
36
|
+
from fast_agent.mcp.oauth_client import build_oauth_provider
|
|
36
37
|
|
|
37
38
|
if TYPE_CHECKING:
|
|
38
39
|
from fast_agent.context import Context
|
|
@@ -341,6 +342,8 @@ class MCPConnectionManager(ContextDependent):
|
|
|
341
342
|
|
|
342
343
|
def transport_context_factory():
|
|
343
344
|
if config.transport == "stdio":
|
|
345
|
+
if not config.command:
|
|
346
|
+
raise ValueError(f"Server '{server_name}' uses stdio transport but no command is specified")
|
|
344
347
|
server_params = StdioServerParameters(
|
|
345
348
|
command=config.command,
|
|
346
349
|
args=config.args if config.args is not None else [],
|
|
@@ -353,18 +356,33 @@ class MCPConnectionManager(ContextDependent):
|
|
|
353
356
|
logger.debug(f"{server_name}: Creating stdio client with custom error handler")
|
|
354
357
|
return _add_none_to_context(stdio_client(server_params, errlog=error_handler))
|
|
355
358
|
elif config.transport == "sse":
|
|
359
|
+
if not config.url:
|
|
360
|
+
raise ValueError(f"Server '{server_name}' uses sse transport but no url is specified")
|
|
356
361
|
# Suppress MCP library error spam
|
|
357
362
|
self._suppress_mcp_sse_errors()
|
|
358
|
-
|
|
363
|
+
oauth_auth = build_oauth_provider(config)
|
|
364
|
+
# If using OAuth, strip any pre-existing Authorization headers to avoid conflicts
|
|
365
|
+
headers = dict(config.headers or {})
|
|
366
|
+
if oauth_auth is not None:
|
|
367
|
+
headers.pop("Authorization", None)
|
|
368
|
+
headers.pop("X-HF-Authorization", None)
|
|
359
369
|
return _add_none_to_context(
|
|
360
370
|
sse_client(
|
|
361
371
|
config.url,
|
|
362
|
-
|
|
372
|
+
headers,
|
|
363
373
|
sse_read_timeout=config.read_transport_sse_timeout_seconds,
|
|
374
|
+
auth=oauth_auth,
|
|
364
375
|
)
|
|
365
376
|
)
|
|
366
377
|
elif config.transport == "http":
|
|
367
|
-
|
|
378
|
+
if not config.url:
|
|
379
|
+
raise ValueError(f"Server '{server_name}' uses http transport but no url is specified")
|
|
380
|
+
oauth_auth = build_oauth_provider(config)
|
|
381
|
+
headers = dict(config.headers or {})
|
|
382
|
+
if oauth_auth is not None:
|
|
383
|
+
headers.pop("Authorization", None)
|
|
384
|
+
headers.pop("X-HF-Authorization", None)
|
|
385
|
+
return streamablehttp_client(config.url, headers, auth=oauth_auth)
|
|
368
386
|
else:
|
|
369
387
|
raise ValueError(f"Unsupported transport: {config.transport}")
|
|
370
388
|
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth v2.1 integration helpers for MCP client transports.
|
|
3
|
+
|
|
4
|
+
Provides token storage (in-memory and OS keyring), a local callback server
|
|
5
|
+
with paste-URL fallback, and a builder for OAuthClientProvider that can be
|
|
6
|
+
passed to SSE/HTTP transports as the `auth` parameter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
16
|
+
from urllib.parse import parse_qs, urlparse
|
|
17
|
+
|
|
18
|
+
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
|
19
|
+
from mcp.shared.auth import (
|
|
20
|
+
OAuthClientInformationFull,
|
|
21
|
+
OAuthClientMetadata,
|
|
22
|
+
OAuthToken,
|
|
23
|
+
)
|
|
24
|
+
from pydantic import AnyUrl
|
|
25
|
+
|
|
26
|
+
from fast_agent.core.logging.logger import get_logger
|
|
27
|
+
from fast_agent.ui import console
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from fast_agent.config import MCPServerSettings
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InMemoryTokenStorage(TokenStorage):
|
|
36
|
+
"""Non-persistent token storage (process memory only)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._tokens: OAuthToken | None = None
|
|
40
|
+
self._client_info: OAuthClientInformationFull | None = None
|
|
41
|
+
|
|
42
|
+
async def get_tokens(self) -> OAuthToken | None:
|
|
43
|
+
return self._tokens
|
|
44
|
+
|
|
45
|
+
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
46
|
+
self._tokens = tokens
|
|
47
|
+
|
|
48
|
+
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
49
|
+
return self._client_info
|
|
50
|
+
|
|
51
|
+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
52
|
+
self._client_info = client_info
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class _CallbackResult:
|
|
57
|
+
authorization_code: str | None = None
|
|
58
|
+
state: str | None = None
|
|
59
|
+
error: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
63
|
+
"""HTTP handler to capture OAuth callback query params."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, *args, result: _CallbackResult, expected_path: str, **kwargs):
|
|
66
|
+
self._result = result
|
|
67
|
+
self._expected_path = expected_path.rstrip("/") or "/callback"
|
|
68
|
+
super().__init__(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
def do_GET(self) -> None: # noqa: N802 - http.server signature
|
|
71
|
+
parsed = urlparse(self.path)
|
|
72
|
+
|
|
73
|
+
# Only accept the configured callback path
|
|
74
|
+
if (parsed.path.rstrip("/") or "/callback") != self._expected_path:
|
|
75
|
+
self.send_response(404)
|
|
76
|
+
self.end_headers()
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
params = parse_qs(parsed.query)
|
|
80
|
+
if "code" in params:
|
|
81
|
+
self._result.authorization_code = params["code"][0]
|
|
82
|
+
self._result.state = params.get("state", [None])[0]
|
|
83
|
+
self.send_response(200)
|
|
84
|
+
self.send_header("Content-Type", "text/html")
|
|
85
|
+
self.end_headers()
|
|
86
|
+
self.wfile.write(
|
|
87
|
+
b"""
|
|
88
|
+
<html><body>
|
|
89
|
+
<h1>Authorization Successful</h1>
|
|
90
|
+
<p>You can close this window.</p>
|
|
91
|
+
<script>setTimeout(() => window.close(), 1000);</script>
|
|
92
|
+
</body></html>
|
|
93
|
+
"""
|
|
94
|
+
)
|
|
95
|
+
elif "error" in params:
|
|
96
|
+
self._result.error = params["error"][0]
|
|
97
|
+
self.send_response(400)
|
|
98
|
+
self.send_header("Content-Type", "text/html")
|
|
99
|
+
self.end_headers()
|
|
100
|
+
self.wfile.write(
|
|
101
|
+
f"""
|
|
102
|
+
<html><body>
|
|
103
|
+
<h1>Authorization Failed</h1>
|
|
104
|
+
<p>Error: {self._result.error}</p>
|
|
105
|
+
</body></html>
|
|
106
|
+
""".encode()
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
self.send_response(404)
|
|
110
|
+
self.end_headers()
|
|
111
|
+
|
|
112
|
+
def log_message(self, fmt: str, *args: Any) -> None: # silence default logging
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _CallbackServer:
|
|
117
|
+
"""Simple background HTTP server to receive a single OAuth callback."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, port: int, path: str) -> None:
|
|
120
|
+
self._port = port
|
|
121
|
+
self._path = path.rstrip("/") or "/callback"
|
|
122
|
+
self._result = _CallbackResult()
|
|
123
|
+
self._server: HTTPServer | None = None
|
|
124
|
+
self._thread: threading.Thread | None = None
|
|
125
|
+
|
|
126
|
+
def _make_handler(self) -> Callable[..., BaseHTTPRequestHandler]:
|
|
127
|
+
result = self._result
|
|
128
|
+
expected_path = self._path
|
|
129
|
+
|
|
130
|
+
def handler(*args, **kwargs):
|
|
131
|
+
return _CallbackHandler(*args, result=result, expected_path=expected_path, **kwargs)
|
|
132
|
+
|
|
133
|
+
return handler
|
|
134
|
+
|
|
135
|
+
def start(self) -> None:
|
|
136
|
+
self._server = HTTPServer(("localhost", self._port), self._make_handler())
|
|
137
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
138
|
+
self._thread.start()
|
|
139
|
+
logger.info(f"OAuth callback server listening on http://localhost:{self._port}{self._path}")
|
|
140
|
+
|
|
141
|
+
def stop(self) -> None:
|
|
142
|
+
if self._server:
|
|
143
|
+
try:
|
|
144
|
+
self._server.shutdown()
|
|
145
|
+
self._server.server_close()
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
if self._thread:
|
|
149
|
+
self._thread.join(timeout=1)
|
|
150
|
+
|
|
151
|
+
def wait(self, timeout_seconds: int = 300) -> tuple[str, str | None]:
|
|
152
|
+
start = time.time()
|
|
153
|
+
while time.time() - start < timeout_seconds:
|
|
154
|
+
if self._result.authorization_code:
|
|
155
|
+
return self._result.authorization_code, self._result.state
|
|
156
|
+
if self._result.error:
|
|
157
|
+
raise RuntimeError(f"OAuth error: {self._result.error}")
|
|
158
|
+
time.sleep(0.1)
|
|
159
|
+
raise TimeoutError("Timeout waiting for OAuth callback")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _derive_base_server_url(url: str | None) -> str | None:
|
|
163
|
+
"""Derive the base server URL for OAuth discovery from an MCP endpoint URL.
|
|
164
|
+
|
|
165
|
+
- Strips a trailing "/mcp" or "/sse" path segment
|
|
166
|
+
- Ignores query and fragment parts entirely
|
|
167
|
+
"""
|
|
168
|
+
if not url:
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
from urllib.parse import urlparse, urlunparse
|
|
172
|
+
|
|
173
|
+
parsed = urlparse(url)
|
|
174
|
+
# Normalize path without trailing slash
|
|
175
|
+
path = parsed.path or ""
|
|
176
|
+
path = path[:-1] if path.endswith("/") else path
|
|
177
|
+
# Remove one trailing segment if it is mcp or sse
|
|
178
|
+
for suffix in ("/mcp", "/sse"):
|
|
179
|
+
if path.endswith(suffix):
|
|
180
|
+
path = path[: -len(suffix)]
|
|
181
|
+
break
|
|
182
|
+
# Ensure path is at least '/'
|
|
183
|
+
if not path:
|
|
184
|
+
path = "/"
|
|
185
|
+
# Rebuild URL without query/fragment
|
|
186
|
+
clean = parsed._replace(path=path, params="", query="", fragment="")
|
|
187
|
+
base = urlunparse(clean)
|
|
188
|
+
# Drop trailing slash except for root
|
|
189
|
+
if base.endswith("/") and base.count("/") > 2:
|
|
190
|
+
base = base[:-1]
|
|
191
|
+
return base
|
|
192
|
+
except Exception:
|
|
193
|
+
return url
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def compute_server_identity(server_config: MCPServerSettings) -> str:
|
|
197
|
+
"""Compute a stable identity for token storage.
|
|
198
|
+
|
|
199
|
+
Prefer the normalized base server URL; fall back to configured name, then 'default'.
|
|
200
|
+
"""
|
|
201
|
+
base = _derive_base_server_url(server_config.url)
|
|
202
|
+
if base:
|
|
203
|
+
return base
|
|
204
|
+
if server_config.name:
|
|
205
|
+
return server_config.name
|
|
206
|
+
return "default"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def keyring_has_token(server_config: MCPServerSettings) -> bool:
|
|
210
|
+
"""Check if keyring has a token stored for this server."""
|
|
211
|
+
try:
|
|
212
|
+
import keyring
|
|
213
|
+
|
|
214
|
+
identity = compute_server_identity(server_config)
|
|
215
|
+
token_key = f"oauth:tokens:{identity}"
|
|
216
|
+
return keyring.get_password("fast-agent-mcp", token_key) is not None
|
|
217
|
+
except Exception:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _print_authorization_link(auth_url: str) -> None:
|
|
222
|
+
"""Emit a clickable authorization link using rich console markup."""
|
|
223
|
+
console.console.print("[bold]Open this link to authorize:[/bold]", markup=True)
|
|
224
|
+
console.console.print(f"[link={auth_url}]{auth_url}[/link]")
|
|
225
|
+
logger.info("OAuth authorization URL emitted to console")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class KeyringTokenStorage(TokenStorage):
|
|
229
|
+
"""Token storage backed by the OS keychain using 'keyring'."""
|
|
230
|
+
|
|
231
|
+
def __init__(self, service_name: str, server_identity: str) -> None:
|
|
232
|
+
self._service = service_name
|
|
233
|
+
self._identity = server_identity
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def _token_key(self) -> str:
|
|
237
|
+
return f"oauth:tokens:{self._identity}"
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def _client_key(self) -> str:
|
|
241
|
+
return f"oauth:client_info:{self._identity}"
|
|
242
|
+
|
|
243
|
+
async def get_tokens(self) -> OAuthToken | None:
|
|
244
|
+
try:
|
|
245
|
+
import keyring
|
|
246
|
+
|
|
247
|
+
payload = keyring.get_password(self._service, self._token_key)
|
|
248
|
+
if not payload:
|
|
249
|
+
return None
|
|
250
|
+
return OAuthToken.model_validate_json(payload)
|
|
251
|
+
except Exception:
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
255
|
+
try:
|
|
256
|
+
import keyring
|
|
257
|
+
|
|
258
|
+
keyring.set_password(self._service, self._token_key, tokens.model_dump_json())
|
|
259
|
+
# Update index
|
|
260
|
+
add_identity_to_index(self._service, self._identity)
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
265
|
+
try:
|
|
266
|
+
import keyring
|
|
267
|
+
|
|
268
|
+
payload = keyring.get_password(self._service, self._client_key)
|
|
269
|
+
if not payload:
|
|
270
|
+
return None
|
|
271
|
+
return OAuthClientInformationFull.model_validate_json(payload)
|
|
272
|
+
except Exception:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
276
|
+
try:
|
|
277
|
+
import keyring
|
|
278
|
+
|
|
279
|
+
keyring.set_password(self._service, self._client_key, client_info.model_dump_json())
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --- Keyring index helpers (to enable cross-platform token enumeration) ---
|
|
285
|
+
|
|
286
|
+
def _index_username() -> str:
|
|
287
|
+
return "oauth:index"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _read_index(service: str) -> set[str]:
|
|
291
|
+
try:
|
|
292
|
+
import json
|
|
293
|
+
|
|
294
|
+
import keyring
|
|
295
|
+
|
|
296
|
+
raw = keyring.get_password(service, _index_username())
|
|
297
|
+
if not raw:
|
|
298
|
+
return set()
|
|
299
|
+
data = json.loads(raw)
|
|
300
|
+
if isinstance(data, list):
|
|
301
|
+
return set([str(x) for x in data])
|
|
302
|
+
return set()
|
|
303
|
+
except Exception:
|
|
304
|
+
return set()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _write_index(service: str, identities: set[str]) -> None:
|
|
308
|
+
try:
|
|
309
|
+
import json
|
|
310
|
+
|
|
311
|
+
import keyring
|
|
312
|
+
|
|
313
|
+
payload = json.dumps(sorted(list(identities)))
|
|
314
|
+
keyring.set_password(service, _index_username(), payload)
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def add_identity_to_index(service: str, identity: str) -> None:
|
|
320
|
+
identities = _read_index(service)
|
|
321
|
+
if identity not in identities:
|
|
322
|
+
identities.add(identity)
|
|
323
|
+
_write_index(service, identities)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def remove_identity_from_index(service: str, identity: str) -> None:
|
|
327
|
+
identities = _read_index(service)
|
|
328
|
+
if identity in identities:
|
|
329
|
+
identities.remove(identity)
|
|
330
|
+
_write_index(service, identities)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def list_keyring_tokens(service: str = "fast-agent-mcp") -> list[str]:
|
|
334
|
+
"""List identities with stored tokens in keyring (using our index).
|
|
335
|
+
|
|
336
|
+
Returns only identities that currently have a corresponding token entry.
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
import keyring
|
|
340
|
+
|
|
341
|
+
identities = _read_index(service)
|
|
342
|
+
present: list[str] = []
|
|
343
|
+
for ident in sorted(identities):
|
|
344
|
+
tok_key = f"oauth:tokens:{ident}"
|
|
345
|
+
if keyring.get_password(service, tok_key):
|
|
346
|
+
present.append(ident)
|
|
347
|
+
return present
|
|
348
|
+
except Exception:
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def clear_keyring_token(identity: str, service: str = "fast-agent-mcp") -> bool:
|
|
353
|
+
"""Remove token+client info for identity and update the index.
|
|
354
|
+
|
|
355
|
+
Returns True if anything was removed.
|
|
356
|
+
"""
|
|
357
|
+
removed = False
|
|
358
|
+
try:
|
|
359
|
+
import keyring
|
|
360
|
+
|
|
361
|
+
tok_key = f"oauth:tokens:{identity}"
|
|
362
|
+
cli_key = f"oauth:client_info:{identity}"
|
|
363
|
+
try:
|
|
364
|
+
keyring.delete_password(service, tok_key)
|
|
365
|
+
removed = True
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
try:
|
|
369
|
+
keyring.delete_password(service, cli_key)
|
|
370
|
+
removed = True or removed
|
|
371
|
+
except Exception:
|
|
372
|
+
pass
|
|
373
|
+
if removed:
|
|
374
|
+
remove_identity_from_index(service, identity)
|
|
375
|
+
except Exception:
|
|
376
|
+
return False
|
|
377
|
+
return removed
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def build_oauth_provider(server_config: MCPServerSettings) -> OAuthClientProvider | None:
|
|
381
|
+
"""
|
|
382
|
+
Build an OAuthClientProvider for the given server config if applicable.
|
|
383
|
+
|
|
384
|
+
Returns None for unsupported transports, or when disabled via config.
|
|
385
|
+
"""
|
|
386
|
+
# Only for SSE/HTTP transports
|
|
387
|
+
if server_config.transport not in ("sse", "http"):
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
# Determine if OAuth should be enabled. Default to True if no auth block provided
|
|
391
|
+
enable_oauth = True
|
|
392
|
+
redirect_port = 3030
|
|
393
|
+
redirect_path = "/callback"
|
|
394
|
+
scope_value: str | None = None
|
|
395
|
+
persist_mode: str = "keyring"
|
|
396
|
+
|
|
397
|
+
if server_config.auth is not None:
|
|
398
|
+
try:
|
|
399
|
+
enable_oauth = getattr(server_config.auth, "oauth", True)
|
|
400
|
+
redirect_port = getattr(server_config.auth, "redirect_port", 3030)
|
|
401
|
+
redirect_path = getattr(server_config.auth, "redirect_path", "/callback")
|
|
402
|
+
scope_field = getattr(server_config.auth, "scope", None)
|
|
403
|
+
persist_mode = getattr(server_config.auth, "persist", "keyring")
|
|
404
|
+
if isinstance(scope_field, list):
|
|
405
|
+
scope_value = " ".join(scope_field)
|
|
406
|
+
elif isinstance(scope_field, str):
|
|
407
|
+
scope_value = scope_field
|
|
408
|
+
except Exception:
|
|
409
|
+
logger.debug("Malformed auth configuration; using defaults.")
|
|
410
|
+
|
|
411
|
+
if not enable_oauth:
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
base_url = _derive_base_server_url(server_config.url)
|
|
415
|
+
if not base_url:
|
|
416
|
+
# No usable URL -> cannot build provider
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
# Construct client metadata with minimal defaults
|
|
420
|
+
redirect_uri = f"http://localhost:{redirect_port}{redirect_path}"
|
|
421
|
+
metadata_kwargs: dict[str, Any] = {
|
|
422
|
+
"client_name": "fast-agent",
|
|
423
|
+
"redirect_uris": [AnyUrl(redirect_uri)],
|
|
424
|
+
"grant_types": ["authorization_code", "refresh_token"],
|
|
425
|
+
"response_types": ["code"],
|
|
426
|
+
}
|
|
427
|
+
if scope_value:
|
|
428
|
+
metadata_kwargs["scope"] = scope_value
|
|
429
|
+
|
|
430
|
+
client_metadata = OAuthClientMetadata.model_validate(metadata_kwargs)
|
|
431
|
+
|
|
432
|
+
# Local callback server handler
|
|
433
|
+
async def _redirect_handler(authorization_url: str) -> None:
|
|
434
|
+
await _print_authorization_link(authorization_url)
|
|
435
|
+
|
|
436
|
+
async def _callback_handler() -> tuple[str, str | None]:
|
|
437
|
+
# Try local HTTP capture first
|
|
438
|
+
try:
|
|
439
|
+
server = _CallbackServer(port=redirect_port, path=redirect_path)
|
|
440
|
+
server.start()
|
|
441
|
+
try:
|
|
442
|
+
code, state = server.wait(timeout_seconds=300)
|
|
443
|
+
return code, state
|
|
444
|
+
finally:
|
|
445
|
+
server.stop()
|
|
446
|
+
except Exception as e:
|
|
447
|
+
# Fallback to paste-URL flow
|
|
448
|
+
logger.info(f"OAuth local callback server unavailable, fallback to paste flow: {e}")
|
|
449
|
+
try:
|
|
450
|
+
import sys
|
|
451
|
+
|
|
452
|
+
print("Paste the full callback URL after authorization:", file=sys.stderr)
|
|
453
|
+
callback_url = input("Callback URL: ").strip()
|
|
454
|
+
except Exception as ee:
|
|
455
|
+
raise RuntimeError(f"Failed to read callback URL from user: {ee}")
|
|
456
|
+
|
|
457
|
+
params = parse_qs(urlparse(callback_url).query)
|
|
458
|
+
code = params.get("code", [None])[0]
|
|
459
|
+
state = params.get("state", [None])[0]
|
|
460
|
+
if not code:
|
|
461
|
+
raise RuntimeError("Callback URL missing authorization code")
|
|
462
|
+
return code, state
|
|
463
|
+
|
|
464
|
+
# Choose storage
|
|
465
|
+
storage: TokenStorage
|
|
466
|
+
if persist_mode == "keyring":
|
|
467
|
+
identity = compute_server_identity(server_config)
|
|
468
|
+
# Update index on write via storage methods; creation here doesn't modify index yet.
|
|
469
|
+
storage = KeyringTokenStorage(service_name="fast-agent-mcp", server_identity=identity)
|
|
470
|
+
else:
|
|
471
|
+
storage = InMemoryTokenStorage()
|
|
472
|
+
|
|
473
|
+
provider = OAuthClientProvider(
|
|
474
|
+
server_url=base_url,
|
|
475
|
+
client_metadata=client_metadata,
|
|
476
|
+
storage=storage,
|
|
477
|
+
redirect_handler=_redirect_handler,
|
|
478
|
+
callback_handler=_callback_handler,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
return provider
|