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,287 @@
1
+ """SSE transport wrapper that emits channel events for UI display."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextlib import asynccontextmanager
7
+ from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable
8
+ from urllib.parse import parse_qs, urljoin, urlparse
9
+
10
+ import anyio
11
+ import httpx
12
+ import mcp.types as types
13
+ from httpx_sse import aconnect_sse
14
+ from httpx_sse._exceptions import SSEError
15
+ from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
16
+ from mcp.shared.message import SessionMessage
17
+
18
+ from fast_agent.mcp.transport_tracking import ChannelEvent, ChannelName
19
+
20
+ if TYPE_CHECKING:
21
+ from anyio.abc import TaskStatus
22
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ ChannelHook = Callable[[ChannelEvent], None]
27
+
28
+
29
+ def _extract_session_id(endpoint_url: str) -> str | None:
30
+ parsed = urlparse(endpoint_url)
31
+ query_params = parse_qs(parsed.query)
32
+ for key in ("sessionId", "session_id", "session"):
33
+ values = query_params.get(key)
34
+ if values:
35
+ return values[0]
36
+ return None
37
+
38
+
39
+ def _emit_channel_event(
40
+ channel_hook: ChannelHook | None,
41
+ channel: ChannelName,
42
+ event_type: str,
43
+ *,
44
+ message: types.JSONRPCMessage | None = None,
45
+ raw_event: str | None = None,
46
+ detail: str | None = None,
47
+ status_code: int | None = None,
48
+ ) -> None:
49
+ if channel_hook is None:
50
+ return
51
+ try:
52
+ channel_hook(
53
+ ChannelEvent(
54
+ channel=channel,
55
+ event_type=event_type, # type: ignore[arg-type]
56
+ message=message,
57
+ raw_event=raw_event,
58
+ detail=detail,
59
+ status_code=status_code,
60
+ )
61
+ )
62
+ except Exception:
63
+ logger.debug("Channel hook raised an exception", exc_info=True)
64
+
65
+
66
+ def _format_http_error(exc: httpx.HTTPStatusError) -> tuple[int | None, str]:
67
+ status_code: int | None = None
68
+ detail = str(exc)
69
+ if exc.response is not None:
70
+ status_code = exc.response.status_code
71
+ reason = exc.response.reason_phrase or ""
72
+ if not reason:
73
+ try:
74
+ reason = (exc.response.text or "").strip()
75
+ except Exception:
76
+ reason = ""
77
+ detail = f"HTTP {status_code}: {reason or 'response'}"
78
+ return status_code, detail
79
+
80
+
81
+ @asynccontextmanager
82
+ async def tracking_sse_client(
83
+ url: str,
84
+ headers: dict[str, Any] | None = None,
85
+ timeout: float = 5,
86
+ sse_read_timeout: float = 60 * 5,
87
+ httpx_client_factory: McpHttpClientFactory = create_mcp_http_client,
88
+ auth: httpx.Auth | None = None,
89
+ channel_hook: ChannelHook | None = None,
90
+ ) -> AsyncGenerator[
91
+ tuple[
92
+ MemoryObjectReceiveStream[SessionMessage | Exception],
93
+ MemoryObjectSendStream[SessionMessage],
94
+ Callable[[], str | None],
95
+ ],
96
+ None,
97
+ ]:
98
+ """
99
+ Client transport for SSE with channel activity tracking.
100
+ """
101
+
102
+ read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](
103
+ 0
104
+ )
105
+ write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0)
106
+
107
+ session_id: str | None = None
108
+
109
+ def get_session_id() -> str | None:
110
+ return session_id
111
+
112
+ async with anyio.create_task_group() as tg:
113
+ try:
114
+ logger.debug("Connecting to SSE endpoint: %s", url)
115
+ async with httpx_client_factory(
116
+ headers=headers,
117
+ auth=auth,
118
+ timeout=httpx.Timeout(timeout, read=sse_read_timeout),
119
+ ) as client:
120
+ connected = False
121
+ post_connected = False
122
+
123
+ async def sse_reader(
124
+ task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED,
125
+ ):
126
+ try:
127
+ async for sse in event_source.aiter_sse():
128
+ if sse.event == "endpoint":
129
+ endpoint_url = urljoin(url, sse.data)
130
+ logger.debug("Received SSE endpoint URL: %s", endpoint_url)
131
+
132
+ url_parsed = urlparse(url)
133
+ endpoint_parsed = urlparse(endpoint_url)
134
+ if (
135
+ url_parsed.scheme != endpoint_parsed.scheme
136
+ or url_parsed.netloc != endpoint_parsed.netloc
137
+ ):
138
+ error_msg = (
139
+ "Endpoint origin does not match connection origin: "
140
+ f"{endpoint_url}"
141
+ )
142
+ logger.error(error_msg)
143
+ _emit_channel_event(
144
+ channel_hook,
145
+ "get",
146
+ "error",
147
+ detail=error_msg,
148
+ )
149
+ raise ValueError(error_msg)
150
+
151
+ nonlocal session_id
152
+ session_id = _extract_session_id(endpoint_url)
153
+ task_status.started(endpoint_url)
154
+ elif sse.event == "message":
155
+ try:
156
+ message = types.JSONRPCMessage.model_validate_json(sse.data)
157
+ except Exception as exc:
158
+ logger.exception("Error parsing server message")
159
+ _emit_channel_event(
160
+ channel_hook,
161
+ "get",
162
+ "error",
163
+ detail="Error parsing server message",
164
+ )
165
+ await read_stream_writer.send(exc)
166
+ continue
167
+
168
+ _emit_channel_event(channel_hook, "get", "message", message=message)
169
+ await read_stream_writer.send(SessionMessage(message))
170
+ else:
171
+ _emit_channel_event(
172
+ channel_hook,
173
+ "get",
174
+ "keepalive",
175
+ raw_event=sse.event or "keepalive",
176
+ )
177
+ except SSEError as sse_exc:
178
+ logger.exception("Encountered SSE exception")
179
+ _emit_channel_event(
180
+ channel_hook,
181
+ "get",
182
+ "error",
183
+ detail=str(sse_exc),
184
+ )
185
+ raise
186
+ except Exception as exc:
187
+ logger.exception("Error in sse_reader")
188
+ _emit_channel_event(
189
+ channel_hook,
190
+ "get",
191
+ "error",
192
+ detail=str(exc),
193
+ )
194
+ await read_stream_writer.send(exc)
195
+ finally:
196
+ await read_stream_writer.aclose()
197
+
198
+ async def post_writer(endpoint_url: str):
199
+ try:
200
+ async with write_stream_reader:
201
+ async for session_message in write_stream_reader:
202
+ try:
203
+ payload = session_message.message.model_dump(
204
+ by_alias=True,
205
+ mode="json",
206
+ exclude_none=True,
207
+ )
208
+ except Exception:
209
+ logger.exception("Invalid session message payload")
210
+ continue
211
+
212
+ _emit_channel_event(
213
+ channel_hook,
214
+ "post-sse",
215
+ "message",
216
+ message=session_message.message,
217
+ )
218
+
219
+ try:
220
+ response = await client.post(endpoint_url, json=payload)
221
+ response.raise_for_status()
222
+ except httpx.HTTPStatusError as exc:
223
+ status_code, detail = _format_http_error(exc)
224
+ _emit_channel_event(
225
+ channel_hook,
226
+ "post-sse",
227
+ "error",
228
+ detail=detail,
229
+ status_code=status_code,
230
+ )
231
+ raise
232
+ except httpx.HTTPStatusError:
233
+ logger.exception("HTTP error in post_writer")
234
+ except Exception:
235
+ logger.exception("Error in post_writer")
236
+ _emit_channel_event(
237
+ channel_hook,
238
+ "post-sse",
239
+ "error",
240
+ detail="Error sending client message",
241
+ )
242
+ finally:
243
+ await write_stream.aclose()
244
+
245
+ try:
246
+ async with aconnect_sse(
247
+ client,
248
+ "GET",
249
+ url,
250
+ ) as event_source:
251
+ try:
252
+ event_source.response.raise_for_status()
253
+ except httpx.HTTPStatusError as exc:
254
+ status_code, detail = _format_http_error(exc)
255
+ _emit_channel_event(
256
+ channel_hook,
257
+ "get",
258
+ "error",
259
+ detail=detail,
260
+ status_code=status_code,
261
+ )
262
+ raise
263
+
264
+ _emit_channel_event(channel_hook, "get", "connect")
265
+ connected = True
266
+
267
+ endpoint_url = await tg.start(sse_reader)
268
+ _emit_channel_event(channel_hook, "post-sse", "connect")
269
+ post_connected = True
270
+ tg.start_soon(post_writer, endpoint_url)
271
+
272
+ try:
273
+ yield read_stream, write_stream, get_session_id
274
+ finally:
275
+ tg.cancel_scope.cancel()
276
+ except Exception:
277
+ raise
278
+ finally:
279
+ if connected:
280
+ _emit_channel_event(channel_hook, "get", "disconnect")
281
+ if post_connected:
282
+ _emit_channel_event(channel_hook, "post-sse", "disconnect")
283
+ finally:
284
+ await read_stream_writer.aclose()
285
+ await read_stream.aclose()
286
+ await write_stream_reader.aclose()
287
+ await write_stream.aclose()
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import asynccontextmanager
5
+ from typing import TYPE_CHECKING, AsyncGenerator, Callable
6
+
7
+ from mcp.client.stdio import StdioServerParameters, stdio_client
8
+
9
+ from fast_agent.mcp.transport_tracking import ChannelEvent
10
+
11
+ if TYPE_CHECKING:
12
+ from anyio.abc import ObjectReceiveStream, ObjectSendStream
13
+ from mcp.shared.message import SessionMessage
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ ChannelHook = Callable[[ChannelEvent], None]
18
+
19
+
20
+ @asynccontextmanager
21
+ async def tracking_stdio_client(
22
+ server_params: StdioServerParameters,
23
+ *,
24
+ channel_hook: ChannelHook | None = None,
25
+ errlog: Callable[[str], None] | None = None,
26
+ ) -> AsyncGenerator[
27
+ tuple[ObjectReceiveStream[SessionMessage | Exception], ObjectSendStream[SessionMessage]], None
28
+ ]:
29
+ """Context manager for stdio client with basic connection tracking."""
30
+
31
+ def emit_channel_event(event_type: str, detail: str | None = None) -> None:
32
+ if channel_hook is None:
33
+ return
34
+ try:
35
+ channel_hook(
36
+ ChannelEvent(
37
+ channel="stdio",
38
+ event_type=event_type, # type: ignore[arg-type]
39
+ detail=detail,
40
+ )
41
+ )
42
+ except Exception: # pragma: no cover - hook errors must not break transport
43
+ logger.exception("Channel hook raised an exception")
44
+
45
+ try:
46
+ # Emit connection event
47
+ emit_channel_event("connect")
48
+
49
+ # Use the original stdio_client without stream interception
50
+ async with stdio_client(server_params, errlog=errlog) as (read_stream, write_stream):
51
+ yield read_stream, write_stream
52
+
53
+ except Exception as exc:
54
+ # Emit error event
55
+ emit_channel_event("error", detail=str(exc))
56
+ raise
57
+ finally:
58
+ # Emit disconnection event
59
+ emit_channel_event("disconnect")
@@ -0,0 +1,309 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import asynccontextmanager
5
+ from typing import TYPE_CHECKING, AsyncGenerator, Awaitable, Callable
6
+
7
+ import anyio
8
+ import httpx
9
+ from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
10
+ from mcp.client.streamable_http import (
11
+ RequestContext,
12
+ RequestId,
13
+ StreamableHTTPTransport,
14
+ StreamWriter,
15
+ )
16
+ from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
17
+ from mcp.shared.message import SessionMessage
18
+ from mcp.types import JSONRPCError, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse
19
+
20
+ from fast_agent.mcp.transport_tracking import ChannelEvent, ChannelName
21
+
22
+ if TYPE_CHECKING:
23
+ from datetime import timedelta
24
+
25
+ from anyio.abc import ObjectReceiveStream, ObjectSendStream
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ ChannelHook = Callable[[ChannelEvent], None]
30
+
31
+
32
+ class ChannelTrackingStreamableHTTPTransport(StreamableHTTPTransport):
33
+ """Streamable HTTP transport that emits channel events before dispatching."""
34
+
35
+ def __init__(
36
+ self,
37
+ url: str,
38
+ *,
39
+ headers: dict[str, str] | None = None,
40
+ timeout: float | timedelta = 30,
41
+ sse_read_timeout: float | timedelta = 60 * 5,
42
+ auth: httpx.Auth | None = None,
43
+ channel_hook: ChannelHook | None = None,
44
+ ) -> None:
45
+ super().__init__(
46
+ url,
47
+ headers=headers,
48
+ timeout=timeout,
49
+ sse_read_timeout=sse_read_timeout,
50
+ auth=auth,
51
+ )
52
+ self._channel_hook = channel_hook
53
+
54
+ def _emit_channel_event(
55
+ self,
56
+ channel: ChannelName,
57
+ event_type: str,
58
+ *,
59
+ message: JSONRPCMessage | None = None,
60
+ raw_event: str | None = None,
61
+ detail: str | None = None,
62
+ status_code: int | None = None,
63
+ ) -> None:
64
+ if self._channel_hook is None:
65
+ return
66
+ try:
67
+ self._channel_hook(
68
+ ChannelEvent(
69
+ channel=channel,
70
+ event_type=event_type, # type: ignore[arg-type]
71
+ message=message,
72
+ raw_event=raw_event,
73
+ detail=detail,
74
+ status_code=status_code,
75
+ )
76
+ )
77
+ except Exception: # pragma: no cover - hook errors must not break transport
78
+ logger.exception("Channel hook raised an exception")
79
+
80
+ async def _handle_json_response( # type: ignore[override]
81
+ self,
82
+ response: httpx.Response,
83
+ read_stream_writer: StreamWriter,
84
+ is_initialization: bool = False,
85
+ ) -> None:
86
+ try:
87
+ content = await response.aread()
88
+ message = JSONRPCMessage.model_validate_json(content)
89
+
90
+ if is_initialization:
91
+ self._maybe_extract_protocol_version_from_message(message)
92
+
93
+ self._emit_channel_event("post-json", "message", message=message)
94
+ await read_stream_writer.send(SessionMessage(message))
95
+ except Exception as exc: # pragma: no cover - propagate to session
96
+ logger.exception("Error parsing JSON response")
97
+ await read_stream_writer.send(exc)
98
+
99
+ async def _handle_sse_event_with_channel(
100
+ self,
101
+ channel: ChannelName,
102
+ sse: ServerSentEvent,
103
+ read_stream_writer: StreamWriter,
104
+ original_request_id: RequestId | None = None,
105
+ resumption_callback: Callable[[str], Awaitable[None]] | None = None,
106
+ is_initialization: bool = False,
107
+ ) -> bool:
108
+ if sse.event != "message":
109
+ # Treat non-message events (e.g. ping) as keepalive notifications
110
+ self._emit_channel_event(channel, "keepalive", raw_event=sse.event or "keepalive")
111
+ return False
112
+
113
+ try:
114
+ message = JSONRPCMessage.model_validate_json(sse.data)
115
+ if is_initialization:
116
+ self._maybe_extract_protocol_version_from_message(message)
117
+
118
+ if original_request_id is not None and isinstance(
119
+ message.root, (JSONRPCResponse, JSONRPCError)
120
+ ):
121
+ message.root.id = original_request_id
122
+
123
+ self._emit_channel_event(channel, "message", message=message)
124
+ await read_stream_writer.send(SessionMessage(message))
125
+
126
+ if sse.id and resumption_callback:
127
+ await resumption_callback(sse.id)
128
+
129
+ return isinstance(message.root, (JSONRPCResponse, JSONRPCError))
130
+ except Exception as exc: # pragma: no cover - propagate to session
131
+ logger.exception("Error parsing SSE message")
132
+ await read_stream_writer.send(exc)
133
+ return False
134
+
135
+ async def handle_get_stream( # type: ignore[override]
136
+ self,
137
+ client: httpx.AsyncClient,
138
+ read_stream_writer: StreamWriter,
139
+ ) -> None:
140
+ if not self.session_id:
141
+ return
142
+
143
+ headers = self._prepare_request_headers(self.request_headers)
144
+ connected = False
145
+ try:
146
+ async with aconnect_sse(
147
+ client,
148
+ "GET",
149
+ self.url,
150
+ headers=headers,
151
+ timeout=httpx.Timeout(self.timeout, read=self.sse_read_timeout),
152
+ ) as event_source:
153
+ event_source.response.raise_for_status()
154
+ self._emit_channel_event("get", "connect")
155
+ connected = True
156
+ async for sse in event_source.aiter_sse():
157
+ await self._handle_sse_event_with_channel(
158
+ "get",
159
+ sse,
160
+ read_stream_writer,
161
+ )
162
+ except Exception as exc: # pragma: no cover - non fatal stream errors
163
+ logger.debug("GET stream error (non-fatal): %s", exc)
164
+ status_code = None
165
+ detail = str(exc)
166
+ if isinstance(exc, httpx.HTTPStatusError):
167
+ if exc.response is not None:
168
+ status_code = exc.response.status_code
169
+ reason = exc.response.reason_phrase or ""
170
+ if not reason:
171
+ try:
172
+ reason = (exc.response.text or "").strip()
173
+ except Exception:
174
+ reason = ""
175
+ detail = f"HTTP {status_code}: {reason or 'response'}"
176
+ else:
177
+ status_code = exc.response.status_code if hasattr(exc, "response") else None
178
+ self._emit_channel_event("get", "error", detail=detail, status_code=status_code)
179
+ finally:
180
+ if connected:
181
+ self._emit_channel_event("get", "disconnect")
182
+
183
+ async def _handle_resumption_request( # type: ignore[override]
184
+ self,
185
+ ctx: RequestContext,
186
+ ) -> None:
187
+ headers = self._prepare_request_headers(ctx.headers)
188
+ if ctx.metadata and ctx.metadata.resumption_token:
189
+ headers["last-event-id"] = ctx.metadata.resumption_token
190
+ else: # pragma: no cover - defensive
191
+ raise ValueError("Resumption request requires a resumption token")
192
+
193
+ original_request_id: RequestId | None = None
194
+ if isinstance(ctx.session_message.message.root, JSONRPCRequest):
195
+ original_request_id = ctx.session_message.message.root.id
196
+
197
+ async with aconnect_sse(
198
+ ctx.client,
199
+ "GET",
200
+ self.url,
201
+ headers=headers,
202
+ timeout=httpx.Timeout(self.timeout, read=self.sse_read_timeout),
203
+ ) as event_source:
204
+ event_source.response.raise_for_status()
205
+ async for sse in event_source.aiter_sse():
206
+ is_complete = await self._handle_sse_event_with_channel(
207
+ "resumption",
208
+ sse,
209
+ ctx.read_stream_writer,
210
+ original_request_id,
211
+ ctx.metadata.on_resumption_token_update if ctx.metadata else None,
212
+ )
213
+ if is_complete:
214
+ await event_source.response.aclose()
215
+ break
216
+
217
+ async def _handle_sse_response( # type: ignore[override]
218
+ self,
219
+ response: httpx.Response,
220
+ ctx: RequestContext,
221
+ is_initialization: bool = False,
222
+ ) -> None:
223
+ try:
224
+ event_source = EventSource(response)
225
+ async for sse in event_source.aiter_sse():
226
+ is_complete = await self._handle_sse_event_with_channel(
227
+ "post-sse",
228
+ sse,
229
+ ctx.read_stream_writer,
230
+ resumption_callback=(
231
+ ctx.metadata.on_resumption_token_update if ctx.metadata else None
232
+ ),
233
+ is_initialization=is_initialization,
234
+ )
235
+ if is_complete:
236
+ await response.aclose()
237
+ break
238
+ except Exception as exc: # pragma: no cover - propagate to session
239
+ logger.exception("Error reading SSE stream")
240
+ await ctx.read_stream_writer.send(exc)
241
+
242
+
243
+ @asynccontextmanager
244
+ async def tracking_streamablehttp_client(
245
+ url: str,
246
+ headers: dict[str, str] | None = None,
247
+ *,
248
+ timeout: float | timedelta = 30,
249
+ sse_read_timeout: float | timedelta = 60 * 5,
250
+ terminate_on_close: bool = True,
251
+ httpx_client_factory: McpHttpClientFactory = create_mcp_http_client,
252
+ auth: httpx.Auth | None = None,
253
+ channel_hook: ChannelHook | None = None,
254
+ ) -> AsyncGenerator[
255
+ tuple[
256
+ ObjectReceiveStream[SessionMessage | Exception],
257
+ ObjectSendStream[SessionMessage],
258
+ Callable[[], str | None],
259
+ ],
260
+ None,
261
+ ]:
262
+ """Context manager mirroring streamablehttp_client with channel tracking."""
263
+
264
+ transport = ChannelTrackingStreamableHTTPTransport(
265
+ url,
266
+ headers=headers,
267
+ timeout=timeout,
268
+ sse_read_timeout=sse_read_timeout,
269
+ auth=auth,
270
+ channel_hook=channel_hook,
271
+ )
272
+
273
+ read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](
274
+ 0
275
+ )
276
+ write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0)
277
+
278
+ async with anyio.create_task_group() as tg:
279
+ try:
280
+ async with httpx_client_factory(
281
+ headers=transport.request_headers,
282
+ timeout=httpx.Timeout(transport.timeout, read=transport.sse_read_timeout),
283
+ auth=transport.auth,
284
+ ) as client:
285
+
286
+ def start_get_stream() -> None:
287
+ tg.start_soon(transport.handle_get_stream, client, read_stream_writer)
288
+
289
+ tg.start_soon(
290
+ transport.post_writer,
291
+ client,
292
+ write_stream_reader,
293
+ read_stream_writer,
294
+ write_stream,
295
+ start_get_stream,
296
+ tg,
297
+ )
298
+
299
+ try:
300
+ yield read_stream, write_stream, transport.get_session_id
301
+ finally:
302
+ if transport.session_id and terminate_on_close:
303
+ await transport.terminate_session(client)
304
+ tg.cancel_scope.cancel()
305
+ finally:
306
+ await read_stream_writer.aclose()
307
+ await read_stream.aclose()
308
+ await write_stream_reader.aclose()
309
+ await write_stream.aclose()