tools: register MCP server tools in the catalog (mcp source, purge)

This commit is contained in:
2026-09-08 12:06:34 +08:00
parent 7967095dbc
commit d603615d6a
3 changed files with 211 additions and 3 deletions
+10 -3
View File
@@ -12,7 +12,7 @@ from plyngent.agent.tools import ToolDefinition, ToolTag
if TYPE_CHECKING:
from collections.abc import Container, Generator
type ToolSourceKind = Literal["builtin", "plugin"]
type ToolSourceKind = Literal["builtin", "plugin", "mcp"]
type ToolSurface = Literal["local", "public"]
@@ -29,8 +29,8 @@ class ToolSource:
def __str__(self) -> str:
if self.kind == "builtin":
return "builtin"
plugin = self.plugin_id or "?"
return f"plugin:{plugin}"
id_part = self.plugin_id or "?"
return f"{self.kind}:{id_part}"
@dataclass(frozen=True, slots=True)
@@ -152,6 +152,13 @@ class ToolCatalog:
def clear(self) -> None:
self._by_name.clear()
def purge_kind(self, kind: ToolSourceKind) -> int:
"""Remove every entry registered from *kind*; return the count removed."""
doomed = [name for name, entry in self._by_name.items() if entry.source.kind == kind]
for name in doomed:
del self._by_name[name]
return len(doomed)
def snapshot(self) -> dict[str, RegisteredTool]:
return dict(self._by_name)
+108
View File
@@ -0,0 +1,108 @@
"""Register MCP server tools into the tool catalog (namespaced, LOCAL tags).
Mirrors :mod:`plyngent.tools.plugins`: the catalog only *registers* what the
model may see; hosts still select into a :class:`ToolRegistry`. Connections
must already be started (see :class:`~plyngent.runtime.mcp_client.McpManager`);
this module is sync-only so registry rebuilds never need an event loop.
Tool names are namespaced ``mcp__<server>__<tool>`` so two servers (or a
server and a builtin) can never collide on sanitized names; the catalog still
refuses true clashes.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from plyngent.agent.tools import ToolDefinition, ToolTag
from plyngent.tools.catalog import ToolSource, get_catalog
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from plyngent.config.models import McpConfig
from plyngent.runtime.mcp_client import McpManager, McpTool
_MCP_PREFIX = "mcp__"
_NAME_SEGMENT_MAX = 64
_SANITIZE_PATTERN = re.compile(r"[^A-Za-z0-9_-]")
def _sanitize_segment(part: str) -> str:
"""Reduce a server/tool name to a model-safe, bounded name segment."""
return _SANITIZE_PATTERN.sub("_", part)[:_NAME_SEGMENT_MAX] or "srv"
def mcp_tool_name(server: str, tool: str) -> str:
"""Namespaced catalog name for one MCP tool (collision-free per server)."""
return f"{_MCP_PREFIX}{_sanitize_segment(server)}__{_sanitize_segment(tool)}"
def _make_handler(
manager: McpManager,
server: str,
tool: str,
) -> Callable[..., Awaitable[str]]:
async def handler(**arguments: object) -> str:
return await manager.call(server, tool, dict(arguments))
return handler
def register_mcp_server_tools(
server: str,
tools: list[McpTool],
*,
manager: McpManager,
read_only: bool = False,
) -> list[str]:
"""Register one server's advertised tools; returns the registered names.
Tags: LOCAL (plus READ_ONLY when the server config opts in) — never YOLO or
TRUSTABLE, so host-side danger classification always sees every call.
"""
catalog = get_catalog()
source = ToolSource(kind="mcp", plugin_id=server)
names: list[str] = []
for entry in tools:
if not entry.name:
continue
tags = ToolTag.LOCAL
if read_only:
tags |= ToolTag.READ_ONLY
description = entry.description or f"MCP tool {entry.name!r} from server {server!r}"
definition = ToolDefinition(
name=mcp_tool_name(server, entry.name),
description=f"[mcp:{server}] {description}",
parameters=dict(entry.input_schema),
handler=_make_handler(manager, server, entry.name),
tags=tags,
)
catalog.register(definition, source=source)
names.append(definition.name)
return names
def purge_mcp_tools() -> int:
"""Remove previously registered MCP tools (call before re-registering)."""
return get_catalog().purge_kind("mcp")
def register_mcp_tools(manager: McpManager, config: McpConfig) -> list[str]:
"""Register every connected server's tools; replaces prior MCP entries."""
_ = purge_mcp_tools()
names: list[str] = []
for connection in manager.connections():
if not connection.connected or not connection.tools:
continue
server_cfg = config.servers.get(connection.name)
names.extend(
register_mcp_server_tools(
connection.name,
connection.tools,
manager=manager,
read_only=server_cfg.read_only if server_cfg is not None else False,
)
)
return names