Portable Agent Plugin packages fold the plugin name into the MCP registry name three times over (slug, digest, and again as the server key), so mcp__<server>__<tool> routinely exceeds the 64-char function name limit OpenAI-compatible providers enforce — while the same server registered via `hermes mcp add` stays well under it. The oversized name is never rejected loudly; the tool just becomes unreachable. Clamp mcp_prefixed_tool_name() to 64 chars with a deterministic, collision- safe hash suffix, mirroring the existing property-key clamp in schema_sanitizer.py. Dispatch is unaffected since handlers already close over the original unprefixed tool name. Fixes #81331
243 lines
12 KiB
Python
243 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 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
|
|
|
|
|
|
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 = {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
|
|
|
|
|
|
# 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"}
|