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.
- bareagent/__init__.py +10 -0
- bareagent/concurrency/__init__.py +6 -0
- bareagent/concurrency/background.py +97 -0
- bareagent/concurrency/notification.py +61 -0
- bareagent/concurrency/scheduler.py +136 -0
- bareagent/config.toml +299 -0
- bareagent/core/__init__.py +1 -0
- bareagent/core/config_paths.py +49 -0
- bareagent/core/context.py +127 -0
- bareagent/core/fileutil.py +103 -0
- bareagent/core/goal.py +214 -0
- bareagent/core/handlers/__init__.py +1 -0
- bareagent/core/handlers/bash.py +79 -0
- bareagent/core/handlers/file_edit.py +47 -0
- bareagent/core/handlers/file_read.py +270 -0
- bareagent/core/handlers/file_write.py +34 -0
- bareagent/core/handlers/glob_search.py +30 -0
- bareagent/core/handlers/goal.py +60 -0
- bareagent/core/handlers/grep_search.py +52 -0
- bareagent/core/handlers/memory.py +71 -0
- bareagent/core/handlers/plan.py +106 -0
- bareagent/core/handlers/search_utils.py +77 -0
- bareagent/core/handlers/skill.py +87 -0
- bareagent/core/handlers/subagent_send.py +70 -0
- bareagent/core/handlers/web_fetch.py +126 -0
- bareagent/core/handlers/web_search.py +165 -0
- bareagent/core/handlers/workflow.py +190 -0
- bareagent/core/loop.py +535 -0
- bareagent/core/retry.py +131 -0
- bareagent/core/sandbox.py +27 -0
- bareagent/core/schema.py +21 -0
- bareagent/core/tools.py +779 -0
- bareagent/core/workflow.py +517 -0
- bareagent/core/workflow_registry.py +219 -0
- bareagent/debug/__init__.py +0 -0
- bareagent/debug/interaction_log.py +263 -0
- bareagent/debug/viewer.html +1750 -0
- bareagent/debug/web_viewer.py +157 -0
- bareagent/hooks/__init__.py +32 -0
- bareagent/hooks/config.py +118 -0
- bareagent/hooks/engine.py +197 -0
- bareagent/hooks/errors.py +14 -0
- bareagent/hooks/events.py +22 -0
- bareagent/lsp/__init__.py +63 -0
- bareagent/lsp/config.py +134 -0
- bareagent/lsp/coord.py +118 -0
- bareagent/lsp/diagnostics.py +240 -0
- bareagent/lsp/errors.py +24 -0
- bareagent/lsp/manager.py +866 -0
- bareagent/lsp/tools.py +629 -0
- bareagent/lsp/workspace_edit.py +305 -0
- bareagent/main.py +4205 -0
- bareagent/mcp/__init__.py +69 -0
- bareagent/mcp/_sse.py +69 -0
- bareagent/mcp/client.py +341 -0
- bareagent/mcp/config.py +169 -0
- bareagent/mcp/errors.py +32 -0
- bareagent/mcp/manager.py +318 -0
- bareagent/mcp/protocol.py +187 -0
- bareagent/mcp/registry.py +557 -0
- bareagent/mcp/transport/__init__.py +15 -0
- bareagent/mcp/transport/base.py +149 -0
- bareagent/mcp/transport/http_legacy.py +192 -0
- bareagent/mcp/transport/http_streamable.py +217 -0
- bareagent/mcp/transport/stdio.py +202 -0
- bareagent/memory/__init__.py +1 -0
- bareagent/memory/compact.py +203 -0
- bareagent/memory/conversation_io.py +226 -0
- bareagent/memory/embedding.py +194 -0
- bareagent/memory/persistent.py +515 -0
- bareagent/memory/token_counter.py +67 -0
- bareagent/memory/token_tracker.py +262 -0
- bareagent/memory/transcript.py +100 -0
- bareagent/permission/__init__.py +1 -0
- bareagent/permission/guard.py +329 -0
- bareagent/permission/rules.py +19 -0
- bareagent/planning/__init__.py +19 -0
- bareagent/planning/agent_types.py +169 -0
- bareagent/planning/skill_gen.py +141 -0
- bareagent/planning/skill_store.py +173 -0
- bareagent/planning/skills.py +146 -0
- bareagent/planning/subagent.py +355 -0
- bareagent/planning/subagent_registry.py +77 -0
- bareagent/planning/tasks.py +348 -0
- bareagent/planning/todo.py +153 -0
- bareagent/planning/worktree.py +122 -0
- bareagent/provider/__init__.py +1 -0
- bareagent/provider/anthropic.py +348 -0
- bareagent/provider/base.py +136 -0
- bareagent/provider/factory.py +130 -0
- bareagent/provider/openai.py +881 -0
- bareagent/provider/presets.py +72 -0
- bareagent/provider/setup.py +356 -0
- bareagent/skills/.gitkeep +1 -0
- bareagent/skills/code-review/SKILL.md +68 -0
- bareagent/skills/git/SKILL.md +68 -0
- bareagent/skills/test/SKILL.md +70 -0
- bareagent/team/__init__.py +17 -0
- bareagent/team/autonomous.py +193 -0
- bareagent/team/mailbox.py +239 -0
- bareagent/team/manager.py +155 -0
- bareagent/team/protocols.py +129 -0
- bareagent/tracing/__init__.py +12 -0
- bareagent/tracing/_api.py +92 -0
- bareagent/tracing/_proxy.py +60 -0
- bareagent/tracing/composite.py +115 -0
- bareagent/tracing/json_file.py +115 -0
- bareagent/tracing/langfuse.py +139 -0
- bareagent/tracing/otel.py +107 -0
- bareagent/tracing/setup.py +85 -0
- bareagent/ui/__init__.py +24 -0
- bareagent/ui/console.py +167 -0
- bareagent/ui/prompt.py +78 -0
- bareagent/ui/protocol.py +24 -0
- bareagent/ui/stream.py +66 -0
- bareagent/ui/theme.py +240 -0
- bareagent_cli-0.1.0.dist-info/METADATA +331 -0
- bareagent_cli-0.1.0.dist-info/RECORD +121 -0
- bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
- bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|