fast-agent-mcp 0.3.4__py3-none-any.whl → 0.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.
Potentially problematic release.
This version of fast-agent-mcp might be problematic. Click here for more details.
- fast_agent/agents/llm_agent.py +15 -1
- fast_agent/agents/mcp_agent.py +73 -1
- fast_agent/agents/tool_agent.py +10 -0
- fast_agent/agents/workflow/router_agent.py +10 -2
- fast_agent/cli/__main__.py +8 -5
- fast_agent/cli/commands/auth.py +393 -0
- fast_agent/cli/commands/check_config.py +76 -4
- fast_agent/cli/commands/go.py +8 -2
- fast_agent/cli/commands/quickstart.py +3 -1
- fast_agent/cli/commands/server_helpers.py +10 -2
- fast_agent/cli/commands/setup.py +7 -9
- fast_agent/cli/constants.py +1 -1
- fast_agent/cli/main.py +3 -1
- fast_agent/config.py +63 -9
- fast_agent/mcp/mcp_aggregator.py +30 -0
- fast_agent/mcp/mcp_connection_manager.py +41 -4
- fast_agent/mcp/oauth_client.py +509 -0
- fast_agent/resources/setup/.gitignore +6 -0
- fast_agent/resources/setup/agent.py +8 -1
- fast_agent/resources/setup/fastagent.config.yaml +1 -2
- fast_agent/resources/setup/pyproject.toml.tmpl +6 -0
- fast_agent/ui/console_display.py +48 -31
- fast_agent/ui/enhanced_prompt.py +8 -0
- fast_agent/ui/interactive_prompt.py +54 -0
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/METADATA +39 -2
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/RECORD +29 -27
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,509 @@
|
|
|
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, format: 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, warn_if_no_keyring: bool = False) -> None:
|
|
222
|
+
"""Emit a clickable authorization link using rich console markup.
|
|
223
|
+
|
|
224
|
+
If warn_if_no_keyring is True and the OS keyring backend is unavailable,
|
|
225
|
+
print a warning to indicate tokens won't be persisted.
|
|
226
|
+
"""
|
|
227
|
+
console.console.print("[bold]Open this link to authorize:[/bold]", markup=True)
|
|
228
|
+
console.console.print(f"[link={auth_url}]{auth_url}[/link]")
|
|
229
|
+
if warn_if_no_keyring:
|
|
230
|
+
try:
|
|
231
|
+
import keyring # type: ignore
|
|
232
|
+
|
|
233
|
+
backend = keyring.get_keyring()
|
|
234
|
+
try:
|
|
235
|
+
from keyring.backends.fail import Keyring as FailKeyring # type: ignore
|
|
236
|
+
|
|
237
|
+
if isinstance(backend, FailKeyring):
|
|
238
|
+
console.console.print(
|
|
239
|
+
"[yellow]Warning:[/yellow] Keyring backend not available — tokens will not be persisted."
|
|
240
|
+
)
|
|
241
|
+
except Exception:
|
|
242
|
+
# If we cannot detect the fail backend, do nothing
|
|
243
|
+
pass
|
|
244
|
+
except Exception:
|
|
245
|
+
console.console.print(
|
|
246
|
+
"[yellow]Warning:[/yellow] Keyring backend not available — tokens will not be persisted."
|
|
247
|
+
)
|
|
248
|
+
logger.info("OAuth authorization URL emitted to console")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class KeyringTokenStorage(TokenStorage):
|
|
252
|
+
"""Token storage backed by the OS keychain using 'keyring'."""
|
|
253
|
+
|
|
254
|
+
def __init__(self, service_name: str, server_identity: str) -> None:
|
|
255
|
+
self._service = service_name
|
|
256
|
+
self._identity = server_identity
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def _token_key(self) -> str:
|
|
260
|
+
return f"oauth:tokens:{self._identity}"
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def _client_key(self) -> str:
|
|
264
|
+
return f"oauth:client_info:{self._identity}"
|
|
265
|
+
|
|
266
|
+
async def get_tokens(self) -> OAuthToken | None:
|
|
267
|
+
try:
|
|
268
|
+
import keyring
|
|
269
|
+
|
|
270
|
+
payload = keyring.get_password(self._service, self._token_key)
|
|
271
|
+
if not payload:
|
|
272
|
+
return None
|
|
273
|
+
return OAuthToken.model_validate_json(payload)
|
|
274
|
+
except Exception:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
278
|
+
try:
|
|
279
|
+
import keyring
|
|
280
|
+
|
|
281
|
+
keyring.set_password(self._service, self._token_key, tokens.model_dump_json())
|
|
282
|
+
# Update index
|
|
283
|
+
add_identity_to_index(self._service, self._identity)
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
288
|
+
try:
|
|
289
|
+
import keyring
|
|
290
|
+
|
|
291
|
+
payload = keyring.get_password(self._service, self._client_key)
|
|
292
|
+
if not payload:
|
|
293
|
+
return None
|
|
294
|
+
return OAuthClientInformationFull.model_validate_json(payload)
|
|
295
|
+
except Exception:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
299
|
+
try:
|
|
300
|
+
import keyring
|
|
301
|
+
|
|
302
|
+
keyring.set_password(self._service, self._client_key, client_info.model_dump_json())
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --- Keyring index helpers (to enable cross-platform token enumeration) ---
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _index_username() -> str:
|
|
311
|
+
return "oauth:index"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _read_index(service: str) -> set[str]:
|
|
315
|
+
try:
|
|
316
|
+
import json
|
|
317
|
+
|
|
318
|
+
import keyring
|
|
319
|
+
|
|
320
|
+
raw = keyring.get_password(service, _index_username())
|
|
321
|
+
if not raw:
|
|
322
|
+
return set()
|
|
323
|
+
data = json.loads(raw)
|
|
324
|
+
if isinstance(data, list):
|
|
325
|
+
return set([str(x) for x in data])
|
|
326
|
+
return set()
|
|
327
|
+
except Exception:
|
|
328
|
+
return set()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _write_index(service: str, identities: set[str]) -> None:
|
|
332
|
+
try:
|
|
333
|
+
import json
|
|
334
|
+
|
|
335
|
+
import keyring
|
|
336
|
+
|
|
337
|
+
payload = json.dumps(sorted(list(identities)))
|
|
338
|
+
keyring.set_password(service, _index_username(), payload)
|
|
339
|
+
except Exception:
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def add_identity_to_index(service: str, identity: str) -> None:
|
|
344
|
+
identities = _read_index(service)
|
|
345
|
+
if identity not in identities:
|
|
346
|
+
identities.add(identity)
|
|
347
|
+
_write_index(service, identities)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def remove_identity_from_index(service: str, identity: str) -> None:
|
|
351
|
+
identities = _read_index(service)
|
|
352
|
+
if identity in identities:
|
|
353
|
+
identities.remove(identity)
|
|
354
|
+
_write_index(service, identities)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def list_keyring_tokens(service: str = "fast-agent-mcp") -> list[str]:
|
|
358
|
+
"""List identities with stored tokens in keyring (using our index).
|
|
359
|
+
|
|
360
|
+
Returns only identities that currently have a corresponding token entry.
|
|
361
|
+
"""
|
|
362
|
+
try:
|
|
363
|
+
import keyring
|
|
364
|
+
|
|
365
|
+
identities = _read_index(service)
|
|
366
|
+
present: list[str] = []
|
|
367
|
+
for ident in sorted(identities):
|
|
368
|
+
tok_key = f"oauth:tokens:{ident}"
|
|
369
|
+
if keyring.get_password(service, tok_key):
|
|
370
|
+
present.append(ident)
|
|
371
|
+
return present
|
|
372
|
+
except Exception:
|
|
373
|
+
return []
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def clear_keyring_token(identity: str, service: str = "fast-agent-mcp") -> bool:
|
|
377
|
+
"""Remove token+client info for identity and update the index.
|
|
378
|
+
|
|
379
|
+
Returns True if anything was removed.
|
|
380
|
+
"""
|
|
381
|
+
removed = False
|
|
382
|
+
try:
|
|
383
|
+
import keyring
|
|
384
|
+
|
|
385
|
+
tok_key = f"oauth:tokens:{identity}"
|
|
386
|
+
cli_key = f"oauth:client_info:{identity}"
|
|
387
|
+
try:
|
|
388
|
+
keyring.delete_password(service, tok_key)
|
|
389
|
+
removed = True
|
|
390
|
+
except Exception:
|
|
391
|
+
pass
|
|
392
|
+
try:
|
|
393
|
+
keyring.delete_password(service, cli_key)
|
|
394
|
+
removed = True or removed
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
if removed:
|
|
398
|
+
remove_identity_from_index(service, identity)
|
|
399
|
+
except Exception:
|
|
400
|
+
return False
|
|
401
|
+
return removed
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def build_oauth_provider(server_config: MCPServerSettings) -> OAuthClientProvider | None:
|
|
405
|
+
"""
|
|
406
|
+
Build an OAuthClientProvider for the given server config if applicable.
|
|
407
|
+
|
|
408
|
+
Returns None for unsupported transports, or when disabled via config.
|
|
409
|
+
"""
|
|
410
|
+
# Only for SSE/HTTP transports
|
|
411
|
+
if server_config.transport not in ("sse", "http"):
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
# Determine if OAuth should be enabled. Default to True if no auth block provided
|
|
415
|
+
enable_oauth = True
|
|
416
|
+
redirect_port = 3030
|
|
417
|
+
redirect_path = "/callback"
|
|
418
|
+
scope_value: str | None = None
|
|
419
|
+
persist_mode: str = "keyring"
|
|
420
|
+
|
|
421
|
+
if server_config.auth is not None:
|
|
422
|
+
try:
|
|
423
|
+
enable_oauth = getattr(server_config.auth, "oauth", True)
|
|
424
|
+
redirect_port = getattr(server_config.auth, "redirect_port", 3030)
|
|
425
|
+
redirect_path = getattr(server_config.auth, "redirect_path", "/callback")
|
|
426
|
+
scope_field = getattr(server_config.auth, "scope", None)
|
|
427
|
+
persist_mode = getattr(server_config.auth, "persist", "keyring")
|
|
428
|
+
if isinstance(scope_field, list):
|
|
429
|
+
scope_value = " ".join(scope_field)
|
|
430
|
+
elif isinstance(scope_field, str):
|
|
431
|
+
scope_value = scope_field
|
|
432
|
+
except Exception:
|
|
433
|
+
logger.debug("Malformed auth configuration; using defaults.")
|
|
434
|
+
|
|
435
|
+
if not enable_oauth:
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
base_url = _derive_base_server_url(server_config.url)
|
|
439
|
+
if not base_url:
|
|
440
|
+
# No usable URL -> cannot build provider
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
# Construct client metadata with minimal defaults
|
|
444
|
+
redirect_uri = f"http://localhost:{redirect_port}{redirect_path}"
|
|
445
|
+
metadata_kwargs: dict[str, Any] = {
|
|
446
|
+
"client_name": "fast-agent",
|
|
447
|
+
"redirect_uris": [AnyUrl(redirect_uri)],
|
|
448
|
+
"grant_types": ["authorization_code", "refresh_token"],
|
|
449
|
+
"response_types": ["code"],
|
|
450
|
+
}
|
|
451
|
+
if scope_value:
|
|
452
|
+
metadata_kwargs["scope"] = scope_value
|
|
453
|
+
|
|
454
|
+
client_metadata = OAuthClientMetadata.model_validate(metadata_kwargs)
|
|
455
|
+
|
|
456
|
+
# Local callback server handler
|
|
457
|
+
async def _redirect_handler(authorization_url: str) -> None:
|
|
458
|
+
# Warn if persisting to keyring but no backend is available
|
|
459
|
+
await _print_authorization_link(
|
|
460
|
+
authorization_url,
|
|
461
|
+
warn_if_no_keyring=(persist_mode == "keyring"),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
async def _callback_handler() -> tuple[str, str | None]:
|
|
465
|
+
# Try local HTTP capture first
|
|
466
|
+
try:
|
|
467
|
+
server = _CallbackServer(port=redirect_port, path=redirect_path)
|
|
468
|
+
server.start()
|
|
469
|
+
try:
|
|
470
|
+
code, state = server.wait(timeout_seconds=300)
|
|
471
|
+
return code, state
|
|
472
|
+
finally:
|
|
473
|
+
server.stop()
|
|
474
|
+
except Exception as e:
|
|
475
|
+
# Fallback to paste-URL flow
|
|
476
|
+
logger.info(f"OAuth local callback server unavailable, fallback to paste flow: {e}")
|
|
477
|
+
try:
|
|
478
|
+
import sys
|
|
479
|
+
|
|
480
|
+
print("Paste the full callback URL after authorization:", file=sys.stderr)
|
|
481
|
+
callback_url = input("Callback URL: ").strip()
|
|
482
|
+
except Exception as ee:
|
|
483
|
+
raise RuntimeError(f"Failed to read callback URL from user: {ee}")
|
|
484
|
+
|
|
485
|
+
params = parse_qs(urlparse(callback_url).query)
|
|
486
|
+
code = params.get("code", [None])[0]
|
|
487
|
+
state = params.get("state", [None])[0]
|
|
488
|
+
if not code:
|
|
489
|
+
raise RuntimeError("Callback URL missing authorization code")
|
|
490
|
+
return code, state
|
|
491
|
+
|
|
492
|
+
# Choose storage
|
|
493
|
+
storage: TokenStorage
|
|
494
|
+
if persist_mode == "keyring":
|
|
495
|
+
identity = compute_server_identity(server_config)
|
|
496
|
+
# Update index on write via storage methods; creation here doesn't modify index yet.
|
|
497
|
+
storage = KeyringTokenStorage(service_name="fast-agent-mcp", server_identity=identity)
|
|
498
|
+
else:
|
|
499
|
+
storage = InMemoryTokenStorage()
|
|
500
|
+
|
|
501
|
+
provider = OAuthClientProvider(
|
|
502
|
+
server_url=base_url,
|
|
503
|
+
client_metadata=client_metadata,
|
|
504
|
+
storage=storage,
|
|
505
|
+
redirect_handler=_redirect_handler,
|
|
506
|
+
callback_handler=_callback_handler,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return provider
|
|
@@ -6,8 +6,15 @@ from fast_agent import FastAgent
|
|
|
6
6
|
fast = FastAgent("fast-agent example")
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
default_instruction = """You are a helpful AI Agent.
|
|
10
|
+
|
|
11
|
+
{{serverInstructions}}
|
|
12
|
+
|
|
13
|
+
The current date is {{currentDate}}."""
|
|
14
|
+
|
|
15
|
+
|
|
9
16
|
# Define the agent
|
|
10
|
-
@fast.agent(instruction=
|
|
17
|
+
@fast.agent(instruction=default_instruction)
|
|
11
18
|
async def main():
|
|
12
19
|
# use the --model command line switch or agent arguments to change model
|
|
13
20
|
async with fast.run() as agent:
|