Merge pull request #1302 from NousResearch/hermes/hermes-315847fd
feat(mcp): salvage selective tool loading with utility policies
This commit is contained in:
commit
c1cca65168
8 changed files with 1286 additions and 257 deletions
|
|
@ -688,7 +688,7 @@ class MCPServerTask:
|
|||
__slots__ = (
|
||||
"name", "session", "tool_timeout",
|
||||
"_task", "_ready", "_shutdown_event", "_tools", "_error", "_config",
|
||||
"_sampling",
|
||||
"_sampling", "_registered_tool_names",
|
||||
)
|
||||
|
||||
def __init__(self, name: str):
|
||||
|
|
@ -702,6 +702,7 @@ class MCPServerTask:
|
|||
self._error: Optional[Exception] = None
|
||||
self._config: dict = {}
|
||||
self._sampling: Optional[SamplingHandler] = None
|
||||
self._registered_tool_names: list[str] = []
|
||||
|
||||
def _is_http(self) -> bool:
|
||||
"""Check if this server uses HTTP transport."""
|
||||
|
|
@ -1308,16 +1309,81 @@ def _build_utility_schemas(server_name: str) -> List[dict]:
|
|||
]
|
||||
|
||||
|
||||
def _normalize_name_filter(value: Any, label: str) -> set[str]:
|
||||
"""Normalize include/exclude config to a set of tool names."""
|
||||
if value is None:
|
||||
return set()
|
||||
if isinstance(value, str):
|
||||
return {value}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return {str(item) for item in value}
|
||||
logger.warning("MCP config %s must be a string or list of strings; ignoring %r", label, value)
|
||||
return set()
|
||||
|
||||
|
||||
def _parse_boolish(value: Any, default: bool = True) -> bool:
|
||||
"""Parse a bool-like config value with safe fallback."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
logger.warning("MCP config expected a boolean-ish value, got %r; using default=%s", value, default)
|
||||
return default
|
||||
|
||||
|
||||
_UTILITY_CAPABILITY_METHODS = {
|
||||
"list_resources": "list_resources",
|
||||
"read_resource": "read_resource",
|
||||
"list_prompts": "list_prompts",
|
||||
"get_prompt": "get_prompt",
|
||||
}
|
||||
|
||||
|
||||
def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dict) -> List[dict]:
|
||||
"""Select utility schemas based on config and server capabilities."""
|
||||
tools_filter = config.get("tools") or {}
|
||||
resources_enabled = _parse_boolish(tools_filter.get("resources"), default=True)
|
||||
prompts_enabled = _parse_boolish(tools_filter.get("prompts"), default=True)
|
||||
|
||||
selected: List[dict] = []
|
||||
for entry in _build_utility_schemas(server_name):
|
||||
handler_key = entry["handler_key"]
|
||||
if handler_key in {"list_resources", "read_resource"} and not resources_enabled:
|
||||
logger.debug("MCP server '%s': skipping utility '%s' (resources disabled)", server_name, handler_key)
|
||||
continue
|
||||
if handler_key in {"list_prompts", "get_prompt"} and not prompts_enabled:
|
||||
logger.debug("MCP server '%s': skipping utility '%s' (prompts disabled)", server_name, handler_key)
|
||||
continue
|
||||
|
||||
required_method = _UTILITY_CAPABILITY_METHODS[handler_key]
|
||||
if not hasattr(server.session, required_method):
|
||||
logger.debug(
|
||||
"MCP server '%s': skipping utility '%s' (session lacks %s)",
|
||||
server_name,
|
||||
handler_key,
|
||||
required_method,
|
||||
)
|
||||
continue
|
||||
selected.append(entry)
|
||||
return selected
|
||||
|
||||
|
||||
def _existing_tool_names() -> List[str]:
|
||||
"""Return tool names for all currently connected servers."""
|
||||
names: List[str] = []
|
||||
for sname, server in _servers.items():
|
||||
for _sname, server in _servers.items():
|
||||
if hasattr(server, "_registered_tool_names"):
|
||||
names.extend(server._registered_tool_names)
|
||||
continue
|
||||
for mcp_tool in server._tools:
|
||||
schema = _convert_mcp_schema(sname, mcp_tool)
|
||||
schema = _convert_mcp_schema(server.name, mcp_tool)
|
||||
names.append(schema["name"])
|
||||
# Also include utility tool names
|
||||
for entry in _build_utility_schemas(sname):
|
||||
names.append(entry["schema"]["name"])
|
||||
return names
|
||||
|
||||
|
||||
|
|
@ -1343,7 +1409,27 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
|
|||
registered_names: List[str] = []
|
||||
toolset_name = f"mcp-{name}"
|
||||
|
||||
# Selective tool loading: honour include/exclude lists from config.
|
||||
# Rules (matching issue #690 spec):
|
||||
# tools.include — whitelist: only these tool names are registered
|
||||
# tools.exclude — blacklist: all tools EXCEPT these are registered
|
||||
# include takes precedence over exclude
|
||||
# Neither set → register all tools (backward-compatible default)
|
||||
tools_filter = config.get("tools") or {}
|
||||
include_set = _normalize_name_filter(tools_filter.get("include"), f"mcp_servers.{name}.tools.include")
|
||||
exclude_set = _normalize_name_filter(tools_filter.get("exclude"), f"mcp_servers.{name}.tools.exclude")
|
||||
|
||||
def _should_register(tool_name: str) -> bool:
|
||||
if include_set:
|
||||
return tool_name in include_set
|
||||
if exclude_set:
|
||||
return tool_name not in exclude_set
|
||||
return True
|
||||
|
||||
for mcp_tool in server._tools:
|
||||
if not _should_register(mcp_tool.name):
|
||||
logger.debug("MCP server '%s': skipping tool '%s' (filtered by config)", name, mcp_tool.name)
|
||||
continue
|
||||
schema = _convert_mcp_schema(name, mcp_tool)
|
||||
tool_name_prefixed = schema["name"]
|
||||
|
||||
|
|
@ -1358,7 +1444,8 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
|
|||
)
|
||||
registered_names.append(tool_name_prefixed)
|
||||
|
||||
# Register MCP Resources & Prompts utility tools
|
||||
# Register MCP Resources & Prompts utility tools, filtered by config and
|
||||
# only when the server actually supports the corresponding capability.
|
||||
_handler_factories = {
|
||||
"list_resources": _make_list_resources_handler,
|
||||
"read_resource": _make_read_resource_handler,
|
||||
|
|
@ -1366,7 +1453,7 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
|
|||
"get_prompt": _make_get_prompt_handler,
|
||||
}
|
||||
check_fn = _make_check_fn(name)
|
||||
for entry in _build_utility_schemas(name):
|
||||
for entry in _select_utility_schemas(name, server, config):
|
||||
schema = entry["schema"]
|
||||
handler_key = entry["handler_key"]
|
||||
handler = _handler_factories[handler_key](name, server.tool_timeout)
|
||||
|
|
@ -1382,6 +1469,8 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
|
|||
)
|
||||
registered_names.append(schema["name"])
|
||||
|
||||
server._registered_tool_names = list(registered_names)
|
||||
|
||||
# Create a custom toolset so these tools are discoverable
|
||||
if registered_names:
|
||||
create_custom_toolset(
|
||||
|
|
@ -1424,9 +1513,14 @@ def discover_mcp_tools() -> List[str]:
|
|||
logger.debug("No MCP servers configured")
|
||||
return []
|
||||
|
||||
# Only attempt servers that aren't already connected
|
||||
# Only attempt servers that aren't already connected and are enabled
|
||||
# (enabled: false skips the server entirely without removing its config)
|
||||
with _lock:
|
||||
new_servers = {k: v for k, v in servers.items() if k not in _servers}
|
||||
new_servers = {
|
||||
k: v
|
||||
for k, v in servers.items()
|
||||
if k not in _servers and _parse_boolish(v.get("enabled", True), default=True)
|
||||
}
|
||||
|
||||
if not new_servers:
|
||||
return _existing_tool_names()
|
||||
|
|
@ -1513,7 +1607,7 @@ def get_mcp_status() -> List[dict]:
|
|||
entry = {
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": len(server._tools),
|
||||
"tools": len(server._registered_tool_names) if hasattr(server, "_registered_tool_names") else len(server._tools),
|
||||
"connected": True,
|
||||
}
|
||||
if server._sampling:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue