zettcode-mcp 0.4.1__tar.gz
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.
- zettcode_mcp-0.4.1/.gitignore +37 -0
- zettcode_mcp-0.4.1/PKG-INFO +19 -0
- zettcode_mcp-0.4.1/pyproject.toml +50 -0
- zettcode_mcp-0.4.1/src/zettcode/mcp/__init__.py +14 -0
- zettcode_mcp-0.4.1/src/zettcode/mcp/extension.py +276 -0
- zettcode_mcp-0.4.1/src/zettcode/mcp/plugin.py +34 -0
- zettcode_mcp-0.4.1/src/zettcode/mcp/py.typed +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
**/.venv/
|
|
2
|
+
**/__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.smoke/
|
|
8
|
+
.coverage
|
|
9
|
+
**/.pytest_cache/
|
|
10
|
+
**/.ruff_cache/
|
|
11
|
+
.zett-tool-output/
|
|
12
|
+
.env
|
|
13
|
+
.DS_Store
|
|
14
|
+
|
|
15
|
+
# viztracer writes its trace here by default.
|
|
16
|
+
/result.json
|
|
17
|
+
|
|
18
|
+
# The docs site: dependencies, build output, and VitePress' cache.
|
|
19
|
+
node_modules/
|
|
20
|
+
docs/.vitepress/dist/
|
|
21
|
+
docs/.vitepress/cache/
|
|
22
|
+
docs/.vitepress/.temp/
|
|
23
|
+
|
|
24
|
+
# The TypeScript client is built from its sources; only dist/ is published.
|
|
25
|
+
clients/typescript/dist/
|
|
26
|
+
|
|
27
|
+
# The gateway's page is built from clients/gateway into the package when a distribution is made (`make gateway-ui`);
|
|
28
|
+
# the build output is not kept in the repository. pyproject's `artifacts` puts it in the wheel all the same.
|
|
29
|
+
packages/zettcode-gateway/src/zettcode/gateway/static/
|
|
30
|
+
|
|
31
|
+
# The palette gallery is generated by `make palettes`: every widget painted in
|
|
32
|
+
# every palette, written by docs/scripts/palette_gallery.py. Regenerate it
|
|
33
|
+
# rather than committing a 2 MB copy that goes stale the moment a colour moves.
|
|
34
|
+
docs/internal/palettes.html
|
|
35
|
+
|
|
36
|
+
# The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
|
|
37
|
+
packages/order.txt
|
|
@@ -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,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zettcode-mcp"
|
|
3
|
+
description = "The MCP client of ZettCode: the tools MCP servers offer, as a part of the agent that lives apart from it"
|
|
4
|
+
requires-python = ">=3.10"
|
|
5
|
+
dynamic = ["version"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{name = "Chang-LeHung"}]
|
|
8
|
+
keywords = ["zettcode", "mcp", "agent"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 5 - Production/Stable",
|
|
11
|
+
"Programming Language :: Python :: 3.10",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
"Programming Language :: Python :: 3.15",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
# The plugin base classes, the notice the transcript recognises, and how an error is worded.
|
|
21
|
+
"zettcode-engine",
|
|
22
|
+
# The MCP extension this subclasses.
|
|
23
|
+
"zett-agent>=0.2.7,<0.3",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# A part of the program, not a plugin: the `zettcode.plugins` group is what the plugin release line looks for. It is
|
|
27
|
+
# activated with the agent's other parts and `[mcp] enabled` is what turns it off.
|
|
28
|
+
[project.entry-points."zettcode.agent"]
|
|
29
|
+
mcp = "zettcode.mcp.plugin:McpTools"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/Chang-LeHung/zettcode"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.version]
|
|
39
|
+
# One version for the whole repository: the engine ships the file.
|
|
40
|
+
path = "../zettcode-engine/src/zettcode/version.py"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/zettcode"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["/src", "/pyproject.toml"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
asyncio_mode = "auto"
|
|
@@ -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
|
|
@@ -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)
|
|
File without changes
|