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,557 @@
1
+ """MCP -> BareAgent tool schema + handler injection.
2
+
3
+ Each running MCP server contributes its tools to the BareAgent tool list under
4
+ the ``mcp__<server>__<tool>`` namespace. ``inputSchema`` is forwarded
5
+ verbatim: research shows real-world servers stick to a small core of standard
6
+ JSON Schema keywords (``$ref`` / ``$defs`` / ``anyOf`` / ``enum`` / ``default``),
7
+ and provider SDKs accept them. The registry deliberately does NOT normalize
8
+ or strip; that is the LLM provider's job.
9
+
10
+ PR3 additionally injects two synthetic tools per server that declares the
11
+ ``resources`` capability: ``mcp__<server>__resource_list`` (no args) and
12
+ ``mcp__<server>__resource_read`` (single ``uri`` arg). LLMs use these to
13
+ discover and fetch MCP resources without needing system-prompt injection.
14
+
15
+ PR5 wires multimodal results through. ``tools/call`` and ``resources/read``
16
+ success paths now return a ``list[dict]`` of BareAgent-internal content
17
+ blocks (Anthropic-native ``image`` shape + text placeholders for other
18
+ modalities). Error paths still degrade to the legacy ``str`` form so the
19
+ loop's existing string handling continues to work.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from collections.abc import Callable
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from .config import MCPConfig
29
+ from .errors import MCPCallError, MCPError
30
+
31
+ if TYPE_CHECKING:
32
+ from .manager import MCPManager
33
+
34
+ _log = logging.getLogger(__name__)
35
+
36
+ _NAME_SEPARATOR = "__"
37
+ _NAME_PREFIX = "mcp"
38
+
39
+ # Legacy ``_flatten_content`` (still used for error paths and prompts/transcript
40
+ # injection) renders every non-text block as ``[<type> omitted: PR5]``. The
41
+ # real multimodal path now goes through :func:`_to_content_blocks`.
42
+ _PR5_OMITTED_TYPES = {"image", "audio", "resource", "resource_link"}
43
+
44
+ # Anthropic Messages API accepts only these mime types in tool_result image
45
+ # blocks; anything else is degraded to a text placeholder so we never push a
46
+ # payload the API will reject.
47
+ _SUPPORTED_IMAGE_MIME_TYPES = frozenset(
48
+ {"image/png", "image/jpeg", "image/gif", "image/webp"}
49
+ )
50
+
51
+ _RESOURCE_LIST_SUFFIX = "resource_list"
52
+ _RESOURCE_READ_SUFFIX = "resource_read"
53
+
54
+ # Truncation suffix templates. Stable text so trellis-check / tests can match
55
+ # them verbatim and the LLM can reliably detect that it was truncated and
56
+ # adjust its next request (e.g. ask for less / paginate).
57
+ _TEXT_TRUNCATED_SUFFIX = "\n[truncated, original size: {n} bytes]"
58
+ _RESOURCE_OMITTED_TEMPLATE = "[Resource omitted: too large ({n} bytes)]"
59
+
60
+
61
+ def _truncate_text(text: str, max_bytes: int) -> str:
62
+ """Return ``text`` truncated to fit within ``max_bytes`` UTF-8 bytes.
63
+
64
+ Appends ``[truncated, original size: N bytes]`` when truncation kicks in
65
+ so the LLM knows the payload was cut. ``max_bytes <= 0`` disables the
66
+ cap (helpful in tests that construct a bare ``MCPConfig``).
67
+ """
68
+ if max_bytes <= 0:
69
+ return text
70
+ encoded = text.encode("utf-8")
71
+ if len(encoded) <= max_bytes:
72
+ return text
73
+ original_size = len(encoded)
74
+ # Slice at byte boundary, then decode with replacement so we never split a
75
+ # multibyte character mid-codepoint.
76
+ truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
77
+ return truncated + _TEXT_TRUNCATED_SUFFIX.format(n=original_size)
78
+
79
+
80
+ def _estimate_base64_bytes(data: str) -> int:
81
+ """Estimate the decoded size of a base64 string without allocating it.
82
+
83
+ Each 4 base64 chars decode to 3 bytes (minus padding). Approximation is
84
+ accurate to within ~2 bytes, plenty precise for a single-threshold check
85
+ and *much* cheaper than ``base64.b64decode`` on a 6 MiB payload.
86
+ """
87
+ if not data:
88
+ return 0
89
+ padding = data.count("=", max(0, len(data) - 2))
90
+ return (len(data) * 3) // 4 - padding
91
+
92
+
93
+ def mcp_tool_name(server_name: str, tool_name: str) -> str:
94
+ """Build the BareAgent-visible tool name (``mcp__<server>__<tool>``)."""
95
+ return f"{_NAME_PREFIX}{_NAME_SEPARATOR}{server_name}{_NAME_SEPARATOR}{tool_name}"
96
+
97
+
98
+ def build_mcp_tool_schemas(manager: MCPManager) -> list[dict[str, Any]]:
99
+ """Produce BareAgent-shaped tool schemas for every running MCP tool.
100
+
101
+ Raises ``MCPError`` if two servers expose tools that collide after the
102
+ ``mcp__<server>__<tool>`` rewrite — duplicate server names are already
103
+ prevented by config parsing, so the only realistic collision is a server
104
+ returning the same tool name twice in its ``tools/list``.
105
+ """
106
+ schemas: list[dict[str, Any]] = []
107
+ seen: set[str] = set()
108
+ for server_name, client in manager.iter_running_clients():
109
+ try:
110
+ tools = client.list_tools()
111
+ except Exception as exc:
112
+ _log.warning(
113
+ "MCP server %r tools/list failed during schema build: %s",
114
+ server_name,
115
+ exc,
116
+ )
117
+ tools = []
118
+ seen_in_server: set[str] = set()
119
+ for tool in tools:
120
+ original_name = tool.get("name")
121
+ if not isinstance(original_name, str) or not original_name:
122
+ continue
123
+ if original_name in seen_in_server:
124
+ _log.warning(
125
+ "MCP server %r returned duplicate tool name %r; keeping the first",
126
+ server_name,
127
+ original_name,
128
+ )
129
+ continue
130
+ seen_in_server.add(original_name)
131
+ full_name = mcp_tool_name(server_name, original_name)
132
+ if full_name in seen:
133
+ raise MCPError(
134
+ f"MCP tool name collision after namespacing: {full_name!r}"
135
+ )
136
+ seen.add(full_name)
137
+ schema: dict[str, Any] = {
138
+ "name": full_name,
139
+ "description": tool.get("description") or "",
140
+ "input_schema": _coerce_input_schema(tool.get("inputSchema")),
141
+ }
142
+ schemas.append(schema)
143
+
144
+ # Resource discovery + fetch tools — only when the server opted into them.
145
+ if _client_has_capability(client, "resources"):
146
+ for schema in _resource_tool_schemas(server_name):
147
+ full_name = schema["name"]
148
+ if full_name in seen:
149
+ raise MCPError(
150
+ f"MCP tool name collision after namespacing: {full_name!r}"
151
+ )
152
+ seen.add(full_name)
153
+ schemas.append(schema)
154
+ return schemas
155
+
156
+
157
+ def build_mcp_handlers(manager: MCPManager) -> dict[str, Callable[..., Any]]:
158
+ """Produce ``{tool_name: handler}`` callables that forward to the right client.
159
+
160
+ Handlers swallow ``MCPCallError`` and ``isError: true`` results into the
161
+ BareAgent error-as-text convention so the agent loop never crashes on a
162
+ misbehaving MCP server. The active :class:`MCPConfig` is captured by each
163
+ handler closure so :func:`_to_content_blocks` can enforce the configured
164
+ text / binary truncation thresholds without round-tripping through the
165
+ manager on every call.
166
+ """
167
+ handlers: dict[str, Callable[..., Any]] = {}
168
+ # MagicMock-based test managers may not expose a real ``MCPConfig`` — fall
169
+ # back to ``None`` (= no truncation) so the registry stays usable in unit
170
+ # tests without forcing every fixture to wire a config.
171
+ raw_config = getattr(manager, "config", None)
172
+ config = raw_config if isinstance(raw_config, MCPConfig) else None
173
+ for server_name, client in manager.iter_running_clients():
174
+ try:
175
+ tools = client.list_tools()
176
+ except Exception as exc:
177
+ _log.warning(
178
+ "MCP server %r tools/list failed during handler build: %s",
179
+ server_name,
180
+ exc,
181
+ )
182
+ tools = []
183
+ seen_in_server: set[str] = set()
184
+ for tool in tools:
185
+ original_name = tool.get("name")
186
+ if not isinstance(original_name, str) or not original_name:
187
+ continue
188
+ if original_name in seen_in_server:
189
+ continue
190
+ seen_in_server.add(original_name)
191
+ full_name = mcp_tool_name(server_name, original_name)
192
+ handlers[full_name] = _make_handler(
193
+ manager, server_name, original_name, config
194
+ )
195
+
196
+ if _client_has_capability(client, "resources"):
197
+ list_name = mcp_tool_name(server_name, _RESOURCE_LIST_SUFFIX)
198
+ read_name = mcp_tool_name(server_name, _RESOURCE_READ_SUFFIX)
199
+ handlers[list_name] = _make_resource_list_handler(manager, server_name)
200
+ handlers[read_name] = _make_resource_read_handler(
201
+ manager, server_name, config
202
+ )
203
+ return handlers
204
+
205
+
206
+ def _make_handler(
207
+ manager: MCPManager,
208
+ server_name: str,
209
+ original_tool_name: str,
210
+ config: MCPConfig | None,
211
+ ) -> Callable[..., str | list[dict[str, Any]]]:
212
+ """Closure: looks up the live client at call-time so reload/crashes show up.
213
+
214
+ On the success path the handler returns the multimodal ``list[dict]`` shape
215
+ (BareAgent-internal content blocks); on any failure or ``isError: true`` it
216
+ falls back to the legacy ``str`` form so the agent loop's stringify path
217
+ keeps working unchanged.
218
+ """
219
+
220
+ def _handler(**kwargs: Any) -> str | list[dict[str, Any]]:
221
+ client = manager.get_client(server_name)
222
+ if client is None:
223
+ return f"Error: MCP server {server_name!r} is not running"
224
+ try:
225
+ result = client.call_tool(original_tool_name, kwargs)
226
+ except MCPCallError as exc:
227
+ return str(exc)
228
+ except Exception as exc:
229
+ return f"Error: {type(exc).__name__}: {exc}"
230
+ content = result.get("content")
231
+ content_list = content if isinstance(content, list) else []
232
+ if result.get("isError"):
233
+ body = _flatten_content(content_list, config=config)
234
+ return f"Error: {body}" if body else "Error: (no content)"
235
+ return _to_content_blocks(content_list, config=config)
236
+
237
+ _handler.__name__ = f"mcp_handler_{server_name}_{original_tool_name}"
238
+ return _handler
239
+
240
+
241
+ def _make_resource_list_handler(
242
+ manager: MCPManager,
243
+ server_name: str,
244
+ ) -> Callable[..., str]:
245
+ """Closure: ``resources/list`` -> a human-readable multi-line string."""
246
+
247
+ def _handler(**_kwargs: Any) -> str:
248
+ client = manager.get_client(server_name)
249
+ if client is None:
250
+ return f"Error: MCP server {server_name!r} is not running"
251
+ try:
252
+ resources = client.list_resources()
253
+ except MCPCallError as exc:
254
+ return str(exc)
255
+ except Exception as exc:
256
+ return f"Error: {type(exc).__name__}: {exc}"
257
+ if not resources:
258
+ return "(no resources)"
259
+ lines: list[str] = []
260
+ for resource in resources:
261
+ uri = str(resource.get("uri", ""))
262
+ name = str(resource.get("name", ""))
263
+ description = str(resource.get("description", ""))
264
+ mime_type = str(resource.get("mimeType", ""))
265
+ lines.append(f"{uri} | {name} | {description} | {mime_type}")
266
+ return "\n".join(lines)
267
+
268
+ _handler.__name__ = f"mcp_handler_{server_name}_resource_list"
269
+ return _handler
270
+
271
+
272
+ def _make_resource_read_handler(
273
+ manager: MCPManager,
274
+ server_name: str,
275
+ config: MCPConfig | None,
276
+ ) -> Callable[..., str | list[dict[str, Any]]]:
277
+ """Closure: ``resources/read`` -> multimodal content blocks on success.
278
+
279
+ Mirrors :func:`_make_handler`: success returns ``list[dict]`` so binary
280
+ resources (e.g. images) reach the LLM intact; error / ``isError: true``
281
+ paths still return a ``str`` prefixed with ``Error:``.
282
+ """
283
+
284
+ def _handler(uri: str | None = None, **_kwargs: Any) -> str | list[dict[str, Any]]:
285
+ client = manager.get_client(server_name)
286
+ if client is None:
287
+ return f"Error: MCP server {server_name!r} is not running"
288
+ if not isinstance(uri, str) or not uri:
289
+ return "Error: resource_read requires a non-empty 'uri' argument"
290
+ try:
291
+ result = client.read_resource(uri)
292
+ except MCPCallError as exc:
293
+ return str(exc)
294
+ except Exception as exc:
295
+ return f"Error: {type(exc).__name__}: {exc}"
296
+ contents = result.get("contents")
297
+ contents_list = contents if isinstance(contents, list) else []
298
+ if result.get("isError"):
299
+ body = _flatten_content(contents_list, config=config)
300
+ return f"Error: {body}" if body else "Error: (no content)"
301
+ return _to_content_blocks(contents_list, config=config)
302
+
303
+ _handler.__name__ = f"mcp_handler_{server_name}_resource_read"
304
+ return _handler
305
+
306
+
307
+ def _resource_tool_schemas(server_name: str) -> list[dict[str, Any]]:
308
+ return [
309
+ {
310
+ "name": mcp_tool_name(server_name, _RESOURCE_LIST_SUFFIX),
311
+ "description": (
312
+ f"List available resources from MCP server {server_name!r}"
313
+ ),
314
+ "input_schema": {
315
+ "type": "object",
316
+ "properties": {},
317
+ "required": [],
318
+ },
319
+ },
320
+ {
321
+ "name": mcp_tool_name(server_name, _RESOURCE_READ_SUFFIX),
322
+ "description": (f"Read a resource from MCP server {server_name!r}"),
323
+ "input_schema": {
324
+ "type": "object",
325
+ "properties": {
326
+ "uri": {
327
+ "type": "string",
328
+ "description": "Resource URI to read",
329
+ }
330
+ },
331
+ "required": ["uri"],
332
+ },
333
+ },
334
+ ]
335
+
336
+
337
+ def _flatten_content(
338
+ content: list[dict[str, Any]],
339
+ *,
340
+ config: MCPConfig | None = None,
341
+ ) -> str:
342
+ """Stringify an MCP content array (tools/call, resources/read, prompts).
343
+
344
+ ``text`` blocks are concatenated verbatim with a newline between them. Any
345
+ other block becomes a ``[<type> omitted: PR5]`` placeholder. PR5 keeps this
346
+ helper around for error paths (where we want a single error string) and
347
+ for prompts / transcript injection (where the consumer is a chat message
348
+ that must be plain text).
349
+
350
+ When ``config`` is supplied the final joined string is truncated to fit
351
+ ``config.max_result_text_bytes`` so error payloads (which the LLM will see
352
+ next turn) stay within the same envelope as success payloads.
353
+ """
354
+ parts: list[str] = []
355
+ for block in content:
356
+ if not isinstance(block, dict):
357
+ continue
358
+ block_type = block.get("type")
359
+ if block_type == "text":
360
+ text = block.get("text")
361
+ if isinstance(text, str):
362
+ parts.append(text)
363
+ elif block_type in _PR5_OMITTED_TYPES:
364
+ parts.append(f"[{block_type} omitted: PR5]")
365
+ else:
366
+ parts.append(f"[{block_type or 'unknown'} omitted: PR5]")
367
+ joined = "\n".join(parts)
368
+ if config is not None:
369
+ joined = _truncate_text(joined, config.max_result_text_bytes)
370
+ return joined
371
+
372
+
373
+ def _to_content_blocks(
374
+ mcp_content: list[dict[str, Any]],
375
+ *,
376
+ config: MCPConfig | None = None,
377
+ ) -> list[dict[str, Any]]:
378
+ """Normalize an MCP ``content`` array into BareAgent-internal content blocks.
379
+
380
+ Output blocks use Anthropic's native shape so the Anthropic provider can
381
+ forward them verbatim. The OpenAI provider lifts image blocks into a
382
+ follow-up user message at serialization time (see
383
+ ``OpenAIProvider._convert_non_assistant_message``).
384
+
385
+ Conversions:
386
+
387
+ - ``{type: "text", text}`` → ``{type: "text", text}``
388
+ - ``{type: "image", data, mimeType}``
389
+ → ``{type: "image", source: {type: "base64", media_type, data}}``
390
+ (only when ``mimeType`` is in the Anthropic-supported whitelist and
391
+ ``data`` is non-empty; otherwise degraded to a text placeholder.)
392
+ - ``{type: "audio", ...}`` → text placeholder
393
+ - ``{type: "embedded_resource", resource: {uri, mimeType, ...}}`` → text placeholder with URI
394
+ - ``{type: "resource_link", uri, ...}`` → text placeholder with URI
395
+ - anything else → text placeholder
396
+
397
+ Degradations always emit ``logger.warning`` rather than raising, so a
398
+ misbehaving server can never kill the agent loop.
399
+ """
400
+ max_text_bytes = config.max_result_text_bytes if config is not None else 0
401
+ max_binary_bytes = config.max_result_binary_bytes if config is not None else 0
402
+ blocks: list[dict[str, Any]] = []
403
+ for block in mcp_content:
404
+ if not isinstance(block, dict):
405
+ _log.warning("MCP content array contained non-dict block: %r", block)
406
+ blocks.append(
407
+ {
408
+ "type": "text",
409
+ "text": f"[Unknown content block: {type(block).__name__}]",
410
+ }
411
+ )
412
+ continue
413
+ block_type = block.get("type")
414
+ if block_type == "text":
415
+ raw_text = block.get("text")
416
+ text = raw_text if isinstance(raw_text, str) else ""
417
+ if max_text_bytes > 0:
418
+ text = _truncate_text(text, max_text_bytes)
419
+ blocks.append({"type": "text", "text": text})
420
+ continue
421
+ if block_type == "image":
422
+ blocks.append(
423
+ _image_block_or_placeholder(block, max_binary_bytes=max_binary_bytes)
424
+ )
425
+ continue
426
+ if block_type == "audio":
427
+ _log.warning(
428
+ "MCP audio content block degraded to text placeholder "
429
+ "(not supported by current providers)"
430
+ )
431
+ blocks.append(
432
+ {
433
+ "type": "text",
434
+ "text": "[Audio omitted: not supported by current providers]",
435
+ }
436
+ )
437
+ continue
438
+ if block_type == "embedded_resource":
439
+ blocks.append(
440
+ _embedded_resource_placeholder(block, max_binary_bytes=max_binary_bytes)
441
+ )
442
+ continue
443
+ if block_type == "resource_link":
444
+ uri = block.get("uri")
445
+ uri_text = uri if isinstance(uri, str) and uri else "unknown"
446
+ blocks.append({"type": "text", "text": f"[Resource link: {uri_text}]"})
447
+ continue
448
+ _log.warning("MCP content block has unknown type %r", block_type)
449
+ blocks.append(
450
+ {
451
+ "type": "text",
452
+ "text": f"[Unknown content block: {block_type or 'unknown'}]",
453
+ }
454
+ )
455
+ return blocks
456
+
457
+
458
+ def _image_block_or_placeholder(
459
+ block: dict[str, Any],
460
+ *,
461
+ max_binary_bytes: int = 0,
462
+ ) -> dict[str, Any]:
463
+ """Convert an MCP ``image`` content block to BareAgent's internal shape.
464
+
465
+ Falls back to a text placeholder on any of the standard degradation paths
466
+ (missing ``mimeType``, empty ``data``, unsupported mime). All degradations
467
+ log at WARNING.
468
+ """
469
+ mime = block.get("mimeType")
470
+ data = block.get("data")
471
+ if not isinstance(mime, str) or not mime:
472
+ _log.warning("MCP image block missing mimeType; degrading to placeholder")
473
+ return {"type": "text", "text": "[Image omitted: missing mimeType]"}
474
+ if mime not in _SUPPORTED_IMAGE_MIME_TYPES:
475
+ _log.warning("MCP image block mime %r is not in the supported whitelist", mime)
476
+ return {
477
+ "type": "text",
478
+ "text": f"[Image omitted: unsupported mime type {mime!r}]",
479
+ }
480
+ if not isinstance(data, str) or not data:
481
+ _log.warning("MCP image block has empty/missing data; degrading to placeholder")
482
+ return {"type": "text", "text": "[Image omitted: empty data]"}
483
+ if max_binary_bytes > 0:
484
+ # Estimate the decoded byte length from the base64 string size; avoids
485
+ # allocating a 6 MiB bytes object just to discover we should drop it.
486
+ estimated = _estimate_base64_bytes(data)
487
+ if estimated > max_binary_bytes:
488
+ _log.warning(
489
+ "MCP image block exceeds max_result_binary_bytes (%d > %d); omitting",
490
+ estimated,
491
+ max_binary_bytes,
492
+ )
493
+ return {
494
+ "type": "text",
495
+ "text": _RESOURCE_OMITTED_TEMPLATE.format(n=estimated),
496
+ }
497
+ return {
498
+ "type": "image",
499
+ "source": {
500
+ "type": "base64",
501
+ "media_type": mime,
502
+ "data": data,
503
+ },
504
+ }
505
+
506
+
507
+ def _embedded_resource_placeholder(
508
+ block: dict[str, Any],
509
+ *,
510
+ max_binary_bytes: int = 0,
511
+ ) -> dict[str, Any]:
512
+ resource = block.get("resource")
513
+ if not isinstance(resource, dict):
514
+ _log.warning(
515
+ "MCP embedded_resource block missing 'resource' field; degrading to placeholder"
516
+ )
517
+ return {"type": "text", "text": "[Resource: unknown (unknown)]"}
518
+ uri = resource.get("uri")
519
+ mime = resource.get("mimeType")
520
+ uri_text = uri if isinstance(uri, str) and uri else "unknown"
521
+ mime_text = mime if isinstance(mime, str) and mime else "unknown"
522
+ # If the resource was inlined as base64 ``blob`` and exceeds the binary
523
+ # cap, surface the omission in a clear, LLM-readable form. ``text`` (no
524
+ # blob) resources fall back to the legacy placeholder unchanged.
525
+ blob = resource.get("blob")
526
+ if isinstance(blob, str) and blob and max_binary_bytes > 0:
527
+ estimated = _estimate_base64_bytes(blob)
528
+ if estimated > max_binary_bytes:
529
+ _log.warning(
530
+ "MCP embedded_resource blob exceeds max_result_binary_bytes "
531
+ "(%d > %d); omitting",
532
+ estimated,
533
+ max_binary_bytes,
534
+ )
535
+ return {
536
+ "type": "text",
537
+ "text": _RESOURCE_OMITTED_TEMPLATE.format(n=estimated),
538
+ }
539
+ return {"type": "text", "text": f"[Resource: {uri_text} ({mime_text})]"}
540
+
541
+
542
+ def _client_has_capability(client: Any, name: str) -> bool:
543
+ """Best-effort capability check that tolerates MagicMock-style fakes in tests."""
544
+ check = getattr(client, "has_capability", None)
545
+ if not callable(check):
546
+ return False
547
+ try:
548
+ return bool(check(name))
549
+ except Exception:
550
+ return False
551
+
552
+
553
+ def _coerce_input_schema(schema: Any) -> dict[str, Any]:
554
+ """Pass through the MCP ``inputSchema``, defaulting to an empty object schema."""
555
+ if isinstance(schema, dict):
556
+ return schema
557
+ return {"type": "object", "properties": {}}
@@ -0,0 +1,15 @@
1
+ """MCP transport implementations: stdio, HTTP legacy, HTTP streamable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import Transport
6
+ from .http_legacy import HttpLegacyTransport
7
+ from .http_streamable import HttpStreamableTransport
8
+ from .stdio import StdioTransport
9
+
10
+ __all__ = [
11
+ "HttpLegacyTransport",
12
+ "HttpStreamableTransport",
13
+ "StdioTransport",
14
+ "Transport",
15
+ ]