diff --git a/.env.example b/.env.example
index 4e072eeaf9..c4fa979563 100644
--- a/.env.example
+++ b/.env.example
@@ -3023,8 +3023,8 @@ PLUGINS_CLI_MARKUP_MODE=rich
# Unsafe URI pattern
# VALIDATION_UNSAFE_URI_PATTERN="[<>\"'\\]"
-# MCP tool naming pattern per SEP-986
-# VALIDATION_TOOL_NAME_PATTERN="^[a-zA-Z0-9_][a-zA-Z0-9._/-]*$"
+# MCP tool naming pattern
+# VALIDATION_TOOL_NAME_PATTERN="^[a-zA-Z0-9_.-]+$"
# MCP tool method naming pattern
# VALIDATION_TOOL_METHOD_PATTERN="^[a-zA-Z][a-zA-Z0-9_\\./-]*$"
diff --git a/.secrets.baseline b/.secrets.baseline
index ccf0853d47..900fe0dd23 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -3,7 +3,7 @@
"files": "(?x)( package-lock\\.json$ |Cargo\\.lock$ |uv\\.lock$ |go\\.sum$ |mcpgateway/sri_hashes\\.json$ )|^.secrets.baseline$",
"lines": null
},
- "generated_at": "2026-07-21T16:03:44Z",
+ "generated_at": "2026-07-22T13:20:53Z",
"plugins_used": [
{
"name": "AWSKeyDetector"
@@ -266,7 +266,7 @@
"hashed_secret": "b4673e578b9b30fe8bba1b555b7b59883444c697",
"is_secret": false,
"is_verified": false,
- "line_number": 1889,
+ "line_number": 1891,
"type": "Secret Keyword",
"verified_result": null
},
@@ -274,7 +274,7 @@
"hashed_secret": "4a0a2df96d4c9a13a282268cab33ac4b8cbb2c72",
"is_secret": false,
"is_verified": false,
- "line_number": 1977,
+ "line_number": 1979,
"type": "Secret Keyword",
"verified_result": null
},
@@ -282,7 +282,7 @@
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_secret": false,
"is_verified": false,
- "line_number": 2327,
+ "line_number": 2329,
"type": "Basic Auth Credentials",
"verified_result": null
},
@@ -290,7 +290,7 @@
"hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3",
"is_secret": false,
"is_verified": false,
- "line_number": 3692,
+ "line_number": 3694,
"type": "Basic Auth Credentials",
"verified_result": null
},
@@ -298,7 +298,7 @@
"hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3",
"is_secret": false,
"is_verified": false,
- "line_number": 3783,
+ "line_number": 3785,
"type": "Secret Keyword",
"verified_result": null
},
@@ -306,7 +306,7 @@
"hashed_secret": "ac371b6dcce28a86c90d12bc57d946a800eebf17",
"is_secret": false,
"is_verified": false,
- "line_number": 3826,
+ "line_number": 3828,
"type": "Secret Keyword",
"verified_result": null
},
@@ -314,7 +314,7 @@
"hashed_secret": "0b6ec68df700dec4dcd64babd0eda1edccddace1",
"is_secret": false,
"is_verified": false,
- "line_number": 3831,
+ "line_number": 3833,
"type": "Secret Keyword",
"verified_result": null
},
@@ -322,7 +322,7 @@
"hashed_secret": "4ad6f0082ee224001beb3ca5c3e81c8ceea5ed86",
"is_secret": false,
"is_verified": false,
- "line_number": 3836,
+ "line_number": 3838,
"type": "Secret Keyword",
"verified_result": null
}
@@ -3838,7 +3838,7 @@
"hashed_secret": "c377074d6473f35a91001981355da793dc808ffd",
"is_secret": false,
"is_verified": false,
- "line_number": 749,
+ "line_number": 746,
"type": "Hex High Entropy String",
"verified_result": null
}
@@ -3874,7 +3874,7 @@
"hashed_secret": "c377074d6473f35a91001981355da793dc808ffd",
"is_secret": false,
"is_verified": false,
- "line_number": 4576,
+ "line_number": 4580,
"type": "Hex High Entropy String",
"verified_result": null
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1dc9cbabb..fda6660298 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,8 @@ Release 1.0.6 consolidates **61 PRs** focused on **OAuth RFC 8693 token exchange
### Changed
+- **MCP Tool Names and Virtual-Server Aliases** ([#5553](https://github.com/IBM/mcp-context-forge/pull/5553)) - Virtual servers now expose custom tool names and resolve them to upstream names. Tool names follow the MCP 2025-11-25 character set and 128-character limit, with full-string validation that also rejects trailing newlines. **Breaking:** slash-delimited tool names are no longer accepted; rename them to use letters, numbers, periods, underscores, or hyphens.
+
#### **API**
- **Custom Auth Headers on Tools** ([#5314](https://github.com/IBM/mcp-context-forge/pull/5314)) - `POST /tools` and `PUT /tools/{tool_id}` now correctly persist the `auth_headers` array instead of silently storing `auth_value: null`. Invalid header keys/values are rejected with a 422 rather than an unhandled 500.
diff --git a/docs/docs/testing/acceptance.md b/docs/docs/testing/acceptance.md
index 11c8f51309..34e30c4436 100644
--- a/docs/docs/testing/acceptance.md
+++ b/docs/docs/testing/acceptance.md
@@ -352,11 +352,11 @@ MCPGATEWAY_ADMIN_API_ENABLED=true
| Feature | URL | Commands | Expected Result | Status | Notes |
|---------|-----|----------|-----------------|--------|-------|
-| XSS in Tool Name | `curl -X POST $GW_URL/tools -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" -H "Content-Type: application/json" -d '{"name": "", "url": "https://example.com"}'` | Attempt XSS | 422 - `"Tool name must start with a letter, number, or underscore and contain only letters, numbers, periods, underscores, hyphens, and slashes"` | ☐ | Input sanitization |
+| XSS in Tool Name | `curl -X POST $GW_URL/tools -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" -H "Content-Type: application/json" -d '{"name": "", "url": "https://example.com"}'` | Attempt XSS | 422 - `"Tool name may contain only letters, numbers, underscores, hyphens, and periods"` | ☐ | Input sanitization |
| SQL Injection Pattern | `curl -X POST $GW_URL/admin/tools -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" -H "Content-Type: application/x-www-form-urlencoded" -d 'name=tool"; DROP TABLE tools; --&url=https://example.com&integrationType=REST&requestType=GET'` | SQL injection | 400/500 - Validation error | ☐ | Pattern blocked |
| Invalid URL Scheme | `curl -X POST $GW_URL/tools -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" -H "Content-Type: application/json" -d '{"name": "test_tool", "url": "javascript:alert(1)"}'` | JavaScript URL | 422 - `"Tool URL must start with one of: http://, https://, ws://, wss://"` | ☐ | URL validation |
| Directory Traversal | `curl -X POST $GW_URL/admin/resources -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" -H "Content-Type: application/x-www-form-urlencoded" -d 'uri=../../etc/passwd&name=test&content=data'` | Path traversal | 500 - `"Resource URI cannot contain directory traversal sequences"` | ☐ | Path validation |
-| Name Too Long | `curl -X POST $GW_URL/tools -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" -H "Content-Type: application/json" -d '{"name": "'"$(printf 'a%.0s' {1..300})"'", "url": "https://example.com"}'` | 300 char name | 422 - `"Tool name exceeds maximum length of 255"` | ☐ | Length validation |
+| Name Too Long | `curl -X POST $GW_URL/tools -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" -H "Content-Type: application/json" -d '{"name": "'"$(printf 'a%.0s' {1..300})"'", "url": "https://example.com"}'` | 300 char name | 422 - `"Tool name exceeds MCP spec limit of 128 characters"` | ☐ | Length validation |
| Empty Required Field | `curl -X POST $GW_URL/tools -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" -H "Content-Type: application/json" -d '{"name": "", "url": "https://example.com"}'` | Empty name | 422 - `"Tool name cannot be empty"` | ☐ | Required validation |
| Whitespace Only | `curl -X POST $GW_URL/tools -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" -H "Content-Type: application/json" -d '{"name": " ", "url": "https://example.com"}'` | Spaces only | 422 - `"Tool name cannot be empty"` | ☐ | Trimming validation |
diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py
index 725662b17b..a24f6f32e8 100644
--- a/mcpgateway/admin.py
+++ b/mcpgateway/admin.py
@@ -18383,8 +18383,7 @@ async def get_observability_traces(
user_email: QueryUserIdentifierNoDescription = None,
name_search: Optional[str] = Query(None, max_length=500),
attribute_search: Optional[str] = Query(None, max_length=500),
- # tool_name pattern follows MCP SEP-986 (Specify Format for Tool Names), matching
- # mcpgateway.config.Settings.validation_tool_name_pattern. Allows namespacing via '/'.
+ # tool_name follows mcpgateway.config.Settings.validation_tool_name_pattern.
tool_name: QueryToolName = None,
_user=Depends(get_current_user_with_permissions),
db: Session = Depends(get_db),
diff --git a/mcpgateway/cache/registry_cache.py b/mcpgateway/cache/registry_cache.py
index fe031315a1..0a46d96c3c 100644
--- a/mcpgateway/cache/registry_cache.py
+++ b/mcpgateway/cache/registry_cache.py
@@ -873,6 +873,8 @@ class CacheInvalidationSubscriber:
- registry:{cache_type} - Invalidate registry cache (tools, prompts, etc.)
- tool_lookup:{name} - Invalidate specific tool lookup
- tool_lookup:gateway:{gateway_id} - Invalidate all tools for a gateway
+ - tool_lookup:server:{server_id} - Invalidate all tools scoped to a virtual server
+ - tool_lookup:scoped - Invalidate all virtual-server-scoped tool lookups
- admin:{prefix} - Invalidate admin stats cache
- user:{email} - Invalidate auth user cache
- revoke:{jti} - Invalidate auth revocation cache
@@ -1061,6 +1063,30 @@ async def _process_invalidation(self, message: str, *, channel: str = "") -> Non
cache._cache.pop(key, None) # pyright: ignore[reportPrivateUsage]
logger.debug("CacheInvalidationSubscriber: Cleared local registry:%s cache (%d keys)", cache_type, len(keys_to_remove))
+ elif message == "tool_lookup:scoped":
+ # Handle all virtual-server-scoped tool lookup invalidation
+ # First-Party
+ from mcpgateway.cache.tool_lookup_cache import tool_lookup_cache # pylint: disable=import-outside-toplevel
+
+ with tool_lookup_cache._lock: # pyright: ignore[reportPrivateUsage]
+ to_remove = [name for name in tool_lookup_cache._cache if name.startswith("server:")] # pyright: ignore[reportPrivateUsage]
+ for name in to_remove:
+ tool_lookup_cache._cache.pop(name, None) # pyright: ignore[reportPrivateUsage]
+ logger.debug("CacheInvalidationSubscriber: Cleared all local scoped tool lookups (%d keys)", len(to_remove))
+
+ elif message.startswith("tool_lookup:server:"):
+ # Handle virtual-server-scoped tool lookup invalidation
+ server_id = message[len("tool_lookup:server:") :]
+ # First-Party
+ from mcpgateway.cache.tool_lookup_cache import tool_lookup_cache # pylint: disable=import-outside-toplevel
+
+ key_prefix = tool_lookup_cache._cache_key("", server_id) # pyright: ignore[reportPrivateUsage]
+ with tool_lookup_cache._lock: # pyright: ignore[reportPrivateUsage]
+ to_remove = [name for name in tool_lookup_cache._cache if name.startswith(key_prefix)] # pyright: ignore[reportPrivateUsage]
+ for name in to_remove:
+ tool_lookup_cache._cache.pop(name, None) # pyright: ignore[reportPrivateUsage]
+ logger.debug("CacheInvalidationSubscriber: Cleared local tool_lookup for server %s (%d keys)", server_id, len(to_remove))
+
elif message.startswith("tool_lookup:gateway:"):
# Handle gateway-wide tool lookup invalidation
gateway_id = message[len("tool_lookup:gateway:") :]
diff --git a/mcpgateway/cache/tool_lookup_cache.py b/mcpgateway/cache/tool_lookup_cache.py
index c7a7ff950e..c33ea3e99d 100644
--- a/mcpgateway/cache/tool_lookup_cache.py
+++ b/mcpgateway/cache/tool_lookup_cache.py
@@ -54,7 +54,7 @@ def is_expired(self) -> bool:
class ToolLookupCache:
- """Two-tier cache for tool lookups by name.
+ """Two-tier cache for global or virtual-server-scoped tool lookups.
L1: in-memory LRU/TTL per worker.
L2: Redis (optional, shared across workers).
@@ -131,6 +131,19 @@ def _redis_key(self, name: str) -> str:
"""
return f"{self._cache_prefix}tool_lookup:{name}"
+ @staticmethod
+ def _cache_key(name: str, server_id: Optional[str] = None) -> str:
+ """Build an internal cache key for a global or server-scoped lookup.
+
+ Args:
+ name: Requested tool name.
+ server_id: Optional virtual server scope.
+
+ Returns:
+ An internal cache key that isolates aliases between virtual servers.
+ """
+ return f"server:{server_id}:{name}" if server_id else name
+
def _gateway_set_key(self, gateway_id: str) -> str:
"""Build the Redis set key for tools in a gateway.
@@ -142,7 +155,26 @@ def _gateway_set_key(self, gateway_id: str) -> str:
"""
return f"{self._cache_prefix}tool_lookup:gateway:{gateway_id}"
- async def _get_redis_client(self):
+ def _server_set_key(self, server_id: str) -> str:
+ """Build the Redis set key for cached lookups in a virtual server.
+
+ Args:
+ server_id: Virtual server ID.
+
+ Returns:
+ Redis set key for server-scoped lookup keys.
+ """
+ return f"{self._cache_prefix}tool_lookup:server:{server_id}"
+
+ def _scoped_set_key(self) -> str:
+ """Build the Redis set key for all virtual-server-scoped lookups.
+
+ Returns:
+ Redis set key for all server-scoped lookup keys.
+ """
+ return f"{self._cache_prefix}tool_lookup_index:scoped"
+
+ async def _get_redis_client(self) -> Any:
"""Return a Redis client if L2 is enabled and available.
Returns:
@@ -201,11 +233,12 @@ def _set_l1(self, name: str, value: Dict[str, Any], ttl: int) -> None:
self._cache.popitem(last=False)
self._cache[name] = CacheEntry(value=value, expiry=time.time() + ttl)
- async def get(self, name: str) -> Optional[Dict[str, Any]]:
- """Get cached payload for a tool name, checking L1 then L2.
+ async def get(self, name: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
+ """Get a cached payload for a global or server-scoped tool name.
Args:
name: Tool name.
+ server_id: Optional virtual server scope.
Returns:
Cached payload dict or None.
@@ -225,7 +258,8 @@ async def get(self, name: str) -> Optional[Dict[str, Any]]:
if not self._enabled:
return None
- cached = self._get_l1(name)
+ cache_key = self._cache_key(name, server_id)
+ cached = self._get_l1(cache_key)
if cached is not None:
return cached
@@ -234,25 +268,33 @@ async def get(self, name: str) -> Optional[Dict[str, Any]]:
return None
try:
- data = await redis.get(self._redis_key(name))
+ data = await redis.get(self._redis_key(cache_key))
if data:
self._l2_hit_count += 1
- payload = orjson.loads(data)
- self._set_l1(name, payload, self._ttl_seconds)
+ payload: Dict[str, Any] = orjson.loads(data)
+ self._set_l1(cache_key, payload, self._ttl_seconds)
return payload
self._l2_miss_count += 1
except Exception as exc:
logger.debug("ToolLookupCache Redis get failed: %s", exc)
return None
- async def set(self, name: str, payload: Dict[str, Any], ttl: Optional[int] = None, gateway_id: Optional[str] = None) -> None:
- """Store a payload in cache and update gateway index if provided.
+ async def set(
+ self,
+ name: str,
+ payload: Dict[str, Any],
+ ttl: Optional[int] = None,
+ gateway_id: Optional[str] = None,
+ server_id: Optional[str] = None,
+ ) -> None:
+ """Store a payload in cache and update invalidation indexes.
Args:
name: Tool name.
payload: Payload to cache.
ttl: Time to live in seconds (defaults to configured TTL).
gateway_id: Gateway ID for invalidation set tracking.
+ server_id: Optional virtual server scope and invalidation index.
Examples:
>>> import asyncio
@@ -268,27 +310,34 @@ async def set(self, name: str, payload: Dict[str, Any], ttl: Optional[int] = Non
return
effective_ttl = ttl if ttl is not None else self._ttl_seconds
- self._set_l1(name, payload, effective_ttl)
+ cache_key = self._cache_key(name, server_id)
+ self._set_l1(cache_key, payload, effective_ttl)
redis = await self._get_redis_client()
if not redis:
return
try:
- await redis.setex(self._redis_key(name), effective_ttl, orjson.dumps(payload))
+ await redis.setex(self._redis_key(cache_key), effective_ttl, orjson.dumps(payload))
if gateway_id:
set_key = self._gateway_set_key(gateway_id)
- await redis.sadd(set_key, name)
+ await redis.sadd(set_key, cache_key)
await redis.expire(set_key, max(effective_ttl, self._ttl_seconds))
+ if server_id:
+ for set_key in (self._server_set_key(server_id), self._scoped_set_key()):
+ await redis.sadd(set_key, cache_key)
+ await redis.expire(set_key, max(effective_ttl, self._ttl_seconds))
except Exception as exc:
logger.debug("ToolLookupCache Redis set failed: %s", exc)
- async def set_negative(self, name: str, status: str) -> None:
+ async def set_negative(self, name: str, status: str, gateway_id: Optional[str] = None, server_id: Optional[str] = None) -> None:
"""Store a negative cache entry for a tool name.
Args:
name: Tool name.
status: Negative status (missing, inactive, offline).
+ gateway_id: Optional gateway ID for invalidation tracking.
+ server_id: Optional virtual server scope.
Examples:
>>> import asyncio
@@ -301,14 +350,15 @@ async def set_negative(self, name: str, status: str) -> None:
{'status': 'missing'}
"""
payload = {"status": status}
- await self.set(name=name, payload=payload, ttl=self._negative_ttl_seconds)
+ await self.set(name=name, payload=payload, ttl=self._negative_ttl_seconds, gateway_id=gateway_id, server_id=server_id)
- async def invalidate(self, name: str, gateway_id: Optional[str] = None) -> None:
+ async def invalidate(self, name: str, gateway_id: Optional[str] = None, server_id: Optional[str] = None) -> None:
"""Invalidate a tool cache entry by name.
Args:
name: Tool name.
- gateway_id: Gateway ID for invalidation set tracking.
+ gateway_id: Gateway ID. When present, all aliases for that gateway are invalidated.
+ server_id: Optional virtual server scope for a targeted invalidation.
Examples:
>>> import asyncio
@@ -324,21 +374,83 @@ async def invalidate(self, name: str, gateway_id: Optional[str] = None) -> None:
if not self._enabled:
return
+ if gateway_id:
+ await self.invalidate_gateway(gateway_id)
+ return
+
+ cache_key = self._cache_key(name, server_id)
+
with self._lock:
- self._cache.pop(name, None)
+ self._cache.pop(cache_key, None)
+
+ if server_id is None:
+ await self.invalidate_all_scoped()
redis = await self._get_redis_client()
if not redis:
return
try:
- await redis.delete(self._redis_key(name))
- if gateway_id:
- await redis.srem(self._gateway_set_key(gateway_id), name)
- await redis.publish("mcpgw:cache:invalidate", f"tool_lookup:{name}")
+ await redis.delete(self._redis_key(cache_key))
+ if server_id:
+ await redis.srem(self._server_set_key(server_id), cache_key)
+ await redis.publish("mcpgw:cache:invalidate", f"tool_lookup:{cache_key}")
except Exception as exc:
logger.debug("ToolLookupCache Redis invalidate failed: %s", exc)
+ async def invalidate_all_scoped(self) -> None:
+ """Invalidate every virtual-server-scoped tool lookup."""
+ if not self._enabled:
+ return
+
+ with self._lock:
+ for cache_key in [key for key in self._cache if key.startswith("server:")]:
+ self._cache.pop(cache_key, None)
+
+ redis = await self._get_redis_client()
+ if not redis:
+ return
+
+ set_key = self._scoped_set_key()
+ try:
+ cache_keys = await redis.smembers(set_key)
+ if cache_keys:
+ keys = [self._redis_key(cache_key.decode() if isinstance(cache_key, bytes) else cache_key) for cache_key in cache_keys]
+ await redis.delete(*keys)
+ await redis.delete(set_key)
+ await redis.publish("mcpgw:cache:invalidate", "tool_lookup:scoped")
+ except Exception as exc:
+ logger.debug("ToolLookupCache Redis invalidate_all_scoped failed: %s", exc)
+
+ async def invalidate_server(self, server_id: str) -> None:
+ """Invalidate all cached tool lookups scoped to a virtual server.
+
+ Args:
+ server_id: Virtual server ID.
+ """
+ if not self._enabled:
+ return
+
+ key_prefix = self._cache_key("", server_id)
+ with self._lock:
+ for cache_key in [key for key in self._cache if key.startswith(key_prefix)]:
+ self._cache.pop(cache_key, None)
+
+ redis = await self._get_redis_client()
+ if not redis:
+ return
+
+ set_key = self._server_set_key(server_id)
+ try:
+ cache_keys = await redis.smembers(set_key)
+ if cache_keys:
+ keys = [self._redis_key(cache_key.decode() if isinstance(cache_key, bytes) else cache_key) for cache_key in cache_keys]
+ await redis.delete(*keys)
+ await redis.delete(set_key)
+ await redis.publish("mcpgw:cache:invalidate", f"tool_lookup:server:{server_id}")
+ except Exception as exc:
+ logger.debug("ToolLookupCache Redis invalidate_server failed: %s", exc)
+
async def invalidate_gateway(self, gateway_id: str) -> None:
"""Invalidate all cached tools for a gateway.
diff --git a/mcpgateway/common/query_params.py b/mcpgateway/common/query_params.py
index 5d9ea695fe..73c4b7f08b 100644
--- a/mcpgateway/common/query_params.py
+++ b/mcpgateway/common/query_params.py
@@ -151,7 +151,7 @@
QueryToolName = Annotated[
Optional[str],
Query(
- max_length=255,
+ max_length=SecurityValidator.MAX_TOOL_NAME_LENGTH,
pattern=SecurityValidator.TOOL_NAME_PATTERN,
),
]
diff --git a/mcpgateway/common/validators.py b/mcpgateway/common/validators.py
index 9245895ae4..2526b23b9a 100644
--- a/mcpgateway/common/validators.py
+++ b/mcpgateway/common/validators.py
@@ -75,7 +75,7 @@
# precompiled at module level.
# Static inline patterns used multiple times
-_HTML_SPECIAL_CHARS_RE: Pattern[str] = re.compile(r'[<>"\']') # / removed per SEP-986
+_HTML_SPECIAL_CHARS_RE: Pattern[str] = re.compile(r'[<>"\']')
_DANGEROUS_TEMPLATE_TAGS_RE: Pattern[str] = re.compile(r"<(script|iframe|object|embed|link|meta|base|form)\b", re.IGNORECASE)
_EVENT_HANDLER_RE: Pattern[str] = re.compile(r"on\w+\s*=", re.IGNORECASE)
_MIME_TYPE_RE: Pattern[str] = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*(?:\s*;\s*[a-zA-Z0-9!#$&\-\^_+\.]+=(?:[a-zA-Z0-9!#$&\-\^_+\.]+|"[^"\r\n]*"))*$')
@@ -342,7 +342,7 @@ class SecurityValidator:
IDENTIFIER_PATTERN = settings.validation_identifier_pattern # Default: ^[a-zA-Z0-9_\-\.]+$
VALIDATION_SAFE_URI_PATTERN = settings.validation_safe_uri_pattern # Default: ^[a-zA-Z0-9_\-.:/?=&%]+$
VALIDATION_UNSAFE_URI_PATTERN = settings.validation_unsafe_uri_pattern # Default: [<>"\'\\]
- TOOL_NAME_PATTERN = settings.validation_tool_name_pattern # Default: ^[a-zA-Z0-9_][a-zA-Z0-9._/-]*$ (SEP-986)
+ TOOL_NAME_PATTERN = settings.validation_tool_name_pattern # Default: ^[a-zA-Z0-9_.-]+$ (MCP 2025-11-25)
# MCP-compliant limits (configurable)
MAX_NAME_LENGTH = settings.validation_max_name_length # Default: 255
@@ -672,23 +672,20 @@ def validate_tool_name(cls, value: str) -> str:
>>> try:
... SecurityValidator.validate_tool_name('tool