Files
hermes-agent/tools/mcp_tool_schema.py
teknium1 3dbaffd814 fix(tools): coerce a missing required to [] and pin the MCP normalizer entry (#56123)
Review follow-up. Object nodes that never had a `required` key sanitized to exactly
{type: object, properties: {}} (built-in read_window_below, read_terminal, ...), the shape
strict OpenAI-compatible backends reject as `null is not of type "array"`. Both sanitizer
sites (tools/schema_sanitizer.py::_sanitize_node and the top-level/default shapes, and the MCP
mirror tools/mcp_tool_schema.py::_repair_object_shape) now always emit a `required` list.

The MCP test now drives `_normalize_mcp_input_schema` (the production entry) instead of the
private repair helper, so removing the repair call goes red; the docstring describes the real
base behaviour (the key was dropped only when every entry was invalid).
2026-09-19 10:16:59 -07:00

253 lines
13 KiB
Python

"""MCP tool schema conversion and naming: JSON-schema normalisation for provider
compatibility, mcp__server__tool naming, utility-tool schemas, include/exclude filters and
description injection scanning."""
import logging
import hashlib
import fnmatch
import re
from typing import Any, List
from tools.ansi_strip import strip_unicode_tags
from tools.mcp_tool_common import mcp_field
logger = logging.getLogger("tools.mcp_tool")
# Prompt-injection indicators in MCP tool descriptions. WARNING-level only: log but never
# block, since false positives would break legitimate servers.
_MCP_INJECTION_PATTERNS = [
(re.compile(pattern, re.I), reason)
for pattern, reason in (
(r"ignore\s+(all\s+)?previous\s+instructions", "prompt override attempt ('ignore previous instructions')"),
(r"you\s+are\s+now\s+a", "identity override attempt ('you are now a...')"),
(r"your\s+new\s+(task|role|instructions?)\s+(is|are)", "task override attempt"),
(r"system\s*:\s*", "system prompt injection attempt"),
(r"<\s*(system|human|assistant)\s*>", "role tag injection attempt"),
(r"do\s+not\s+(tell|inform|mention|reveal)", "concealment instruction"),
(r"(curl|wget|fetch)\s+https?://", "network command in description"),
(r"base64\.(b64decode|decodebytes)", "base64 decode reference"),
(r"exec\s*\(|eval\s*\(", "code execution reference"),
(r"import\s+(subprocess|os|shutil|socket)", "dangerous import reference"))]
def _scan_mcp_description(server_name: str, tool_name: str, description: str) -> List[str]:
"""Scan a tool description for injection patterns; returns finding strings (empty =
clean) and logs a warning when any match."""
if not description:
return []
findings = [reason for pattern, reason in _MCP_INJECTION_PATTERNS if pattern.search(description)]
if findings:
logger.warning("MCP server '%s' tool '%s': suspicious description content — %s. Description: %.200s",
server_name, tool_name, "; ".join(findings), description)
return findings
_EMPTY_OBJECT_SCHEMA = {"type": "object", "properties": {}}
def _rewrite_local_refs(node):
"""Promote legacy ``definitions`` to ``$defs`` (Moonshot rejects the draft-07 form) — ONLY
where it is a JSON Schema meta-keyword, never as a property NAME inside
``properties``/``patternProperties``: a parameter legitimately named ``definitions``
rewritten to ``$defs`` would 400 the whole tool array (Anthropic/OpenAI forbid ``$`` in
property names)."""
if isinstance(node, list):
return [_rewrite_local_refs(item) for item in node]
if not isinstance(node, dict):
return node
normalized = {}
for key, value in node.items():
if key in ("properties", "patternProperties") and isinstance(value, dict):
normalized[key] = {name: _rewrite_local_refs(schema) for name, schema in value.items()}
else:
normalized["$defs" if key == "definitions" else key] = _rewrite_local_refs(value)
ref = normalized.get("$ref")
if isinstance(ref, str) and ref.startswith("#/definitions/"):
normalized["$ref"] = "#/$defs/" + ref[len("#/definitions/"):]
return normalized
# Mapping-valued JSON Schema keywords: the value maps entry NAMES to schemas and is never a
# schema node itself. Repair must recurse into the map's values only (#110530).
_SCHEMA_MAP_KEYS = ("properties", "patternProperties", "$defs", "definitions", "dependentSchemas")
def _repair_object_shape(node):
"""Recursively fill a missing object ``type``, ensure ``properties`` (so ``required``
can't dangle) and prune ``required`` to names present in ``properties`` (Gemini 400s
otherwise)."""
if isinstance(node, list):
return [_repair_object_shape(item) for item in node]
if not isinstance(node, dict):
return node
repaired = {}
for key, value in node.items():
if key in _SCHEMA_MAP_KEYS and isinstance(value, dict):
# A schema map (entry name -> schema), never a schema node itself: recursing over
# the whole map would inject a bogus ``"type": "object"`` *entry* when one of its
# keys is literally named ``properties``/``required`` (#110530).
repaired[key] = {name: _repair_object_shape(schema) for name, schema in value.items()}
else:
repaired[key] = _repair_object_shape(value)
if not repaired.get("type") and ("properties" in repaired or "required" in repaired):
repaired["type"] = "object"
if repaired.get("type") == "object":
if not isinstance(repaired.get("properties"), dict):
repaired["properties"] = {}
# Always a list: a missing/non-list ``required`` reads as ``null`` on strict
# OpenAI-compatible backends (#56123); ``[]`` is valid everywhere (Gemini included).
required = repaired.get("required")
props = repaired.get("properties") or {}
repaired["required"] = ([r for r in required if isinstance(r, str) and r in props]
if isinstance(required, list) else [])
return repaired
# Lazy (schema-cache registered) servers are available: the first real call spawns/connects them (#56832).
def _normalize_mcp_input_schema(schema: dict | None) -> dict:
"""Normalize MCP input schemas so one form is valid on OpenAI, Anthropic, Gemini and
Moonshot. Order matters: ``definitions`` -> ``$defs``; nullable ``anyOf`` unions collapsed
to the non-null branch (Anthropic rejects nullable branches; optionality lives in the
parent's ``required``; the ``nullable: true`` hint is kept so runtime coercion can map a
model-emitted ``"null"`` string to ``None``); same-typed const unions -> enum (AFTER the
nullable strip); then object-shape repair.
* Missing or ``null`` ``type`` on an object-shaped node is coerced to ``"object"`` (some servers omit
it). See PR #4897. * When an ``object`` node lacks ``properties``, an empty ``properties`` dict is added
so ``required`` entries don't dangle. * ``required`` arrays are pruned to only names that exist in
``properties``; otherwise Google AI Studio / Gemini 400s with ``property is not defined``. See PR #4651.
* MCP/Pydantic optional fields commonly arrive as ``anyOf: [{...}, {"type": "null"}], default: null``.
"""
if not schema:
return dict(_EMPTY_OBJECT_SCHEMA)
from tools.schema_sanitizer import collapse_const_unions, strip_nullable_unions
normalized = _rewrite_local_refs(schema)
normalized = strip_nullable_unions(normalized, keep_nullable_hint=True)
normalized = collapse_const_unions(normalized)
normalized = _repair_object_shape(normalized)
if not isinstance(normalized, dict):
return dict(_EMPTY_OBJECT_SCHEMA)
if normalized.get("type") == "object" and "properties" not in normalized:
normalized = {**normalized, "properties": {}}
return normalized
def sanitize_mcp_name_component(value: str) -> str:
"""Replace every char outside ``[A-Za-z0-9_]`` with ``_`` (hyphens included, the
historical behavior) so generated names pass provider validation."""
return re.sub(r"[^A-Za-z0-9_]", "_", str(value or ""))
# ``mcp__<server>__<tool>``: the convention shared by Claude Code, Codex and OpenCode. The
# double underscore disambiguates the server/tool boundary even when either contains
# underscores, and matches the Anthropic-OAuth wire form.
# Native MCP tool-name prefix. It also aligns native registration with the Anthropic-OAuth wire form
# (``_MCP_TOOL_PREFIX`` in anthropic_adapter.py), removing the single->double rewrite that path previously
# had to perform. See #33533.
MCP_TOOL_NAME_PREFIX = "mcp__"
# OpenAI-compatible providers validate function names against ``^[a-zA-Z0-9_-]{1,64}$`` and 400 the
# whole request when one generated name is longer. Portable plugin server keys fold the plugin name in
# several times, so ``mcp__<server>__<tool>`` routinely passes 64 chars there (#81331). Clamp with a
# deterministic hash suffix (same idea as ``schema_sanitizer.sanitize_property_key``); dispatch is
# unaffected because handlers close over the original unprefixed tool name.
_MCP_TOOL_NAME_MAX_LENGTH = 64
_MCP_TOOL_NAME_HASH_LENGTH = 8
_clamped_names_warned: set[str] = set()
def mcp_prefixed_tool_name(server_name: str, tool_name: str) -> str:
"""Registry/wire name: ``mcp__<sanitizedServer>__<sanitizedTool>``, clamped to 64 chars with a
stable hash suffix when the natural name is longer."""
full_name = f"{MCP_TOOL_NAME_PREFIX}{sanitize_mcp_name_component(server_name)}__{sanitize_mcp_name_component(tool_name)}"
if len(full_name) <= _MCP_TOOL_NAME_MAX_LENGTH:
return full_name
suffix = "_" + hashlib.sha256(full_name.encode("utf-8")).hexdigest()[:_MCP_TOOL_NAME_HASH_LENGTH]
if full_name not in _clamped_names_warned: # recomputed on every health refresh; warn once
_clamped_names_warned.add(full_name)
logger.warning("MCP tool name %r (%d chars) exceeds the %d-char provider limit; shortened to a "
"deterministic hash-suffixed name", full_name, len(full_name), _MCP_TOOL_NAME_MAX_LENGTH)
return full_name[:_MCP_TOOL_NAME_MAX_LENGTH - len(suffix)] + suffix
def _convert_mcp_schema(server_name: str, mcp_tool) -> dict:
"""Convert an MCP ``Tool`` (``.input_schema``, or ``.inputSchema`` before mcp 2.0) to a
``registry.register(schema=...)`` dict."""
return {
"name": mcp_prefixed_tool_name(server_name, mcp_tool.name),
"description": strip_unicode_tags(mcp_tool.description or f"MCP tool {mcp_tool.name} from {server_name}"),
"parameters": _normalize_mcp_input_schema(mcp_field(mcp_tool, "input_schema", "inputSchema")),
}
# Utility tools generated per server: handler_key -> (description template, parameter
# properties, required names). Schemas are FROZEN wire bytes — the key order emitted by
# ``_build_utility_schemas`` must not change.
_UTILITY_TOOL_SPECS = (
("list_resources", "List available resources from MCP server '{server}'", {}, None),
("read_resource", "Read a resource by URI from MCP server '{server}'",
{"uri": {"type": "string", "description": "URI of the resource to read"}}, ["uri"]),
("list_prompts", "List available prompts from MCP server '{server}'", {}, None),
("get_prompt", "Get a prompt by name from MCP server '{server}'",
{
"name": {"type": "string", "description": "Name of the prompt to retrieve"},
"arguments": {
"type": "object",
"description": "Optional arguments to pass to the prompt",
"properties": {},
"additionalProperties": True},
}, ["name"]))
def _build_utility_schemas(server_name: str) -> List[dict]:
"""Schemas for the resource/prompt utility tools as ``{schema, handler_key}`` dicts."""
out = []
for handler_key, description, properties, required in _UTILITY_TOOL_SPECS:
parameters = {"type": "object", "properties": {k: dict(v) for k, v in properties.items()}}
if required is not None:
parameters["required"] = list(required)
out.append({
"schema": {
"name": mcp_prefixed_tool_name(server_name, handler_key),
"description": description.format(server=server_name),
"parameters": parameters},
"handler_key": handler_key})
return out
def _normalize_name_filter(value: Any, label: str) -> set[str]:
"""Normalize include/exclude config to a set of exact names or fnmatch globs."""
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 matches_name_filter(tool_name: str, patterns: set[str]) -> bool:
"""True if ``tool_name`` matches any entry: exact names literally, entries with
``*``/``?``/``[`` as case-sensitive globs (same semantics as ``approvals.deny``). Exact
membership is checked first so big lists stay O(1)."""
if not patterns:
return False
if tool_name in patterns:
return True
return any(fnmatch.fnmatchcase(tool_name, p) for p in patterns if "*" in p or "?" in p or "[" in p)
# Utility handler -> capability key that must be non-None on the server's ``initialize``
# response for the handler to be registered. Without this gate a tools-only server got all
# four stubs and every call returned JSON-RPC -32601, making the model conclude the server
# was broken.
# Source of truth: MCP spec — capabilities.resources / capabilities.prompts are present on the response only
# when the server actually implements those request families. Context7 @upstash/context7-mcp, which
# advertises only ``tools``) had all four utility stubs registered and every model call to them came back
# with JSON-RPC ``-32601 Method not found``, which made the model conclude the server was broken even when
# the real tools worked. See #18051.
_UTILITY_CAPABILITY_ATTRS = {
"list_resources": "resources", "read_resource": "resources",
"list_prompts": "prompts", "get_prompt": "prompts"}