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.
Files changed (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. 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"]