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
bareagent/mcp/manager.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Multi-server MCP manager: concurrent startup, status tracking, lookup.
|
|
2
|
+
|
|
3
|
+
The manager constructs one ``Transport`` + one ``MCPClient`` per configured
|
|
4
|
+
server, then launches every handshake in parallel through a thread pool. A
|
|
5
|
+
single slow server cannot block REPL boot — handshake timeouts and exceptions
|
|
6
|
+
mark that server unhealthy while the rest continue.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from threading import Lock
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from .client import MCPClient
|
|
19
|
+
from .config import MCPConfig, MCPServerConfig
|
|
20
|
+
from .errors import MCPError
|
|
21
|
+
from .transport.base import Transport
|
|
22
|
+
from .transport.http_legacy import HttpLegacyTransport
|
|
23
|
+
from .transport.http_streamable import HttpStreamableTransport
|
|
24
|
+
from .transport.stdio import StdioTransport
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from bareagent.concurrency.background import BackgroundManager
|
|
28
|
+
from bareagent.ui.protocol import UIProtocol
|
|
29
|
+
|
|
30
|
+
_log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ServerStatus(StrEnum):
|
|
34
|
+
"""Lifecycle states a managed MCP server moves through."""
|
|
35
|
+
|
|
36
|
+
STARTING = "starting"
|
|
37
|
+
RUNNING = "running"
|
|
38
|
+
UNHEALTHY = "unhealthy"
|
|
39
|
+
STOPPED = "stopped"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MCPManager:
|
|
43
|
+
"""Orchestrates a fleet of MCP server clients.
|
|
44
|
+
|
|
45
|
+
Use ``start_all()`` once at boot, then ``iter_running_clients()`` to feed
|
|
46
|
+
the tool registry. Failed servers are skipped (logged + warned via the
|
|
47
|
+
UI console if supplied) so REPL startup is never blocked.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
config: MCPConfig,
|
|
53
|
+
console: UIProtocol | None = None,
|
|
54
|
+
notifier: BackgroundManager | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._config = config
|
|
57
|
+
self._console = console
|
|
58
|
+
# ``notifier`` is the shared ``BackgroundManager`` already used for
|
|
59
|
+
# background-task completion notifications. When a managed MCP server
|
|
60
|
+
# disconnects unexpectedly, the manager posts a "failed" notification
|
|
61
|
+
# through the same channel so the REPL surface treats it as an async
|
|
62
|
+
# event (see ``concurrency/notification.py``).
|
|
63
|
+
self._notifier = notifier
|
|
64
|
+
self._clients: dict[str, MCPClient] = {}
|
|
65
|
+
self._status: dict[str, ServerStatus] = {}
|
|
66
|
+
self._lock = Lock()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def config(self) -> MCPConfig:
|
|
70
|
+
return self._config
|
|
71
|
+
|
|
72
|
+
def start_all(self) -> None:
|
|
73
|
+
"""Spawn every configured server in parallel; never raises.
|
|
74
|
+
|
|
75
|
+
Each handshake runs in its own worker thread; failures are caught and
|
|
76
|
+
recorded. The call returns when every server has either reached
|
|
77
|
+
``RUNNING`` or been marked ``UNHEALTHY``.
|
|
78
|
+
"""
|
|
79
|
+
servers = list(self._config.servers)
|
|
80
|
+
if not servers:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
with self._lock:
|
|
84
|
+
for server in servers:
|
|
85
|
+
self._status[server.name] = ServerStatus.STARTING
|
|
86
|
+
|
|
87
|
+
max_workers = max(1, len(servers))
|
|
88
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
89
|
+
futures = {
|
|
90
|
+
pool.submit(self._start_one, server): server for server in servers
|
|
91
|
+
}
|
|
92
|
+
for future in as_completed(futures):
|
|
93
|
+
server = futures[future]
|
|
94
|
+
try:
|
|
95
|
+
future.result()
|
|
96
|
+
except Exception as exc: # pragma: no cover — defensive net
|
|
97
|
+
_log.warning(
|
|
98
|
+
"MCP server %r start crashed unexpectedly: %s",
|
|
99
|
+
server.name,
|
|
100
|
+
exc,
|
|
101
|
+
)
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._status[server.name] = ServerStatus.UNHEALTHY
|
|
104
|
+
self._warn(f"MCP server {server.name!r} failed to start: {exc}")
|
|
105
|
+
|
|
106
|
+
def _start_one(self, server: MCPServerConfig) -> None:
|
|
107
|
+
try:
|
|
108
|
+
client = self._build_client(server)
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
_log.warning(
|
|
111
|
+
"MCP transport construction failed for %r: %s", server.name, exc
|
|
112
|
+
)
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._status[server.name] = ServerStatus.UNHEALTHY
|
|
115
|
+
self._warn(f"MCP server {server.name!r} transport setup failed: {exc}")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
client.start(timeout=server.start_timeout)
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
_log.warning("MCP server %r handshake failed: %s", server.name, exc)
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._status[server.name] = ServerStatus.UNHEALTHY
|
|
124
|
+
self._warn(f"MCP server {server.name!r} unhealthy: {exc}")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
with self._lock:
|
|
128
|
+
self._clients[server.name] = client
|
|
129
|
+
self._status[server.name] = ServerStatus.RUNNING
|
|
130
|
+
|
|
131
|
+
def _build_client(self, server: MCPServerConfig) -> MCPClient:
|
|
132
|
+
"""Construct a transport + MCPClient for ``server``.
|
|
133
|
+
|
|
134
|
+
Extracted from ``_start_one`` so ``reload`` can rebuild a server using
|
|
135
|
+
the exact same wiring. Raises whatever the transport / config layer
|
|
136
|
+
raises — the caller decides how to mark the server.
|
|
137
|
+
"""
|
|
138
|
+
transport = self._construct_transport(server)
|
|
139
|
+
# Register the proactive disconnect hook so the manager learns about
|
|
140
|
+
# subprocess death / SSE stream loss the moment the reader thread sees
|
|
141
|
+
# it — without waiting for the next call to surface the failure.
|
|
142
|
+
transport.set_disconnect_handler(
|
|
143
|
+
lambda reason, _name=server.name: self._on_disconnect(_name, reason)
|
|
144
|
+
)
|
|
145
|
+
return MCPClient(server, transport)
|
|
146
|
+
|
|
147
|
+
def _on_disconnect(self, name: str, reason: str) -> None:
|
|
148
|
+
"""Mark a server unhealthy and surface the event to the user immediately.
|
|
149
|
+
|
|
150
|
+
Called by transport reader threads on unexpected disconnect (EOF,
|
|
151
|
+
broken pipe, SSE stream break). Idempotent: if the server is already
|
|
152
|
+
non-RUNNING, the console / notifier still fire so the user always sees
|
|
153
|
+
the message at least once per real failure.
|
|
154
|
+
"""
|
|
155
|
+
with self._lock:
|
|
156
|
+
self._status[name] = ServerStatus.UNHEALTHY
|
|
157
|
+
self._clients.pop(name, None)
|
|
158
|
+
message = f"MCP server {name!r} disconnected: {reason}"
|
|
159
|
+
if self._console is not None:
|
|
160
|
+
try:
|
|
161
|
+
self._console.print_error(message)
|
|
162
|
+
except Exception: # pragma: no cover — console must never crash reader
|
|
163
|
+
pass
|
|
164
|
+
if self._notifier is not None:
|
|
165
|
+
try:
|
|
166
|
+
self._notifier.notify(f"mcp:{name}", message)
|
|
167
|
+
except Exception: # pragma: no cover — notification must never crash
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
def get_client(self, name: str) -> MCPClient | None:
|
|
171
|
+
"""Return the running client for ``name`` or ``None`` if it isn't healthy."""
|
|
172
|
+
with self._lock:
|
|
173
|
+
status = self._status.get(name)
|
|
174
|
+
if status != ServerStatus.RUNNING:
|
|
175
|
+
return None
|
|
176
|
+
return self._clients.get(name)
|
|
177
|
+
|
|
178
|
+
def get_status(self, name: str) -> ServerStatus | None:
|
|
179
|
+
with self._lock:
|
|
180
|
+
return self._status.get(name)
|
|
181
|
+
|
|
182
|
+
def iter_running_clients(self) -> Iterator[tuple[str, MCPClient]]:
|
|
183
|
+
"""Yield ``(name, client)`` pairs only for servers currently RUNNING."""
|
|
184
|
+
with self._lock:
|
|
185
|
+
snapshot = [
|
|
186
|
+
(name, client)
|
|
187
|
+
for name, client in self._clients.items()
|
|
188
|
+
if self._status.get(name) == ServerStatus.RUNNING
|
|
189
|
+
]
|
|
190
|
+
yield from snapshot
|
|
191
|
+
|
|
192
|
+
def reload(self, name: str) -> None:
|
|
193
|
+
"""Tear down ``name`` and rebuild it from the current config entry.
|
|
194
|
+
|
|
195
|
+
Failure path follows the fleet-wide convention: the old client is
|
|
196
|
+
dropped, status becomes ``UNHEALTHY``, and the exception is re-raised
|
|
197
|
+
so the REPL handler can render a message. The config file is NOT
|
|
198
|
+
re-read — config hot-reload is intentionally out of scope for v1.
|
|
199
|
+
"""
|
|
200
|
+
server_cfg = next(
|
|
201
|
+
(s for s in self._config.servers if s.name == name),
|
|
202
|
+
None,
|
|
203
|
+
)
|
|
204
|
+
if server_cfg is None:
|
|
205
|
+
raise MCPError(f"MCP server {name!r} is not in config")
|
|
206
|
+
|
|
207
|
+
with self._lock:
|
|
208
|
+
old_client = self._clients.pop(name, None)
|
|
209
|
+
self._status[name] = ServerStatus.STARTING
|
|
210
|
+
|
|
211
|
+
if old_client is not None:
|
|
212
|
+
try:
|
|
213
|
+
old_client.close()
|
|
214
|
+
except Exception as exc: # pragma: no cover — close is idempotent
|
|
215
|
+
_log.warning(
|
|
216
|
+
"MCP server %r old client close failed during reload: %s",
|
|
217
|
+
name,
|
|
218
|
+
exc,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
new_client = self._build_client(server_cfg)
|
|
223
|
+
new_client.start(timeout=server_cfg.start_timeout)
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
_log.warning("MCP server %r reload failed: %s", name, exc)
|
|
226
|
+
with self._lock:
|
|
227
|
+
self._status[name] = ServerStatus.UNHEALTHY
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
with self._lock:
|
|
231
|
+
self._clients[name] = new_client
|
|
232
|
+
self._status[name] = ServerStatus.RUNNING
|
|
233
|
+
|
|
234
|
+
def summarize(self) -> list[dict[str, Any]]:
|
|
235
|
+
"""Return a per-server status dict for the ``/mcp status`` REPL command.
|
|
236
|
+
|
|
237
|
+
Server order follows ``config.servers`` (insertion order in TOML), not
|
|
238
|
+
the internal ``_clients`` dict — that way the listing stays stable
|
|
239
|
+
across reloads. Tool / prompt counts read the client caches directly;
|
|
240
|
+
they are zero for non-running servers.
|
|
241
|
+
"""
|
|
242
|
+
out: list[dict[str, Any]] = []
|
|
243
|
+
with self._lock:
|
|
244
|
+
for server in self._config.servers:
|
|
245
|
+
name = server.name
|
|
246
|
+
status = self._status.get(name, ServerStatus.STOPPED)
|
|
247
|
+
client = self._clients.get(name)
|
|
248
|
+
is_running = status == ServerStatus.RUNNING and client is not None
|
|
249
|
+
tool_count = 0
|
|
250
|
+
prompt_count = 0
|
|
251
|
+
has_resources = False
|
|
252
|
+
if is_running and client is not None:
|
|
253
|
+
cached_tools = getattr(client, "_tools_cache", None)
|
|
254
|
+
if isinstance(cached_tools, list):
|
|
255
|
+
tool_count = len(cached_tools)
|
|
256
|
+
cached_prompts = getattr(client, "_prompts", None)
|
|
257
|
+
if isinstance(cached_prompts, list):
|
|
258
|
+
prompt_count = len(cached_prompts)
|
|
259
|
+
has_resources = client.has_capability("resources")
|
|
260
|
+
out.append(
|
|
261
|
+
{
|
|
262
|
+
"name": name,
|
|
263
|
+
"status": status.value,
|
|
264
|
+
"tool_count": tool_count,
|
|
265
|
+
"has_resources": has_resources,
|
|
266
|
+
"prompt_count": prompt_count,
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
return out
|
|
270
|
+
|
|
271
|
+
def close_all(self) -> None:
|
|
272
|
+
"""Tear down every managed client. Idempotent; safe to call on exit."""
|
|
273
|
+
with self._lock:
|
|
274
|
+
clients = list(self._clients.items())
|
|
275
|
+
self._clients.clear()
|
|
276
|
+
for name, client in clients:
|
|
277
|
+
try:
|
|
278
|
+
client.close()
|
|
279
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
280
|
+
_log.warning("MCP server %r close failed: %s", name, exc)
|
|
281
|
+
with self._lock:
|
|
282
|
+
self._status[name] = ServerStatus.STOPPED
|
|
283
|
+
|
|
284
|
+
def _construct_transport(self, server: MCPServerConfig) -> Transport:
|
|
285
|
+
if server.transport == "stdio":
|
|
286
|
+
command = list(server.command) + list(server.args)
|
|
287
|
+
return StdioTransport(command, env=server.env or None, cwd=server.cwd)
|
|
288
|
+
if server.transport == "http_legacy":
|
|
289
|
+
if not server.url:
|
|
290
|
+
raise MCPError(
|
|
291
|
+
f"mcp.servers[{server.name}].url required for http_legacy"
|
|
292
|
+
)
|
|
293
|
+
return HttpLegacyTransport(
|
|
294
|
+
server.url,
|
|
295
|
+
headers=server.headers,
|
|
296
|
+
start_timeout=server.start_timeout,
|
|
297
|
+
)
|
|
298
|
+
if server.transport == "http_streamable":
|
|
299
|
+
if not server.url:
|
|
300
|
+
raise MCPError(
|
|
301
|
+
f"mcp.servers[{server.name}].url required for http_streamable"
|
|
302
|
+
)
|
|
303
|
+
return HttpStreamableTransport(
|
|
304
|
+
server.url,
|
|
305
|
+
headers=server.headers,
|
|
306
|
+
start_timeout=server.start_timeout,
|
|
307
|
+
)
|
|
308
|
+
raise MCPError(
|
|
309
|
+
f"mcp.servers[{server.name}].transport unsupported: {server.transport!r}"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def _warn(self, message: str) -> None:
|
|
313
|
+
if self._console is None:
|
|
314
|
+
return
|
|
315
|
+
try:
|
|
316
|
+
self._console.print_error(message)
|
|
317
|
+
except Exception: # pragma: no cover — console must never break boot
|
|
318
|
+
pass
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 message types and codec for MCP.
|
|
2
|
+
|
|
3
|
+
MCP `2025-06-18` removed batch support, so this module deliberately does not
|
|
4
|
+
implement JSON-RPC batch arrays — top-level arrays are rejected by callers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import MCPProtocolError
|
|
16
|
+
|
|
17
|
+
# JSON-RPC 2.0 standard error codes.
|
|
18
|
+
PARSE_ERROR = -32700
|
|
19
|
+
INVALID_REQUEST = -32600
|
|
20
|
+
METHOD_NOT_FOUND = -32601
|
|
21
|
+
INVALID_PARAMS = -32602
|
|
22
|
+
INTERNAL_ERROR = -32603
|
|
23
|
+
# Server error range (application-defined): -32000 .. -32099.
|
|
24
|
+
SERVER_ERROR_MIN = -32099
|
|
25
|
+
SERVER_ERROR_MAX = -32000
|
|
26
|
+
|
|
27
|
+
JSONRPC_VERSION = "2.0"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class ErrorObject:
|
|
32
|
+
"""JSON-RPC error object embedded in a Response."""
|
|
33
|
+
|
|
34
|
+
code: int
|
|
35
|
+
message: str
|
|
36
|
+
data: Any = None
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
payload: dict[str, Any] = {"code": self.code, "message": self.message}
|
|
40
|
+
if self.data is not None:
|
|
41
|
+
payload["data"] = self.data
|
|
42
|
+
return payload
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class Request:
|
|
47
|
+
"""Outbound or inbound JSON-RPC request (has an id)."""
|
|
48
|
+
|
|
49
|
+
id: int
|
|
50
|
+
method: str
|
|
51
|
+
params: dict[str, Any] | None = None
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
payload: dict[str, Any] = {
|
|
55
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
56
|
+
"id": self.id,
|
|
57
|
+
"method": self.method,
|
|
58
|
+
}
|
|
59
|
+
if self.params is not None:
|
|
60
|
+
payload["params"] = self.params
|
|
61
|
+
return payload
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class Response:
|
|
66
|
+
"""JSON-RPC response: either result or error, never both."""
|
|
67
|
+
|
|
68
|
+
id: int | None
|
|
69
|
+
result: Any = None
|
|
70
|
+
error: ErrorObject | None = None
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict[str, Any]:
|
|
73
|
+
payload: dict[str, Any] = {"jsonrpc": JSONRPC_VERSION, "id": self.id}
|
|
74
|
+
if self.error is not None:
|
|
75
|
+
payload["error"] = self.error.to_dict()
|
|
76
|
+
else:
|
|
77
|
+
payload["result"] = self.result
|
|
78
|
+
return payload
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(slots=True)
|
|
82
|
+
class Notification:
|
|
83
|
+
"""JSON-RPC notification: a request without an id (never gets a reply)."""
|
|
84
|
+
|
|
85
|
+
method: str
|
|
86
|
+
params: dict[str, Any] | None = field(default=None)
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
payload: dict[str, Any] = {"jsonrpc": JSONRPC_VERSION, "method": self.method}
|
|
90
|
+
if self.params is not None:
|
|
91
|
+
payload["params"] = self.params
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_id_counter = itertools.count(1)
|
|
96
|
+
_id_lock = threading.Lock()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def new_request_id() -> int:
|
|
100
|
+
"""Return a monotonically increasing request id (threadsafe)."""
|
|
101
|
+
with _id_lock:
|
|
102
|
+
return next(_id_counter)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def encode_message(msg: Request | Response | Notification) -> str:
|
|
106
|
+
"""Serialize a message to a single-line JSON string (no trailing newline).
|
|
107
|
+
|
|
108
|
+
Callers are responsible for appending the framing delimiter (e.g. `\\n` for
|
|
109
|
+
stdio NDJSON). The compact separators guarantee the encoded form contains
|
|
110
|
+
no embedded newline, which is required by MCP stdio framing.
|
|
111
|
+
"""
|
|
112
|
+
line = json.dumps(msg.to_dict(), ensure_ascii=False, separators=(",", ":"))
|
|
113
|
+
if "\n" in line: # pragma: no cover — defensive; json never emits raw \n
|
|
114
|
+
raise MCPProtocolError("encoded JSON-RPC message contains embedded newline")
|
|
115
|
+
return line
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def decode_message(line: str) -> Request | Response | Notification:
|
|
119
|
+
"""Parse a single JSON-RPC envelope.
|
|
120
|
+
|
|
121
|
+
Raises `MCPProtocolError` for malformed input, batch arrays (unsupported in
|
|
122
|
+
MCP 2025-06-18), or envelopes missing required JSON-RPC fields.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
payload = json.loads(line)
|
|
126
|
+
except json.JSONDecodeError as exc:
|
|
127
|
+
raise MCPProtocolError(f"invalid JSON: {exc.msg}") from exc
|
|
128
|
+
|
|
129
|
+
if isinstance(payload, list):
|
|
130
|
+
raise MCPProtocolError(
|
|
131
|
+
"JSON-RPC batch arrays are not supported (MCP 2025-06-18)"
|
|
132
|
+
)
|
|
133
|
+
if not isinstance(payload, dict):
|
|
134
|
+
raise MCPProtocolError(
|
|
135
|
+
f"JSON-RPC envelope must be an object, got {type(payload).__name__}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if payload.get("jsonrpc") != JSONRPC_VERSION:
|
|
139
|
+
raise MCPProtocolError(
|
|
140
|
+
f"missing or wrong jsonrpc version: {payload.get('jsonrpc')!r}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if "method" in payload:
|
|
144
|
+
method = payload["method"]
|
|
145
|
+
if not isinstance(method, str):
|
|
146
|
+
raise MCPProtocolError(
|
|
147
|
+
f"method must be a string, got {type(method).__name__}"
|
|
148
|
+
)
|
|
149
|
+
params = payload.get("params")
|
|
150
|
+
if params is not None and not isinstance(params, dict):
|
|
151
|
+
raise MCPProtocolError("params must be an object")
|
|
152
|
+
if "id" in payload:
|
|
153
|
+
msg_id = payload["id"]
|
|
154
|
+
if not isinstance(msg_id, int):
|
|
155
|
+
raise MCPProtocolError(f"request id must be an integer, got {msg_id!r}")
|
|
156
|
+
return Request(id=msg_id, method=method, params=params)
|
|
157
|
+
return Notification(method=method, params=params)
|
|
158
|
+
|
|
159
|
+
# Response: has id + (result xor error)
|
|
160
|
+
if "id" not in payload:
|
|
161
|
+
raise MCPProtocolError("envelope has neither method nor id")
|
|
162
|
+
msg_id = payload["id"]
|
|
163
|
+
if msg_id is not None and not isinstance(msg_id, int):
|
|
164
|
+
raise MCPProtocolError(f"response id must be int or null, got {msg_id!r}")
|
|
165
|
+
|
|
166
|
+
has_result = "result" in payload
|
|
167
|
+
has_error = "error" in payload
|
|
168
|
+
if has_result == has_error:
|
|
169
|
+
raise MCPProtocolError(
|
|
170
|
+
"response must contain exactly one of 'result' or 'error'"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if has_error:
|
|
174
|
+
err = payload["error"]
|
|
175
|
+
if not isinstance(err, dict):
|
|
176
|
+
raise MCPProtocolError("error must be an object")
|
|
177
|
+
try:
|
|
178
|
+
code = int(err["code"])
|
|
179
|
+
message = str(err["message"])
|
|
180
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
181
|
+
raise MCPProtocolError(f"malformed error object: {exc}") from exc
|
|
182
|
+
return Response(
|
|
183
|
+
id=msg_id,
|
|
184
|
+
error=ErrorObject(code=code, message=message, data=err.get("data")),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return Response(id=msg_id, result=payload["result"])
|