python-agent-harness 1.5.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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Context management for the agent loop: ratio tracking + compaction.
|
|
2
|
+
|
|
3
|
+
Extracted from agent.py (no logic changes): the FSM driver delegates
|
|
4
|
+
``_update_context_ratio`` / ``_need_compaction`` / ``compact`` to
|
|
5
|
+
``ContextManager``, which reads/writes the loop's shared state
|
|
6
|
+
(``messages`` / ``session``) through the loop reference.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import config
|
|
14
|
+
from .prompts import compact_summary, compacted_messages, user_prompt_texts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContextManager:
|
|
18
|
+
"""Context-ratio tracking and compaction for one agent loop.
|
|
19
|
+
|
|
20
|
+
``update_context_ratio`` receives the two token-estimator functions
|
|
21
|
+
from the loop's delegate so the call site keeps resolving them
|
|
22
|
+
through the ``agent`` module namespace (tests patch
|
|
23
|
+
``python_agent_harness.agent.estimate_payload_tokens``).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, loop: Any) -> None:
|
|
27
|
+
self.loop = loop
|
|
28
|
+
|
|
29
|
+
def update_context_ratio(
|
|
30
|
+
self,
|
|
31
|
+
estimate_payload_tokens: Any,
|
|
32
|
+
context_window_for: Any,
|
|
33
|
+
) -> None:
|
|
34
|
+
loop = self.loop
|
|
35
|
+
raw = estimate_payload_tokens(
|
|
36
|
+
loop.system,
|
|
37
|
+
[m.to_api() for m in loop.messages],
|
|
38
|
+
[t.to_api() for t in loop.session.tool_specs()],
|
|
39
|
+
)
|
|
40
|
+
loop.session.calibrator.last_raw_estimate = raw
|
|
41
|
+
calibrated = loop.session.calibrator.calibrate(raw)
|
|
42
|
+
window = context_window_for(loop.session.model)
|
|
43
|
+
loop.session.context_ratio = calibrated / float(window)
|
|
44
|
+
loop.session.notify("context")
|
|
45
|
+
|
|
46
|
+
def need_compaction(self) -> bool:
|
|
47
|
+
loop = self.loop
|
|
48
|
+
return (
|
|
49
|
+
loop.top_level
|
|
50
|
+
and loop.session.tools_enabled
|
|
51
|
+
and not loop.session.compacting
|
|
52
|
+
and loop.session.context_ratio is not None
|
|
53
|
+
and loop.session.context_ratio > config.CONTEXT_TRIGGER
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def compact(self) -> bool:
|
|
57
|
+
"""Compact the conversation; return True on success.
|
|
58
|
+
|
|
59
|
+
On success the history is replaced by the summary frame followed
|
|
60
|
+
by every real user prompt (nudges and other harness-injected
|
|
61
|
+
messages excluded), so the model keeps the actual requests; the
|
|
62
|
+
last prompt is the resume request for the next round.
|
|
63
|
+
"""
|
|
64
|
+
loop = self.loop
|
|
65
|
+
prompts = user_prompt_texts(loop.messages)
|
|
66
|
+
if not prompts:
|
|
67
|
+
return False
|
|
68
|
+
loop.session.compacting = True
|
|
69
|
+
try:
|
|
70
|
+
conversation = "\n\n".join(f"{m.role}: {m.text()}" for m in loop.messages if m.text())
|
|
71
|
+
summary = compact_summary(
|
|
72
|
+
loop.session.client, conversation, cancel_check=loop._is_cancelled
|
|
73
|
+
)
|
|
74
|
+
if not summary:
|
|
75
|
+
return False
|
|
76
|
+
# The summary replaces the whole conversation history EXCEPT
|
|
77
|
+
# the system prompt (loop.system is passed separately and
|
|
78
|
+
# stays untouched): it is part of the user turn, never a
|
|
79
|
+
# system message. Every real user prompt (nudges and other
|
|
80
|
+
# harness-injected messages excluded) is preserved verbatim
|
|
81
|
+
# after the frame, so the model keeps the actual requests.
|
|
82
|
+
loop.messages = compacted_messages(summary, prompts)
|
|
83
|
+
# The shared conversation now is the compacted one: mirror it
|
|
84
|
+
# onto session.last_messages so the TUI (renders from it) and
|
|
85
|
+
# a later manual /compact start from the summary, not the old
|
|
86
|
+
# full history.
|
|
87
|
+
if loop.top_level and not loop._is_cancelled():
|
|
88
|
+
loop.session.last_messages = list(loop.messages)
|
|
89
|
+
# Fresh start for the resumed conversation: the pre-compaction
|
|
90
|
+
# nudge budget must not carry over, or the first terminal
|
|
91
|
+
# answer after compaction ends the run immediately.
|
|
92
|
+
loop.supervisor.reset_nudges()
|
|
93
|
+
loop.session.notify("compact")
|
|
94
|
+
return True
|
|
95
|
+
except Exception as e: # noqa: BLE001 - compaction failure is non-fatal
|
|
96
|
+
loop.session.notify("error")
|
|
97
|
+
loop.session.log(f"compaction failed: {e}")
|
|
98
|
+
return False
|
|
99
|
+
finally:
|
|
100
|
+
loop.session.compacting = False
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Unified-diff generation and rich rendering for file-changing tools.
|
|
2
|
+
|
|
3
|
+
``unified_diff`` builds a standard unified diff between two file
|
|
4
|
+
contents (used by Edit/Write to record what actually changed).
|
|
5
|
+
``render_diff`` turns that text into a red/green ``rich`` renderable
|
|
6
|
+
suitable for the TUI's tool-output panel.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
|
|
13
|
+
from rich.console import Group
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
MAX_DIFF_LINES = 400 # truncation cap for the rendered (not stored) diff
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def unified_diff(
|
|
20
|
+
old_content: str,
|
|
21
|
+
new_content: str,
|
|
22
|
+
path: str,
|
|
23
|
+
context_lines: int = 3,
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Return a unified diff string between OLD_CONTENT and NEW_CONTENT.
|
|
26
|
+
|
|
27
|
+
Empty string when the two are identical (nothing to show).
|
|
28
|
+
Lines at EOF without a trailing newline get a git-style
|
|
29
|
+
``\`` marker so the diff round-trips
|
|
30
|
+
(the Edit tool's diff mode parses markers and applies them).
|
|
31
|
+
"""
|
|
32
|
+
if old_content == new_content:
|
|
33
|
+
return ""
|
|
34
|
+
old_lines = old_content.splitlines(keepends=True)
|
|
35
|
+
new_lines = new_content.splitlines(keepends=True)
|
|
36
|
+
diff = difflib.unified_diff(
|
|
37
|
+
old_lines,
|
|
38
|
+
new_lines,
|
|
39
|
+
fromfile=f"a/{path}",
|
|
40
|
+
tofile=f"b/{path}",
|
|
41
|
+
n=context_lines,
|
|
42
|
+
lineterm="\n",
|
|
43
|
+
)
|
|
44
|
+
out: list[str] = []
|
|
45
|
+
for line in diff:
|
|
46
|
+
out.append(line)
|
|
47
|
+
if line[:1] in ("-", "+", " ") and not line.endswith("\n"):
|
|
48
|
+
# a content line at EOF without a trailing newline: difflib
|
|
49
|
+
# emits it bare, which would corrupt the joined text; mark
|
|
50
|
+
# it like git does so the diff round-trips (the Edit tool's
|
|
51
|
+
# diff mode parses markers and applies them)
|
|
52
|
+
out.append("\n\\n")
|
|
53
|
+
return "".join(out)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_diff(diff_text: str, max_lines: int = MAX_DIFF_LINES) -> Group:
|
|
57
|
+
"""Render a unified diff as a rich renderable (red '-' / green '+').
|
|
58
|
+
|
|
59
|
+
Hunk headers (@@ ...@@) and file headers (---/+++) are dimmed;
|
|
60
|
+
added lines are green, removed lines are red, context lines are
|
|
61
|
+
plain. Long diffs are truncated with a marker line.
|
|
62
|
+
"""
|
|
63
|
+
lines = diff_text.splitlines()
|
|
64
|
+
truncated = len(lines) > max_lines
|
|
65
|
+
if truncated:
|
|
66
|
+
lines = lines[:max_lines]
|
|
67
|
+
|
|
68
|
+
rows: list[Text] = []
|
|
69
|
+
for line in lines:
|
|
70
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
71
|
+
rows.append(Text(line, style="dim bold"))
|
|
72
|
+
elif line.startswith("@@"):
|
|
73
|
+
rows.append(Text(line, style="cyan"))
|
|
74
|
+
elif line.startswith("+"):
|
|
75
|
+
rows.append(Text(line, style="green"))
|
|
76
|
+
elif line.startswith("-"):
|
|
77
|
+
rows.append(Text(line, style="red"))
|
|
78
|
+
else:
|
|
79
|
+
rows.append(Text(line, style="dim"))
|
|
80
|
+
if truncated:
|
|
81
|
+
rows.append(Text("… [diff truncated]", style="dim italic"))
|
|
82
|
+
if not rows:
|
|
83
|
+
rows.append(Text("(no changes)", style="dim italic"))
|
|
84
|
+
return Group(*rows)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) client support.
|
|
2
|
+
|
|
3
|
+
Optional: requires the ``mcp`` extra (``pip install -e ".[mcp]"``).
|
|
4
|
+
Importing this package never requires the SDK — only actually
|
|
5
|
+
connecting to an MCP server does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .client import MCPClient, MCPUnavailableError
|
|
11
|
+
from .config import MCPConfig, MCPServerConfig
|
|
12
|
+
from .manager import MCPManager, MCPToolSpec
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"MCPClient",
|
|
16
|
+
"MCPConfig",
|
|
17
|
+
"MCPManager",
|
|
18
|
+
"MCPServerConfig",
|
|
19
|
+
"MCPToolSpec",
|
|
20
|
+
"MCPUnavailableError",
|
|
21
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Thin wrapper around the official MCP Python SDK (client side).
|
|
2
|
+
|
|
3
|
+
This module is the ONLY place that touches the SDK. The rest of the
|
|
4
|
+
harness sees plain dicts and strings, so a future SDK API change (the
|
|
5
|
+
v1 → v2 line already renamed FastMCP → MCPServer and moved the client
|
|
6
|
+
onto ``mcp.Client``) stays contained here.
|
|
7
|
+
|
|
8
|
+
The SDK is optional: importing this module never fails without it. The
|
|
9
|
+
``mcp`` extra (``pip install -e ".[mcp]"``) provides ``mcp>=2.0,<3``;
|
|
10
|
+
without it every operation raises :class:`MCPUnavailableError`.
|
|
11
|
+
|
|
12
|
+
The wrapper is async (the SDK is async); :class:`MCPManager` drives it
|
|
13
|
+
from the harness's synchronous world through a dedicated event-loop
|
|
14
|
+
thread. SDK types never leak out: ``list_tools`` returns plain dicts
|
|
15
|
+
and ``call_tool`` returns a plain dict with keys ``content`` (list of
|
|
16
|
+
content-block dicts), ``structured_content`` and ``is_error``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from mcp import Client
|
|
26
|
+
from mcp.client.sse import sse_client
|
|
27
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
28
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
29
|
+
|
|
30
|
+
_MCP_AVAILABLE = True
|
|
31
|
+
except ImportError: # pragma: no cover - depends on the optional extra
|
|
32
|
+
_MCP_AVAILABLE = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MCPUnavailableError(RuntimeError):
|
|
36
|
+
"""Raised when MCP is configured but the optional SDK is not installed.
|
|
37
|
+
|
|
38
|
+
Fix: ``pip install -e ".[mcp]"``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_sdk() -> None:
|
|
43
|
+
if not _MCP_AVAILABLE:
|
|
44
|
+
raise MCPUnavailableError(
|
|
45
|
+
"MCP support requires the optional `mcp` extra — install with `pip install -e '.[mcp]'`"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MCPClient:
|
|
50
|
+
"""One connection to one MCP server; hides the official SDK.
|
|
51
|
+
|
|
52
|
+
Use as an async context manager::
|
|
53
|
+
|
|
54
|
+
async with MCPClient(config) as client:
|
|
55
|
+
tools = await client.list_tools()
|
|
56
|
+
result = await client.call_tool("search", {"q": "x"})
|
|
57
|
+
|
|
58
|
+
All SDK exceptions propagate as-is (connection refused, protocol
|
|
59
|
+
errors, ...); the manager/tool layer turns them into normal tool
|
|
60
|
+
error strings.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, config: Any) -> None:
|
|
64
|
+
from .config import MCPServerConfig
|
|
65
|
+
|
|
66
|
+
self.config: MCPServerConfig = config
|
|
67
|
+
self._client: Any = None
|
|
68
|
+
self._http_client: Any = None
|
|
69
|
+
|
|
70
|
+
async def __aenter__(self) -> MCPClient:
|
|
71
|
+
await self.connect()
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
75
|
+
await self.close()
|
|
76
|
+
|
|
77
|
+
def _transport(self) -> Any:
|
|
78
|
+
"""Build the SDK transport for this server's config."""
|
|
79
|
+
cfg = self.config
|
|
80
|
+
if cfg.transport not in ("stdio", "streamable-http", "sse"):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"MCP server {cfg.name or '(unnamed)'!r}: unknown transport {cfg.transport!r}"
|
|
83
|
+
)
|
|
84
|
+
if cfg.transport == "stdio":
|
|
85
|
+
# command is validated non-None by MCPServerConfig.validate
|
|
86
|
+
assert cfg.command is not None
|
|
87
|
+
env = None
|
|
88
|
+
if cfg.env:
|
|
89
|
+
env = {name: os.environ[name] for name in cfg.env if name in os.environ}
|
|
90
|
+
return stdio_client(StdioServerParameters(command=cfg.command, args=cfg.args, env=env))
|
|
91
|
+
# url is validated non-None by MCPServerConfig.validate for the
|
|
92
|
+
# HTTP transports
|
|
93
|
+
assert cfg.url is not None
|
|
94
|
+
if cfg.transport == "streamable-http":
|
|
95
|
+
if cfg.headers:
|
|
96
|
+
import httpx2 # shipped as part of the mcp SDK
|
|
97
|
+
|
|
98
|
+
self._http_client = httpx2.AsyncClient(headers=cfg.headers)
|
|
99
|
+
return streamable_http_client(cfg.url, http_client=self._http_client)
|
|
100
|
+
return streamable_http_client(cfg.url)
|
|
101
|
+
if cfg.transport == "sse":
|
|
102
|
+
return sse_client(cfg.url, headers=cfg.headers or None)
|
|
103
|
+
# unreachable: the transport whitelist at the top rejects
|
|
104
|
+
# everything else
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"MCP server {cfg.name!r}: unknown transport {cfg.transport!r}"
|
|
107
|
+
) # pragma: no cover
|
|
108
|
+
|
|
109
|
+
async def connect(self) -> None:
|
|
110
|
+
"""Establish the connection (spawn process / open HTTP session)."""
|
|
111
|
+
_require_sdk()
|
|
112
|
+
self._client = Client(
|
|
113
|
+
server=self._transport(),
|
|
114
|
+
read_timeout_seconds=self.config.timeout,
|
|
115
|
+
)
|
|
116
|
+
await self._client.__aenter__()
|
|
117
|
+
|
|
118
|
+
async def close(self) -> None:
|
|
119
|
+
"""Tear down the connection (best effort, never raises)."""
|
|
120
|
+
import contextlib
|
|
121
|
+
|
|
122
|
+
client, self._client = self._client, None
|
|
123
|
+
if client is not None:
|
|
124
|
+
with contextlib.suppress(Exception): # teardown noise is not an error
|
|
125
|
+
await client.__aexit__(None, None, None)
|
|
126
|
+
http_client, self._http_client = self._http_client, None
|
|
127
|
+
if http_client is not None:
|
|
128
|
+
with contextlib.suppress(Exception): # teardown noise is not an error
|
|
129
|
+
await http_client.aclose()
|
|
130
|
+
|
|
131
|
+
async def list_tools(self) -> list[dict[str, Any]]:
|
|
132
|
+
"""tools/list — the server's tool descriptors as plain dicts."""
|
|
133
|
+
_require_sdk()
|
|
134
|
+
result = await self._client.list_tools()
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
"name": t.name,
|
|
138
|
+
"description": t.description or "",
|
|
139
|
+
"input_schema": t.input_schema if isinstance(t.input_schema, dict) else {},
|
|
140
|
+
}
|
|
141
|
+
for t in result.tools
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
"""tools/call — returns a plain dict (content blocks, structured
|
|
146
|
+
content, is_error). Server-reported failures surface as
|
|
147
|
+
``is_error=True``, NOT as exceptions."""
|
|
148
|
+
_require_sdk()
|
|
149
|
+
result = await self._client.call_tool(name, arguments or {})
|
|
150
|
+
return {
|
|
151
|
+
"content": [_content_block_to_dict(b) for b in result.content],
|
|
152
|
+
"structured_content": result.structured_content,
|
|
153
|
+
"is_error": bool(result.is_error),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _content_block_to_dict(block: Any) -> dict[str, Any]:
|
|
158
|
+
"""One MCP content block → plain dict (text/image/audio/resource/...)."""
|
|
159
|
+
if hasattr(block, "model_dump"):
|
|
160
|
+
return block.model_dump(exclude_none=True)
|
|
161
|
+
return {"type": "unknown", "raw": str(block)}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""MCP server configuration for python-agent-harness.
|
|
2
|
+
|
|
3
|
+
MCP support is OPTIONAL: install the harness with ``pip install -e ".[mcp]"``
|
|
4
|
+
to get the official ``mcp`` SDK (the ``mcp`` extra). This module only
|
|
5
|
+
defines plain configuration data classes — importing it never imports the
|
|
6
|
+
SDK, so the base harness stays dependency-free.
|
|
7
|
+
|
|
8
|
+
Transports (per the MCP spec):
|
|
9
|
+
|
|
10
|
+
- ``stdio``: spawn ``command`` with ``args`` as a subprocess; ``env``
|
|
11
|
+
lists environment variable names passed through from the harness
|
|
12
|
+
process (e.g. ``["GITHUB_TOKEN"]``).
|
|
13
|
+
- ``streamable-http``: connect to ``url``; ``headers`` (e.g.
|
|
14
|
+
Authorization) are sent with every request. The direction to target
|
|
15
|
+
for new remote deployments.
|
|
16
|
+
- ``sse``: connect to ``url`` over the legacy SSE transport.
|
|
17
|
+
|
|
18
|
+
Concurrency policy: ``parallel`` marks the server's tools as safe for
|
|
19
|
+
concurrent execution (read-only servers); the tool then runs in the
|
|
20
|
+
background like Bash/Agent. The default is conservative serial
|
|
21
|
+
execution. The harness — not the MCP protocol — retains authority
|
|
22
|
+
over this.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
TRANSPORTS = ("stdio", "streamable-http", "sse")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class MCPServerConfig:
|
|
36
|
+
"""Configuration for one MCP server connection.
|
|
37
|
+
|
|
38
|
+
``name`` is optional: when the server lives in an ``MCPConfig``
|
|
39
|
+
dict the dict key is authoritative (it fills ``name`` on
|
|
40
|
+
construction), so the design's compact form works::
|
|
41
|
+
|
|
42
|
+
MCPConfig(servers={"github": MCPServerConfig(command="npx", ...)})
|
|
43
|
+
|
|
44
|
+
``enabled=False`` keeps the server in the config file without
|
|
45
|
+
connecting it (a documented example, or a temporarily-disabled
|
|
46
|
+
server).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: str = ""
|
|
50
|
+
transport: str = "stdio"
|
|
51
|
+
command: str | None = None
|
|
52
|
+
args: list[str] = field(default_factory=list)
|
|
53
|
+
env: list[str] = field(default_factory=list)
|
|
54
|
+
url: str | None = None
|
|
55
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
parallel: bool = False
|
|
57
|
+
timeout: float | None = None
|
|
58
|
+
enabled: bool = True
|
|
59
|
+
|
|
60
|
+
def validate(self) -> None:
|
|
61
|
+
label = self.name or "(unnamed)"
|
|
62
|
+
if self.transport not in TRANSPORTS:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"MCP server {label!r}: unknown transport {self.transport!r} "
|
|
65
|
+
f"(expected one of {', '.join(TRANSPORTS)})"
|
|
66
|
+
)
|
|
67
|
+
if self.transport == "stdio":
|
|
68
|
+
if not self.command:
|
|
69
|
+
raise ValueError(f"MCP server {label!r}: stdio transport requires `command`")
|
|
70
|
+
elif not self.url:
|
|
71
|
+
raise ValueError(f"MCP server {label!r}: {self.transport} transport requires `url`")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class MCPConfig:
|
|
76
|
+
"""The set of MCP servers for one session.
|
|
77
|
+
|
|
78
|
+
Usage (the compact form — the dict key IS the server name)::
|
|
79
|
+
|
|
80
|
+
config = MCPConfig(
|
|
81
|
+
servers={
|
|
82
|
+
"github": MCPServerConfig(
|
|
83
|
+
transport="stdio",
|
|
84
|
+
command="npx",
|
|
85
|
+
args=["-y", "@modelcontextprotocol/server-github"],
|
|
86
|
+
env=["GITHUB_TOKEN"],
|
|
87
|
+
),
|
|
88
|
+
"remote": MCPServerConfig(
|
|
89
|
+
transport="streamable-http",
|
|
90
|
+
url="http://localhost:8000/mcp",
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
servers: dict[str, MCPServerConfig] = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
# The dict key is the authoritative server name: fill in any
|
|
100
|
+
# config whose name was left unset (compact construction).
|
|
101
|
+
for key, server in self.servers.items():
|
|
102
|
+
if not server.name:
|
|
103
|
+
server.name = key
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_dict(cls, data: Mapping[str, Any] | None) -> MCPConfig:
|
|
107
|
+
"""Build from a plain mapping (e.g. the config file's ``mcp.servers``).
|
|
108
|
+
|
|
109
|
+
Raises ValueError on malformed entries (unknown transport, missing
|
|
110
|
+
command/url) so config errors surface at session start, not mid-run.
|
|
111
|
+
"""
|
|
112
|
+
config = cls()
|
|
113
|
+
for name, raw in (data or {}).items():
|
|
114
|
+
if not isinstance(raw, dict):
|
|
115
|
+
raise ValueError(f"MCP server {name!r}: expected an object")
|
|
116
|
+
server = MCPServerConfig(
|
|
117
|
+
name=name,
|
|
118
|
+
transport=str(raw.get("transport", "stdio")),
|
|
119
|
+
command=raw.get("command"),
|
|
120
|
+
args=[str(a) for a in (raw.get("args") or [])],
|
|
121
|
+
env=[str(e) for e in (raw.get("env") or [])],
|
|
122
|
+
url=raw.get("url"),
|
|
123
|
+
headers={str(k): str(v) for k, v in (raw.get("headers") or {}).items()},
|
|
124
|
+
parallel=bool(raw.get("parallel", False)),
|
|
125
|
+
timeout=raw.get("timeout"),
|
|
126
|
+
enabled=bool(raw.get("enabled", True)),
|
|
127
|
+
)
|
|
128
|
+
server.validate()
|
|
129
|
+
config.servers[name] = server
|
|
130
|
+
return config
|