stcode 0.0.1a0__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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP client — external tool servers, loaded from a JSON config.
|
|
3
|
+
|
|
4
|
+
We do not reimplement MCP: the official `mcp` SDK owns the protocol, and this is the
|
|
5
|
+
adapter to `harness/tools/base.py`. Config is the familiar `mcpServers` object, so an
|
|
6
|
+
existing `.mcp.json` works unchanged:
|
|
7
|
+
|
|
8
|
+
{"mcpServers": {
|
|
9
|
+
"fs": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]},
|
|
10
|
+
"docs": {"type": "http", "url": "https://example.com/mcp"}
|
|
11
|
+
}}
|
|
12
|
+
|
|
13
|
+
Three rules at the boundary:
|
|
14
|
+
|
|
15
|
+
* **Schemas pass through untouched.** The server's own description is authoritative;
|
|
16
|
+
validating against a model we invented would reject calls it would have accepted.
|
|
17
|
+
* **Names are namespaced** `mcp__<server>__<tool>`. Two servers offering `search` is
|
|
18
|
+
normal, and a silent shadow is very hard to notice from outside.
|
|
19
|
+
* **Every MCP tool is `EXECUTE`.** We cannot see what a server does — the name says
|
|
20
|
+
"search" and the code may write files — so the permission reflects what is known.
|
|
21
|
+
|
|
22
|
+
## Two ways to expose a server (`[mcp] expose`)
|
|
23
|
+
|
|
24
|
+
**`"code"`, the default.** Nothing is registered. Each tool becomes a Python file under
|
|
25
|
+
`.stcode/mcp_servers/<server>/<tool>.py`; the agent greps, reads the one it needs, and
|
|
26
|
+
calls it from `repl`. Tool definitions live in the prompt prefix, so three mid-sized
|
|
27
|
+
servers cost 10-30k tokens *per turn, forever*; as code they cost a grep and an import,
|
|
28
|
+
and the result stays in a REPL variable.
|
|
29
|
+
|
|
30
|
+
**`"tools"`, the escape hatch.** One small server with two tools is cheaper advertised
|
|
31
|
+
directly than discovered over three REPL round-trips. The token argument is real but
|
|
32
|
+
not universal.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import contextlib
|
|
39
|
+
import json
|
|
40
|
+
import keyword
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
import shutil
|
|
44
|
+
from contextlib import AsyncExitStack
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from pydantic import BaseModel, Field
|
|
49
|
+
|
|
50
|
+
from stcode.core.harness.approvals import ToolPermission
|
|
51
|
+
from stcode.core.harness.tools.base import Runtime, Tool, ToolError
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("stcode.harness.mcp")
|
|
54
|
+
|
|
55
|
+
MCP_CONFIG_ENV = "STCODE_MCP_CONFIG"
|
|
56
|
+
MCP_CONFIG_NAMES = (".mcp.json", "mcp.json")
|
|
57
|
+
USER_MCP_DIR = Path.home() / ".stcode"
|
|
58
|
+
"""Where the global config lives. A module constant, like `USER_SKILLS_DIR`, so a test
|
|
59
|
+
can move it — `$HOME` is read at import time and setting it later is too late."""
|
|
60
|
+
|
|
61
|
+
CONNECT_TIMEOUT = 30.0
|
|
62
|
+
MCP_MAX_OUTPUT = 16384
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MCPServerConfig(BaseModel):
|
|
66
|
+
"""One server entry. Either a command to launch, or a URL to connect to."""
|
|
67
|
+
|
|
68
|
+
command: str | None = None
|
|
69
|
+
args: list[str] = Field(default_factory=list)
|
|
70
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
71
|
+
cwd: str | None = None
|
|
72
|
+
url: str | None = None
|
|
73
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
74
|
+
"""Sent with every request to a `url` server. This is how a hosted server is
|
|
75
|
+
authenticated — the key is a header, not an argument — so dropping the field would
|
|
76
|
+
leave a configured credential silently unsent and the server answering as anonymous."""
|
|
77
|
+
|
|
78
|
+
type: str | None = None
|
|
79
|
+
enabled: bool = True
|
|
80
|
+
|
|
81
|
+
def describe(self) -> str:
|
|
82
|
+
return self.url or " ".join([self.command or "?", *self.args])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def mcp_config_paths(path: str | Path | None = None, cwd: Path | None = None) -> list[Path]:
|
|
86
|
+
"""Every config that applies, **lowest precedence first**.
|
|
87
|
+
|
|
88
|
+
Global (`~/.stcode/mcp.json`), then the project's, then whatever was named
|
|
89
|
+
explicitly. The order is the merge order, so the last file to mention a server name
|
|
90
|
+
is the one that owns it.
|
|
91
|
+
"""
|
|
92
|
+
candidates: list[Path] = [USER_MCP_DIR / name for name in MCP_CONFIG_NAMES]
|
|
93
|
+
root = cwd or Path.cwd()
|
|
94
|
+
candidates += [root / name for name in MCP_CONFIG_NAMES]
|
|
95
|
+
if os.environ.get(MCP_CONFIG_ENV):
|
|
96
|
+
candidates.append(Path(os.environ[MCP_CONFIG_ENV]).expanduser())
|
|
97
|
+
if path:
|
|
98
|
+
candidates.append(Path(path).expanduser())
|
|
99
|
+
return [candidate for candidate in candidates if candidate.is_file()]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_mcp_config(path: str | Path | None = None, cwd: Path | None = None) -> dict[str, MCPServerConfig]:
|
|
103
|
+
"""Read `mcpServers` from every config that applies, merged by server name.
|
|
104
|
+
|
|
105
|
+
**Merged, not first-wins.** A global `~/.stcode/mcp.json` holds the servers you want
|
|
106
|
+
everywhere (context7, a docs server); a project's `.mcp.json` holds the ones this
|
|
107
|
+
repository needs and is checked in. Making one of them shadow the other entirely
|
|
108
|
+
means adding a single project server silently unplugs every global one.
|
|
109
|
+
|
|
110
|
+
The unit of precedence is the **whole entry**, not its fields: a name defined in two
|
|
111
|
+
places takes the later file's definition outright. Half a command from one file and
|
|
112
|
+
half from another is a server nobody configured.
|
|
113
|
+
"""
|
|
114
|
+
servers: dict[str, MCPServerConfig] = {}
|
|
115
|
+
for candidate in mcp_config_paths(path, cwd):
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(candidate.read_text(encoding="utf-8"))
|
|
118
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
119
|
+
raise ToolError(f"Could not read MCP config {candidate}: {exc}") from exc
|
|
120
|
+
entries = data.get("mcpServers", data)
|
|
121
|
+
if not isinstance(entries, dict):
|
|
122
|
+
continue
|
|
123
|
+
for name, entry in entries.items():
|
|
124
|
+
if isinstance(entry, dict):
|
|
125
|
+
servers[name] = MCPServerConfig.model_validate(entry)
|
|
126
|
+
return servers
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class MCPManager:
|
|
130
|
+
"""Owns the live connections and the tools they expose.
|
|
131
|
+
|
|
132
|
+
Opened once per session and held on an `AsyncExitStack`, not reopened per call: a
|
|
133
|
+
stdio server is a subprocess, and process startup turns a 20 ms call into a 2 s one.
|
|
134
|
+
|
|
135
|
+
**One task owns the stack.** The SDK's transports are anyio cancel scopes, and
|
|
136
|
+
anyio refuses to let one be exited by a task other than the one that entered it.
|
|
137
|
+
Opening in `Harness.create` and closing in `Harness.aclose` are different tasks, so
|
|
138
|
+
`open()` hands the stack to a task of its own. Requests may come from any task; only
|
|
139
|
+
entering and leaving is pinned.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self) -> None:
|
|
143
|
+
self._stack = AsyncExitStack()
|
|
144
|
+
self._owner: asyncio.Task[None] | None = None
|
|
145
|
+
self._ready: asyncio.Event = asyncio.Event()
|
|
146
|
+
self._stop: asyncio.Event = asyncio.Event()
|
|
147
|
+
self._clients: dict[str, Any] = {}
|
|
148
|
+
self.tools: dict[str, Tool[Any]] = {}
|
|
149
|
+
self.problems: list[str] = []
|
|
150
|
+
"""Servers that failed to connect. One broken server must not take down the
|
|
151
|
+
session; the agent is told which are missing rather than silently getting a
|
|
152
|
+
shorter tool list."""
|
|
153
|
+
|
|
154
|
+
async def open(
|
|
155
|
+
self, servers: dict[str, MCPServerConfig], *, timeout: float = CONNECT_TIMEOUT
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Connect to every server and keep them open until `aclose()`.
|
|
158
|
+
|
|
159
|
+
The way to use this class. `connect`/`connect_all` are its pieces; calling them
|
|
160
|
+
directly means owning the task problem yourself.
|
|
161
|
+
"""
|
|
162
|
+
if self._owner is not None:
|
|
163
|
+
return
|
|
164
|
+
self._owner = asyncio.create_task(self._hold(servers, timeout), name="stcode-mcp")
|
|
165
|
+
await self._ready.wait()
|
|
166
|
+
|
|
167
|
+
async def _hold(self, servers: dict[str, MCPServerConfig], timeout: float) -> None:
|
|
168
|
+
"""Enter the stack, connect, then sit here until told to stop. One task, which
|
|
169
|
+
is the point — see the class docstring."""
|
|
170
|
+
try:
|
|
171
|
+
async with self._stack:
|
|
172
|
+
await self.connect_all(servers, timeout=timeout)
|
|
173
|
+
self._ready.set()
|
|
174
|
+
await self._stop.wait()
|
|
175
|
+
finally:
|
|
176
|
+
self._ready.set() # a failure to connect must not leave `open()` waiting
|
|
177
|
+
self._clients.clear()
|
|
178
|
+
self.tools.clear()
|
|
179
|
+
|
|
180
|
+
async def connect_all(
|
|
181
|
+
self, servers: dict[str, MCPServerConfig], *, timeout: float = CONNECT_TIMEOUT
|
|
182
|
+
) -> None:
|
|
183
|
+
for name, config in servers.items():
|
|
184
|
+
if not config.enabled:
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
await self.connect(name, config, timeout=timeout)
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
self.problems.append(f"{name} ({config.describe()}): {exc}")
|
|
190
|
+
logger.warning("MCP server %s failed to connect: %s", name, exc)
|
|
191
|
+
|
|
192
|
+
async def connect(self, name: str, config: MCPServerConfig, *, timeout: float = CONNECT_TIMEOUT) -> None:
|
|
193
|
+
from mcp import StdioServerParameters
|
|
194
|
+
from mcp.client import Client
|
|
195
|
+
|
|
196
|
+
if config.url and config.headers:
|
|
197
|
+
# A URL alone is enough for the SDK, but a URL *with headers* is not: the
|
|
198
|
+
# string form builds its own HTTP client and there is nowhere to put them.
|
|
199
|
+
# So the transport is built here instead — the alternative is accepting a
|
|
200
|
+
# configured API key and never sending it.
|
|
201
|
+
# `create_mcp_http_client` is public and documented but absent from the
|
|
202
|
+
# module's `__all__`, which is a packaging detail of the SDK rather than a
|
|
203
|
+
# statement about the function.
|
|
204
|
+
from mcp.client.streamable_http import ( # type: ignore[attr-defined]
|
|
205
|
+
create_mcp_http_client,
|
|
206
|
+
streamable_http_client,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
target: Any = streamable_http_client(
|
|
210
|
+
config.url, http_client=create_mcp_http_client(dict(config.headers))
|
|
211
|
+
)
|
|
212
|
+
elif config.url:
|
|
213
|
+
target = config.url
|
|
214
|
+
elif config.command:
|
|
215
|
+
target = StdioServerParameters(
|
|
216
|
+
command=config.command,
|
|
217
|
+
args=config.args,
|
|
218
|
+
# Layered over the real environment: a server given only its own `env`
|
|
219
|
+
# loses PATH and cannot find its interpreter.
|
|
220
|
+
env={**os.environ, **config.env},
|
|
221
|
+
cwd=config.cwd,
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
raise ToolError(f"MCP server {name!r} has neither `command` nor `url`.")
|
|
225
|
+
|
|
226
|
+
client = await asyncio.wait_for(self._stack.enter_async_context(Client(target)), timeout)
|
|
227
|
+
self._clients[name] = client
|
|
228
|
+
|
|
229
|
+
listing = await asyncio.wait_for(client.list_tools(), timeout)
|
|
230
|
+
for remote in listing.tools:
|
|
231
|
+
wrapped = self._wrap(name, client, remote)
|
|
232
|
+
self.tools[wrapped.name] = wrapped
|
|
233
|
+
|
|
234
|
+
def _wrap(self, server: str, client: Any, remote: Any) -> Tool[Any]:
|
|
235
|
+
qualified = f"mcp__{server}__{remote.name}"
|
|
236
|
+
|
|
237
|
+
async def call(runtime: Runtime[Any], **arguments: Any) -> str:
|
|
238
|
+
result = await client.call_tool(remote.name, arguments)
|
|
239
|
+
text = _render_content(result)
|
|
240
|
+
if getattr(result, "is_error", False):
|
|
241
|
+
raise ToolError(f"{qualified} failed: {text}")
|
|
242
|
+
return text
|
|
243
|
+
|
|
244
|
+
return Tool(
|
|
245
|
+
call,
|
|
246
|
+
name=qualified,
|
|
247
|
+
description=(remote.description or f"Tool {remote.name} from the {server!r} MCP server.").strip(),
|
|
248
|
+
permission=ToolPermission.EXECUTE,
|
|
249
|
+
max_output=MCP_MAX_OUTPUT,
|
|
250
|
+
input_schema=remote.input_schema or {"type": "object", "properties": {}},
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def schemas(self) -> dict[str, dict[str, dict[str, Any]]]:
|
|
254
|
+
"""`{server: {tool: {description, input_schema}}}` — the generator's input."""
|
|
255
|
+
by_server: dict[str, dict[str, dict[str, Any]]] = {}
|
|
256
|
+
for qualified, entry in self.tools.items():
|
|
257
|
+
_, server, tool = qualified.split("__", 2)
|
|
258
|
+
by_server.setdefault(server, {})[tool] = {
|
|
259
|
+
"description": entry.description,
|
|
260
|
+
"input_schema": entry.input_schema,
|
|
261
|
+
}
|
|
262
|
+
return by_server
|
|
263
|
+
|
|
264
|
+
def catalogue(self) -> str:
|
|
265
|
+
"""Server and tool names for the prompt. Names only — the schemas are the
|
|
266
|
+
expensive half, and reading the generated file is how you get them."""
|
|
267
|
+
lines = []
|
|
268
|
+
for server, tools in sorted(self.schemas().items()):
|
|
269
|
+
lines.append(f"- `{server}/` — " + ", ".join(f"`{name}`" for name in sorted(tools)))
|
|
270
|
+
return "\n".join(lines)
|
|
271
|
+
|
|
272
|
+
async def aclose(self) -> None:
|
|
273
|
+
"""Stop. Unwinds in the task that opened, or here if nothing owns the stack."""
|
|
274
|
+
if self._owner is not None:
|
|
275
|
+
self._stop.set()
|
|
276
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
277
|
+
await self._owner
|
|
278
|
+
self._owner = None
|
|
279
|
+
return
|
|
280
|
+
await self._stack.aclose()
|
|
281
|
+
self._clients.clear()
|
|
282
|
+
self.tools.clear()
|
|
283
|
+
|
|
284
|
+
async def __aenter__(self) -> "MCPManager":
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
288
|
+
await self.aclose()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---- MCP as code ----------------------------------------------------------------
|
|
292
|
+
#
|
|
293
|
+
# Stubs run in the REPL subprocess — the same interpreter — so they import `call` from
|
|
294
|
+
# here instead of carrying a copy of the client. That keeps each generated file small
|
|
295
|
+
# enough to be worth reading, and credentials out of the workspace.
|
|
296
|
+
|
|
297
|
+
MCP_CODE_DIRNAME = ".stcode/mcp_servers"
|
|
298
|
+
|
|
299
|
+
_HEADER = '''"""
|
|
300
|
+
{title}
|
|
301
|
+
|
|
302
|
+
Generated by stcode from {source}. Do not edit — regenerated every session.
|
|
303
|
+
"""
|
|
304
|
+
'''
|
|
305
|
+
|
|
306
|
+
_REPL_MANAGERS: dict[str, tuple[Any, "MCPManager"]] = {}
|
|
307
|
+
"""One manager per server, in the REPL process, **with the loop it was opened on**.
|
|
308
|
+
|
|
309
|
+
Each manager owns a task holding its stack, so a second server joining an already-open
|
|
310
|
+
manager would never actually connect. The loop is remembered because a connection does
|
|
311
|
+
not outlive it: a cell that runs `asyncio.run(...)` gets a fresh loop, and closing it
|
|
312
|
+
cancels the task holding the stack, whose `finally` empties `_clients`. Cached without
|
|
313
|
+
the loop, the next call finds a manager that is still in the dict and no longer
|
|
314
|
+
connected — which surfaced as a bare `KeyError: '<server>'` from inside the stub, three
|
|
315
|
+
layers below anything that could explain it.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _live_manager(server: str) -> "MCPManager | None":
|
|
320
|
+
"""The cached manager for `server`, if it is still usable from this task.
|
|
321
|
+
|
|
322
|
+
Same loop, still holding a client. Anything else is discarded rather than returned:
|
|
323
|
+
a stale entry is why reconnecting was impossible without restarting the REPL.
|
|
324
|
+
"""
|
|
325
|
+
cached = _REPL_MANAGERS.get(server)
|
|
326
|
+
if cached is None:
|
|
327
|
+
return None
|
|
328
|
+
loop, manager = cached
|
|
329
|
+
if loop is asyncio.get_running_loop() and server in manager._clients:
|
|
330
|
+
return manager
|
|
331
|
+
_REPL_MANAGERS.pop(server, None)
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def call(server: str, tool: str, arguments: dict[str, Any]) -> Any:
|
|
336
|
+
"""Call one MCP tool. What every generated stub ends up in.
|
|
337
|
+
|
|
338
|
+
Connects on first use and holds the connection for as long as the event loop that
|
|
339
|
+
opened it lives — which, in the REPL, is the whole session unless a cell closes it.
|
|
340
|
+
|
|
341
|
+
Returns structured data when the server provides it, then parsed JSON, then text —
|
|
342
|
+
the reason to call a tool from code is to filter before printing, and you cannot
|
|
343
|
+
filter a paragraph.
|
|
344
|
+
"""
|
|
345
|
+
manager = _live_manager(server)
|
|
346
|
+
if manager is None:
|
|
347
|
+
servers = load_mcp_config()
|
|
348
|
+
if server not in servers:
|
|
349
|
+
raise ToolError(
|
|
350
|
+
f"No MCP server named {server!r} in the config. Available: "
|
|
351
|
+
f"{', '.join(servers) or '(none)'}."
|
|
352
|
+
)
|
|
353
|
+
manager = MCPManager()
|
|
354
|
+
await manager.open({server: servers[server]})
|
|
355
|
+
if server not in manager._clients:
|
|
356
|
+
problem = "; ".join(manager.problems) or "the server did not start"
|
|
357
|
+
raise ToolError(f"Could not connect to the {server!r} MCP server: {problem}")
|
|
358
|
+
_REPL_MANAGERS[server] = (asyncio.get_running_loop(), manager)
|
|
359
|
+
|
|
360
|
+
client = manager._clients[server]
|
|
361
|
+
result = await client.call_tool(tool, {k: v for k, v in arguments.items() if v is not None})
|
|
362
|
+
if getattr(result, "is_error", False):
|
|
363
|
+
raise ToolError(f"{server}.{tool} failed: {_render_content(result)}")
|
|
364
|
+
return _structured(result)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _structured(result: Any) -> Any:
|
|
368
|
+
"""The most useful shape the result comes in."""
|
|
369
|
+
if getattr(result, "structured_content", None):
|
|
370
|
+
return result.structured_content
|
|
371
|
+
text = _render_content(result)
|
|
372
|
+
try:
|
|
373
|
+
return json.loads(text)
|
|
374
|
+
except (json.JSONDecodeError, TypeError):
|
|
375
|
+
return text
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
_PY_TYPES = {
|
|
379
|
+
"string": "str",
|
|
380
|
+
"number": "float",
|
|
381
|
+
"integer": "int",
|
|
382
|
+
"boolean": "bool",
|
|
383
|
+
"array": "list[Any]",
|
|
384
|
+
"object": "dict[str, Any]",
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _annotation(schema: dict[str, Any]) -> str:
|
|
389
|
+
kind = schema.get("type")
|
|
390
|
+
if isinstance(kind, list): # e.g. ["string", "null"]
|
|
391
|
+
kind = next((k for k in kind if k != "null"), None)
|
|
392
|
+
return _PY_TYPES.get(str(kind), "Any")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def generate_server_code(
|
|
396
|
+
root: Path, servers: dict[str, dict[str, Any]], *, source: str = ".mcp.json"
|
|
397
|
+
) -> Path:
|
|
398
|
+
"""Write `.stcode/mcp_servers/` from `{server: {tool: schema}}`. Returns the root.
|
|
399
|
+
|
|
400
|
+
Rewritten from scratch each session: a stale stub is worse than a missing one, as
|
|
401
|
+
the agent reads it, believes it, and calls something that is not there.
|
|
402
|
+
"""
|
|
403
|
+
package = root / MCP_CODE_DIRNAME
|
|
404
|
+
if package.exists():
|
|
405
|
+
shutil.rmtree(package, ignore_errors=True)
|
|
406
|
+
package.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
# Generated from the config on every session, so committing it would be committing
|
|
408
|
+
# a build artefact that is wrong the moment a server changes. The directory ignores
|
|
409
|
+
# itself rather than asking every project to edit its own `.gitignore` — and because
|
|
410
|
+
# the tree is deleted and rewritten each session, this file is written each time too.
|
|
411
|
+
(package / ".gitignore").write_text("*\n", encoding="utf-8")
|
|
412
|
+
|
|
413
|
+
listing = "\n".join(
|
|
414
|
+
f" {name}/ -> " + ", ".join(sorted(tools)) for name, tools in sorted(servers.items())
|
|
415
|
+
)
|
|
416
|
+
(package / "__init__.py").write_text(
|
|
417
|
+
_HEADER.format(
|
|
418
|
+
title="MCP servers, as importable Python.\n\n" + (listing or " (no servers)"),
|
|
419
|
+
source=source,
|
|
420
|
+
)
|
|
421
|
+
+ "\n__all__ = [" + ", ".join(repr(name) for name in sorted(servers)) + "]\n",
|
|
422
|
+
encoding="utf-8",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
for server, tools in servers.items():
|
|
426
|
+
directory = package / _identifier(server)
|
|
427
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
for tool_name, schema in tools.items():
|
|
429
|
+
(directory / f"{_identifier(tool_name)}.py").write_text(
|
|
430
|
+
_render_stub(server, tool_name, schema, source), encoding="utf-8"
|
|
431
|
+
)
|
|
432
|
+
exports = sorted(_identifier(name) for name in tools)
|
|
433
|
+
(directory / "__init__.py").write_text(
|
|
434
|
+
_HEADER.format(title=f"Tools from the {server!r} MCP server.", source=source)
|
|
435
|
+
+ "\n"
|
|
436
|
+
+ "\n".join(f"from .{name} import {name}" for name in exports)
|
|
437
|
+
+ f"\n\n__all__ = [{', '.join(repr(name) for name in exports)}]\n",
|
|
438
|
+
encoding="utf-8",
|
|
439
|
+
)
|
|
440
|
+
return package
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _render_stub(server: str, tool: str, schema: dict[str, Any], source: str) -> str:
|
|
444
|
+
"""One tool, as a Python file the agent can read and import.
|
|
445
|
+
|
|
446
|
+
The docstring is the whole interface — the server's description, then one line per
|
|
447
|
+
argument. Reading this file replaces having its schema in the prompt.
|
|
448
|
+
"""
|
|
449
|
+
properties: dict[str, Any] = schema.get("input_schema", {}).get("properties", {}) or {}
|
|
450
|
+
required = list(schema.get("input_schema", {}).get("required", []) or [])
|
|
451
|
+
function = _identifier(tool)
|
|
452
|
+
|
|
453
|
+
parameters, arguments, documented, skipped = [], [], [], []
|
|
454
|
+
for name, spec in properties.items():
|
|
455
|
+
safe = _identifier(name)
|
|
456
|
+
if safe != name:
|
|
457
|
+
# A name Python cannot spell. Say so rather than generating a call the
|
|
458
|
+
# agent cannot make.
|
|
459
|
+
skipped.append(name)
|
|
460
|
+
continue
|
|
461
|
+
annotation = _annotation(spec if isinstance(spec, dict) else {})
|
|
462
|
+
if name in required:
|
|
463
|
+
parameters.append(f"{safe}: {annotation}")
|
|
464
|
+
else:
|
|
465
|
+
parameters.append(f"{safe}: {annotation} | None = None")
|
|
466
|
+
arguments.append(f'"{name}": {safe}')
|
|
467
|
+
description = (spec.get("description") or "").strip().replace("\n", " ") if isinstance(spec, dict) else ""
|
|
468
|
+
documented.append(f" {safe}: {description or '(no description)'}")
|
|
469
|
+
|
|
470
|
+
lines = [_HEADER.format(title=f"{server}.{tool}", source=source)]
|
|
471
|
+
lines.append("from typing import Any\n")
|
|
472
|
+
lines.append("from stcode.core.harness.mcp import call\n")
|
|
473
|
+
lines.append("")
|
|
474
|
+
lines.append(f"async def {function}({', '.join(parameters)}) -> Any:")
|
|
475
|
+
|
|
476
|
+
body = [f' """{(schema.get("description") or f"The {tool} tool.").strip()}']
|
|
477
|
+
if documented:
|
|
478
|
+
body += ["", " Args:", *documented]
|
|
479
|
+
if skipped:
|
|
480
|
+
body += ["", " Not callable from here (the server names them in a way Python cannot):",
|
|
481
|
+
" " + ", ".join(skipped)]
|
|
482
|
+
body += [
|
|
483
|
+
"",
|
|
484
|
+
" Returns parsed data when the server sends it, otherwise text.",
|
|
485
|
+
" Arguments left as None are not sent.",
|
|
486
|
+
' """',
|
|
487
|
+
]
|
|
488
|
+
lines += body
|
|
489
|
+
lines.append(f' return await call("{server}", "{tool}", {{{", ".join(arguments)}}})')
|
|
490
|
+
return "\n".join(lines) + "\n"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _identifier(name: str) -> str:
|
|
494
|
+
"""A name Python can spell, without inventing collisions."""
|
|
495
|
+
cleaned = "".join(char if char.isalnum() or char == "_" else "_" for char in name)
|
|
496
|
+
if not cleaned or cleaned[0].isdigit():
|
|
497
|
+
cleaned = f"_{cleaned}"
|
|
498
|
+
return cleaned + "_" if keyword.iskeyword(cleaned) else cleaned
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _render_content(result: Any) -> str:
|
|
502
|
+
"""Flatten an MCP `CallToolResult` into text.
|
|
503
|
+
|
|
504
|
+
Text blocks are joined; other block types are named rather than dropped — "the
|
|
505
|
+
server returned an image" is actionable, an empty string is not.
|
|
506
|
+
"""
|
|
507
|
+
parts: list[str] = []
|
|
508
|
+
for block in getattr(result, "content", None) or []:
|
|
509
|
+
text = getattr(block, "text", None)
|
|
510
|
+
if text:
|
|
511
|
+
parts.append(text)
|
|
512
|
+
else:
|
|
513
|
+
parts.append(f"[{getattr(block, 'type', 'content')} omitted — not text]")
|
|
514
|
+
if not parts and getattr(result, "structured_content", None):
|
|
515
|
+
return json.dumps(result.structured_content, indent=2, default=str)
|
|
516
|
+
return "\n".join(parts) or "(no content)"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
__all__ = [
|
|
520
|
+
"MCPManager",
|
|
521
|
+
"MCPServerConfig",
|
|
522
|
+
"MCP_CODE_DIRNAME",
|
|
523
|
+
"MCP_CONFIG_ENV",
|
|
524
|
+
"MCP_CONFIG_NAMES",
|
|
525
|
+
"USER_MCP_DIR",
|
|
526
|
+
"call",
|
|
527
|
+
"generate_server_code",
|
|
528
|
+
"load_mcp_config",
|
|
529
|
+
"mcp_config_paths",
|
|
530
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`OutputStore` — where elided tool output is parked, with a ceiling.
|
|
3
|
+
|
|
4
|
+
Rule 1 says output is elided rather than summarised, *provided the whole value stays
|
|
5
|
+
somewhere the model can reach*. That place is this mapping (and the REPL's `tool_out`,
|
|
6
|
+
which mirrors it). Nothing ever removed an entry, so a long session paid for every
|
|
7
|
+
large result it had produced since it started, for as long as it ran.
|
|
8
|
+
|
|
9
|
+
A ceiling instead: newest wins, oldest goes. The trade is deliberate — the alternative
|
|
10
|
+
to dropping the oldest spill is a daemon that grows until it is killed, and an
|
|
11
|
+
`output_id` from forty tool calls ago is one nobody is going to slice.
|
|
12
|
+
|
|
13
|
+
Beside `harness.py`, not in `core/common/`: the harness is the only thing that holds
|
|
14
|
+
one. `elide` is shared because `core/repl/` uses it too; the store is not.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections import OrderedDict
|
|
20
|
+
from typing import Any, Iterator, MutableMapping
|
|
21
|
+
|
|
22
|
+
MAX_ENTRIES = 32
|
|
23
|
+
"""Spilled results kept. A turn tops out at 40 tool calls and few of them spill, so
|
|
24
|
+
this reaches back further than any context the model still has."""
|
|
25
|
+
|
|
26
|
+
MAX_CHARS = 8 * 1024 * 1024
|
|
27
|
+
"""Total size of the text held, across every entry. A single 8 MB result therefore
|
|
28
|
+
evicts everything else and stays — which is right: it is the one just referred to."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def size_of(value: Any) -> int:
|
|
32
|
+
"""How much of the budget a value spends. Text is measured; anything else is free.
|
|
33
|
+
|
|
34
|
+
Not `sys.getsizeof` or a pickle round-trip: the thing being bounded is transcript
|
|
35
|
+
bulk, and a structure the tool parsed for us is small precisely because it is
|
|
36
|
+
structured.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(value, (str, bytes, bytearray)):
|
|
39
|
+
return len(value)
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OutputStore(MutableMapping[str, Any]):
|
|
44
|
+
"""An insertion-ordered mapping that evicts the oldest entry when full."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, *, max_entries: int = MAX_ENTRIES, max_chars: int = MAX_CHARS) -> None:
|
|
47
|
+
self.max_entries = max_entries
|
|
48
|
+
self.max_chars = max_chars
|
|
49
|
+
self._items: OrderedDict[str, Any] = OrderedDict()
|
|
50
|
+
self._chars = 0
|
|
51
|
+
|
|
52
|
+
# ---- mapping ----
|
|
53
|
+
|
|
54
|
+
def __getitem__(self, key: str) -> Any:
|
|
55
|
+
value = self._items[key]
|
|
56
|
+
# Reading counts as use: the entry the model just sliced is the one most likely
|
|
57
|
+
# to be sliced again.
|
|
58
|
+
self._items.move_to_end(key)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
62
|
+
if key in self._items:
|
|
63
|
+
self._chars -= size_of(self._items.pop(key))
|
|
64
|
+
self._items[key] = value
|
|
65
|
+
self._chars += size_of(value)
|
|
66
|
+
self._evict()
|
|
67
|
+
|
|
68
|
+
def __delitem__(self, key: str) -> None:
|
|
69
|
+
self._chars -= size_of(self._items.pop(key))
|
|
70
|
+
|
|
71
|
+
def __iter__(self) -> Iterator[str]:
|
|
72
|
+
return iter(self._items)
|
|
73
|
+
|
|
74
|
+
def __len__(self) -> int:
|
|
75
|
+
return len(self._items)
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
return f"OutputStore({len(self._items)} entries, {self._chars:,} chars)"
|
|
79
|
+
|
|
80
|
+
# ---- the ceiling ----
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def chars(self) -> int:
|
|
84
|
+
"""Text currently held. What the ceiling is measured against."""
|
|
85
|
+
return self._chars
|
|
86
|
+
|
|
87
|
+
def _evict(self) -> None:
|
|
88
|
+
"""Drop oldest-first until both ceilings hold.
|
|
89
|
+
|
|
90
|
+
Never drops the entry just written, even when it alone is over the char budget:
|
|
91
|
+
the caller has already told the model where to find it.
|
|
92
|
+
"""
|
|
93
|
+
while len(self._items) > self.max_entries or (
|
|
94
|
+
self._chars > self.max_chars and len(self._items) > 1
|
|
95
|
+
):
|
|
96
|
+
self._chars -= size_of(self._items.popitem(last=False)[1])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["MAX_CHARS", "MAX_ENTRIES", "OutputStore", "size_of"]
|