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,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"])