bareagent-cli 0.1.0__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 (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,149 @@
1
+ """Transport ABC + shared id-routing machinery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Callable
9
+ from concurrent.futures import Future
10
+
11
+ from ..errors import MCPProtocolError, MCPTransportError
12
+ from ..protocol import Notification, Request, Response, encode_message
13
+
14
+ NotificationCallback = Callable[[Notification], None]
15
+ DisconnectCallback = Callable[[str], None]
16
+
17
+ _log = logging.getLogger(__name__)
18
+
19
+
20
+ class Transport(ABC):
21
+ """Abstract bidirectional message channel for one MCP server.
22
+
23
+ Concrete subclasses (stdio, http_legacy, http_streamable) implement
24
+ `start` / `send` / `close` / `is_alive`. The shared `request` / `notify`
25
+ helpers plus the pending-future routing live here so all transports route
26
+ server responses identically.
27
+
28
+ Subclasses must distinguish ``graceful close`` (user called ``close()``)
29
+ from ``unexpected disconnect`` (subprocess EOF, broken pipe, SSE stream
30
+ error) and only invoke the registered disconnect handler in the
31
+ unexpected case. See :meth:`_invoke_disconnect`.
32
+ """
33
+
34
+ def __init__(self) -> None:
35
+ self._pending: dict[int, Future[Response]] = {}
36
+ self._pending_lock = threading.Lock()
37
+ self._notification_callbacks: list[NotificationCallback] = []
38
+ self._callbacks_lock = threading.Lock()
39
+ self._disconnect_handler: DisconnectCallback | None = None
40
+ self._disconnect_invoked = False
41
+
42
+ @abstractmethod
43
+ def start(self) -> None:
44
+ """Open the underlying connection / launch the subprocess."""
45
+
46
+ @abstractmethod
47
+ def send(self, message: str) -> None:
48
+ """Write a single already-encoded JSON-RPC message."""
49
+
50
+ @abstractmethod
51
+ def close(self) -> None:
52
+ """Release all resources (subprocess, sockets, threads)."""
53
+
54
+ @abstractmethod
55
+ def is_alive(self) -> bool:
56
+ """Return True while the underlying transport is healthy."""
57
+
58
+ def request(self, request: Request, *, timeout: float) -> Response:
59
+ """Send a request and block until the matching response arrives."""
60
+ future: Future[Response] = Future()
61
+ with self._pending_lock:
62
+ if request.id in self._pending:
63
+ raise MCPProtocolError(f"request id {request.id} already in flight")
64
+ self._pending[request.id] = future
65
+ try:
66
+ self.send(encode_message(request))
67
+ except BaseException:
68
+ with self._pending_lock:
69
+ self._pending.pop(request.id, None)
70
+ raise
71
+ try:
72
+ return future.result(timeout=timeout)
73
+ except TimeoutError as exc:
74
+ raise MCPProtocolError(
75
+ f"request {request.id} timed out after {timeout}s"
76
+ ) from exc
77
+ finally:
78
+ with self._pending_lock:
79
+ self._pending.pop(request.id, None)
80
+
81
+ def notify(self, notification: Notification) -> None:
82
+ """Send a notification (no response expected)."""
83
+ self.send(encode_message(notification))
84
+
85
+ def on_notification(self, callback: NotificationCallback) -> None:
86
+ """Register a server->client notification callback."""
87
+ with self._callbacks_lock:
88
+ self._notification_callbacks.append(callback)
89
+
90
+ def set_disconnect_handler(self, callback: DisconnectCallback | None) -> None:
91
+ """Register a one-shot callback for unexpected transport disconnects.
92
+
93
+ Called by subclass reader threads when they detect EOF / broken pipe /
94
+ unexpected stream termination — never on user-initiated ``close()``.
95
+ Passing ``None`` clears the handler.
96
+ """
97
+ self._disconnect_handler = callback
98
+
99
+ # --- internal hooks used by subclass readers ---
100
+
101
+ def _route_response(self, response: Response) -> None:
102
+ """Deliver a response to its waiting future, or drop it if abandoned."""
103
+ if response.id is None:
104
+ return # parse-error responses with null id have no waiter
105
+ with self._pending_lock:
106
+ future = self._pending.get(response.id)
107
+ if future is None or future.done():
108
+ return # orphan / late response — ignore per JSON-RPC convention
109
+ future.set_result(response)
110
+
111
+ def _route_notification(self, notification: Notification) -> None:
112
+ """Fan out a server->client notification to registered callbacks."""
113
+ with self._callbacks_lock:
114
+ callbacks = list(self._notification_callbacks)
115
+ for cb in callbacks:
116
+ try:
117
+ cb(notification)
118
+ except Exception:
119
+ # Callbacks must not break the reader thread.
120
+ pass
121
+
122
+ def _fail_all_pending(self, message: str) -> None:
123
+ """Signal connection loss to every in-flight request."""
124
+ with self._pending_lock:
125
+ pending = list(self._pending.items())
126
+ self._pending.clear()
127
+ for _, future in pending:
128
+ if not future.done():
129
+ future.set_exception(MCPTransportError(message))
130
+
131
+ def _invoke_disconnect(self, reason: str) -> None:
132
+ """Fire the disconnect handler exactly once on unexpected termination.
133
+
134
+ Subclasses call this from their reader thread when they detect an
135
+ unexpected disconnect (EOF, broken pipe, SSE stream broken, subprocess
136
+ died). Calls after the first one are no-ops so a reader that detects
137
+ the same condition twice does not double-notify the manager. A
138
+ graceful ``close()`` must not call this.
139
+ """
140
+ if self._disconnect_invoked:
141
+ return
142
+ self._disconnect_invoked = True
143
+ handler = self._disconnect_handler
144
+ if handler is None:
145
+ return
146
+ try:
147
+ handler(reason)
148
+ except Exception as exc: # pragma: no cover — handler must never crash reader
149
+ _log.warning("MCP transport disconnect handler raised: %s", exc)
@@ -0,0 +1,192 @@
1
+ """MCP 2024-11-05 HTTP+SSE transport (two endpoints).
2
+
3
+ Connection lifecycle:
4
+
5
+ 1. `start()` opens a long-lived SSE GET to `url`. The first SSE event MUST be
6
+ `event: endpoint`, with `data` = the POST endpoint URL (a plain string,
7
+ not JSON). That URL becomes the write target.
8
+ 2. `send()` POSTs each JSON-RPC envelope to the captured endpoint.
9
+ 3. All server -> client traffic (responses + notifications) arrives as
10
+ `event: message` SSE events whose data field is one JSON-RPC envelope.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import threading
17
+ from urllib.parse import urljoin
18
+
19
+ import httpx
20
+
21
+ from .._sse import parse_sse_stream
22
+ from ..errors import MCPProtocolError, MCPTransportError
23
+ from ..protocol import Notification, Request, Response, decode_message
24
+ from .base import Transport
25
+
26
+ _log = logging.getLogger(__name__)
27
+
28
+ _PROTOCOL_VERSION = "2024-11-05"
29
+ _PROTOCOL_HEADERS_GET = {
30
+ "Accept": "text/event-stream",
31
+ "MCP-Protocol-Version": _PROTOCOL_VERSION,
32
+ }
33
+ _PROTOCOL_HEADERS_POST = {
34
+ "Content-Type": "application/json",
35
+ "Accept": "application/json, text/event-stream",
36
+ "MCP-Protocol-Version": _PROTOCOL_VERSION,
37
+ }
38
+
39
+
40
+ class HttpLegacyTransport(Transport):
41
+ """Two-endpoint MCP HTTP transport (GET SSE + POST writes)."""
42
+
43
+ def __init__(
44
+ self,
45
+ url: str,
46
+ *,
47
+ headers: dict[str, str] | None = None,
48
+ start_timeout: float = 10.0,
49
+ ) -> None:
50
+ super().__init__()
51
+ self._url = url
52
+ self._user_headers = dict(headers or {})
53
+ self._start_timeout = start_timeout
54
+ self._client: httpx.Client | None = None
55
+ self._response: httpx.Response | None = None
56
+ self._stream_cm = None # type: ignore[var-annotated]
57
+ self._reader: threading.Thread | None = None
58
+ self._endpoint_url: str | None = None
59
+ self._endpoint_event = threading.Event()
60
+ self._closed = False
61
+ # Reader uses ``_closing`` to skip the disconnect handler when shutdown
62
+ # is user-initiated. Set by ``close()`` before tearing down the stream.
63
+ self._closing = False
64
+
65
+ def start(self) -> None:
66
+ if self._client is not None:
67
+ raise RuntimeError("HttpLegacyTransport already started")
68
+ timeout = httpx.Timeout(
69
+ connect=self._start_timeout, read=None, write=10.0, pool=10.0
70
+ )
71
+ self._client = httpx.Client(timeout=timeout)
72
+ headers = self._merge_headers(_PROTOCOL_HEADERS_GET)
73
+ try:
74
+ self._stream_cm = self._client.stream("GET", self._url, headers=headers)
75
+ self._response = self._stream_cm.__enter__()
76
+ self._response.raise_for_status()
77
+ except httpx.HTTPError as exc:
78
+ self._cleanup()
79
+ raise MCPTransportError(f"failed to open SSE stream: {exc}") from exc
80
+
81
+ self._reader = threading.Thread(
82
+ target=self._read_loop, name="mcp-http-legacy-reader", daemon=True
83
+ )
84
+ self._reader.start()
85
+
86
+ if not self._endpoint_event.wait(timeout=self._start_timeout):
87
+ self.close()
88
+ raise MCPTransportError(
89
+ "did not receive endpoint event within start timeout"
90
+ )
91
+
92
+ def send(self, message: str) -> None:
93
+ if self._closed:
94
+ raise MCPTransportError("transport is closed")
95
+ if self._client is None or self._endpoint_url is None:
96
+ raise MCPTransportError("transport not started")
97
+ headers = self._merge_headers(_PROTOCOL_HEADERS_POST)
98
+ try:
99
+ resp = self._client.post(
100
+ self._endpoint_url, headers=headers, content=message.encode("utf-8")
101
+ )
102
+ resp.raise_for_status()
103
+ except httpx.HTTPError as exc:
104
+ raise MCPTransportError(f"POST failed: {exc}") from exc
105
+
106
+ def close(self) -> None:
107
+ if self._closed:
108
+ return
109
+ self._closed = True
110
+ self._closing = True
111
+ self._cleanup()
112
+ self._fail_all_pending("HTTP legacy transport closed")
113
+
114
+ def is_alive(self) -> bool:
115
+ return (
116
+ not self._closed
117
+ and self._client is not None
118
+ and self._endpoint_url is not None
119
+ )
120
+
121
+ # --- internals ---
122
+
123
+ def _merge_headers(self, base: dict[str, str]) -> dict[str, str]:
124
+ # User-supplied headers must not override protocol-mandated ones.
125
+ merged = dict(self._user_headers)
126
+ merged.update(base)
127
+ return merged
128
+
129
+ def _cleanup(self) -> None:
130
+ if self._stream_cm is not None:
131
+ try:
132
+ self._stream_cm.__exit__(None, None, None)
133
+ except Exception:
134
+ pass
135
+ self._stream_cm = None
136
+ if self._client is not None:
137
+ try:
138
+ self._client.close()
139
+ except Exception:
140
+ pass
141
+
142
+ def _read_loop(self) -> None:
143
+ assert self._response is not None
144
+ disconnect_reason: str | None = None
145
+ try:
146
+ for event in parse_sse_stream(self._response.iter_lines()):
147
+ if event["event"] == "endpoint":
148
+ self._on_endpoint(event["data"])
149
+ elif event["event"] == "message":
150
+ self._on_message(event["data"])
151
+ else:
152
+ _log.warning(
153
+ "MCP http_legacy: unknown SSE event %r", event["event"]
154
+ )
155
+ except httpx.HTTPError as exc:
156
+ disconnect_reason = f"SSE stream broken: {exc}"
157
+ _log.warning("MCP http_legacy: SSE stream broken: %s", exc)
158
+ except Exception as exc: # pragma: no cover — defensive
159
+ disconnect_reason = f"reader crashed: {exc}"
160
+ _log.warning("MCP http_legacy reader crashed: %s", exc)
161
+ finally:
162
+ if not self._closing:
163
+ self._invoke_disconnect(
164
+ disconnect_reason or "SSE stream ended unexpectedly"
165
+ )
166
+ self._fail_all_pending("SSE stream closed (server disconnect or error)")
167
+
168
+ def _on_endpoint(self, data: str) -> None:
169
+ # data is a relative or absolute URL string; resolve against the base URL.
170
+ endpoint = data.strip()
171
+ if not endpoint:
172
+ _log.warning("MCP http_legacy: empty endpoint event")
173
+ return
174
+ self._endpoint_url = urljoin(self._url, endpoint)
175
+ self._endpoint_event.set()
176
+
177
+ def _on_message(self, data: str) -> None:
178
+ try:
179
+ msg = decode_message(data)
180
+ except MCPProtocolError as exc:
181
+ _log.warning("MCP http_legacy: bad message event: %s", exc)
182
+ return
183
+ if isinstance(msg, Response):
184
+ self._route_response(msg)
185
+ elif isinstance(msg, Notification):
186
+ self._route_notification(msg)
187
+ else:
188
+ # Server-to-client Request: not handled in PR1.
189
+ assert isinstance(msg, Request)
190
+ _log.warning(
191
+ "MCP http_legacy: ignoring server-to-client request %r", msg.method
192
+ )
@@ -0,0 +1,217 @@
1
+ """MCP 2025-03-26 Streamable HTTP transport (single endpoint).
2
+
3
+ Key differences vs HTTP legacy:
4
+
5
+ - One URL handles both directions. POST writes go to `url`; the server
6
+ responds with either `application/json` (a single envelope) or
7
+ `text/event-stream` (one or more `event: message` envelopes then closes).
8
+ - An optional long-lived GET stream is opened for server -> client
9
+ notifications. If the server returns 405 / 404 for the GET we silently
10
+ fall back to POST-only mode.
11
+ - Session continuity uses the `Mcp-Session-Id` response header echoed on
12
+ every subsequent request.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import threading
19
+
20
+ import httpx
21
+
22
+ from .._sse import parse_sse_stream
23
+ from ..errors import MCPProtocolError, MCPTransportError
24
+ from ..protocol import Notification, Request, Response, decode_message
25
+ from .base import Transport
26
+
27
+ _log = logging.getLogger(__name__)
28
+
29
+ _PROTOCOL_VERSION = "2025-06-18"
30
+ _SESSION_HEADER = "Mcp-Session-Id"
31
+ _PROTOCOL_HEADERS_BASE = {
32
+ "Accept": "application/json, text/event-stream",
33
+ "MCP-Protocol-Version": _PROTOCOL_VERSION,
34
+ }
35
+ _PROTOCOL_HEADERS_POST = {**_PROTOCOL_HEADERS_BASE, "Content-Type": "application/json"}
36
+
37
+
38
+ class HttpStreamableTransport(Transport):
39
+ """Single-endpoint MCP HTTP transport per the 2025-03-26 spec."""
40
+
41
+ def __init__(
42
+ self,
43
+ url: str,
44
+ *,
45
+ headers: dict[str, str] | None = None,
46
+ start_timeout: float = 10.0,
47
+ ) -> None:
48
+ super().__init__()
49
+ self._url = url
50
+ self._user_headers = dict(headers or {})
51
+ self._start_timeout = start_timeout
52
+ self._client: httpx.Client | None = None
53
+ self._listen_response: httpx.Response | None = None
54
+ self._listen_cm = None # type: ignore[var-annotated]
55
+ self._listen_thread: threading.Thread | None = None
56
+ self._session_id: str | None = None
57
+ self._session_lock = threading.Lock()
58
+ self._closed = False
59
+ # Distinguishes ``close()``-initiated shutdown from real disconnects so
60
+ # the reader thread does not fire the disconnect handler on a normal
61
+ # exit.
62
+ self._closing = False
63
+
64
+ def start(self) -> None:
65
+ if self._client is not None:
66
+ raise RuntimeError("HttpStreamableTransport already started")
67
+ timeout = httpx.Timeout(
68
+ connect=self._start_timeout, read=None, write=10.0, pool=10.0
69
+ )
70
+ self._client = httpx.Client(timeout=timeout)
71
+
72
+ # Best-effort GET for server-push notifications. Servers may refuse.
73
+ try:
74
+ headers = self._merge_headers(_PROTOCOL_HEADERS_BASE)
75
+ self._listen_cm = self._client.stream("GET", self._url, headers=headers)
76
+ response = self._listen_cm.__enter__()
77
+ if response.status_code >= 400:
78
+ # Server doesn't offer a listening stream; that's fine for POST-only.
79
+ self._listen_cm.__exit__(None, None, None)
80
+ self._listen_cm = None
81
+ self._listen_response = None
82
+ else:
83
+ self._capture_session(response)
84
+ self._listen_response = response
85
+ self._listen_thread = threading.Thread(
86
+ target=self._listen_loop, name="mcp-http-stream-reader", daemon=True
87
+ )
88
+ self._listen_thread.start()
89
+ except httpx.HTTPError as exc:
90
+ # Listening stream is optional — log and continue with POST-only mode.
91
+ _log.warning("MCP http_streamable: GET listen stream unavailable: %s", exc)
92
+ if self._listen_cm is not None:
93
+ try:
94
+ self._listen_cm.__exit__(None, None, None)
95
+ except Exception:
96
+ pass
97
+ self._listen_cm = None
98
+
99
+ def send(self, message: str) -> None:
100
+ if self._closed:
101
+ raise MCPTransportError("transport is closed")
102
+ if self._client is None:
103
+ raise MCPTransportError("transport not started")
104
+ headers = self._merge_headers(_PROTOCOL_HEADERS_POST)
105
+ try:
106
+ resp = self._client.post(
107
+ self._url, headers=headers, content=message.encode("utf-8")
108
+ )
109
+ resp.raise_for_status()
110
+ except httpx.HTTPError as exc:
111
+ raise MCPTransportError(f"POST failed: {exc}") from exc
112
+
113
+ self._capture_session(resp)
114
+ self._handle_response_body(resp)
115
+
116
+ def close(self) -> None:
117
+ if self._closed:
118
+ return
119
+ self._closed = True
120
+ self._closing = True
121
+ if self._listen_cm is not None:
122
+ try:
123
+ self._listen_cm.__exit__(None, None, None)
124
+ except Exception:
125
+ pass
126
+ self._listen_cm = None
127
+ if self._client is not None:
128
+ try:
129
+ self._client.close()
130
+ except Exception:
131
+ pass
132
+ self._fail_all_pending("HTTP streamable transport closed")
133
+
134
+ def is_alive(self) -> bool:
135
+ return not self._closed and self._client is not None
136
+
137
+ # --- internals ---
138
+
139
+ def _merge_headers(self, base: dict[str, str]) -> dict[str, str]:
140
+ merged = dict(self._user_headers)
141
+ merged.update(base)
142
+ with self._session_lock:
143
+ if self._session_id is not None:
144
+ merged[_SESSION_HEADER] = self._session_id
145
+ return merged
146
+
147
+ def _capture_session(self, response: httpx.Response) -> None:
148
+ session = response.headers.get(_SESSION_HEADER)
149
+ if session:
150
+ with self._session_lock:
151
+ self._session_id = session
152
+
153
+ def _handle_response_body(self, response: httpx.Response) -> None:
154
+ # POST body can be either a single JSON envelope or an SSE stream.
155
+ content_type = response.headers.get("Content-Type", "").lower()
156
+ if "text/event-stream" in content_type:
157
+ try:
158
+ for event in parse_sse_stream(response.iter_lines()):
159
+ if event["event"] == "message":
160
+ self._on_message(event["data"])
161
+ else:
162
+ _log.warning(
163
+ "MCP http_streamable: unknown SSE event %r in POST response",
164
+ event["event"],
165
+ )
166
+ except httpx.HTTPError as exc:
167
+ _log.warning("MCP http_streamable: POST SSE stream broken: %s", exc)
168
+ return
169
+ if not response.content:
170
+ return # 202 Accepted with empty body — response will arrive on listen stream
171
+ if "application/json" in content_type or response.content.lstrip().startswith(
172
+ b"{"
173
+ ):
174
+ self._on_message(response.text)
175
+ return
176
+ _log.warning("MCP http_streamable: unexpected Content-Type %r", content_type)
177
+
178
+ def _listen_loop(self) -> None:
179
+ assert self._listen_response is not None
180
+ disconnect_reason: str | None = None
181
+ try:
182
+ for event in parse_sse_stream(self._listen_response.iter_lines()):
183
+ if event["event"] == "message":
184
+ self._on_message(event["data"])
185
+ else:
186
+ _log.warning(
187
+ "MCP http_streamable: unknown SSE event %r on listen stream",
188
+ event["event"],
189
+ )
190
+ except httpx.HTTPError as exc:
191
+ disconnect_reason = f"listen stream broken: {exc}"
192
+ _log.warning("MCP http_streamable: listen stream broken: %s", exc)
193
+ except Exception as exc: # pragma: no cover — defensive
194
+ disconnect_reason = f"listener crashed: {exc}"
195
+ _log.warning("MCP http_streamable listener crashed: %s", exc)
196
+ finally:
197
+ if not self._closing:
198
+ self._invoke_disconnect(
199
+ disconnect_reason or "HTTP streamable listen stream ended"
200
+ )
201
+ self._fail_all_pending("HTTP streamable listen stream closed")
202
+
203
+ def _on_message(self, data: str) -> None:
204
+ try:
205
+ msg = decode_message(data)
206
+ except MCPProtocolError as exc:
207
+ _log.warning("MCP http_streamable: bad message: %s", exc)
208
+ return
209
+ if isinstance(msg, Response):
210
+ self._route_response(msg)
211
+ elif isinstance(msg, Notification):
212
+ self._route_notification(msg)
213
+ else:
214
+ assert isinstance(msg, Request)
215
+ _log.warning(
216
+ "MCP http_streamable: ignoring server-to-client request %r", msg.method
217
+ )