agentkernel-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.
- agentkernel/__init__.py +7 -0
- agentkernel/__main__.py +5 -0
- agentkernel/agent.py +311 -0
- agentkernel/approval/__init__.py +23 -0
- agentkernel/approval/base.py +34 -0
- agentkernel/approval/cli.py +129 -0
- agentkernel/approval/policy.py +58 -0
- agentkernel/approval/risk.py +91 -0
- agentkernel/approval/sandbox.py +201 -0
- agentkernel/budget.py +64 -0
- agentkernel/checkpoint.py +50 -0
- agentkernel/cli.py +1482 -0
- agentkernel/config.py +224 -0
- agentkernel/context/__init__.py +17 -0
- agentkernel/context/manager.py +216 -0
- agentkernel/context/truncate.py +35 -0
- agentkernel/cron.py +146 -0
- agentkernel/curation.py +183 -0
- agentkernel/doctor.py +141 -0
- agentkernel/embeddings.py +132 -0
- agentkernel/evaluation.py +186 -0
- agentkernel/improvement.py +133 -0
- agentkernel/insights.py +141 -0
- agentkernel/kanban.py +114 -0
- agentkernel/knowledge.py +383 -0
- agentkernel/loops.py +145 -0
- agentkernel/mcp/__init__.py +23 -0
- agentkernel/mcp/client.py +181 -0
- agentkernel/mcp/config.py +59 -0
- agentkernel/mcp/tools.py +96 -0
- agentkernel/memory.py +1208 -0
- agentkernel/paths.py +73 -0
- agentkernel/plugins.py +76 -0
- agentkernel/profiles.py +70 -0
- agentkernel/progress.py +89 -0
- agentkernel/providers/__init__.py +35 -0
- agentkernel/providers/_http.py +157 -0
- agentkernel/providers/anthropic.py +282 -0
- agentkernel/providers/base.py +38 -0
- agentkernel/providers/credentials.py +65 -0
- agentkernel/providers/local.py +34 -0
- agentkernel/providers/openai.py +260 -0
- agentkernel/redaction.py +77 -0
- agentkernel/semantic_index.py +139 -0
- agentkernel/semantic_memory.py +253 -0
- agentkernel/skills.py +268 -0
- agentkernel/subagent.py +161 -0
- agentkernel/telemetry.py +199 -0
- agentkernel/templates/README.md +35 -0
- agentkernel/templates/SKILL.md +28 -0
- agentkernel/templates/eval-suite.toml +22 -0
- agentkernel/templates/loop.toml +29 -0
- agentkernel/templates/mcp-servers.toml +22 -0
- agentkernel/templates/profile.toml +29 -0
- agentkernel/templates/tool_module.py +64 -0
- agentkernel/tools/__init__.py +5 -0
- agentkernel/tools/base.py +100 -0
- agentkernel/tools/builtin/__init__.py +37 -0
- agentkernel/tools/builtin/checkpoint_tool.py +33 -0
- agentkernel/tools/builtin/clarify.py +60 -0
- agentkernel/tools/builtin/files.py +221 -0
- agentkernel/tools/builtin/kanban_tool.py +100 -0
- agentkernel/tools/builtin/search.py +225 -0
- agentkernel/tools/builtin/shell.py +67 -0
- agentkernel/tools/builtin/todo.py +106 -0
- agentkernel/tui/__init__.py +50 -0
- agentkernel/tui/app.py +594 -0
- agentkernel/types.py +127 -0
- agentkernel/worktree.py +64 -0
- agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
- agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
- agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
- agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentkernel_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""A minimal MCP client over JSON-RPC 2.0 stdio (Phase 2).
|
|
2
|
+
|
|
3
|
+
Messages are newline-delimited JSON objects exchanged with the server
|
|
4
|
+
subprocess's stdin/stdout. A background reader thread drains stdout into a
|
|
5
|
+
queue so requests can wait with a timeout (a misbehaving server must never hang
|
|
6
|
+
the agent loop).
|
|
7
|
+
|
|
8
|
+
This client supports exactly what tool discovery and invocation need:
|
|
9
|
+
``initialize`` → ``notifications/initialized`` → ``tools/list`` / ``tools/call``.
|
|
10
|
+
Server-initiated requests (sampling, roots) are ignored in v1.
|
|
11
|
+
|
|
12
|
+
When ``log_dir`` is provided, each server's stderr is captured to a file
|
|
13
|
+
``<log_dir>/<server-name>.log`` for post-mortem debugging.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import queue
|
|
21
|
+
import subprocess
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from agentkernel import __version__
|
|
28
|
+
from agentkernel.mcp.config import MCPServerConfig
|
|
29
|
+
|
|
30
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MCPError(RuntimeError):
|
|
34
|
+
"""An MCP transport or protocol fault (connection, timeout, JSON-RPC error)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MCPClient:
|
|
38
|
+
"""Connects to one MCP server over stdio and issues JSON-RPC requests."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
config: MCPServerConfig,
|
|
43
|
+
*,
|
|
44
|
+
timeout: float = 30.0,
|
|
45
|
+
log_dir: str | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._config = config
|
|
48
|
+
self._timeout = timeout
|
|
49
|
+
self._log_dir = log_dir
|
|
50
|
+
self._proc: subprocess.Popen | None = None
|
|
51
|
+
self._reader: threading.Thread | None = None
|
|
52
|
+
self._stderr_reader: threading.Thread | None = None
|
|
53
|
+
self._incoming: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
54
|
+
self._next_id = 0
|
|
55
|
+
self.server_info: dict[str, Any] | None = None
|
|
56
|
+
self.stderr_log_path: str | None = None
|
|
57
|
+
|
|
58
|
+
# --- lifecycle ---------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def connect(self) -> MCPClient:
|
|
61
|
+
if self._proc is not None:
|
|
62
|
+
raise MCPError(f"MCP client {self._config.name!r} is already connected")
|
|
63
|
+
env = {**os.environ, **(self._config.env or {})}
|
|
64
|
+
|
|
65
|
+
stderr_target: int = subprocess.DEVNULL
|
|
66
|
+
if self._log_dir:
|
|
67
|
+
log_path = Path(self._log_dir)
|
|
68
|
+
log_path.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self.stderr_log_path = str(log_path / f"{self._config.name}.log")
|
|
70
|
+
stderr_target = subprocess.PIPE
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
self._proc = subprocess.Popen(
|
|
74
|
+
[self._config.command, *self._config.args],
|
|
75
|
+
stdin=subprocess.PIPE,
|
|
76
|
+
stdout=subprocess.PIPE,
|
|
77
|
+
stderr=stderr_target,
|
|
78
|
+
text=True,
|
|
79
|
+
bufsize=1,
|
|
80
|
+
env=env,
|
|
81
|
+
cwd=self._config.cwd,
|
|
82
|
+
)
|
|
83
|
+
except (OSError, ValueError) as exc:
|
|
84
|
+
raise MCPError(f"failed to launch MCP server {self._config.name!r}: {exc}")
|
|
85
|
+
|
|
86
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
87
|
+
self._reader.start()
|
|
88
|
+
|
|
89
|
+
if self.stderr_log_path is not None and self._proc.stderr is not None:
|
|
90
|
+
self._stderr_reader = threading.Thread(
|
|
91
|
+
target=self._read_loop_stderr,
|
|
92
|
+
args=(self.stderr_log_path, self._proc.stderr),
|
|
93
|
+
daemon=True,
|
|
94
|
+
)
|
|
95
|
+
self._stderr_reader.start()
|
|
96
|
+
|
|
97
|
+
result = self._request(
|
|
98
|
+
"initialize",
|
|
99
|
+
{
|
|
100
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
101
|
+
"capabilities": {},
|
|
102
|
+
"clientInfo": {"name": "agentkernel", "version": __version__},
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
self.server_info = result.get("serverInfo")
|
|
106
|
+
self._notify("notifications/initialized", {})
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
proc, self._proc = self._proc, None
|
|
111
|
+
if proc is None or proc.poll() is not None:
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
proc.terminate()
|
|
115
|
+
proc.wait(timeout=5)
|
|
116
|
+
except Exception: # noqa: BLE001 - best effort shutdown
|
|
117
|
+
proc.kill()
|
|
118
|
+
# Give the stderr logger a moment to drain and flush.
|
|
119
|
+
if self._stderr_reader is not None:
|
|
120
|
+
self._stderr_reader.join(timeout=1.0)
|
|
121
|
+
|
|
122
|
+
# --- MCP operations ----------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def list_tools(self) -> list[dict[str, Any]]:
|
|
125
|
+
return self._request("tools/list", {}).get("tools", [])
|
|
126
|
+
|
|
127
|
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
128
|
+
return self._request("tools/call", {"name": name, "arguments": arguments})
|
|
129
|
+
|
|
130
|
+
# --- JSON-RPC plumbing -------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _read_loop(self) -> None:
|
|
133
|
+
assert self._proc is not None and self._proc.stdout is not None
|
|
134
|
+
for line in self._proc.stdout:
|
|
135
|
+
line = line.strip()
|
|
136
|
+
if not line:
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
self._incoming.put(json.loads(line))
|
|
140
|
+
except json.JSONDecodeError:
|
|
141
|
+
continue # ignore non-JSON noise on stdout
|
|
142
|
+
|
|
143
|
+
def _read_loop_stderr(self, log_path: str, stream) -> None:
|
|
144
|
+
with open(log_path, "a", encoding="utf-8") as fh:
|
|
145
|
+
for line in stream:
|
|
146
|
+
fh.write(line)
|
|
147
|
+
fh.flush()
|
|
148
|
+
|
|
149
|
+
def _send(self, message: dict[str, Any]) -> None:
|
|
150
|
+
if self._proc is None or self._proc.poll() is not None:
|
|
151
|
+
raise MCPError(f"MCP server {self._config.name!r} is not running")
|
|
152
|
+
assert self._proc.stdin is not None
|
|
153
|
+
self._proc.stdin.write(json.dumps(message) + "\n")
|
|
154
|
+
self._proc.stdin.flush()
|
|
155
|
+
|
|
156
|
+
def _notify(self, method: str, params: dict[str, Any]) -> None:
|
|
157
|
+
self._send({"jsonrpc": "2.0", "method": method, "params": params})
|
|
158
|
+
|
|
159
|
+
def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
160
|
+
request_id = self._next_id
|
|
161
|
+
self._next_id += 1
|
|
162
|
+
self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
|
|
163
|
+
|
|
164
|
+
deadline = time.monotonic() + self._timeout
|
|
165
|
+
while True:
|
|
166
|
+
remaining = deadline - time.monotonic()
|
|
167
|
+
if remaining <= 0:
|
|
168
|
+
raise MCPError(f"timed out waiting for response to {method!r}")
|
|
169
|
+
try:
|
|
170
|
+
msg = self._incoming.get(timeout=remaining)
|
|
171
|
+
except queue.Empty:
|
|
172
|
+
raise MCPError(f"timed out waiting for response to {method!r}")
|
|
173
|
+
# Skip server-initiated requests/notifications and stale responses.
|
|
174
|
+
if msg.get("method") is not None or msg.get("id") != request_id:
|
|
175
|
+
continue
|
|
176
|
+
if "error" in msg:
|
|
177
|
+
err = msg["error"]
|
|
178
|
+
raise MCPError(
|
|
179
|
+
f"{method!r} failed: {err.get('message')} (code {err.get('code')})"
|
|
180
|
+
)
|
|
181
|
+
return msg.get("result", {})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""MCP server declarations (Phase 2).
|
|
2
|
+
|
|
3
|
+
Servers are declared in the config TOML as an array of tables:
|
|
4
|
+
|
|
5
|
+
[[mcp_servers]]
|
|
6
|
+
name = "filesystem"
|
|
7
|
+
command = "npx"
|
|
8
|
+
args = ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
9
|
+
timeout = 60
|
|
10
|
+
|
|
11
|
+
[[mcp_servers]]
|
|
12
|
+
name = "git"
|
|
13
|
+
command = "uvx"
|
|
14
|
+
args = ["mcp-server-git"]
|
|
15
|
+
env = { GIT_AUTHOR_NAME = "agent" }
|
|
16
|
+
|
|
17
|
+
This loader is kept separate from ``Config`` because the scalar config loader
|
|
18
|
+
(``config.py``) only handles flat values, while server definitions are nested.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import tomllib
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class MCPServerConfig:
|
|
30
|
+
"""How to launch and connect to one MCP server over stdio."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
command: str
|
|
34
|
+
args: list[str] = field(default_factory=list)
|
|
35
|
+
env: dict[str, str] | None = None
|
|
36
|
+
cwd: str | None = None
|
|
37
|
+
timeout: float | None = None # seconds; falls back to MCPClient default (30)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_mcp_servers(config_file: str | Path) -> list[MCPServerConfig]:
|
|
41
|
+
"""Read ``[[mcp_servers]]`` from the config TOML. Missing file → no servers."""
|
|
42
|
+
path = Path(config_file)
|
|
43
|
+
if not path.is_file():
|
|
44
|
+
return []
|
|
45
|
+
with path.open("rb") as fh:
|
|
46
|
+
data = tomllib.load(fh)
|
|
47
|
+
servers: list[MCPServerConfig] = []
|
|
48
|
+
for entry in data.get("mcp_servers", []):
|
|
49
|
+
servers.append(
|
|
50
|
+
MCPServerConfig(
|
|
51
|
+
name=entry["name"],
|
|
52
|
+
command=entry["command"],
|
|
53
|
+
args=list(entry.get("args", [])),
|
|
54
|
+
env=entry.get("env"),
|
|
55
|
+
cwd=entry.get("cwd"),
|
|
56
|
+
timeout=entry.get("timeout"),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return servers
|
agentkernel/mcp/tools.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Adapt MCP tools into the kernel's ``ToolSpec`` (Phase 2, design §13).
|
|
2
|
+
|
|
3
|
+
Each discovered MCP tool becomes a ``ToolSpec`` whose handler issues a
|
|
4
|
+
``tools/call`` and maps the MCP result back to a canonical ``ToolResult``. The
|
|
5
|
+
registry and loop are untouched — this module is the entire MCP seam.
|
|
6
|
+
|
|
7
|
+
Gating: MCP tools can do anything, so they default to ``requires_approval``.
|
|
8
|
+
A tool that advertises ``annotations.readOnlyHint`` is treated as read-only and
|
|
9
|
+
left ungated.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from agentkernel.mcp.client import MCPClient, MCPError
|
|
17
|
+
from agentkernel.tools.base import ToolSpec
|
|
18
|
+
from agentkernel.types import ToolResult
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from agentkernel.mcp.config import MCPServerConfig
|
|
22
|
+
from agentkernel.tools import ToolRegistry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _result_to_tool_result(result: dict[str, Any]) -> ToolResult:
|
|
26
|
+
"""Flatten an MCP call result's content blocks into a ToolResult."""
|
|
27
|
+
text_parts = [
|
|
28
|
+
block.get("text", "")
|
|
29
|
+
for block in result.get("content", [])
|
|
30
|
+
if block.get("type") == "text"
|
|
31
|
+
]
|
|
32
|
+
content = "\n".join(p for p in text_parts if p) or "(no text content)"
|
|
33
|
+
return ToolResult(
|
|
34
|
+
"",
|
|
35
|
+
content,
|
|
36
|
+
is_error=bool(result.get("isError", False)),
|
|
37
|
+
data={"mcp_result": result},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _make_handler(client: MCPClient, name: str):
|
|
42
|
+
def handler(arguments: dict[str, Any]) -> ToolResult:
|
|
43
|
+
try:
|
|
44
|
+
result = client.call_tool(name, arguments)
|
|
45
|
+
except MCPError as exc:
|
|
46
|
+
# Transport/protocol faults become error results, not raises, so the
|
|
47
|
+
# model can recover (design §8.3).
|
|
48
|
+
return ToolResult("", f"MCP error: {exc}", is_error=True)
|
|
49
|
+
return _result_to_tool_result(result)
|
|
50
|
+
|
|
51
|
+
return handler
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def mcp_tool_specs(client: MCPClient) -> list[ToolSpec]:
|
|
55
|
+
"""Discover the client's tools and wrap each as a ToolSpec."""
|
|
56
|
+
specs: list[ToolSpec] = []
|
|
57
|
+
for tool in client.list_tools():
|
|
58
|
+
annotations = tool.get("annotations") or {}
|
|
59
|
+
read_only = bool(annotations.get("readOnlyHint", False))
|
|
60
|
+
specs.append(
|
|
61
|
+
ToolSpec(
|
|
62
|
+
name=tool["name"],
|
|
63
|
+
description=tool.get("description", ""),
|
|
64
|
+
parameters=tool.get("inputSchema") or {"type": "object"},
|
|
65
|
+
handler=_make_handler(client, tool["name"]),
|
|
66
|
+
requires_approval=not read_only,
|
|
67
|
+
mutates=not read_only,
|
|
68
|
+
category="mcp",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return specs
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def register_mcp_servers(
|
|
75
|
+
registry: ToolRegistry, servers: list[MCPServerConfig], log_dir: str | None = None
|
|
76
|
+
) -> list[MCPClient]:
|
|
77
|
+
"""Connect each server and register its tools. Returns the open clients so
|
|
78
|
+
the caller can close them. On any failure, already-opened clients are
|
|
79
|
+
closed before the error propagates.
|
|
80
|
+
|
|
81
|
+
``log_dir`` optionally captures each server's stderr to
|
|
82
|
+
``<log_dir>/<server-name>.log``."""
|
|
83
|
+
clients: list[MCPClient] = []
|
|
84
|
+
try:
|
|
85
|
+
for server in servers:
|
|
86
|
+
client = MCPClient(
|
|
87
|
+
server, timeout=server.timeout or 30.0, log_dir=log_dir
|
|
88
|
+
).connect()
|
|
89
|
+
clients.append(client)
|
|
90
|
+
for spec in mcp_tool_specs(client):
|
|
91
|
+
registry.register(spec)
|
|
92
|
+
except Exception:
|
|
93
|
+
for client in clients:
|
|
94
|
+
client.close()
|
|
95
|
+
raise
|
|
96
|
+
return clients
|