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