diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index c513761494..794ddf0ff5 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -12153,6 +12153,24 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use else: passthrough_headers = None + # Handle tools_include filter + tools_include_str = str(form.get("tools_include", "")) + tools_include: Optional[list[str]] = None + if tools_include_str and tools_include_str.strip(): + try: + tools_include = orjson.loads(tools_include_str) + except (orjson.JSONDecodeError, ValueError): + tools_include = [p.strip() for p in tools_include_str.split(",") if p.strip()] + + # Handle tools_exclude filter + tools_exclude_str = str(form.get("tools_exclude", "")) + tools_exclude: Optional[list[str]] = None + if tools_exclude_str and tools_exclude_str.strip(): + try: + tools_exclude = orjson.loads(tools_exclude_str) + except (orjson.JSONDecodeError, ValueError): + tools_exclude = [p.strip() for p in tools_exclude_str.split(",") if p.strip()] + # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth" auth_type_from_form = str(form.get("auth_type", "")) LOGGER.info(f"DEBUG: auth_type from form: '{auth_type_from_form}', oauth_config present: {oauth_config is not None}") @@ -12203,6 +12221,8 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use oauth_config=oauth_config, one_time_auth=form.get("one_time_auth", False), passthrough_headers=passthrough_headers, + tools_include=tools_include, + tools_exclude=tools_exclude, visibility=visibility, ca_certificate=ca_certificate, ca_certificate_sig=sig if sig else None, @@ -12349,6 +12369,25 @@ async def admin_edit_gateway( else: passthrough_headers = None + # Handle tools_include filter + # Use [] (empty list) to signal "user cleared the filter" vs None (not provided) + tools_include_str = str(form.get("tools_include", "")) + tools_include: Optional[List[str]] = [] + if tools_include_str and tools_include_str.strip(): + try: + tools_include = orjson.loads(tools_include_str) + except (orjson.JSONDecodeError, ValueError): + tools_include = [p.strip() for p in tools_include_str.split(",") if p.strip()] + + # Handle tools_exclude filter + tools_exclude_str = str(form.get("tools_exclude", "")) + tools_exclude: Optional[List[str]] = [] + if tools_exclude_str and tools_exclude_str.strip(): + try: + tools_exclude = orjson.loads(tools_exclude_str) + except (orjson.JSONDecodeError, ValueError): + tools_exclude = [p.strip() for p in tools_exclude_str.split(",") if p.strip()] + # Parse OAuth configuration - support both JSON string and individual form fields oauth_config_json = str(form.get("oauth_config")) oauth_config: Optional[dict[str, Any]] = None @@ -12454,6 +12493,8 @@ async def admin_edit_gateway( auth_query_param_value=str(form.get("auth_query_param_value", "")) or None, one_time_auth=form.get("one_time_auth", False), passthrough_headers=passthrough_headers, + tools_include=tools_include, + tools_exclude=tools_exclude, oauth_config=oauth_config, visibility=visibility, owner_email=user_email, diff --git a/mcpgateway/admin_ui/formSubmitHandlers.js b/mcpgateway/admin_ui/formSubmitHandlers.js index 31ee784ec9..444b71c5d9 100644 --- a/mcpgateway/admin_ui/formSubmitHandlers.js +++ b/mcpgateway/admin_ui/formSubmitHandlers.js @@ -790,6 +790,28 @@ export const handleEditGatewayFormSubmit = async function (e) { formData.append("passthrough_headers", JSON.stringify(passthroughHeaders)); + // Process tools_include - convert comma-separated string to JSON array + const toolsIncludeString = formData.get("tools_include") || ""; + const toolsInclude = toolsIncludeString + .split(",") + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern.length > 0); + if (toolsInclude.length > 0) { + formData.delete("tools_include"); + formData.append("tools_include", JSON.stringify(toolsInclude)); + } + + // Process tools_exclude - convert comma-separated string to JSON array + const toolsExcludeString = formData.get("tools_exclude") || ""; + const toolsExclude = toolsExcludeString + .split(",") + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern.length > 0); + if (toolsExclude.length > 0) { + formData.delete("tools_exclude"); + formData.append("tools_exclude", JSON.stringify(toolsExclude)); + } + // Handle OAuth configuration // NOTE: OAuth config assembly is now handled by the backend (mcpgateway/admin.py) // The backend assembles individual form fields into oauth_config with proper field names @@ -884,6 +906,28 @@ export const handleEditA2AAgentFormSubmit = async function (e) { formData.append("passthrough_headers", JSON.stringify(passthroughHeaders)); + // Process tools_include - convert comma-separated string to JSON array + const editToolsIncludeString = formData.get("tools_include") || ""; + const editToolsInclude = editToolsIncludeString + .split(",") + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern.length > 0); + if (editToolsInclude.length > 0) { + formData.delete("tools_include"); + formData.append("tools_include", JSON.stringify(editToolsInclude)); + } + + // Process tools_exclude - convert comma-separated string to JSON array + const editToolsExcludeString = formData.get("tools_exclude") || ""; + const editToolsExclude = editToolsExcludeString + .split(",") + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern.length > 0); + if (editToolsExclude.length > 0) { + formData.delete("tools_exclude"); + formData.append("tools_exclude", JSON.stringify(editToolsExclude)); + } + // Handle auth_headers JSON field const authHeadersJson = formData.get("auth_headers"); if (authHeadersJson) { diff --git a/mcpgateway/admin_ui/gateways.js b/mcpgateway/admin_ui/gateways.js index c8028dcf6e..ec45ebe908 100644 --- a/mcpgateway/admin_ui/gateways.js +++ b/mcpgateway/admin_ui/gateways.js @@ -576,6 +576,26 @@ export const editGateway = async function (gatewayId) { } } + // Handle tools_include + const toolsIncludeField = safeGetElement("edit-gateway-tools-include"); + if (toolsIncludeField) { + if (gateway.toolsInclude && Array.isArray(gateway.toolsInclude)) { + toolsIncludeField.value = gateway.toolsInclude.join(", "); + } else { + toolsIncludeField.value = ""; + } + } + + // Handle tools_exclude + const toolsExcludeField = safeGetElement("edit-gateway-tools-exclude"); + if (toolsExcludeField) { + if (gateway.toolsExclude && Array.isArray(gateway.toolsExclude)) { + toolsExcludeField.value = gateway.toolsExclude.join(", "); + } else { + toolsExcludeField.value = ""; + } + } + openModal("gateway-edit-modal"); applyVisibilityRestrictions(["edit-gateway-visibility"]); // Disable public radio if restricted, preserve checked state console.log("✓ Gateway edit modal loaded successfully"); diff --git a/mcpgateway/alembic/versions/b3c4d5e6f7a8_add_gateway_tool_filters.py b/mcpgateway/alembic/versions/b3c4d5e6f7a8_add_gateway_tool_filters.py new file mode 100644 index 0000000000..5bb531e2bf --- /dev/null +++ b/mcpgateway/alembic/versions/b3c4d5e6f7a8_add_gateway_tool_filters.py @@ -0,0 +1,36 @@ +"""Add tools_include and tools_exclude columns to gateways table. + +Revision ID: b3c4d5e6f7a8 +Revises: a7f3c9e1b2d4 +Create Date: 2026-04-03 10:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = "b3c4d5e6f7a8" +down_revision = "a7f3c9e1b2d4" +branch_labels = None +depends_on = None + + +def upgrade(): + """Add tool filter columns to gateways.""" + conn = op.get_bind() + inspector = inspect(conn) + existing_columns = {col["name"] for col in inspector.get_columns("gateways")} + + if "tools_include" not in existing_columns: + op.add_column("gateways", sa.Column("tools_include", sa.JSON(), nullable=True, comment="Glob patterns to include tools (whitelist)")) + if "tools_exclude" not in existing_columns: + op.add_column("gateways", sa.Column("tools_exclude", sa.JSON(), nullable=True, comment="Glob patterns to exclude tools (blacklist)")) + + +def downgrade(): + """Remove tool filter columns from gateways.""" + with op.batch_alter_table("gateways", schema=None) as batch_op: + batch_op.drop_column("tools_exclude") + batch_op.drop_column("tools_include") diff --git a/mcpgateway/db.py b/mcpgateway/db.py index bf1eafef6a..591fcf511c 100644 --- a/mcpgateway/db.py +++ b/mcpgateway/db.py @@ -4668,6 +4668,13 @@ def team(self) -> Optional[str]: # - 'direct_proxy': All RPC calls are proxied directly to remote MCP server with no database caching gateway_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="cache", comment="Gateway mode: 'cache' (database caching) or 'direct_proxy' (pass-through mode)") + # Tool filtering: include/exclude patterns (fnmatch glob syntax) + # - tools_include: only tools matching at least one pattern are imported (whitelist) + # - tools_exclude: tools matching any pattern are excluded (blacklist) + # - If both are set, include is applied first, then exclude + tools_include: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True, default=None, comment="Glob patterns to include tools (whitelist)") + tools_exclude: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True, default=None, comment="Glob patterns to exclude tools (blacklist)") + # Relationship with OAuth tokens oauth_tokens: Mapped[List["OAuthToken"]] = relationship("OAuthToken", back_populates="gateway", cascade="all, delete-orphan") diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index 2691f68273..0d11ebb0b7 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -2815,6 +2815,10 @@ class GatewayCreate(BaseModelWithConfigDict): # Gateway mode configuration gateway_mode: str = Field(default="cache", description="Gateway mode: 'cache' (database caching, default) or 'direct_proxy' (pass-through mode with no caching)", pattern="^(cache|direct_proxy)$") + # Tool filtering: fnmatch glob patterns to include/exclude tools during refresh + tools_include: Optional[List[str]] = Field(None, description="Glob patterns to whitelist tools (only matching tools are imported)") + tools_exclude: Optional[List[str]] = Field(None, description="Glob patterns to blacklist tools (matching tools are excluded)") + @field_validator("gateway_mode", mode="before") @classmethod def default_gateway_mode(cls, v: Optional[str]) -> str: @@ -3151,6 +3155,10 @@ class GatewayUpdate(BaseModelWithConfigDict): # Gateway mode configuration gateway_mode: Optional[str] = Field(None, description="Gateway mode: 'cache' (database caching, default) or 'direct_proxy' (pass-through mode with no caching)", pattern="^(cache|direct_proxy)$") + # Tool filtering: fnmatch glob patterns to include/exclude tools during refresh + tools_include: Optional[List[str]] = Field(None, description="Glob patterns to whitelist tools (only matching tools are imported)") + tools_exclude: Optional[List[str]] = Field(None, description="Glob patterns to blacklist tools (matching tools are excluded)") + # CA certificate configuration for custom TLS trust ca_certificate: Optional[str] = Field(None, description="Custom CA certificate for TLS verification") ca_certificate_sig: Optional[str] = Field(None, description="Signature of the custom CA certificate") @@ -3520,6 +3528,10 @@ class GatewayRead(BaseModelWithConfigDict): # Gateway mode configuration gateway_mode: str = Field(default="cache", description="Gateway mode: 'cache' (database caching, default) or 'direct_proxy' (pass-through mode with no caching)") + # Tool filtering: fnmatch glob patterns to include/exclude tools during refresh + tools_include: Optional[List[str]] = Field(None, description="Glob patterns to whitelist tools (only matching tools are imported)") + tools_exclude: Optional[List[str]] = Field(None, description="Glob patterns to blacklist tools (matching tools are excluded)") + _normalize_visibility = field_validator("visibility", mode="before")(classmethod(lambda cls, v: _coerce_visibility(v))) # Tool count (populated from the tools relationship; 0 when not loaded) diff --git a/mcpgateway/services/export_service.py b/mcpgateway/services/export_service.py index dcfa73c6d7..82c340d756 100644 --- a/mcpgateway/services/export_service.py +++ b/mcpgateway/services/export_service.py @@ -512,6 +512,12 @@ async def _export_gateways(self, db: Session, tags: Optional[List[str]], include "passthrough_headers": gateway.passthrough_headers or [], } + # Tool filtering patterns + if gateway.tools_include: + gateway_data["tools_include"] = gateway.tools_include + if gateway.tools_exclude: + gateway_data["tools_exclude"] = gateway.tools_exclude + # Handle authentication data securely - use batch-fetched values if gateway.auth_type and gateway.auth_value: if gateway.auth_value == settings.masked_auth_value: @@ -938,6 +944,12 @@ async def _export_selected_gateways(self, db: Session, gateway_ids: List[str], u "passthrough_headers": db_gateway.passthrough_headers or [], } + # Tool filtering patterns + if db_gateway.tools_include: + gateway_data["tools_include"] = db_gateway.tools_include + if db_gateway.tools_exclude: + gateway_data["tools_exclude"] = db_gateway.tools_exclude + # Include auth data directly from DB (already have raw values) if db_gateway.auth_type: gateway_data["auth_type"] = db_gateway.auth_type diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index 90c488e385..8a58c63010 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -45,6 +45,7 @@ import asyncio import binascii from datetime import datetime, timezone +import fnmatch import logging import mimetypes import os @@ -182,6 +183,40 @@ def _resolve_tool_title(tool) -> Optional[str]: return None +def _apply_tool_filters( + tools: List, + tools_include: Optional[List[str]], + tools_exclude: Optional[List[str]], +) -> List: + """Filter a list of tools using fnmatch glob patterns. + + If tools_include is set, only tools whose name matches at least one + include pattern are kept. Then, if tools_exclude is set, tools whose + name matches any exclude pattern are removed. + + Args: + tools: List of ToolCreate objects (must have a ``name`` attribute). + tools_include: Optional whitelist glob patterns. + tools_exclude: Optional blacklist glob patterns. + + Returns: + Filtered list of tools. + """ + if not tools_include and not tools_exclude: + return tools + + filtered = tools + if tools_include: + filtered = [t for t in filtered if any(fnmatch.fnmatch(t.name, p) for p in tools_include)] + if tools_exclude: + filtered = [t for t in filtered if not any(fnmatch.fnmatch(t.name, p) for p in tools_exclude)] + + if len(filtered) != len(tools): + logger.info(f"Tool filter applied: {len(tools)} discovered -> {len(filtered)} kept (include={tools_include}, exclude={tools_exclude})") + + return filtered + + # Cache import (lazy to avoid circular dependencies) _REGISTRY_CACHE = None _TOOL_LOOKUP_CACHE = None @@ -1040,6 +1075,9 @@ async def register_gateway( auth_value = None oauth_config = None + # Apply tool include/exclude filters (fnmatch glob patterns) + tools = _apply_tool_filters(tools, gateway.tools_include, gateway.tools_exclude) + # DbTool.auth_value is Mapped[Optional[str]] (Text), so encode the dict before # storing it there. DbGateway.auth_value is Mapped[Optional[Dict]] (JSON) and # receives the plain dict directly (see assignment above). @@ -1286,6 +1324,9 @@ async def register_gateway( client_key=await self._encrypt_client_key(getattr(gateway, "client_key", None)), # Gateway mode configuration gateway_mode=gateway_mode, + # Tool filtering + tools_include=gateway.tools_include, + tools_exclude=gateway.tools_exclude, ) # Add to DB and commit immediately so tools/resources/prompts are visible @@ -2335,6 +2376,13 @@ async def update_gateway( client_cert=update_client_cert, client_key=update_client_key, ) + + # Apply NEW tool filters so this edit takes effect immediately + # (must read from gateway_update BEFORE persisting to gateway) + effective_include = gateway_update.tools_include if gateway_update.tools_include is not None else gateway.tools_include + effective_exclude = gateway_update.tools_exclude if gateway_update.tools_exclude is not None else gateway.tools_exclude + tools = _apply_tool_filters(tools, effective_include, effective_exclude) + new_tool_names = [tool.name for tool in tools] new_resource_uris = [resource.uri for resource in resources] new_prompt_names = [prompt.name for prompt in prompts] @@ -2466,6 +2514,14 @@ async def update_gateway( raise GatewayError("direct_proxy gateway mode is disabled. Set MCPGATEWAY_DIRECT_PROXY_ENABLED=true to enable.") gateway.gateway_mode = gateway_update.gateway_mode + # Update tool filters if provided + # An empty list [] means "user cleared the filter" → store None + # None means "not provided in this update" → keep existing value + if hasattr(gateway_update, "tools_include") and gateway_update.tools_include is not None: + gateway.tools_include = gateway_update.tools_include if gateway_update.tools_include else None + if hasattr(gateway_update, "tools_exclude") and gateway_update.tools_exclude is not None: + gateway.tools_exclude = gateway_update.tools_exclude if gateway_update.tools_exclude else None + # Update metadata fields gateway.updated_at = datetime.now(timezone.utc) if modified_by: @@ -2817,6 +2873,10 @@ async def set_gateway_state(self, db: Session, gateway_id: str, activate: bool, client_cert=act_client_cert, client_key=act_client_key, ) + + # Apply tool include/exclude filters (fnmatch glob patterns) + tools = _apply_tool_filters(tools, gateway.tools_include, gateway.tools_exclude) + new_tool_names = [tool.name for tool in tools] new_resource_uris = [resource.uri for resource in resources] new_prompt_names = [prompt.name for prompt in prompts] @@ -4892,6 +4952,8 @@ async def _refresh_gateway_tools_resources_prompts( gateway_auth_query_params = None refresh_client_cert = None refresh_client_key = None + gateway_tools_include = None + gateway_tools_exclude = None if gateway: if not gateway.enabled or not gateway.reachable: @@ -4908,6 +4970,8 @@ async def _refresh_gateway_tools_resources_prompts( gateway_auth_query_params = gateway.auth_query_params refresh_client_cert = getattr(gateway, "client_cert", None) refresh_client_key = getattr(gateway, "client_key", None) + gateway_tools_include = getattr(gateway, "tools_include", None) + gateway_tools_exclude = getattr(gateway, "tools_exclude", None) else: with fresh_db_session() as db: gateway_obj = db.execute(select(DbGateway).where(DbGateway.id == gateway_id)).scalar_one_or_none() @@ -4931,6 +4995,8 @@ async def _refresh_gateway_tools_resources_prompts( gateway_auth_query_params = gateway_obj.auth_query_params refresh_client_cert = getattr(gateway_obj, "client_cert", None) refresh_client_key = getattr(gateway_obj, "client_key", None) + gateway_tools_include = getattr(gateway_obj, "tools_include", None) + gateway_tools_exclude = getattr(gateway_obj, "tools_exclude", None) # Preserve base URL before auth mutation for classification poll-state keys gateway_base_url = gateway_url @@ -4979,6 +5045,9 @@ async def _refresh_gateway_tools_resources_prompts( result["error"] = str(e) return result + # Apply tool include/exclude filters (fnmatch glob patterns) + tools = _apply_tool_filters(tools, gateway_tools_include, gateway_tools_exclude) + # For authorization_code OAuth gateways, empty responses may indicate incomplete auth flow # Skip only if it's an auth_code gateway with no data (user may not have completed authorization) is_auth_code_gateway = gateway_oauth_config and isinstance(gateway_oauth_config, dict) and gateway_oauth_config.get("grant_type") == "authorization_code" diff --git a/mcpgateway/services/import_service.py b/mcpgateway/services/import_service.py index eeb387ce43..cbb8454f5e 100644 --- a/mcpgateway/services/import_service.py +++ b/mcpgateway/services/import_service.py @@ -1302,6 +1302,8 @@ def _convert_to_gateway_create(self, gateway_data: Dict[str, Any]) -> GatewayCre description=gateway_data.get("description"), transport=gateway_data.get("transport", "SSE"), passthrough_headers=gateway_data.get("passthrough_headers"), + tools_include=gateway_data.get("tools_include"), + tools_exclude=gateway_data.get("tools_exclude"), tags=gateway_data.get("tags", []), **auth_kwargs, ) @@ -1366,6 +1368,8 @@ def _convert_to_gateway_update(self, gateway_data: Dict[str, Any]) -> GatewayUpd description=gateway_data.get("description"), transport=gateway_data.get("transport"), passthrough_headers=gateway_data.get("passthrough_headers"), + tools_include=gateway_data.get("tools_include"), + tools_exclude=gateway_data.get("tools_exclude"), tags=gateway_data.get("tags"), **auth_kwargs, ) diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index d97241559b..f916b6b8e8 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -6010,6 +6010,40 @@

class="mt-1 px-3 py-2 block w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300" /> +
+ + + Glob patterns for tools to include (comma-separated, e.g., + "manage-ticket*, manage-task*"). Only matching tools will be + imported. Leave empty to include all. + + +
+
+ + + Glob patterns for tools to exclude (comma-separated, e.g., + "manage-project*"). Matching tools will be skipped. Leave + empty to exclude none. + + +
@@ -10321,6 +10355,46 @@

class="mt-1 px-3 py-2 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300" /> +
+ + + Glob patterns for tools to include (comma-separated, + e.g., "manage-ticket*, manage-task*"). Only matching + tools will be imported. Leave empty to include all. + + +
+
+ + + Glob patterns for tools to exclude (comma-separated, + e.g., "manage-project*"). Matching tools will be + skipped. Leave empty to exclude none. + + +