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,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
|
bareagent/mcp/client.py
ADDED
|
@@ -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
|
bareagent/mcp/config.py
ADDED
|
@@ -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)
|
bareagent/mcp/errors.py
ADDED
|
@@ -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."""
|