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.
- fast_agent/__init__.py +183 -0
- fast_agent/acp/__init__.py +19 -0
- fast_agent/acp/acp_aware_mixin.py +304 -0
- fast_agent/acp/acp_context.py +437 -0
- fast_agent/acp/content_conversion.py +136 -0
- fast_agent/acp/filesystem_runtime.py +427 -0
- fast_agent/acp/permission_store.py +269 -0
- fast_agent/acp/server/__init__.py +5 -0
- fast_agent/acp/server/agent_acp_server.py +1472 -0
- fast_agent/acp/slash_commands.py +1050 -0
- fast_agent/acp/terminal_runtime.py +408 -0
- fast_agent/acp/tool_permission_adapter.py +125 -0
- fast_agent/acp/tool_permissions.py +474 -0
- fast_agent/acp/tool_progress.py +814 -0
- fast_agent/agents/__init__.py +85 -0
- fast_agent/agents/agent_types.py +64 -0
- fast_agent/agents/llm_agent.py +350 -0
- fast_agent/agents/llm_decorator.py +1139 -0
- fast_agent/agents/mcp_agent.py +1337 -0
- fast_agent/agents/tool_agent.py +271 -0
- fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
- fast_agent/agents/workflow/chain_agent.py +212 -0
- fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
- fast_agent/agents/workflow/iterative_planner.py +652 -0
- fast_agent/agents/workflow/maker_agent.py +379 -0
- fast_agent/agents/workflow/orchestrator_models.py +218 -0
- fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
- fast_agent/agents/workflow/parallel_agent.py +250 -0
- fast_agent/agents/workflow/router_agent.py +353 -0
- fast_agent/cli/__init__.py +0 -0
- fast_agent/cli/__main__.py +73 -0
- fast_agent/cli/commands/acp.py +159 -0
- fast_agent/cli/commands/auth.py +404 -0
- fast_agent/cli/commands/check_config.py +783 -0
- fast_agent/cli/commands/go.py +514 -0
- fast_agent/cli/commands/quickstart.py +557 -0
- fast_agent/cli/commands/serve.py +143 -0
- fast_agent/cli/commands/server_helpers.py +114 -0
- fast_agent/cli/commands/setup.py +174 -0
- fast_agent/cli/commands/url_parser.py +190 -0
- fast_agent/cli/constants.py +40 -0
- fast_agent/cli/main.py +115 -0
- fast_agent/cli/terminal.py +24 -0
- fast_agent/config.py +798 -0
- fast_agent/constants.py +41 -0
- fast_agent/context.py +279 -0
- fast_agent/context_dependent.py +50 -0
- fast_agent/core/__init__.py +92 -0
- fast_agent/core/agent_app.py +448 -0
- fast_agent/core/core_app.py +137 -0
- fast_agent/core/direct_decorators.py +784 -0
- fast_agent/core/direct_factory.py +620 -0
- fast_agent/core/error_handling.py +27 -0
- fast_agent/core/exceptions.py +90 -0
- fast_agent/core/executor/__init__.py +0 -0
- fast_agent/core/executor/executor.py +280 -0
- fast_agent/core/executor/task_registry.py +32 -0
- fast_agent/core/executor/workflow_signal.py +324 -0
- fast_agent/core/fastagent.py +1186 -0
- fast_agent/core/logging/__init__.py +5 -0
- fast_agent/core/logging/events.py +138 -0
- fast_agent/core/logging/json_serializer.py +164 -0
- fast_agent/core/logging/listeners.py +309 -0
- fast_agent/core/logging/logger.py +278 -0
- fast_agent/core/logging/transport.py +481 -0
- fast_agent/core/prompt.py +9 -0
- fast_agent/core/prompt_templates.py +183 -0
- fast_agent/core/validation.py +326 -0
- fast_agent/event_progress.py +62 -0
- fast_agent/history/history_exporter.py +49 -0
- fast_agent/human_input/__init__.py +47 -0
- fast_agent/human_input/elicitation_handler.py +123 -0
- fast_agent/human_input/elicitation_state.py +33 -0
- fast_agent/human_input/form_elements.py +59 -0
- fast_agent/human_input/form_fields.py +256 -0
- fast_agent/human_input/simple_form.py +113 -0
- fast_agent/human_input/types.py +40 -0
- fast_agent/interfaces.py +310 -0
- fast_agent/llm/__init__.py +9 -0
- fast_agent/llm/cancellation.py +22 -0
- fast_agent/llm/fastagent_llm.py +931 -0
- fast_agent/llm/internal/passthrough.py +161 -0
- fast_agent/llm/internal/playback.py +129 -0
- fast_agent/llm/internal/silent.py +41 -0
- fast_agent/llm/internal/slow.py +38 -0
- fast_agent/llm/memory.py +275 -0
- fast_agent/llm/model_database.py +490 -0
- fast_agent/llm/model_factory.py +388 -0
- fast_agent/llm/model_info.py +102 -0
- fast_agent/llm/prompt_utils.py +155 -0
- fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
- fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
- fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
- fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
- fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
- fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
- fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
- fast_agent/llm/provider/google/google_converter.py +466 -0
- fast_agent/llm/provider/google/llm_google_native.py +681 -0
- fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
- fast_agent/llm/provider/openai/llm_azure.py +143 -0
- fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
- fast_agent/llm/provider/openai/llm_generic.py +35 -0
- fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
- fast_agent/llm/provider/openai/llm_groq.py +42 -0
- fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
- fast_agent/llm/provider/openai/llm_openai.py +1195 -0
- fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
- fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
- fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
- fast_agent/llm/provider/openai/llm_xai.py +38 -0
- fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
- fast_agent/llm/provider/openai/openai_multipart.py +169 -0
- fast_agent/llm/provider/openai/openai_utils.py +67 -0
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/llm/provider_key_manager.py +139 -0
- fast_agent/llm/provider_types.py +34 -0
- fast_agent/llm/request_params.py +61 -0
- fast_agent/llm/sampling_converter.py +98 -0
- fast_agent/llm/stream_types.py +9 -0
- fast_agent/llm/usage_tracking.py +445 -0
- fast_agent/mcp/__init__.py +56 -0
- fast_agent/mcp/common.py +26 -0
- fast_agent/mcp/elicitation_factory.py +84 -0
- fast_agent/mcp/elicitation_handlers.py +164 -0
- fast_agent/mcp/gen_client.py +83 -0
- fast_agent/mcp/helpers/__init__.py +36 -0
- fast_agent/mcp/helpers/content_helpers.py +352 -0
- fast_agent/mcp/helpers/server_config_helpers.py +25 -0
- fast_agent/mcp/hf_auth.py +147 -0
- fast_agent/mcp/interfaces.py +92 -0
- fast_agent/mcp/logger_textio.py +108 -0
- fast_agent/mcp/mcp_agent_client_session.py +411 -0
- fast_agent/mcp/mcp_aggregator.py +2175 -0
- fast_agent/mcp/mcp_connection_manager.py +723 -0
- fast_agent/mcp/mcp_content.py +262 -0
- fast_agent/mcp/mime_utils.py +108 -0
- fast_agent/mcp/oauth_client.py +509 -0
- fast_agent/mcp/prompt.py +159 -0
- fast_agent/mcp/prompt_message_extended.py +155 -0
- fast_agent/mcp/prompt_render.py +84 -0
- fast_agent/mcp/prompt_serialization.py +580 -0
- fast_agent/mcp/prompts/__init__.py +0 -0
- fast_agent/mcp/prompts/__main__.py +7 -0
- fast_agent/mcp/prompts/prompt_constants.py +18 -0
- fast_agent/mcp/prompts/prompt_helpers.py +238 -0
- fast_agent/mcp/prompts/prompt_load.py +186 -0
- fast_agent/mcp/prompts/prompt_server.py +552 -0
- fast_agent/mcp/prompts/prompt_template.py +438 -0
- fast_agent/mcp/resource_utils.py +215 -0
- fast_agent/mcp/sampling.py +200 -0
- fast_agent/mcp/server/__init__.py +4 -0
- fast_agent/mcp/server/agent_server.py +613 -0
- fast_agent/mcp/skybridge.py +44 -0
- fast_agent/mcp/sse_tracking.py +287 -0
- fast_agent/mcp/stdio_tracking_simple.py +59 -0
- fast_agent/mcp/streamable_http_tracking.py +309 -0
- fast_agent/mcp/tool_execution_handler.py +137 -0
- fast_agent/mcp/tool_permission_handler.py +88 -0
- fast_agent/mcp/transport_tracking.py +634 -0
- fast_agent/mcp/types.py +24 -0
- fast_agent/mcp/ui_agent.py +48 -0
- fast_agent/mcp/ui_mixin.py +209 -0
- fast_agent/mcp_server_registry.py +89 -0
- fast_agent/py.typed +0 -0
- fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
- fast_agent/resources/examples/data-analysis/analysis.py +68 -0
- fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
- fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
- fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
- fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
- fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
- fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
- fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
- fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
- fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
- fast_agent/resources/examples/researcher/researcher.py +36 -0
- fast_agent/resources/examples/tensorzero/.env.sample +2 -0
- fast_agent/resources/examples/tensorzero/Makefile +31 -0
- fast_agent/resources/examples/tensorzero/README.md +56 -0
- fast_agent/resources/examples/tensorzero/agent.py +35 -0
- fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
- fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
- fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
- fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
- fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
- fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
- fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
- fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
- fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
- fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
- fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
- fast_agent/resources/examples/workflows/chaining.py +37 -0
- fast_agent/resources/examples/workflows/evaluator.py +77 -0
- fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
- fast_agent/resources/examples/workflows/graded_report.md +89 -0
- fast_agent/resources/examples/workflows/human_input.py +28 -0
- fast_agent/resources/examples/workflows/maker.py +156 -0
- fast_agent/resources/examples/workflows/orchestrator.py +70 -0
- fast_agent/resources/examples/workflows/parallel.py +56 -0
- fast_agent/resources/examples/workflows/router.py +69 -0
- fast_agent/resources/examples/workflows/short_story.md +13 -0
- fast_agent/resources/examples/workflows/short_story.txt +19 -0
- fast_agent/resources/setup/.gitignore +30 -0
- fast_agent/resources/setup/agent.py +28 -0
- fast_agent/resources/setup/fastagent.config.yaml +65 -0
- fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
- fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +235 -0
- fast_agent/tools/elicitation.py +369 -0
- fast_agent/tools/shell_runtime.py +402 -0
- fast_agent/types/__init__.py +59 -0
- fast_agent/types/conversation_summary.py +294 -0
- fast_agent/types/llm_stop_reason.py +78 -0
- fast_agent/types/message_search.py +249 -0
- fast_agent/ui/__init__.py +38 -0
- fast_agent/ui/console.py +59 -0
- fast_agent/ui/console_display.py +1080 -0
- fast_agent/ui/elicitation_form.py +946 -0
- fast_agent/ui/elicitation_style.py +59 -0
- fast_agent/ui/enhanced_prompt.py +1400 -0
- fast_agent/ui/history_display.py +734 -0
- fast_agent/ui/interactive_prompt.py +1199 -0
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +1004 -0
- fast_agent/ui/mcp_display.py +857 -0
- fast_agent/ui/mcp_ui_utils.py +235 -0
- fast_agent/ui/mermaid_utils.py +169 -0
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/notification_tracker.py +205 -0
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/progress_display.py +10 -0
- fast_agent/ui/rich_progress.py +195 -0
- fast_agent/ui/streaming.py +774 -0
- fast_agent/ui/streaming_buffer.py +449 -0
- fast_agent/ui/tool_display.py +422 -0
- fast_agent/ui/usage_display.py +204 -0
- fast_agent/utils/__init__.py +5 -0
- fast_agent/utils/reasoning_stream_parser.py +77 -0
- fast_agent/utils/time.py +22 -0
- fast_agent/workflow_telemetry.py +261 -0
- fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
- fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
- fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
- fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
- 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()
|