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,69 @@
1
+ """MCP (Model Context Protocol) client subpackage.
2
+
3
+ PR1 delivered the transport + protocol scaffolding. PR2 adds the client
4
+ lifecycle (``MCPClient``), multi-server orchestration (``MCPManager``), and
5
+ the BareAgent tool registry shims (``build_mcp_tool_schemas`` /
6
+ ``build_mcp_handlers``). Resources / prompts / multimodal passthrough /
7
+ REPL plumbing / atexit cleanup arrive in subsequent PRs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .client import MCPClient
13
+ from .config import MCPConfig, MCPServerConfig, parse_mcp_config
14
+ from .errors import (
15
+ MCPCallError,
16
+ MCPError,
17
+ MCPHandshakeError,
18
+ MCPProtocolError,
19
+ MCPTransportError,
20
+ )
21
+ from .manager import MCPManager, ServerStatus
22
+ from .protocol import (
23
+ ErrorObject,
24
+ Notification,
25
+ Request,
26
+ Response,
27
+ decode_message,
28
+ encode_message,
29
+ new_request_id,
30
+ )
31
+ from .registry import (
32
+ build_mcp_handlers,
33
+ build_mcp_tool_schemas,
34
+ mcp_tool_name,
35
+ )
36
+ from .transport import (
37
+ HttpLegacyTransport,
38
+ HttpStreamableTransport,
39
+ StdioTransport,
40
+ Transport,
41
+ )
42
+
43
+ __all__ = [
44
+ "ErrorObject",
45
+ "HttpLegacyTransport",
46
+ "HttpStreamableTransport",
47
+ "MCPCallError",
48
+ "MCPClient",
49
+ "MCPConfig",
50
+ "MCPError",
51
+ "MCPHandshakeError",
52
+ "MCPManager",
53
+ "MCPProtocolError",
54
+ "MCPServerConfig",
55
+ "MCPTransportError",
56
+ "Notification",
57
+ "Request",
58
+ "Response",
59
+ "ServerStatus",
60
+ "StdioTransport",
61
+ "Transport",
62
+ "build_mcp_handlers",
63
+ "build_mcp_tool_schemas",
64
+ "decode_message",
65
+ "encode_message",
66
+ "mcp_tool_name",
67
+ "new_request_id",
68
+ "parse_mcp_config",
69
+ ]
bareagent/mcp/_sse.py ADDED
@@ -0,0 +1,69 @@
1
+ """Minimal Server-Sent Events parser per WHATWG.
2
+
3
+ Used by both HTTP transports. Pure-functional: takes an iterable of already-
4
+ split lines (no trailing newlines) and yields event dicts. Last-Event-ID
5
+ reconnect is deferred to a later PR.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable, Iterator
11
+ from typing import TypedDict
12
+
13
+ _BOM = ""
14
+
15
+
16
+ class SSEEvent(TypedDict):
17
+ event: str
18
+ data: str
19
+ id: str
20
+ retry: int | None
21
+
22
+
23
+ def parse_sse_stream(lines: Iterable[str]) -> Iterator[SSEEvent]:
24
+ """Yield SSE events from already-split lines.
25
+
26
+ Caller must split on `\\r?\\n` and not include trailing line terminators
27
+ (httpx `iter_lines()` already does this). Empty line dispatches an event.
28
+ """
29
+ event_type = ""
30
+ data: list[str] = []
31
+ last_id = ""
32
+ retry: int | None = None
33
+ first = True
34
+
35
+ for raw in lines:
36
+ if first:
37
+ first = False
38
+ if raw.startswith(_BOM):
39
+ raw = raw[1:]
40
+ if raw == "":
41
+ if data:
42
+ yield SSEEvent(
43
+ event=event_type or "message",
44
+ data="\n".join(data),
45
+ id=last_id,
46
+ retry=retry,
47
+ )
48
+ event_type = ""
49
+ data = []
50
+ retry = None
51
+ continue
52
+ if raw.startswith(":"):
53
+ continue # comment / heartbeat
54
+ field, sep, value = raw.partition(":")
55
+ if not sep:
56
+ # No colon means the whole line is the field name with empty value.
57
+ field, value = raw, ""
58
+ if value.startswith(" "):
59
+ value = value[1:]
60
+ if field == "event":
61
+ event_type = value
62
+ elif field == "data":
63
+ data.append(value)
64
+ elif field == "id":
65
+ if "\x00" not in value:
66
+ last_id = value
67
+ elif field == "retry" and value.isdigit():
68
+ retry = int(value)
69
+ # unknown fields are silently ignored per spec
@@ -0,0 +1,341 @@
1
+ """Single-server MCP client: initialize handshake + tools / resources / prompts.
2
+
3
+ The client owns the JSON-RPC dialogue but not the connection: a constructed
4
+ ``Transport`` is passed in by the manager so unit tests can substitute a fake.
5
+ PR3 adds resources (``resources/list`` + ``resources/read``) and prompts
6
+ (``prompts/list`` cached at handshake time + ``prompts/get`` on demand). Tools
7
+ remain lazy, as in PR2.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+ from threading import Lock
15
+ from typing import Any
16
+
17
+ from .config import MCPServerConfig
18
+ from .errors import MCPCallError, MCPHandshakeError, MCPProtocolError, MCPTransportError
19
+ from .protocol import Notification, Request, new_request_id
20
+ from .transport.base import Transport
21
+
22
+ _log = logging.getLogger(__name__)
23
+
24
+ # Latest MCP version BareAgent understands. Servers may negotiate down.
25
+ _CLIENT_PROTOCOL_VERSION = "2025-06-18"
26
+ _CLIENT_INFO = {"name": "BareAgent", "version": "0.1.0"}
27
+
28
+ # PRD: only ``[a-zA-Z0-9_-]`` survive — prompt names with other characters can't
29
+ # safely round-trip through the ``/mcp:<server>:<prompt>`` REPL syntax, so the
30
+ # client drops them at catalog time and warns.
31
+ _PROMPT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
32
+
33
+
34
+ class MCPClient:
35
+ """One MCP server connection.
36
+
37
+ Lifecycle: ``start()`` runs the initialize handshake; ``list_tools()`` and
38
+ ``call_tool()`` are the operational surface; ``close()`` shuts down the
39
+ transport. Methods are threadsafe — the underlying transport already
40
+ serializes writes, and the tool cache is guarded by a local lock.
41
+ """
42
+
43
+ def __init__(self, config: MCPServerConfig, transport: Transport) -> None:
44
+ self._config = config
45
+ self._transport = transport
46
+ self._cache_lock = Lock()
47
+ self._tools_cache: list[dict[str, Any]] | None = None
48
+ self._prompts: list[dict[str, Any]] | None = None
49
+ self._server_info: dict[str, Any] = {}
50
+ self._server_capabilities: dict[str, Any] = {}
51
+ self._negotiated_version: str | None = None
52
+ self._started = False
53
+
54
+ @property
55
+ def name(self) -> str:
56
+ return self._config.name
57
+
58
+ @property
59
+ def server_info(self) -> dict[str, Any]:
60
+ return dict(self._server_info)
61
+
62
+ @property
63
+ def server_capabilities(self) -> dict[str, Any]:
64
+ return dict(self._server_capabilities)
65
+
66
+ def start(self, timeout: float) -> None:
67
+ """Open the transport and run the initialize handshake.
68
+
69
+ Raises ``MCPHandshakeError`` on timeout, JSON-RPC error, or any
70
+ transport-level failure during handshake. Successful return guarantees
71
+ the server has acknowledged ``notifications/initialized``.
72
+ """
73
+ if self._started:
74
+ raise MCPHandshakeError(f"client {self._config.name!r} already started")
75
+ try:
76
+ self._transport.start()
77
+ except MCPTransportError as exc:
78
+ raise MCPHandshakeError(f"transport start failed: {exc}") from exc
79
+
80
+ init_request = Request(
81
+ id=new_request_id(),
82
+ method="initialize",
83
+ params={
84
+ "protocolVersion": _CLIENT_PROTOCOL_VERSION,
85
+ "capabilities": {}, # PR2: client offers no capabilities
86
+ "clientInfo": _CLIENT_INFO,
87
+ },
88
+ )
89
+ try:
90
+ response = self._transport.request(init_request, timeout=timeout)
91
+ except (MCPTransportError, MCPProtocolError) as exc:
92
+ self._safe_close()
93
+ raise MCPHandshakeError(f"initialize failed: {exc}") from exc
94
+
95
+ if response.error is not None:
96
+ self._safe_close()
97
+ raise MCPHandshakeError(
98
+ f"initialize returned error: {response.error.code} {response.error.message}"
99
+ )
100
+
101
+ result = response.result if isinstance(response.result, dict) else {}
102
+ self._negotiated_version = result.get("protocolVersion")
103
+ info = result.get("serverInfo")
104
+ if isinstance(info, dict):
105
+ self._server_info = info
106
+ caps = result.get("capabilities")
107
+ if isinstance(caps, dict):
108
+ self._server_capabilities = caps
109
+
110
+ try:
111
+ self._transport.notify(Notification(method="notifications/initialized"))
112
+ except MCPTransportError as exc:
113
+ self._safe_close()
114
+ raise MCPHandshakeError(
115
+ f"failed to send initialized notification: {exc}"
116
+ ) from exc
117
+
118
+ self._started = True
119
+
120
+ # Eagerly cache the prompts catalog if the server declared the capability.
121
+ # Failures here must not undo the handshake — log + fall back to empty.
122
+ if self.has_capability("prompts"):
123
+ try:
124
+ self._prompts = self._fetch_prompts(timeout=timeout)
125
+ except (MCPCallError, MCPProtocolError, MCPTransportError) as exc:
126
+ _log.warning(
127
+ "MCP server %r prompts/list failed during start: %s",
128
+ self._config.name,
129
+ exc,
130
+ )
131
+ self._prompts = []
132
+
133
+ def list_tools(self, *, timeout: float = 30.0) -> list[dict[str, Any]]:
134
+ """Return cached or freshly fetched ``tools/list`` entries.
135
+
136
+ Each entry preserves the raw ``name`` / ``description`` / ``inputSchema``
137
+ from the server (the registry layer adds the ``mcp__<server>__`` prefix
138
+ when assembling BareAgent schemas).
139
+ """
140
+ with self._cache_lock:
141
+ if self._tools_cache is not None:
142
+ return list(self._tools_cache)
143
+
144
+ if "tools" not in self._server_capabilities:
145
+ # Server didn't declare tools capability — skip the call, cache empty.
146
+ # Note: an empty dict ``{}`` still means "supported, no sub-capabilities",
147
+ # so presence (not truthiness) is the right check.
148
+ with self._cache_lock:
149
+ self._tools_cache = []
150
+ return []
151
+
152
+ request = Request(id=new_request_id(), method="tools/list")
153
+ response = self._transport.request(request, timeout=timeout)
154
+ if response.error is not None:
155
+ raise MCPCallError(
156
+ f"MCP Error: {response.error.code} {response.error.message}"
157
+ )
158
+ result = response.result if isinstance(response.result, dict) else {}
159
+ tools = result.get("tools")
160
+ if not isinstance(tools, list):
161
+ tools = []
162
+ # Filter out anything missing the required fields.
163
+ cleaned: list[dict[str, Any]] = []
164
+ for tool in tools:
165
+ if not isinstance(tool, dict):
166
+ continue
167
+ name = tool.get("name")
168
+ if not isinstance(name, str) or not name:
169
+ continue
170
+ cleaned.append(tool)
171
+ with self._cache_lock:
172
+ self._tools_cache = cleaned
173
+ return list(cleaned)
174
+
175
+ def call_tool(
176
+ self,
177
+ name: str,
178
+ arguments: dict[str, Any] | None = None,
179
+ *,
180
+ timeout: float = 60.0,
181
+ ) -> dict[str, Any]:
182
+ """Invoke a tool on the server and return the raw ``result`` object.
183
+
184
+ ``isError: true`` is intentionally NOT raised — the registry layer
185
+ formats it into a plain ``Error: ...`` string so the LLM sees the
186
+ failure as data and can retry. JSON-RPC protocol errors do raise
187
+ (``MCPCallError`` with the ``MCP Error: <code> <message>`` prefix).
188
+ """
189
+ request = Request(
190
+ id=new_request_id(),
191
+ method="tools/call",
192
+ params={"name": name, "arguments": arguments or {}},
193
+ )
194
+ response = self._transport.request(request, timeout=timeout)
195
+ if response.error is not None:
196
+ raise MCPCallError(
197
+ f"MCP Error: {response.error.code} {response.error.message}"
198
+ )
199
+ result = response.result
200
+ if not isinstance(result, dict):
201
+ return {"content": [], "isError": False}
202
+ return result
203
+
204
+ def has_capability(self, name: str) -> bool:
205
+ """Return True if the server declared the named top-level capability.
206
+
207
+ Per MCP 2025-06-18, ``capabilities`` is a flat object whose keys (``tools``,
208
+ ``resources``, ``prompts``, ``logging``, …) signal *presence*; sub-flags
209
+ like ``{"prompts": {"listChanged": true}}`` are advisory. PR3 only checks
210
+ key presence.
211
+ """
212
+ return name in self._server_capabilities
213
+
214
+ def list_prompts(self) -> list[dict[str, Any]]:
215
+ """Return the prompts catalog cached during ``start()``.
216
+
217
+ Never re-fetches: the catalog is populated at handshake time, and if the
218
+ server didn't declare the prompts capability the result is the empty
219
+ list. (Prompts can change via ``notifications/prompts/list_changed`` —
220
+ that subscription is deferred to a later PR.)
221
+ """
222
+ return list(self._prompts or [])
223
+
224
+ def get_prompt(
225
+ self,
226
+ name: str,
227
+ arguments: dict[str, Any] | None = None,
228
+ *,
229
+ timeout: float = 30.0,
230
+ ) -> dict[str, Any]:
231
+ """Invoke ``prompts/get`` and return the raw result.
232
+
233
+ The result typically contains a ``messages`` array shaped for the LLM
234
+ ({role, content}). JSON-RPC errors raise ``MCPCallError`` so the REPL
235
+ dispatcher can render the message verbatim; per-message field validation
236
+ is left to the caller (the spec allows server-specific extensions).
237
+ """
238
+ request = Request(
239
+ id=new_request_id(),
240
+ method="prompts/get",
241
+ params={"name": name, "arguments": arguments or {}},
242
+ )
243
+ response = self._transport.request(request, timeout=timeout)
244
+ if response.error is not None:
245
+ raise MCPCallError(
246
+ f"MCP Error: {response.error.code} {response.error.message}"
247
+ )
248
+ result = response.result
249
+ if not isinstance(result, dict):
250
+ return {"messages": []}
251
+ return result
252
+
253
+ def list_resources(self, *, timeout: float = 30.0) -> list[dict[str, Any]]:
254
+ """Fetch ``resources/list`` fresh — not cached because resources are dynamic.
255
+
256
+ Returns the raw ``resources`` array (entries typically have ``uri`` /
257
+ ``name`` / ``description`` / ``mimeType``). Servers that omit the
258
+ capability still get the call attempted by the registry handler; the
259
+ handler is expected to guard the call site.
260
+ """
261
+ request = Request(id=new_request_id(), method="resources/list")
262
+ response = self._transport.request(request, timeout=timeout)
263
+ if response.error is not None:
264
+ raise MCPCallError(
265
+ f"MCP Error: {response.error.code} {response.error.message}"
266
+ )
267
+ result = response.result if isinstance(response.result, dict) else {}
268
+ resources = result.get("resources")
269
+ if not isinstance(resources, list):
270
+ return []
271
+ return [item for item in resources if isinstance(item, dict)]
272
+
273
+ def read_resource(self, uri: str, *, timeout: float = 60.0) -> dict[str, Any]:
274
+ """Invoke ``resources/read`` and return the raw result.
275
+
276
+ ``contents`` is preserved verbatim (each block has ``type`` /
277
+ ``text`` / ``blob`` / ``uri`` / ``mimeType`` depending on the source);
278
+ ``isError: true`` is intentionally not raised — the registry layer
279
+ flattens it into a ``Error: ...`` string for the LLM, mirroring the
280
+ ``call_tool`` convention.
281
+ """
282
+ request = Request(
283
+ id=new_request_id(),
284
+ method="resources/read",
285
+ params={"uri": uri},
286
+ )
287
+ response = self._transport.request(request, timeout=timeout)
288
+ if response.error is not None:
289
+ raise MCPCallError(
290
+ f"MCP Error: {response.error.code} {response.error.message}"
291
+ )
292
+ result = response.result
293
+ if not isinstance(result, dict):
294
+ return {"contents": [], "isError": False}
295
+ return result
296
+
297
+ def close(self) -> None:
298
+ """Tear down the transport. Idempotent."""
299
+ self._safe_close()
300
+
301
+ def is_alive(self) -> bool:
302
+ return self._started and self._transport.is_alive()
303
+
304
+ def _fetch_prompts(self, *, timeout: float) -> list[dict[str, Any]]:
305
+ request = Request(id=new_request_id(), method="prompts/list")
306
+ response = self._transport.request(request, timeout=timeout)
307
+ if response.error is not None:
308
+ raise MCPCallError(
309
+ f"MCP Error: {response.error.code} {response.error.message}"
310
+ )
311
+ result = response.result if isinstance(response.result, dict) else {}
312
+ prompts = result.get("prompts")
313
+ if not isinstance(prompts, list):
314
+ return []
315
+ cleaned: list[dict[str, Any]] = []
316
+ for prompt in prompts:
317
+ if not isinstance(prompt, dict):
318
+ continue
319
+ name = prompt.get("name")
320
+ if not isinstance(name, str) or not name:
321
+ continue
322
+ if not _PROMPT_NAME_RE.match(name):
323
+ # Names outside [a-zA-Z0-9_-] would collide with the
324
+ # ``/mcp:<server>:<prompt>`` REPL syntax. Skip + warn rather
325
+ # than silently surface unusable entries.
326
+ _log.warning(
327
+ "MCP server %r prompt %r contains characters outside "
328
+ "[a-zA-Z0-9_-]; skipping",
329
+ self._config.name,
330
+ name,
331
+ )
332
+ continue
333
+ cleaned.append(prompt)
334
+ return cleaned
335
+
336
+ def _safe_close(self) -> None:
337
+ try:
338
+ self._transport.close()
339
+ except Exception:
340
+ # Closing must never raise — keeps manager shutdown loops simple.
341
+ pass
@@ -0,0 +1,169 @@
1
+ """MCP server configuration parsing.
2
+
3
+ Reads a `[mcp]` block (plus `[[mcp.servers]]` array) from a TOML-derived dict
4
+ and returns typed dataclasses. The transport field selects which server-side
5
+ fields are required.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from .errors import MCPError
14
+
15
+ _VALID_TRANSPORTS = ("stdio", "http_legacy", "http_streamable")
16
+
17
+ # 256 KiB. The text result of a single MCP tool call lands directly in the
18
+ # next LLM turn; anything significantly larger blows past the typical
19
+ # context window before the model even sees the rest of the turn.
20
+ _DEFAULT_MAX_TEXT_BYTES = 262_144 # 256 KiB
21
+ _DEFAULT_MAX_BINARY_BYTES = 5_242_880 # 5 MiB
22
+ _DEFAULT_START_TIMEOUT = 10.0
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class MCPServerConfig:
27
+ """One MCP server entry. Required fields depend on transport."""
28
+
29
+ name: str
30
+ transport: str
31
+ # stdio fields:
32
+ command: list[str] = field(default_factory=list)
33
+ args: list[str] = field(default_factory=list)
34
+ env: dict[str, str] = field(default_factory=dict)
35
+ cwd: str | None = None
36
+ # http_* fields:
37
+ url: str | None = None
38
+ headers: dict[str, str] = field(default_factory=dict)
39
+ # shared:
40
+ start_timeout: float = _DEFAULT_START_TIMEOUT
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class MCPConfig:
45
+ """Top-level MCP configuration."""
46
+
47
+ servers: list[MCPServerConfig] = field(default_factory=list)
48
+ max_result_text_bytes: int = _DEFAULT_MAX_TEXT_BYTES
49
+ max_result_binary_bytes: int = _DEFAULT_MAX_BINARY_BYTES
50
+ start_timeout: float = _DEFAULT_START_TIMEOUT
51
+
52
+
53
+ def parse_mcp_config(raw: dict[str, Any]) -> MCPConfig:
54
+ """Parse a TOML-derived dict (the `[mcp]` section) into MCPConfig.
55
+
56
+ Accepts either the full document (where `mcp` is a key) or the `[mcp]`
57
+ block itself. Unknown keys are silently ignored to stay forward-compatible.
58
+ """
59
+ if not isinstance(raw, dict):
60
+ raise MCPError(f"mcp config must be a table, got {type(raw).__name__}")
61
+
62
+ block = raw.get("mcp", raw)
63
+ if not isinstance(block, dict):
64
+ raise MCPError("'mcp' must be a table")
65
+
66
+ cfg = MCPConfig(
67
+ max_result_text_bytes=_int(
68
+ block, "max_result_text_bytes", _DEFAULT_MAX_TEXT_BYTES
69
+ ),
70
+ max_result_binary_bytes=_int(
71
+ block, "max_result_binary_bytes", _DEFAULT_MAX_BINARY_BYTES
72
+ ),
73
+ start_timeout=_float(block, "start_timeout", _DEFAULT_START_TIMEOUT),
74
+ )
75
+
76
+ servers_raw = block.get("servers", [])
77
+ if not isinstance(servers_raw, list):
78
+ raise MCPError("'mcp.servers' must be an array of tables")
79
+
80
+ seen: set[str] = set()
81
+ for index, entry in enumerate(servers_raw):
82
+ if not isinstance(entry, dict):
83
+ raise MCPError(f"mcp.servers[{index}] must be a table")
84
+ server = _parse_server(entry, index, default_start_timeout=cfg.start_timeout)
85
+ if server.name in seen:
86
+ raise MCPError(f"duplicate mcp server name: {server.name!r}")
87
+ seen.add(server.name)
88
+ cfg.servers.append(server)
89
+ return cfg
90
+
91
+
92
+ def _parse_server(
93
+ entry: dict[str, Any], index: int, *, default_start_timeout: float
94
+ ) -> MCPServerConfig:
95
+ name = entry.get("name")
96
+ if not isinstance(name, str) or not name:
97
+ raise MCPError(
98
+ f"mcp.servers[{index}].name is required and must be a non-empty string"
99
+ )
100
+
101
+ transport = entry.get("transport")
102
+ if transport not in _VALID_TRANSPORTS:
103
+ raise MCPError(
104
+ f"mcp.servers[{name}].transport must be one of {_VALID_TRANSPORTS}, got {transport!r}"
105
+ )
106
+
107
+ server = MCPServerConfig(
108
+ name=name,
109
+ transport=transport,
110
+ start_timeout=_float(entry, "start_timeout", default_start_timeout),
111
+ )
112
+
113
+ if transport == "stdio":
114
+ command = entry.get("command")
115
+ if isinstance(command, str):
116
+ server.command = [command]
117
+ elif isinstance(command, list) and all(isinstance(s, str) for s in command):
118
+ server.command = list(command)
119
+ else:
120
+ raise MCPError(
121
+ f"mcp.servers[{name}].command is required for stdio transport"
122
+ )
123
+ if not server.command:
124
+ raise MCPError(f"mcp.servers[{name}].command must not be empty")
125
+ args = entry.get("args", [])
126
+ if not isinstance(args, list) or not all(isinstance(s, str) for s in args):
127
+ raise MCPError(f"mcp.servers[{name}].args must be a list of strings")
128
+ server.args = list(args)
129
+ env = entry.get("env", {})
130
+ if not isinstance(env, dict) or not all(
131
+ isinstance(k, str) and isinstance(v, str) for k, v in env.items()
132
+ ):
133
+ raise MCPError(f"mcp.servers[{name}].env must be a string->string table")
134
+ server.env = dict(env)
135
+ cwd = entry.get("cwd")
136
+ if cwd is not None and not isinstance(cwd, str):
137
+ raise MCPError(f"mcp.servers[{name}].cwd must be a string if provided")
138
+ server.cwd = cwd
139
+ else:
140
+ url = entry.get("url")
141
+ if not isinstance(url, str) or not url:
142
+ raise MCPError(
143
+ f"mcp.servers[{name}].url is required for transport {transport!r}"
144
+ )
145
+ server.url = url
146
+ headers = entry.get("headers", {})
147
+ if not isinstance(headers, dict) or not all(
148
+ isinstance(k, str) and isinstance(v, str) for k, v in headers.items()
149
+ ):
150
+ raise MCPError(
151
+ f"mcp.servers[{name}].headers must be a string->string table"
152
+ )
153
+ server.headers = dict(headers)
154
+
155
+ return server
156
+
157
+
158
+ def _int(block: dict[str, Any], key: str, default: int) -> int:
159
+ value = block.get(key, default)
160
+ if not isinstance(value, int) or isinstance(value, bool):
161
+ raise MCPError(f"mcp.{key} must be an integer, got {value!r}")
162
+ return value
163
+
164
+
165
+ def _float(block: dict[str, Any], key: str, default: float) -> float:
166
+ value = block.get(key, default)
167
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
168
+ raise MCPError(f"mcp.{key} must be a number, got {value!r}")
169
+ return float(value)
@@ -0,0 +1,32 @@
1
+ """MCP error hierarchy.
2
+
3
+ Layered failure types: transport (subprocess / socket), protocol (JSON-RPC
4
+ framing / id routing), handshake (initialize lifecycle), and call (tools/call
5
+ returning a JSON-RPC error). Tool execution errors (``result.isError: true``)
6
+ are NOT exceptions — they flow back to the LLM as text via the registry layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class MCPError(Exception):
13
+ """Base class for all MCP-related failures."""
14
+
15
+
16
+ class MCPTransportError(MCPError):
17
+ """Transport-layer failure: connection dropped, framing error, subprocess died."""
18
+
19
+
20
+ class MCPProtocolError(MCPError):
21
+ """JSON-RPC protocol failure: timeout, unknown response id, malformed envelope."""
22
+
23
+
24
+ class MCPHandshakeError(MCPError):
25
+ """Initialize lifecycle failed: timeout, server returned error, or
26
+ incompatible protocol version negotiation."""
27
+
28
+
29
+ class MCPCallError(MCPError):
30
+ """``tools/call`` (or any other request) came back with a JSON-RPC error
31
+ object. The exception message is pre-formatted as ``MCP Error: <code> <message>``
32
+ so registry handlers can return ``str(exc)`` directly to the LLM."""