Files
hermes-agent/tools/mcp_tool_schema.py
T

308 lines
12 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 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(r"ignore\s+(all\s+)?previous\s+instructions", re.I),
"prompt override attempt ('ignore previous instructions')"),
(re.compile(r"you\s+are\s+now\s+a", re.I),
"identity override attempt ('you are now a...')"),
(re.compile(r"your\s+new\s+(task|role|instructions?)\s+(is|are)", re.I),
"task override attempt"),
(re.compile(r"system\s*:\s*", re.I),
"system prompt injection attempt"),
(re.compile(r"<\s*(system|human|assistant)\s*>", re.I),
"role tag injection attempt"),
(re.compile(r"do\s+not\s+(tell|inform|mention|reveal)", re.I),
"concealment instruction"),
(re.compile(r"(curl|wget|fetch)\s+https?://", re.I),
"network command in description"),
(re.compile(r"base64\.(b64decode|decodebytes)", re.I),
"base64 decode reference"),
(re.compile(r"exec\s*\(|eval\s*\(", re.I),
"code execution reference"),
(re.compile(r"import\s+(subprocess|os|shutil|socket)", re.I),
"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."""
findings = []
if not description:
return findings
for pattern, reason in _MCP_INJECTION_PATTERNS:
if pattern.search(description):
findings.append(reason)
if findings:
logger.warning(
"MCP server '%s' tool '%s': suspicious description content — %s. "
"Description: %.200s",
server_name, tool_name, "; ".join(findings),
description,
)
return findings
def _normalize_mcp_input_schema(schema: dict | None) -> dict:
"""Normalize MCP input schemas so one form is valid on OpenAI, Anthropic,
Gemini and Moonshot.
Repairs, applied recursively: ``definitions``/``#/definitions/`` refs ->
``$defs`` (Moonshot rejects the draft-07 form); missing/null ``type`` on an
object-shaped node -> ``"object"``; an object without ``properties`` gets an
empty one so ``required`` can't dangle; ``required`` pruned to names present
in ``properties`` (Gemini 400s otherwise); nullable ``anyOf`` unions
collapsed to the non-null branch (Anthropic rejects nullable branches),
optionality living solely in the parent's ``required``.
"""
if not schema:
return {"type": "object", "properties": {}}
def _rewrite_local_refs(node):
"""Promote legacy ``definitions`` to ``$defs`` — but ONLY where it is a
JSON Schema meta-keyword, never as a property NAME inside ``properties``/
``patternProperties``. A tool parameter legitimately named ``definitions``
rewritten to ``$defs`` would 400 the whole tool array (Anthropic/OpenAI
forbid ``$`` in property names). Property names are kept verbatim and
recursion resumes ordinary semantics inside each property's schema."""
if isinstance(node, dict):
normalized = {}
for key, value in node.items():
if key in ("properties", "patternProperties") and isinstance(value, dict):
normalized[key] = {
prop_name: _rewrite_local_refs(prop_schema)
for prop_name, prop_schema in value.items()
}
else:
out_key = "$defs" if key == "definitions" else key
normalized[out_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
if isinstance(node, list):
return [_rewrite_local_refs(item) for item in node]
return node
def _strip_nullable_union(node):
"""Shared implementation with the Anthropic guard and global sanitizer.
Keeps the ``nullable: true`` hint so runtime coercion can still map a
model-emitted ``"null"`` string to ``None``."""
from tools.schema_sanitizer import strip_nullable_unions
return strip_nullable_unions(node, keep_nullable_hint=True)
def _collapse_const_unions(node):
"""Collapse anyOf/oneOf unions of same-typed consts to enums. Must run
AFTER the nullable strip: consts -> enum, null branch -> ``nullable`` hint."""
from tools.schema_sanitizer import collapse_const_unions
return collapse_const_unions(node)
def _repair_object_shape(node):
"""Recursively fill missing object ``type``, ensure ``properties``, prune ``required``."""
if isinstance(node, list):
return [_repair_object_shape(item) for item in node]
if not isinstance(node, dict):
return node
repaired = {k: _repair_object_shape(v) for k, v in node.items()}
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"] = {}
required = repaired.get("required")
if isinstance(required, list):
props = repaired.get("properties") or {}
valid = [r for r in required if isinstance(r, str) and r in props]
if len(valid) != len(required):
if valid:
repaired["required"] = valid
else:
repaired.pop("required", None)
return repaired
normalized = _rewrite_local_refs(schema)
normalized = _strip_nullable_union(normalized)
normalized = _collapse_const_unions(normalized)
normalized = _repair_object_shape(normalized)
if not isinstance(normalized, dict):
return {"type": "object", "properties": {}}
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.
MCP_TOOL_NAME_PREFIX = "mcp__"
_MCP_NAME_DELIM = "__"
def mcp_prefixed_tool_name(server_name: str, tool_name: str) -> str:
"""Registry/wire name: ``mcp__<sanitizedServer>__<sanitizedTool>``."""
safe_server = sanitize_mcp_name_component(server_name)
safe_tool = sanitize_mcp_name_component(tool_name)
return f"{MCP_TOOL_NAME_PREFIX}{safe_server}{_MCP_NAME_DELIM}{safe_tool}"
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")
),
}
def _build_utility_schemas(server_name: str) -> List[dict]:
"""Schemas for the resource/prompt utility tools as ``{schema, handler_key}`` dicts."""
return [
{
"schema": {
"name": mcp_prefixed_tool_name(server_name, "list_resources"),
"description": f"List available resources from MCP server '{server_name}'",
"parameters": {
"type": "object",
"properties": {},
},
},
"handler_key": "list_resources",
},
{
"schema": {
"name": mcp_prefixed_tool_name(server_name, "read_resource"),
"description": f"Read a resource by URI from MCP server '{server_name}'",
"parameters": {
"type": "object",
"properties": {
"uri": {
"type": "string",
"description": "URI of the resource to read",
},
},
"required": ["uri"],
},
},
"handler_key": "read_resource",
},
{
"schema": {
"name": mcp_prefixed_tool_name(server_name, "list_prompts"),
"description": f"List available prompts from MCP server '{server_name}'",
"parameters": {
"type": "object",
"properties": {},
},
},
"handler_key": "list_prompts",
},
{
"schema": {
"name": mcp_prefixed_tool_name(server_name, "get_prompt"),
"description": f"Get a prompt by name from MCP server '{server_name}'",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the prompt to retrieve",
},
"arguments": {
"type": "object",
"description": "Optional arguments to pass to the prompt",
"properties": {},
"additionalProperties": True,
},
},
"required": ["name"],
},
},
"handler_key": "get_prompt",
},
]
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_CAPABILITY_METHODS = {
"list_resources": "list_resources",
"read_resource": "read_resource",
"list_prompts": "list_prompts",
"get_prompt": "get_prompt",
}
# 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.
_UTILITY_CAPABILITY_ATTRS = {
"list_resources": "resources",
"read_resource": "resources",
"list_prompts": "prompts",
"get_prompt": "prompts",
}