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