zettcode-mcp 0.4.1__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.
@@ -0,0 +1,14 @@
1
+ """The MCP client of ZettCode: the plugin that connects the servers a configuration file names, and the extension it hands
2
+ the agent.
3
+
4
+ This is a part of the program that lives in a distribution of its own, found through the ``zettcode.agent`` entry point
5
+ and activated with the agent's other parts. ``[mcp] enabled`` in the configuration is what turns it off. The extension
6
+ (:mod:`zettcode.mcp.extension`) imports the MCP SDK, which costs more than the first frame, so nothing here imports it
7
+ until the agent is built.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .plugin import McpTools
13
+
14
+ __all__ = ["McpTools"]
@@ -0,0 +1,276 @@
1
+ """The MCP extension ZettCode hands to the runtime: one broken server never stops the task.
2
+
3
+ zett-agent's :class:`McpExtension` connects the servers a configuration file names and registers
4
+ their tools. Two things used to make it brittle, and this module is about not letting either one
5
+ end a conversation:
6
+
7
+ * A server that would not start failed the whole request (zett-agent 0.1.14), and the failure
8
+ named nothing. Since 0.1.15 the framework skips a server that cannot be reached or listed,
9
+ carries on with the others, and tells *the model*. This adds the other half: it tells *the
10
+ reader*, once, through the transcript, because a tool that quietly is not there looks like a
11
+ bug in the program.
12
+ * One malformed entry in ``mcp.json`` invalidated the file, and a file that is not valid JSON
13
+ failed the construction of the extension — which happens while the runtime is being built, so
14
+ the first turn never ran. Entries that cannot be used are skipped and named, and a file that
15
+ cannot be read at all means "no MCP servers" plus a line saying why.
16
+
17
+ * Starting a server is slow and silent. A stdio server is a process — ``npx`` may download a package
18
+ first — and the framework connects and lists its tools while the first request waits, so the
19
+ reader saw "Processing" for as long as that took with nothing to say why. A line is said when each
20
+ server starts and when it is ready, with how long it took and how many tools it brought. A server
21
+ whose tool list is already cached starts nothing, so it says nothing.
22
+
23
+ That is said **once per session, at its first turn**: it explains why that turn was slow, and only
24
+ that turn. The framework forgets a tool list after five minutes and lists it again at the top of the
25
+ next request, which in a long conversation is most turns; a reader does not need to be told again
26
+ each time that servers they already know about were looked at. A subagent, which has a session of its
27
+ own beside the one the reader is in, says nothing: the reader never asked it anything.
28
+
29
+ A tool that fails while it runs is the framework's own business and already harmless: the failure
30
+ comes back to the model as an ordinary failed tool result, and the model carries on.
31
+
32
+ What this does *not* do is catch everything. A cancellation is how the reader stops a request, and
33
+ turning it into some other error would make Escape fail instead of stop; only ``Exception`` is
34
+ absorbed here.
35
+
36
+ The module is imported when a runtime starts, not when the application is imported: the MCP SDK it
37
+ builds on costs more than the first frame.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import time
43
+ from collections.abc import Sequence
44
+ from contextvars import ContextVar
45
+ from pathlib import Path
46
+ from typing import Any, cast
47
+
48
+ from zett_agent.agent import AgentRunContext
49
+ from zett_agent.events import AgentEvent, AgentEventType
50
+ from zett_agent.extensions.mcp import DEFAULT_MCP_SERVER_KEYS, McpConfiguration, McpExtension, McpStdioServer
51
+
52
+ from zettcode.engine.extensions.mcp_notice import MCP_NOTICE_EVENT_NAME, notice_payload
53
+ from zettcode.error_text import describe_error
54
+
55
+ #: The request whose tools are being discovered, so a server starting on its own task can say so in
56
+ #: that request's transcript. Set around the framework's discovery and read by the tasks it spawns, which
57
+ #: inherit it; ``None`` outside one.
58
+ _DISCOVERING: ContextVar[AgentRunContext | None] = ContextVar("zettcode_mcp_discovering", default=None)
59
+
60
+
61
+ class ReportingMcpExtension(McpExtension):
62
+ """An MCP extension that keeps going when a server or a config entry is broken, and says so.
63
+
64
+ Args:
65
+ **options: Passed to :class:`McpExtension`. ``strict_config`` defaults to ``False`` here:
66
+ the framework's default makes one bad entry invalidate every other server.
67
+ """
68
+
69
+ def __init__(self, **options: Any) -> None:
70
+ options.setdefault("strict_config", False)
71
+ super().__init__(**options)
72
+ #: What each unavailable server was last reported as, so a server that stays down is
73
+ #: reported once rather than at the top of every turn.
74
+ self._reported: dict[str, str] = {}
75
+ #: The stamp of the configuration file that was last checked for entries to report.
76
+ self._checked: tuple[int, int] | None | object = _UNCHECKED
77
+ #: Lines to say at the first request: what went wrong before there was a request to say it in.
78
+ self.startup_issues: list[str] = []
79
+ #: The sessions whose first turn has been and gone, which is when the start of the servers is said.
80
+ self._narrated: set[str] = set()
81
+
82
+ async def on_tool(self, context: AgentRunContext) -> None:
83
+ """Register the tools of every server that works, and tell the reader about the ones that do not.
84
+
85
+ An error the framework does not handle itself — it handles a server that cannot connect or
86
+ list — costs this request its MCP tools and nothing else.
87
+ """
88
+ token = _DISCOVERING.set(context)
89
+ try:
90
+ await super().on_tool(context)
91
+ except Exception as error:
92
+ # Not BaseException: a cancellation has to stay a cancellation. The framework has
93
+ # already closed what it opened for this request before the error got here.
94
+ await self._say(context, f"MCP is unavailable for this request: {describe_error(error)}")
95
+ return
96
+ finally:
97
+ _DISCOVERING.reset(token)
98
+ # After the discovery, not before: every server of this turn is narrated or none is.
99
+ if self._is_a_reader_session(context):
100
+ self._narrated.add(cast(str, context.config.session_id))
101
+ for line in (*self.startup_issues, *self._config_issues(), *self._server_issues(context)):
102
+ await self._say(context, line)
103
+ self.startup_issues.clear()
104
+
105
+ async def _discover(self, request: Any, server: Any) -> Any:
106
+ """Connect to one server and list its tools, saying when it starts and when it is ready.
107
+
108
+ The framework's cache decides whether anything is started at all; when it holds this
109
+ server's tools nothing is, and nothing is said. The server's command and arguments are
110
+ never repeated here — they can carry a key — only its name and what kind of server it is.
111
+ """
112
+ context = _DISCOVERING.get()
113
+ cached = self._tool_cache.get(server.name)
114
+ starts = (
115
+ context is not None
116
+ and self._first_turn_of_a_reader_session(context)
117
+ and not (cached is not None and cached.server == server and cached.expires_at > time.monotonic())
118
+ )
119
+ began = time.monotonic()
120
+ if starts:
121
+ kind = "process" if isinstance(server, McpStdioServer) else "connection"
122
+ await self._say_quietly(context, f"Starting MCP server {server.name!r} ({kind})\u2026")
123
+ tools = await super()._discover(request, server)
124
+ if starts and tools is not None:
125
+ count = len(tools)
126
+ took = time.monotonic() - began
127
+ noun = "tool" if count == 1 else "tools"
128
+ await self._say_quietly(context, f"MCP server {server.name!r} is ready: {count} {noun}, {took:.1f}s")
129
+ return tools
130
+
131
+ @staticmethod
132
+ def _is_a_reader_session(context: AgentRunContext) -> bool:
133
+ """Return whether this request belongs to a session the reader is in, not to a subagent's own."""
134
+ return not context.config.parent_session_id
135
+
136
+ def _first_turn_of_a_reader_session(self, context: AgentRunContext) -> bool:
137
+ """Return whether this is the first request its session has made, in a session the reader is in."""
138
+ return self._is_a_reader_session(context) and cast(str, context.config.session_id) not in self._narrated
139
+
140
+ async def _say_quietly(self, context: AgentRunContext | None, text: str, level: str = "info") -> None:
141
+ """Say a line without letting the saying fail the discovery it reports on.
142
+
143
+ These are progress lines (a server is starting, or ready), so they are ``info`` unless said otherwise.
144
+ """
145
+ if context is None:
146
+ return
147
+ try:
148
+ await self._say(context, text, level)
149
+ except Exception: # noqa: BLE001 - news about a server must never be what stops it
150
+ pass
151
+
152
+ def _server_issues(self, context: AgentRunContext) -> list[str]:
153
+ """Return a line for each server that became unavailable, or changed why, since last said.
154
+
155
+ The framework keeps the reason on the request it made for this context and tells the model
156
+ through the system prompt; neither is a way to ask it, so this reads the request. A server
157
+ that works again is forgotten, so that its next failure is news.
158
+ """
159
+ request = self._requests.get(context)
160
+ unavailable: dict[str, str] = dict(getattr(request, "unavailable", {}) or {})
161
+ for name in [name for name in self._reported if name not in unavailable]:
162
+ del self._reported[name]
163
+ lines = []
164
+ for name, reason in unavailable.items():
165
+ if self._reported.get(name) != reason:
166
+ self._reported[name] = reason
167
+ lines.append(f"MCP server {name!r} is not available ({reason}); its tools are skipped until it works")
168
+ return lines
169
+
170
+ def _config_issues(self) -> list[str]:
171
+ """Return a line for each entry of the configuration file that cannot be used.
172
+
173
+ Checked when the file is first seen and again whenever it changes, not on every turn.
174
+ """
175
+ path = self.config_path
176
+ if path is None:
177
+ return []
178
+ try:
179
+ status = path.stat()
180
+ except OSError:
181
+ self._checked = None
182
+ return []
183
+ stamp = (status.st_mtime_ns, status.st_size)
184
+ if stamp == self._checked:
185
+ return []
186
+ self._checked = stamp
187
+ try:
188
+ skipped = McpConfiguration.from_file(path, server_keys=self.server_keys, strict=False).skipped
189
+ except (OSError, ValueError) as error:
190
+ return [f"{path.name} cannot be used, so the last working MCP servers stay: {describe_error(error)}"]
191
+ return [f"MCP server {name!r} in {path.name} is ignored: {reason}" for name, reason in skipped.items()]
192
+
193
+ @staticmethod
194
+ async def _say(context: AgentRunContext, text: str, level: str = "warn") -> None:
195
+ """Publish one line for the transcript.
196
+
197
+ A line is a warning unless it says otherwise: what reaches here from the checks (a server that is down, an
198
+ entry that is ignored, a file that cannot be read) is something the reader may need to act on, and only the
199
+ progress lines of a start say ``info``.
200
+ """
201
+ await context.emit(
202
+ AgentEvent(
203
+ type=AgentEventType.CUSTOM,
204
+ session_id=cast(str, context.config.session_id),
205
+ name=MCP_NOTICE_EVENT_NAME,
206
+ payload=notice_payload(text, level),
207
+ )
208
+ )
209
+
210
+
211
+ #: Marks a stamp that has not been read yet, as distinct from a file that does not exist.
212
+ _UNCHECKED = object()
213
+
214
+
215
+ def build_mcp_extension(path: str | Path) -> ReportingMcpExtension:
216
+ """Return the MCP extension for a server file, whatever state the file is in.
217
+
218
+ The framework reads the file while the extension is constructed and raises when it is not
219
+ valid JSON or names no servers. This runs while the runtime is being built, so letting that
220
+ through would mean the first turn never starts because of a typo in a file the reader may not
221
+ even remember. The extension is built with no servers instead, and the reason is said at the
222
+ first request. Fixing the file takes effect on the next start: an extension that never loaded
223
+ a good file has nothing to reload.
224
+
225
+ Args:
226
+ path: The server file the reader configured.
227
+ """
228
+ try:
229
+ return ReportingMcpExtension(config_path=path)
230
+ except (OSError, ValueError) as error:
231
+ extension = ReportingMcpExtension(servers=())
232
+ name = Path(path).name
233
+ extension.startup_issues.append(
234
+ f"{name} cannot be read, so MCP is off: {describe_error(error)}. Fix it and restart."
235
+ )
236
+ return extension
237
+
238
+
239
+ def build_scoped_mcp_extension(paths: Sequence[Path]) -> ReportingMcpExtension | None:
240
+ """Return the MCP extension for the server files that exist, the first file winning a server name.
241
+
242
+ One file is :func:`build_mcp_extension`, which also notices the file changing. Several are read
243
+ once, here, and merged: the framework watches one path, and a server list assembled from two
244
+ files has no single file to watch. A file that cannot be read costs only its own servers, and
245
+ says why at the first request; an entry in it that cannot be used is named the same way.
246
+
247
+ Args:
248
+ paths: The files to read, the one that wins a name first; a path that is not a file is skipped.
249
+
250
+ Returns:
251
+ The extension, or ``None`` when no path is a file, so a run with nothing configured carries no
252
+ MCP instructions at all.
253
+ """
254
+ present = [path for path in paths if path.is_file()]
255
+ if not present:
256
+ return None
257
+ if len(present) == 1:
258
+ return build_mcp_extension(present[0])
259
+ servers: dict[str, Any] = {}
260
+ issues: list[str] = []
261
+ for path in present:
262
+ label = f"{path.parent.name}/{path.name}"
263
+ try:
264
+ loaded = McpConfiguration.from_file(path, server_keys=DEFAULT_MCP_SERVER_KEYS, strict=False)
265
+ except (OSError, ValueError) as error:
266
+ issues.append(
267
+ f"{label} cannot be read, so its servers are off: {describe_error(error)}. Fix it and restart."
268
+ )
269
+ continue
270
+ issues.extend(f"MCP server {name!r} in {label} is ignored: {reason}" for name, reason in loaded.skipped.items())
271
+ for server in loaded.servers:
272
+ # The earlier file wins, so a project's server of the same name replaces the user's.
273
+ servers.setdefault(server.name, server)
274
+ extension = ReportingMcpExtension(servers=tuple(servers.values()))
275
+ extension.startup_issues.extend(issues)
276
+ return extension
zettcode/mcp/plugin.py ADDED
@@ -0,0 +1,34 @@
1
+ """The plugin that hands the agent the tools MCP servers offer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from zettcode.engine.plugins.plugin import Plugin, PluginSetting
8
+
9
+ if TYPE_CHECKING: # pragma: no cover - annotations only
10
+ from zett_agent.extensions.base import AgentExtension
11
+
12
+ from zettcode.engine.plugins.context import PluginContext
13
+ from zettcode.engine.plugins.state import AgentBuild
14
+
15
+
16
+ class McpTools(Plugin):
17
+ """The tools MCP servers offer. A server that is down is skipped, never fatal."""
18
+
19
+ name = "mcp"
20
+ description = "MCP servers"
21
+ setting = PluginSetting("mcp_enabled", "mcp")
22
+
23
+ def activate(self, ctx: PluginContext) -> None:
24
+ """Register the extension when MCP is on; a run with no server file gets none at all."""
25
+ config = ctx.config
26
+ if not config.mcp_enabled:
27
+ return
28
+
29
+ def build(_: AgentBuild) -> AgentExtension | None:
30
+ from .extension import build_scoped_mcp_extension
31
+
32
+ return build_scoped_mcp_extension(config.mcp_files())
33
+
34
+ ctx.agent.register_extension_factory("ReportingMcpExtension", build)
zettcode/mcp/py.typed ADDED
File without changes
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-mcp
3
+ Version: 0.4.1
4
+ Summary: The MCP client of ZettCode: the tools MCP servers offer, as a part of the agent that lives apart from it
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
+ Author: Chang-LeHung
7
+ License-Expression: MIT
8
+ Keywords: agent,mcp,zettcode
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: zett-agent<0.3,>=0.2.7
19
+ Requires-Dist: zettcode-engine
@@ -0,0 +1,8 @@
1
+ zettcode/mcp/__init__.py,sha256=Y-l0NMi_8U-UezQaGN2KpySLIqpevv5b733o7zPpkx4,613
2
+ zettcode/mcp/extension.py,sha256=CtLlhJ439W5k7JlhKrrpicYijdDTCuuo0-_JUIy7y6E,14171
3
+ zettcode/mcp/plugin.py,sha256=Ao9DHIJpAIgtkLH1GzkkFkbMFqmSUSaVTc1gG7-wBLY,1150
4
+ zettcode/mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ zettcode_mcp-0.4.1.dist-info/METADATA,sha256=ltectZra8idAaYz1ETSTwT5vR0I1NgXdDYOGWPWuh9Y,793
6
+ zettcode_mcp-0.4.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ zettcode_mcp-0.4.1.dist-info/entry_points.txt,sha256=WQ0hkyEsH9tLTk9P8AGItN63Iww--uHJVRx1WOfvA6A,52
8
+ zettcode_mcp-0.4.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [zettcode.agent]
2
+ mcp = zettcode.mcp.plugin:McpTools