fast-agent-mcp 0.4.7__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.
Files changed (261) hide show
  1. fast_agent/__init__.py +183 -0
  2. fast_agent/acp/__init__.py +19 -0
  3. fast_agent/acp/acp_aware_mixin.py +304 -0
  4. fast_agent/acp/acp_context.py +437 -0
  5. fast_agent/acp/content_conversion.py +136 -0
  6. fast_agent/acp/filesystem_runtime.py +427 -0
  7. fast_agent/acp/permission_store.py +269 -0
  8. fast_agent/acp/server/__init__.py +5 -0
  9. fast_agent/acp/server/agent_acp_server.py +1472 -0
  10. fast_agent/acp/slash_commands.py +1050 -0
  11. fast_agent/acp/terminal_runtime.py +408 -0
  12. fast_agent/acp/tool_permission_adapter.py +125 -0
  13. fast_agent/acp/tool_permissions.py +474 -0
  14. fast_agent/acp/tool_progress.py +814 -0
  15. fast_agent/agents/__init__.py +85 -0
  16. fast_agent/agents/agent_types.py +64 -0
  17. fast_agent/agents/llm_agent.py +350 -0
  18. fast_agent/agents/llm_decorator.py +1139 -0
  19. fast_agent/agents/mcp_agent.py +1337 -0
  20. fast_agent/agents/tool_agent.py +271 -0
  21. fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
  22. fast_agent/agents/workflow/chain_agent.py +212 -0
  23. fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
  24. fast_agent/agents/workflow/iterative_planner.py +652 -0
  25. fast_agent/agents/workflow/maker_agent.py +379 -0
  26. fast_agent/agents/workflow/orchestrator_models.py +218 -0
  27. fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
  28. fast_agent/agents/workflow/parallel_agent.py +250 -0
  29. fast_agent/agents/workflow/router_agent.py +353 -0
  30. fast_agent/cli/__init__.py +0 -0
  31. fast_agent/cli/__main__.py +73 -0
  32. fast_agent/cli/commands/acp.py +159 -0
  33. fast_agent/cli/commands/auth.py +404 -0
  34. fast_agent/cli/commands/check_config.py +783 -0
  35. fast_agent/cli/commands/go.py +514 -0
  36. fast_agent/cli/commands/quickstart.py +557 -0
  37. fast_agent/cli/commands/serve.py +143 -0
  38. fast_agent/cli/commands/server_helpers.py +114 -0
  39. fast_agent/cli/commands/setup.py +174 -0
  40. fast_agent/cli/commands/url_parser.py +190 -0
  41. fast_agent/cli/constants.py +40 -0
  42. fast_agent/cli/main.py +115 -0
  43. fast_agent/cli/terminal.py +24 -0
  44. fast_agent/config.py +798 -0
  45. fast_agent/constants.py +41 -0
  46. fast_agent/context.py +279 -0
  47. fast_agent/context_dependent.py +50 -0
  48. fast_agent/core/__init__.py +92 -0
  49. fast_agent/core/agent_app.py +448 -0
  50. fast_agent/core/core_app.py +137 -0
  51. fast_agent/core/direct_decorators.py +784 -0
  52. fast_agent/core/direct_factory.py +620 -0
  53. fast_agent/core/error_handling.py +27 -0
  54. fast_agent/core/exceptions.py +90 -0
  55. fast_agent/core/executor/__init__.py +0 -0
  56. fast_agent/core/executor/executor.py +280 -0
  57. fast_agent/core/executor/task_registry.py +32 -0
  58. fast_agent/core/executor/workflow_signal.py +324 -0
  59. fast_agent/core/fastagent.py +1186 -0
  60. fast_agent/core/logging/__init__.py +5 -0
  61. fast_agent/core/logging/events.py +138 -0
  62. fast_agent/core/logging/json_serializer.py +164 -0
  63. fast_agent/core/logging/listeners.py +309 -0
  64. fast_agent/core/logging/logger.py +278 -0
  65. fast_agent/core/logging/transport.py +481 -0
  66. fast_agent/core/prompt.py +9 -0
  67. fast_agent/core/prompt_templates.py +183 -0
  68. fast_agent/core/validation.py +326 -0
  69. fast_agent/event_progress.py +62 -0
  70. fast_agent/history/history_exporter.py +49 -0
  71. fast_agent/human_input/__init__.py +47 -0
  72. fast_agent/human_input/elicitation_handler.py +123 -0
  73. fast_agent/human_input/elicitation_state.py +33 -0
  74. fast_agent/human_input/form_elements.py +59 -0
  75. fast_agent/human_input/form_fields.py +256 -0
  76. fast_agent/human_input/simple_form.py +113 -0
  77. fast_agent/human_input/types.py +40 -0
  78. fast_agent/interfaces.py +310 -0
  79. fast_agent/llm/__init__.py +9 -0
  80. fast_agent/llm/cancellation.py +22 -0
  81. fast_agent/llm/fastagent_llm.py +931 -0
  82. fast_agent/llm/internal/passthrough.py +161 -0
  83. fast_agent/llm/internal/playback.py +129 -0
  84. fast_agent/llm/internal/silent.py +41 -0
  85. fast_agent/llm/internal/slow.py +38 -0
  86. fast_agent/llm/memory.py +275 -0
  87. fast_agent/llm/model_database.py +490 -0
  88. fast_agent/llm/model_factory.py +388 -0
  89. fast_agent/llm/model_info.py +102 -0
  90. fast_agent/llm/prompt_utils.py +155 -0
  91. fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
  92. fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
  93. fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
  94. fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
  95. fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
  96. fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
  97. fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
  98. fast_agent/llm/provider/google/google_converter.py +466 -0
  99. fast_agent/llm/provider/google/llm_google_native.py +681 -0
  100. fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
  101. fast_agent/llm/provider/openai/llm_azure.py +143 -0
  102. fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
  103. fast_agent/llm/provider/openai/llm_generic.py +35 -0
  104. fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
  105. fast_agent/llm/provider/openai/llm_groq.py +42 -0
  106. fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
  107. fast_agent/llm/provider/openai/llm_openai.py +1195 -0
  108. fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
  109. fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
  110. fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
  111. fast_agent/llm/provider/openai/llm_xai.py +38 -0
  112. fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
  113. fast_agent/llm/provider/openai/openai_multipart.py +169 -0
  114. fast_agent/llm/provider/openai/openai_utils.py +67 -0
  115. fast_agent/llm/provider/openai/responses.py +133 -0
  116. fast_agent/llm/provider_key_manager.py +139 -0
  117. fast_agent/llm/provider_types.py +34 -0
  118. fast_agent/llm/request_params.py +61 -0
  119. fast_agent/llm/sampling_converter.py +98 -0
  120. fast_agent/llm/stream_types.py +9 -0
  121. fast_agent/llm/usage_tracking.py +445 -0
  122. fast_agent/mcp/__init__.py +56 -0
  123. fast_agent/mcp/common.py +26 -0
  124. fast_agent/mcp/elicitation_factory.py +84 -0
  125. fast_agent/mcp/elicitation_handlers.py +164 -0
  126. fast_agent/mcp/gen_client.py +83 -0
  127. fast_agent/mcp/helpers/__init__.py +36 -0
  128. fast_agent/mcp/helpers/content_helpers.py +352 -0
  129. fast_agent/mcp/helpers/server_config_helpers.py +25 -0
  130. fast_agent/mcp/hf_auth.py +147 -0
  131. fast_agent/mcp/interfaces.py +92 -0
  132. fast_agent/mcp/logger_textio.py +108 -0
  133. fast_agent/mcp/mcp_agent_client_session.py +411 -0
  134. fast_agent/mcp/mcp_aggregator.py +2175 -0
  135. fast_agent/mcp/mcp_connection_manager.py +723 -0
  136. fast_agent/mcp/mcp_content.py +262 -0
  137. fast_agent/mcp/mime_utils.py +108 -0
  138. fast_agent/mcp/oauth_client.py +509 -0
  139. fast_agent/mcp/prompt.py +159 -0
  140. fast_agent/mcp/prompt_message_extended.py +155 -0
  141. fast_agent/mcp/prompt_render.py +84 -0
  142. fast_agent/mcp/prompt_serialization.py +580 -0
  143. fast_agent/mcp/prompts/__init__.py +0 -0
  144. fast_agent/mcp/prompts/__main__.py +7 -0
  145. fast_agent/mcp/prompts/prompt_constants.py +18 -0
  146. fast_agent/mcp/prompts/prompt_helpers.py +238 -0
  147. fast_agent/mcp/prompts/prompt_load.py +186 -0
  148. fast_agent/mcp/prompts/prompt_server.py +552 -0
  149. fast_agent/mcp/prompts/prompt_template.py +438 -0
  150. fast_agent/mcp/resource_utils.py +215 -0
  151. fast_agent/mcp/sampling.py +200 -0
  152. fast_agent/mcp/server/__init__.py +4 -0
  153. fast_agent/mcp/server/agent_server.py +613 -0
  154. fast_agent/mcp/skybridge.py +44 -0
  155. fast_agent/mcp/sse_tracking.py +287 -0
  156. fast_agent/mcp/stdio_tracking_simple.py +59 -0
  157. fast_agent/mcp/streamable_http_tracking.py +309 -0
  158. fast_agent/mcp/tool_execution_handler.py +137 -0
  159. fast_agent/mcp/tool_permission_handler.py +88 -0
  160. fast_agent/mcp/transport_tracking.py +634 -0
  161. fast_agent/mcp/types.py +24 -0
  162. fast_agent/mcp/ui_agent.py +48 -0
  163. fast_agent/mcp/ui_mixin.py +209 -0
  164. fast_agent/mcp_server_registry.py +89 -0
  165. fast_agent/py.typed +0 -0
  166. fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
  167. fast_agent/resources/examples/data-analysis/analysis.py +68 -0
  168. fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
  169. fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
  170. fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
  171. fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
  172. fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
  173. fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
  174. fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
  175. fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
  176. fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
  177. fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
  178. fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
  179. fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
  180. fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
  181. fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
  182. fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
  183. fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
  184. fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
  185. fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
  186. fast_agent/resources/examples/researcher/researcher.py +36 -0
  187. fast_agent/resources/examples/tensorzero/.env.sample +2 -0
  188. fast_agent/resources/examples/tensorzero/Makefile +31 -0
  189. fast_agent/resources/examples/tensorzero/README.md +56 -0
  190. fast_agent/resources/examples/tensorzero/agent.py +35 -0
  191. fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
  192. fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
  193. fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
  194. fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
  195. fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
  196. fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
  197. fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
  198. fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
  199. fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
  200. fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
  201. fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
  202. fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
  203. fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
  204. fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
  205. fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
  206. fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
  207. fast_agent/resources/examples/workflows/chaining.py +37 -0
  208. fast_agent/resources/examples/workflows/evaluator.py +77 -0
  209. fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
  210. fast_agent/resources/examples/workflows/graded_report.md +89 -0
  211. fast_agent/resources/examples/workflows/human_input.py +28 -0
  212. fast_agent/resources/examples/workflows/maker.py +156 -0
  213. fast_agent/resources/examples/workflows/orchestrator.py +70 -0
  214. fast_agent/resources/examples/workflows/parallel.py +56 -0
  215. fast_agent/resources/examples/workflows/router.py +69 -0
  216. fast_agent/resources/examples/workflows/short_story.md +13 -0
  217. fast_agent/resources/examples/workflows/short_story.txt +19 -0
  218. fast_agent/resources/setup/.gitignore +30 -0
  219. fast_agent/resources/setup/agent.py +28 -0
  220. fast_agent/resources/setup/fastagent.config.yaml +65 -0
  221. fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
  222. fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
  223. fast_agent/skills/__init__.py +9 -0
  224. fast_agent/skills/registry.py +235 -0
  225. fast_agent/tools/elicitation.py +369 -0
  226. fast_agent/tools/shell_runtime.py +402 -0
  227. fast_agent/types/__init__.py +59 -0
  228. fast_agent/types/conversation_summary.py +294 -0
  229. fast_agent/types/llm_stop_reason.py +78 -0
  230. fast_agent/types/message_search.py +249 -0
  231. fast_agent/ui/__init__.py +38 -0
  232. fast_agent/ui/console.py +59 -0
  233. fast_agent/ui/console_display.py +1080 -0
  234. fast_agent/ui/elicitation_form.py +946 -0
  235. fast_agent/ui/elicitation_style.py +59 -0
  236. fast_agent/ui/enhanced_prompt.py +1400 -0
  237. fast_agent/ui/history_display.py +734 -0
  238. fast_agent/ui/interactive_prompt.py +1199 -0
  239. fast_agent/ui/markdown_helpers.py +104 -0
  240. fast_agent/ui/markdown_truncator.py +1004 -0
  241. fast_agent/ui/mcp_display.py +857 -0
  242. fast_agent/ui/mcp_ui_utils.py +235 -0
  243. fast_agent/ui/mermaid_utils.py +169 -0
  244. fast_agent/ui/message_primitives.py +50 -0
  245. fast_agent/ui/notification_tracker.py +205 -0
  246. fast_agent/ui/plain_text_truncator.py +68 -0
  247. fast_agent/ui/progress_display.py +10 -0
  248. fast_agent/ui/rich_progress.py +195 -0
  249. fast_agent/ui/streaming.py +774 -0
  250. fast_agent/ui/streaming_buffer.py +449 -0
  251. fast_agent/ui/tool_display.py +422 -0
  252. fast_agent/ui/usage_display.py +204 -0
  253. fast_agent/utils/__init__.py +5 -0
  254. fast_agent/utils/reasoning_stream_parser.py +77 -0
  255. fast_agent/utils/time.py +22 -0
  256. fast_agent/workflow_telemetry.py +261 -0
  257. fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
  258. fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
  259. fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
  260. fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
  261. fast_agent_mcp-0.4.7.dist-info/licenses/LICENSE +201 -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
@@ -0,0 +1,159 @@
1
+ """
2
+ Prompt class for easily creating and working with MCP prompt content.
3
+
4
+ This implementation lives in the fast_agent namespace as part of the
5
+ migration away from fast_agent. A compatibility shim remains at
6
+ fast_agent.core.prompt importing this Prompt.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Literal, Union
11
+
12
+ from mcp import CallToolRequest
13
+ from mcp.types import ContentBlock, PromptMessage
14
+
15
+ from fast_agent.mcp.mcp_content import Assistant, MCPPrompt, User
16
+ from fast_agent.types import LlmStopReason, PromptMessageExtended
17
+
18
+
19
+ class Prompt:
20
+ """
21
+ A helper class for working with MCP prompt content.
22
+
23
+ This class provides static methods to create:
24
+ - PromptMessage instances
25
+ - PromptMessageExtended instances
26
+ - Lists of messages for conversations
27
+
28
+ All methods intelligently handle various content types:
29
+ - Strings become TextContent
30
+ - Image file paths become ImageContent
31
+ - Other file paths become EmbeddedResource
32
+ - TextContent objects are used directly
33
+ - ImageContent objects are used directly
34
+ - EmbeddedResource objects are used directly
35
+ - Pre-formatted messages pass through unchanged
36
+ """
37
+
38
+ @classmethod
39
+ def user(
40
+ cls,
41
+ *content_items: Union[
42
+ str, Path, bytes, dict, ContentBlock, PromptMessage, PromptMessageExtended
43
+ ],
44
+ ) -> PromptMessageExtended:
45
+ """
46
+ Create a user PromptMessageExtended with various content items.
47
+ """
48
+ # Handle PromptMessage and PromptMessageExtended directly
49
+ if len(content_items) == 1:
50
+ item = content_items[0]
51
+ if isinstance(item, PromptMessage):
52
+ return PromptMessageExtended(role="user", content=[item.content])
53
+ elif isinstance(item, PromptMessageExtended):
54
+ # Keep the content but change role to user
55
+ return PromptMessageExtended(role="user", content=item.content)
56
+
57
+ # Use the content factory for other types
58
+ messages = User(*content_items)
59
+ return PromptMessageExtended(role="user", content=[msg["content"] for msg in messages])
60
+
61
+ @classmethod
62
+ def assistant(
63
+ cls,
64
+ *content_items: Union[
65
+ str, Path, bytes, dict, ContentBlock, PromptMessage, PromptMessageExtended
66
+ ],
67
+ stop_reason: LlmStopReason | None = None,
68
+ tool_calls: dict[str, CallToolRequest] | None = None,
69
+ ) -> PromptMessageExtended:
70
+ """
71
+ Create an assistant PromptMessageExtended with various content items.
72
+ """
73
+ # Handle PromptMessage and PromptMessageExtended directly
74
+ if len(content_items) == 1:
75
+ item = content_items[0]
76
+ if isinstance(item, PromptMessage):
77
+ return PromptMessageExtended(
78
+ role="assistant",
79
+ content=[item.content],
80
+ stop_reason=stop_reason,
81
+ tool_calls=tool_calls,
82
+ )
83
+ elif isinstance(item, PromptMessageExtended):
84
+ # Keep the content but change role to assistant
85
+ return PromptMessageExtended(
86
+ role="assistant",
87
+ content=item.content,
88
+ stop_reason=stop_reason,
89
+ tool_calls=tool_calls,
90
+ )
91
+
92
+ # Use the content factory for other types
93
+ messages = Assistant(*content_items)
94
+ return PromptMessageExtended(
95
+ role="assistant",
96
+ content=[msg["content"] for msg in messages],
97
+ stop_reason=stop_reason,
98
+ tool_calls=tool_calls,
99
+ )
100
+
101
+ @classmethod
102
+ def message(
103
+ cls,
104
+ *content_items: Union[
105
+ str, Path, bytes, dict, ContentBlock, PromptMessage, PromptMessageExtended
106
+ ],
107
+ role: Literal["user", "assistant"] = "user",
108
+ ) -> PromptMessageExtended:
109
+ """
110
+ Create a PromptMessageExtended with the specified role and content items.
111
+ """
112
+ # Handle PromptMessage and PromptMessageExtended directly
113
+ if len(content_items) == 1:
114
+ item = content_items[0]
115
+ if isinstance(item, PromptMessage):
116
+ return PromptMessageExtended(role=role, content=[item.content])
117
+ elif isinstance(item, PromptMessageExtended):
118
+ # Keep the content but change role as specified
119
+ return PromptMessageExtended(role=role, content=item.content)
120
+
121
+ # Use the content factory for other types
122
+ messages = MCPPrompt(*content_items, role=role)
123
+ return PromptMessageExtended(
124
+ role=messages[0]["role"] if messages else role,
125
+ content=[msg["content"] for msg in messages],
126
+ )
127
+
128
+ @classmethod
129
+ def conversation(cls, *messages) -> list[PromptMessage]:
130
+ """
131
+ Create a list of PromptMessages from various inputs.
132
+ """
133
+ result = []
134
+
135
+ for item in messages:
136
+ if isinstance(item, PromptMessageExtended):
137
+ # Convert PromptMessageExtended to a list of PromptMessages
138
+ result.extend(item.from_multipart())
139
+ elif isinstance(item, dict) and "role" in item and "content" in item:
140
+ # Convert a single message dict to PromptMessage
141
+ result.append(PromptMessage(**item))
142
+ elif isinstance(item, list):
143
+ # Process each item in the list
144
+ for msg in item:
145
+ if isinstance(msg, dict) and "role" in msg and "content" in msg:
146
+ result.append(PromptMessage(**msg))
147
+ # Ignore other types
148
+
149
+ return result
150
+
151
+ @classmethod
152
+ def from_multipart(cls, multipart: list[PromptMessageExtended]) -> list[PromptMessage]:
153
+ """
154
+ Convert a list of PromptMessageExtended objects to PromptMessages.
155
+ """
156
+ result = []
157
+ for mp in multipart:
158
+ result.extend(mp.from_multipart())
159
+ return result