diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py
index 9ef3cddc8..7b2f241b1 100644
--- a/mcpgateway/admin.py
+++ b/mcpgateway/admin.py
@@ -59,6 +59,7 @@
from mcpgateway.db import Tool as DbTool
from mcpgateway.db import utc_now
from mcpgateway.middleware.rbac import get_current_user_with_permissions, require_permission
+from mcpgateway.plugins.framework import get_plugin_manager
from mcpgateway.schemas import (
A2AAgentCreate,
A2AAgentRead,
@@ -2483,6 +2484,7 @@ def _to_dict_and_filter(raw_list):
# Template variables and context: include selected_team_id so the template and frontend can read it
root_path = settings.app_root_path
max_name_length = settings.validation_max_name_length
+ plugin_manager = get_plugin_manager()
response = request.app.state.templates.TemplateResponse(
request,
@@ -2513,6 +2515,7 @@ def _to_dict_and_filter(raw_list):
"user_teams": user_teams,
"mcpgateway_ui_tool_test_timeout": settings.mcpgateway_ui_tool_test_timeout,
"selected_team_id": selected_team_id,
+ "plugin_manager": plugin_manager,
},
)
@@ -11234,7 +11237,14 @@ async def get_plugins_partial(request: Request, db: Session = Depends(get_db), u
stats = plugin_service.get_plugin_statistics()
# Prepare context for template
- context = {"request": request, "plugins": plugins, "stats": stats, "plugins_enabled": plugin_manager is not None, "root_path": request.scope.get("root_path", "")}
+ context = {
+ "request": request,
+ "plugins": plugins,
+ "stats": stats,
+ "plugins_enabled": plugin_manager is not None,
+ "root_path": request.scope.get("root_path", ""),
+ "available_plugins": plugins, # For routing rules modal
+ }
# Render the partial template
return request.app.state.templates.TemplateResponse("plugins_partial.html", context)
@@ -11384,6 +11394,1638 @@ async def get_plugin_details(name: str, request: Request, db: Session = Depends(
raise HTTPException(status_code=500, detail=str(e))
+##################################################
+# Plugin Routing Endpoints
+##################################################
+
+
+@admin_router.get("/plugin-routing/rules", response_class=HTMLResponse)
+async def get_routing_rules(
+ request: Request,
+ db: Session = Depends(get_db),
+ user=Depends(get_current_user_with_permissions),
+):
+ """Get the list of plugin routing rules.
+
+ Returns HTML fragment showing all routing rules from the plugin routing config.
+ """
+ try:
+ # First-Party
+ from mcpgateway.plugins.framework import get_plugin_manager
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Reload config to ensure we have fresh data from disk
+ plugin_manager = get_plugin_manager()
+ if plugin_manager:
+ plugin_manager.reload_config()
+ LOGGER.info("Reloaded plugin config for get_routing_rules")
+
+ route_service = get_plugin_route_service()
+
+ # Get all routing rules from the config
+ rules = []
+ if route_service.config and route_service.config.routes:
+ for idx, route in enumerate(route_service.config.routes):
+ # Try to get display name from metadata, fallback to entity name filter, then to index
+ display_name = route.metadata.get("display_name") if route.metadata else None
+ if not display_name:
+ if isinstance(route.name, str):
+ display_name = route.name
+ elif isinstance(route.name, list) and route.name:
+ display_name = ", ".join(route.name)
+ else:
+ display_name = f"Rule {idx + 1}"
+
+ rule_data = {
+ "index": idx,
+ "name": display_name,
+ "entities": [str(e) for e in (route.entities or [])],
+ "tags": route.tags or [],
+ "hooks": route.hooks or [],
+ "plugins": [
+ {
+ "name": p.name,
+ "priority": p.priority or 0,
+ }
+ for p in (route.plugins or [])
+ ],
+ "reverse_order_on_post": route.reverse_order_on_post or False,
+ "when": route.when or None,
+ }
+ rules.append(rule_data)
+
+ # Get available plugins for the modal
+ plugin_service = get_plugin_service()
+ available_plugins = plugin_service.get_all_plugins()
+
+ # Get root_path for URL generation
+ root_path = request.scope.get("root_path", "")
+
+ # Get all rule indices for bulk operations
+ rule_indices = [rule["index"] for rule in rules]
+
+ context = {
+ "request": request,
+ "root_path": root_path,
+ "rules": rules,
+ "available_plugins": available_plugins,
+ "rule_indices": rule_indices,
+ }
+
+ return request.app.state.templates.TemplateResponse("routing_rules_list.html", context)
+
+ except Exception as e:
+ LOGGER.error(f"Error getting routing rules: {e}", exc_info=True)
+ # Return error HTML instead of raising exception
+ error_html = f"""
+
+
+
Error loading rules
+
{str(e)}
+
+ """
+ return HTMLResponse(content=error_html)
+
+
+@admin_router.get("/plugin-routing/entities")
+async def get_entities_by_type(
+ request: Request,
+ entity_types: str = "", # Comma-separated list of entity types
+ db: Session = Depends(get_db),
+ user=Depends(get_current_user_with_permissions),
+):
+ """Get entities filtered by type for plugin routing.
+
+ Args:
+ request: FastAPI request object.
+ entity_types: Comma-separated list of entity types (tool, prompt, resource, agent, virtual_server, mcp_server).
+ db: Database session.
+ user: Authenticated user.
+
+ Returns:
+ JSON response with entities grouped by type.
+ """
+ try:
+ # First-Party
+ from mcpgateway.db import A2AAgent, Gateway, Prompt, Resource, Server, Tool
+
+ result = {}
+ types = [t.strip() for t in entity_types.split(",") if t.strip()]
+
+ for entity_type in types:
+ entities = []
+ if entity_type == "tool":
+ tools = db.query(Tool).filter(Tool.enabled == True).all()
+ entities = [{"id": t.id, "name": t.name, "display_name": t.original_name} for t in tools]
+ elif entity_type == "prompt":
+ prompts = db.query(Prompt).filter(Prompt.is_active == True).all()
+ entities = [{"id": p.id, "name": p.name, "display_name": p.name} for p in prompts]
+ elif entity_type == "resource":
+ resources = db.query(Resource).filter(Resource.is_active == True).all()
+ entities = [{"id": r.id, "name": r.name, "display_name": r.name} for r in resources]
+ elif entity_type == "agent":
+ agents = db.query(A2AAgent).filter(A2AAgent.enabled == True).all()
+ entities = [{"id": a.id, "name": a.name, "display_name": a.name} for a in agents]
+ elif entity_type == "virtual_server":
+ servers = db.query(Server).filter(Server.is_active == True).all()
+ entities = [{"id": s.id, "name": s.name, "display_name": s.name} for s in servers]
+ elif entity_type == "mcp_server":
+ gateways = db.query(Gateway).filter(Gateway.enabled == True).all()
+ entities = [{"id": g.id, "name": g.name, "display_name": g.name} for g in gateways]
+
+ result[entity_type] = entities
+
+ return result
+
+ except Exception as e:
+ LOGGER.error(f"Error fetching entities: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@admin_router.get("/plugin-routing/tags")
+async def get_all_entity_tags(
+ request: Request,
+ db: Session = Depends(get_db),
+ user=Depends(get_current_user_with_permissions),
+):
+ """Get all unique tags from all entities for plugin routing autocomplete.
+
+ Args:
+ request: FastAPI request object.
+ db: Database session.
+ user: Authenticated user.
+
+ Returns:
+ JSON response with sorted list of unique tags.
+ """
+ try:
+ # First-Party
+ from mcpgateway.db import A2AAgent, Gateway, Prompt, Resource, Server, Tool
+
+ all_tags = set()
+
+ # Collect tags from all entity types
+ for tool in db.query(Tool).filter(Tool.enabled == True).all():
+ if tool.tags:
+ all_tags.update(tool.tags)
+
+ for prompt in db.query(Prompt).filter(Prompt.is_active == True).all():
+ if prompt.tags:
+ all_tags.update(prompt.tags)
+
+ for resource in db.query(Resource).filter(Resource.is_active == True).all():
+ if resource.tags:
+ all_tags.update(resource.tags)
+
+ for agent in db.query(A2AAgent).filter(A2AAgent.enabled == True).all():
+ if agent.tags:
+ all_tags.update(agent.tags)
+
+ for server in db.query(Server).filter(Server.is_active == True).all():
+ if server.tags:
+ all_tags.update(server.tags)
+
+ for gateway in db.query(Gateway).filter(Gateway.enabled == True).all():
+ if gateway.tags:
+ all_tags.update(gateway.tags)
+
+ # Return sorted list
+ return sorted(list(all_tags))
+
+ except Exception as e:
+ LOGGER.error(f"Error fetching entity tags: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@admin_router.get("/plugin-routing/rules/{rule_index}")
+async def get_routing_rule(
+ request: Request,
+ rule_index: int,
+ db: Session = Depends(get_db),
+ user=Depends(get_current_user_with_permissions),
+):
+ """Get a single routing rule by index.
+
+ Args:
+ request: FastAPI request object.
+ rule_index: Index of the rule to retrieve.
+ db: Database session.
+ user: Authenticated user.
+
+ Returns:
+ JSON response with the rule data.
+ """
+ try:
+ # First-Party
+ from mcpgateway.plugins.framework import get_plugin_manager
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Reload config to ensure we have fresh data from disk
+ plugin_manager = get_plugin_manager()
+ if plugin_manager:
+ plugin_manager.reload_config()
+ LOGGER.info("Reloaded plugin config for get_routing_rule")
+
+ route_service = get_plugin_route_service()
+ rule = await route_service.get_rule(rule_index)
+
+ if not rule:
+ return JSONResponse(content={"error": f"Rule at index {rule_index} not found"}, status_code=404)
+
+ # Get display name from metadata or fallback
+ display_name = rule.metadata.get("display_name") if rule.metadata else None
+ if not display_name:
+ if isinstance(rule.name, str):
+ display_name = rule.name
+ elif isinstance(rule.name, list) and rule.name:
+ display_name = ", ".join(rule.name)
+ else:
+ display_name = f"Rule {rule_index + 1}"
+
+ # Convert entity name filter to string for the name_filter field
+ name_filter = ""
+ if isinstance(rule.name, str):
+ name_filter = rule.name
+ elif isinstance(rule.name, list):
+ name_filter = ", ".join(rule.name)
+
+ # Convert rule to dict for JSON serialization
+ rule_data = {
+ "index": rule_index,
+ "display_name": display_name, # Rule display name for UI
+ "name_filter": name_filter, # Entity name filter
+ "entities": [str(e) for e in (rule.entities or [])],
+ "tags": rule.tags or [],
+ "hooks": rule.hooks or [],
+ "plugins": [{"name": p.name, "priority": p.priority or 0} for p in (rule.plugins or [])],
+ "reverse_order_on_post": rule.reverse_order_on_post or False,
+ "when": rule.when or "",
+ }
+
+ return JSONResponse(content=rule_data)
+
+ except Exception as e:
+ LOGGER.error(f"Error getting routing rule {rule_index}: {e}", exc_info=True)
+ return JSONResponse(content={"error": str(e)}, status_code=500)
+
+
+@admin_router.post("/plugin-routing/rules/bulk-delete")
+async def bulk_delete_routing_rules(
+ request: Request,
+ db: Session = Depends(get_db),
+ user=Depends(get_current_user_with_permissions),
+):
+ """Bulk delete routing rules by indices.
+
+ Args:
+ request: FastAPI request object with JSON body containing 'indices' array.
+ db: Database session.
+ user: Authenticated user.
+
+ Returns:
+ HTML response with the updated rules list.
+ """
+ try:
+ # Standard
+ import json
+
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Parse request body
+ body = await request.body()
+ data = json.loads(body)
+ indices = [int(idx) for idx in data.get("indices", [])]
+
+ if not indices:
+ return HTMLResponse(content="No rules selected for deletion", status_code=400)
+
+ route_service = get_plugin_route_service()
+
+ # Sort indices in descending order to delete from end to start
+ # This prevents index shifting issues
+ sorted_indices = sorted(indices, reverse=True)
+
+ deleted_count = 0
+ failed_indices = []
+
+ for rule_index in sorted_indices:
+ try:
+ success = await route_service.delete_rule(rule_index)
+ if success:
+ deleted_count += 1
+ else:
+ failed_indices.append(rule_index)
+ except Exception as e:
+ LOGGER.error(f"Error deleting rule at index {rule_index}: {e}")
+ failed_indices.append(rule_index)
+
+ # Log the bulk operation
+ LOGGER.info(f"User {get_user_email(user)} bulk deleted {deleted_count} routing rules")
+
+ if failed_indices:
+ LOGGER.warning(f"Failed to delete rules at indices: {failed_indices}")
+
+ # Return updated rules list (HTMX will replace the content)
+ return await get_routing_rules(request, db, user)
+
+ except json.JSONDecodeError as e:
+ LOGGER.error(f"Invalid JSON in bulk delete request: {e}")
+ return HTMLResponse(content="Invalid request format", status_code=400)
+ except Exception as e:
+ LOGGER.error(f"Error in bulk delete: {e}", exc_info=True)
+ return HTMLResponse(content=f"Error: {str(e)}", status_code=500)
+
+
+@admin_router.post("/plugin-routing/rules")
+@admin_router.post("/plugin-routing/rules/{rule_index}")
+async def create_or_update_routing_rule(
+ request: Request,
+ rule_index: Optional[int] = None,
+ db: Session = Depends(get_db),
+ user=Depends(get_current_user_with_permissions),
+):
+ """Create a new routing rule or update an existing one.
+
+ Args:
+ request: FastAPI request object.
+ rule_index: Optional index of the rule to update (from path).
+ db: Database session.
+ user: Authenticated user.
+
+ Returns:
+ HTML response with the updated rules list.
+ """
+ try:
+ # First-Party
+ from mcpgateway.plugins.framework.models import EntityType, PluginAttachment, PluginHookRule
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Parse form data
+ form_data = await request.form()
+
+ # Extract form fields
+ rule_name = form_data.get("rule_name", "").strip()
+ if not rule_name:
+ return HTMLResponse(content="Rule name is required", status_code=400)
+
+ # Parse entities
+ entities = form_data.getlist("entities")
+ entity_types = [EntityType(e) for e in entities] if entities else []
+ entity_types = entity_types or None # Convert empty list to None
+
+ # Parse name filter (can be comma-separated)
+ name_filter = form_data.get("name_filter", "").strip()
+ name_list = None
+ if name_filter:
+ names = [n.strip() for n in name_filter.split(",") if n.strip()]
+ name_list = names[0] if len(names) == 1 else names if names else None
+
+ # Parse tags - filter empty strings and convert empty list to None
+ tags = form_data.getlist("tags")
+ tags = [t.strip() for t in tags if t.strip()] or None
+
+ # Parse hooks - filter empty strings and convert empty list to None
+ hooks = form_data.getlist("hooks")
+ hooks = [h.strip() for h in hooks if h.strip()] or None
+
+ # Parse when expression
+ when_expression = form_data.get("when_expression", "").strip()
+ when_expression = when_expression if when_expression else None
+
+ # Parse reverse order flag
+ reverse_order_on_post = form_data.get("reverse_order_on_post") == "true"
+
+ # Debug logging to see what we received
+ LOGGER.info(
+ f"Parsed routing rule data: entities={entity_types}, name_list={name_list}, "
+ f"tags={tags}, hooks={hooks}, when={when_expression}"
+ )
+
+ # Validate that at least one matching criterion is provided
+ has_criteria = (
+ (entity_types is not None and len(entity_types) > 0)
+ or name_list is not None
+ or (tags is not None and len(tags) > 0)
+ or (hooks is not None and len(hooks) > 0)
+ or (when_expression is not None and len(when_expression) > 0)
+ )
+ if not has_criteria:
+ error_msg = (
+ "Routing rule must have at least one matching criterion. "
+ "Please specify: entity types, entity names, tags, hooks, or a condition expression."
+ )
+ LOGGER.error(f"Validation failed: {error_msg}")
+ return HTMLResponse(content=error_msg, status_code=400)
+
+ # Parse plugins JSON
+ # Standard
+ import json
+
+ plugins_json = form_data.get("plugins", "[]")
+ plugins_data = json.loads(plugins_json)
+
+ if not plugins_data:
+ return HTMLResponse(content="At least one plugin is required", status_code=400)
+
+ # Create PluginAttachment objects with advanced configuration
+ plugin_attachments = []
+ for p in plugins_data:
+ if not p.get("name"):
+ continue
+
+ # Parse config JSON if present
+ config = {}
+ config_str = p.get("config", "").strip()
+ if config_str:
+ try:
+ config = json.loads(config_str)
+ except json.JSONDecodeError as e:
+ LOGGER.warning(f"Invalid JSON in plugin config for {p['name']}: {e}")
+ # Continue with empty dict rather than failing
+
+ # Parse when expression
+ when = p.get("when", "").strip() or None
+
+ # Parse override flag
+ override = p.get("override", False)
+ if isinstance(override, str):
+ override = override.lower() in ("true", "1", "yes")
+
+ # Parse mode (convert empty string to None)
+ mode = p.get("mode", "").strip() or None
+
+ plugin_attachments.append(
+ PluginAttachment(
+ name=p["name"],
+ priority=int(p.get("priority", 10)),
+ config=config,
+ when=when,
+ override=override,
+ mode=mode,
+ )
+ )
+
+ # Check if rule_index is also in form data (for update)
+ form_rule_index = form_data.get("rule_index")
+ if form_rule_index is not None and form_rule_index != "":
+ rule_index = int(form_rule_index)
+
+ # Create PluginHookRule
+ # Note: 'name' field is for entity name filtering, not rule display name
+ # We'll store the display name in metadata
+ rule = PluginHookRule(
+ name=name_list, # Entity name filter
+ entities=entity_types,
+ tags=tags,
+ hooks=hooks,
+ when=when_expression,
+ reverse_order_on_post=reverse_order_on_post,
+ plugins=plugin_attachments,
+ metadata={"display_name": rule_name}, # Store friendly name in metadata
+ )
+
+ # Save to config (add_or_update_rule saves internally with file locking)
+ route_service = get_plugin_route_service()
+ index = await route_service.add_or_update_rule(rule, rule_index)
+
+ LOGGER.info(f"User {get_user_email(user)} {'updated' if rule_index is not None else 'created'} routing rule at index {index}")
+
+ # Return updated rules list (HTMX will replace the content)
+ return await get_routing_rules(request, db, user)
+
+ except Exception as e:
+ LOGGER.error(f"Error creating/updating routing rule: {e}", exc_info=True)
+ return HTMLResponse(content=f"Error: {str(e)}", status_code=500)
+
+
+@admin_router.delete("/plugin-routing/rules/{rule_index}")
+async def delete_routing_rule(
+ request: Request,
+ rule_index: int,
+ db: Session = Depends(get_db),
+ user=Depends(get_current_user_with_permissions),
+):
+ """Delete a routing rule by index.
+
+ Args:
+ request: FastAPI request object.
+ rule_index: Index of the rule to delete.
+ db: Database session.
+ user: Authenticated user.
+
+ Returns:
+ HTML response with the updated rules list.
+ """
+ try:
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ route_service = get_plugin_route_service()
+ success = await route_service.delete_rule(rule_index)
+
+ if not success:
+ return HTMLResponse(content=f"Rule at index {rule_index} not found", status_code=404)
+
+ # Note: delete_rule saves internally with file locking
+
+ LOGGER.info(f"User {get_user_email(user)} deleted routing rule at index {rule_index}")
+
+ # Return updated rules list (HTMX will replace the content)
+ return await get_routing_rules(request, db, user)
+
+ except ValueError as e:
+ LOGGER.error(f"Error deleting routing rule {rule_index}: {e}")
+ return HTMLResponse(content=str(e), status_code=400)
+ except Exception as e:
+ LOGGER.error(f"Error deleting routing rule {rule_index}: {e}", exc_info=True)
+ return HTMLResponse(content=f"Error: {str(e)}", status_code=500)
+
+
+async def _get_entity_by_id(db: Session, entity_type: str, entity_id: str):
+ """Helper to get an entity by ID and type.
+
+ Args:
+ db: Database session.
+ entity_type: Type of entity (tool, prompt, resource).
+ entity_id: Entity ID (UUID string).
+
+ Returns:
+ The entity model object.
+
+ Raises:
+ HTTPException: If entity not found.
+ """
+ if entity_type == "tool":
+ entity = db.query(DbTool).filter(DbTool.id == entity_id).first()
+ entity_name = "Tool"
+ elif entity_type == "prompt":
+ entity = db.query(DbPrompt).filter(DbPrompt.id == entity_id).first()
+ entity_name = "Prompt"
+ elif entity_type == "resource":
+ entity = db.query(DbResource).filter(DbResource.id == entity_id).first()
+ entity_name = "Resource"
+ else:
+ raise HTTPException(status_code=400, detail=f"Unsupported entity type: {entity_type}")
+
+ if not entity:
+ raise HTTPException(status_code=404, detail=f"{entity_name} {entity_id} not found")
+
+ return entity
+
+
+async def _get_plugins_for_entity_and_hook(
+ route_service,
+ entity_type: str,
+ entity_name: str,
+ entity_id: str,
+ tags: list[str],
+ server_name: Optional[str],
+ server_id: Optional[str],
+ hook_type: str,
+):
+ """Helper to get plugins for a specific entity and hook type.
+
+ This calls the route service with a specific hook_type so the resolver
+ applies proper ordering (including post-hook reversal if configured).
+
+ Args:
+ route_service: PluginRouteService instance.
+ entity_type: Type of entity.
+ entity_name: Name of entity.
+ entity_id: Entity ID.
+ tags: Entity tags.
+ server_name: Server name.
+ server_id: Server ID.
+ hook_type: Specific hook type to resolve plugins for.
+
+ Returns:
+ List of plugin data dicts with name, priority, and config.
+ """
+ plugins = await route_service.get_routes_for_entity(
+ entity_type=entity_type,
+ entity_name=entity_name,
+ entity_id=entity_id,
+ tags=tags,
+ server_name=server_name,
+ server_id=server_id,
+ hook_type=hook_type,
+ )
+
+ return [{"name": p.name, "priority": p.priority, "config": p.config} for p in plugins]
+
+
+@admin_router.get("/tools/{tool_id}/plugins", response_class=JSONResponse)
+async def get_tool_plugins(
+ _request: Request,
+ tool_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get plugins that apply to a specific tool.
+
+ Returns both pre-invoke and post-invoke plugins with their execution order.
+ Post-hooks are shown in their actual execution order (may be reversed based on config).
+ """
+ try:
+ # First-Party
+ from mcpgateway.plugins.framework.hooks.registry import get_hook_registry, HookPhase
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Get services
+ route_service = get_plugin_route_service()
+ hook_registry = get_hook_registry()
+
+ # Get pre and post hook types for tools using the registry
+ pre_hook_types = hook_registry.get_hooks_for_entity_type("tool", HookPhase.PRE)
+ post_hook_types = hook_registry.get_hooks_for_entity_type("tool", HookPhase.POST)
+
+ # Get plugins for each hook type (resolver applies correct ordering)
+ pre_hooks = []
+ post_hooks = []
+
+ # Tool has many-to-many with servers, get first if available
+ first_server = tool.servers[0] if tool.servers else None
+
+ # Use first pre-hook type (typically tool_pre_invoke)
+ if pre_hook_types:
+ pre_hooks = await _get_plugins_for_entity_and_hook(
+ route_service,
+ entity_type="tool",
+ entity_name=tool.name,
+ entity_id=str(tool.id),
+ tags=tool.tags or [],
+ server_name=first_server.name if first_server else None,
+ server_id=str(first_server.id) if first_server else None,
+ hook_type=pre_hook_types[0],
+ )
+
+ # Use first post-hook type (typically tool_post_invoke)
+ if post_hook_types:
+ post_hooks = await _get_plugins_for_entity_and_hook(
+ route_service,
+ entity_type="tool",
+ entity_name=tool.name,
+ entity_id=str(tool.id),
+ tags=tool.tags or [],
+ server_name=first_server.name if first_server else None,
+ server_id=str(first_server.id) if first_server else None,
+ hook_type=post_hook_types[0],
+ )
+
+ return {
+ "tool_id": tool.id,
+ "tool_name": tool.name,
+ "pre_hooks": pre_hooks,
+ "post_hooks": post_hooks,
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ LOGGER.error(f"Error getting tool plugins: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# Bulk plugin operations (must be before parameterized routes)
+@admin_router.get("/tools/bulk/plugins/status", response_class=JSONResponse)
+async def get_bulk_plugin_status(
+ request: Request,
+ tool_ids: str = Query(..., description="Comma-separated list of tool IDs"),
+ db: Session = Depends(get_db),
+ _user=Depends(get_current_user_with_permissions),
+):
+ """Get plugin configuration status for multiple tools.
+
+ Returns which plugins are configured on all, some, or none of the selected tools.
+
+ Args:
+ request: FastAPI request object
+ tool_ids: Comma-separated tool IDs
+ db: Database session
+ _user: Current authenticated user
+
+ Returns:
+ JSON with plugin status breakdown
+ """
+ try:
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ tool_id_list = [tid.strip() for tid in tool_ids.split(",") if tid.strip()]
+
+ if not tool_id_list:
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": "No tool IDs provided"},
+ )
+
+ # First-Party
+ from mcpgateway.plugins.framework import get_plugin_manager
+ from mcpgateway.plugins.framework.hooks.registry import get_hook_registry
+
+ route_service = get_plugin_route_service()
+ hook_registry = get_hook_registry()
+
+ # Reload config to ensure we have fresh data from disk
+ plugin_manager = get_plugin_manager()
+ if plugin_manager:
+ plugin_manager.reload_config()
+ LOGGER.info("Reloaded plugin config for bulk plugin status")
+
+ # First-Party
+ from mcpgateway.plugins.framework.hooks.registry import HookPhase
+
+ # Get plugin configurations for each tool (with actual hook configuration)
+ tool_plugins = {} # tool_id -> {plugin_name -> {pre: bool, post: bool, priority: int}}
+ tool_names = {}
+
+ for tool_id in tool_id_list:
+ try:
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+ tool_names[tool_id] = tool.name
+ first_server = tool.servers[0] if tool.servers else None
+
+ # Get pre-hook plugins for this tool
+ pre_hook_types = hook_registry.get_hooks_for_entity_type("tool", HookPhase.PRE)
+ post_hook_types = hook_registry.get_hooks_for_entity_type("tool", HookPhase.POST)
+
+ pre_plugins = []
+ post_plugins = []
+
+ if pre_hook_types:
+ pre_plugins = await _get_plugins_for_entity_and_hook(
+ route_service,
+ entity_type="tool",
+ entity_name=tool.name,
+ entity_id=str(tool.id),
+ tags=tool.tags or [],
+ server_name=first_server.name if first_server else None,
+ server_id=str(first_server.id) if first_server else None,
+ hook_type=pre_hook_types[0],
+ )
+
+ if post_hook_types:
+ post_plugins = await _get_plugins_for_entity_and_hook(
+ route_service,
+ entity_type="tool",
+ entity_name=tool.name,
+ entity_id=str(tool.id),
+ tags=tool.tags or [],
+ server_name=first_server.name if first_server else None,
+ server_id=str(first_server.id) if first_server else None,
+ hook_type=post_hook_types[0],
+ )
+
+ # Build plugin info with actual hook configuration
+ plugin_info = {}
+ for p in pre_plugins:
+ name = p.get("name", p.get("plugin_name", ""))
+ if name not in plugin_info:
+ plugin_info[name] = {"pre": False, "post": False, "priority": p.get("priority", 0)}
+ plugin_info[name]["pre"] = True
+ plugin_info[name]["priority"] = max(plugin_info[name]["priority"], p.get("priority", 0))
+
+ for p in post_plugins:
+ name = p.get("name", p.get("plugin_name", ""))
+ if name not in plugin_info:
+ plugin_info[name] = {"pre": False, "post": False, "priority": p.get("priority", 0)}
+ plugin_info[name]["post"] = True
+ plugin_info[name]["priority"] = max(plugin_info[name]["priority"], p.get("priority", 0))
+
+ tool_plugins[tool_id] = plugin_info
+
+ except Exception as e:
+ LOGGER.warning(f"Could not get plugins for tool {tool_id}: {e}")
+ tool_plugins[tool_id] = {}
+ tool_names[tool_id] = f"Tool {tool_id}"
+
+ # Analyze plugin distribution across tools
+ all_plugins = set()
+ for plugins in tool_plugins.values():
+ all_plugins.update(plugins.keys())
+
+ LOGGER.info(f"Tool plugins mapping: {tool_plugins}")
+ LOGGER.info(f"All unique plugins found: {all_plugins}")
+
+ plugin_status = {}
+ for plugin in all_plugins:
+ # Count tools that have this plugin
+ tool_count = sum(1 for plugins in tool_plugins.values() if plugin in plugins)
+ total_tools = len(tool_id_list)
+
+ if tool_count == total_tools:
+ status = "all"
+ elif tool_count > 0:
+ status = "some"
+ else:
+ status = "none"
+
+ # Aggregate hook configuration across tools
+ has_pre = any(plugins.get(plugin, {}).get("pre", False) for plugins in tool_plugins.values())
+ has_post = any(plugins.get(plugin, {}).get("post", False) for plugins in tool_plugins.values())
+
+ # Get max priority across tools
+ max_priority = max((plugins.get(plugin, {}).get("priority", 0) for plugins in tool_plugins.values()), default=0)
+
+ plugin_status[plugin] = {
+ "status": status,
+ "count": tool_count,
+ "total": total_tools,
+ "pre_hooks": ["tool_pre_invoke"] if has_pre else [],
+ "post_hooks": ["tool_post_invoke"] if has_post else [],
+ "priority": max_priority,
+ }
+
+ return JSONResponse(
+ content={
+ "success": True,
+ "tool_count": len(tool_id_list),
+ "tool_names": tool_names,
+ "tool_plugins": {tid: list(plugins.keys()) for tid, plugins in tool_plugins.items()},
+ "plugin_status": plugin_status,
+ }
+ )
+ except Exception as e:
+ LOGGER.error(f"Error getting bulk plugin status: {e}", exc_info=True)
+ return JSONResponse(
+ status_code=500,
+ content={"success": False, "message": str(e)},
+ )
+
+
+@admin_router.post("/tools/bulk/plugins", response_class=JSONResponse)
+async def add_bulk_plugins(
+ request: Request,
+ db: Session = Depends(get_db),
+ _user=Depends(get_current_user_with_permissions),
+):
+ """Add a plugin to multiple tools at once (bulk operation).
+
+ Args:
+ request: FastAPI request object
+ db: Database session
+ _user: Current authenticated user
+
+ Returns:
+ JSON response with success/failure counts
+ """
+ try:
+ # Parse form data manually
+ form_data = await request.form()
+
+ # Extract and validate form fields
+ tool_ids = form_data.getlist("tool_ids")
+ # Accept both plugin_name (singular) and plugin_names (plural) for flexibility
+ plugin_name = form_data.get("plugin_name") or form_data.get("plugin_names")
+ priority_str = form_data.get("priority", "10")
+ priority = int(priority_str) if priority_str and priority_str.strip() else 10
+ hooks = form_data.getlist("hooks") if "hooks" in form_data else None
+ reverse_order_on_post = form_data.get("reverse_order_on_post") == "true"
+
+ # Parse new advanced fields
+ config_str = form_data.get("config", "").strip()
+ config = None
+ if config_str:
+ try:
+ config = json.loads(config_str)
+ except json.JSONDecodeError as e:
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": f"Invalid JSON in config: {e}"},
+ )
+
+ override = form_data.get("override") == "true"
+ scope = form_data.get("scope", "local")
+ mode = form_data.get("mode") or None # Convert empty string to None
+
+ LOGGER.info("=== BULK ADD PLUGIN REQUEST ===")
+ LOGGER.info(f"Tool IDs: {tool_ids}")
+ LOGGER.info(f"Plugin Name: {plugin_name}")
+ LOGGER.info(f"Priority: {priority}")
+ LOGGER.info(f"Hooks: {hooks}")
+ LOGGER.info(f"Reverse order on post: {reverse_order_on_post}")
+ LOGGER.info(f"Config: {config}")
+ LOGGER.info(f"Override: {override}")
+ LOGGER.info(f"Scope: {scope}")
+ LOGGER.info(f"Mode: {mode}")
+
+ if not tool_ids:
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": "No tool IDs provided"},
+ )
+
+ if not plugin_name:
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": "No plugin name provided"},
+ )
+
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ route_service = get_plugin_route_service()
+ success_count = 0
+ failed_count = 0
+ errors = []
+
+ for tool_id in tool_ids:
+ try:
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Add plugin route (now saves internally with file locking)
+ await route_service.add_simple_route(
+ entity_type="tool",
+ entity_name=tool.name,
+ plugin_name=plugin_name,
+ priority=priority,
+ hooks=hooks if hooks else None,
+ reverse_order_on_post=reverse_order_on_post,
+ config=config,
+ override=override,
+ mode=mode,
+ )
+ success_count += 1
+
+ except Exception as e:
+ LOGGER.error(f"Failed to add plugin {plugin_name} to tool {tool_id}: {e}")
+ failed_count += 1
+ errors.append({"tool_id": tool_id, "error": str(e)})
+
+ # Note: No need to save config here - add_simple_route saves internally with file locking
+
+ return JSONResponse(
+ content={
+ "success": True,
+ "message": f"Added plugin to {success_count} tools" + (f", {failed_count} failed" if failed_count > 0 else ""),
+ "success_count": success_count,
+ "failed_count": failed_count,
+ "errors": errors if errors else None,
+ }
+ )
+
+ except Exception as e:
+ LOGGER.error(f"Error in bulk add plugins: {e}", exc_info=True)
+ return JSONResponse(
+ status_code=500,
+ content={"success": False, "message": str(e)},
+ )
+
+
+@admin_router.delete("/tools/bulk/plugins", response_class=JSONResponse)
+async def remove_bulk_plugins(
+ request: Request,
+ db: Session = Depends(get_db),
+ _user=Depends(get_current_user_with_permissions),
+):
+ """Remove a plugin from multiple tools at once (bulk operation).
+
+ Args:
+ request: FastAPI request object
+ db: Database session
+ _user: Current authenticated user
+
+ Returns:
+ JSON response with success/failure counts
+ """
+ # Parse form data manually to debug
+ form_data = await request.form()
+ LOGGER.info(f"Bulk remove plugins - raw form data keys: {list(form_data.keys())}")
+ LOGGER.info(f"Bulk remove plugins - raw form data: {dict(form_data)}")
+ LOGGER.info(f"Bulk remove plugins - multi(): {list(form_data.multi_items())}")
+
+ # Extract and validate form fields
+ tool_ids = form_data.getlist("tool_ids")
+ # Accept both singular and plural forms, and support multiple plugins
+ plugin_names_list = form_data.getlist("plugin_names")
+ plugin_name_list = form_data.getlist("plugin_name")
+ LOGGER.info(f"getlist('plugin_names'): {plugin_names_list}")
+ LOGGER.info(f"getlist('plugin_name'): {plugin_name_list}")
+
+ plugin_names = plugin_names_list if plugin_names_list else plugin_name_list
+ # Also check for single value if getlist returns empty
+ if not plugin_names:
+ single_name = form_data.get("plugin_names") or form_data.get("plugin_name")
+ LOGGER.info(f"Fallback single_name: {single_name}")
+ if single_name:
+ plugin_names = [single_name]
+
+ # Check for clear_all flag
+ clear_all = form_data.get("clear_all") == "true"
+
+ LOGGER.info(f"Bulk remove plugins - tool_ids: {tool_ids}, plugin_names: {plugin_names}, clear_all: {clear_all}")
+
+ if not tool_ids:
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": "No tool IDs provided"},
+ )
+
+ if not plugin_names and not clear_all:
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": "No plugin name provided"},
+ )
+
+ # First-Party
+ from mcpgateway.plugins.framework.hooks.registry import get_hook_registry, HookPhase
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ route_service = get_plugin_route_service()
+ hook_registry = get_hook_registry()
+ success_count = 0
+ failed_count = 0
+ errors = []
+
+ # If clear_all, get all plugins for each tool and remove them
+ if clear_all:
+ for tool_id in tool_ids:
+ try:
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+ first_server = tool.servers[0] if tool.servers else None
+
+ # Get all configured plugins for this tool
+ pre_hook_types = hook_registry.get_hooks_for_entity_type("tool", HookPhase.PRE)
+ post_hook_types = hook_registry.get_hooks_for_entity_type("tool", HookPhase.POST)
+
+ all_plugin_names = set()
+
+ if pre_hook_types:
+ pre_plugins = await _get_plugins_for_entity_and_hook(
+ route_service,
+ entity_type="tool",
+ entity_name=tool.name,
+ entity_id=str(tool.id),
+ tags=tool.tags or [],
+ server_name=first_server.name if first_server else None,
+ server_id=str(first_server.id) if first_server else None,
+ hook_type=pre_hook_types[0],
+ )
+ all_plugin_names.update(p.get("name", "") for p in pre_plugins)
+
+ if post_hook_types:
+ post_plugins = await _get_plugins_for_entity_and_hook(
+ route_service,
+ entity_type="tool",
+ entity_name=tool.name,
+ entity_id=str(tool.id),
+ tags=tool.tags or [],
+ server_name=first_server.name if first_server else None,
+ server_id=str(first_server.id) if first_server else None,
+ hook_type=post_hook_types[0],
+ )
+ all_plugin_names.update(p.get("name", "") for p in post_plugins)
+
+ # Remove all plugins
+ for pname in all_plugin_names:
+ if pname:
+ removed = await route_service.remove_plugin_from_entity(
+ entity_type="tool",
+ entity_name=tool.name,
+ plugin_name=pname,
+ )
+ if removed:
+ success_count += 1
+ else:
+ failed_count += 1
+
+ except Exception as e:
+ LOGGER.warning(f"Error clearing plugins for tool {tool_id}: {e}")
+ failed_count += 1
+ errors.append({"tool_id": tool_id, "error": str(e)})
+
+ # Note: No need to save - remove_plugin_from_entity saves internally with file locking
+
+ return JSONResponse(
+ content={
+ "success": True,
+ "message": f"Cleared plugins from {len(tool_ids)} tools",
+ "removed": success_count,
+ "failed": failed_count,
+ "errors": errors if errors else None,
+ }
+ )
+
+ # Loop over all tools and all plugins (non-clear_all case)
+ for tool_id in tool_ids:
+ for plugin_name in plugin_names:
+ try:
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Remove plugin from entity
+ removed = await route_service.remove_plugin_from_entity(
+ entity_type="tool",
+ entity_name=tool.name,
+ plugin_name=plugin_name,
+ )
+
+ if removed:
+ success_count += 1
+ else:
+ failed_count += 1
+ errors.append({"tool_id": tool_id, "plugin": plugin_name, "error": "Plugin not found on this tool"})
+
+ except Exception as e:
+ LOGGER.error(f"Failed to remove plugin {plugin_name} from tool {tool_id}: {e}")
+ failed_count += 1
+ errors.append({"tool_id": tool_id, "plugin": plugin_name, "error": str(e)})
+
+ # Note: No need to save config here - remove_plugin_from_entity saves internally with file locking
+
+ return JSONResponse(
+ content={
+ "success": True,
+ "message": f"Removed {success_count} plugin attachment(s)" + (f", {failed_count} failed" if failed_count > 0 else ""),
+ "success_count": success_count,
+ "failed_count": failed_count,
+ "errors": errors if errors else None,
+ }
+ )
+
+
+@admin_router.post("/tools/bulk/plugins/priority", response_class=JSONResponse)
+async def update_bulk_plugin_priority(
+ request: Request,
+ db: Session = Depends(get_db),
+ _user=Depends(get_current_user_with_permissions),
+):
+ """Update plugin priority for multiple tools at once (bulk operation).
+
+ Args:
+ request: FastAPI request object
+ db: Database session
+ _user: Current authenticated user
+
+ Returns:
+ JSON response with success/failure counts
+ """
+ try:
+ # Parse form data
+ form_data = await request.form()
+ tool_ids = form_data.getlist("tool_ids")
+ plugin_name = form_data.get("plugin_name")
+ new_priority = int(form_data.get("priority", 10))
+
+ LOGGER.info(f"Bulk update priority - tool_ids: {tool_ids}, plugin: {plugin_name}, priority: {new_priority}")
+
+ if not tool_ids:
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": "No tool IDs provided"},
+ )
+
+ if not plugin_name:
+ return JSONResponse(
+ status_code=400,
+ content={"success": False, "message": "No plugin name provided"},
+ )
+
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ route_service = get_plugin_route_service()
+ success_count = 0
+ failed_count = 0
+ errors = []
+
+ for tool_id in tool_ids:
+ try:
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Update plugin priority for this tool
+ updated = await route_service.update_plugin_priority(
+ entity_type="tool",
+ entity_name=tool.name,
+ plugin_name=plugin_name,
+ new_priority=new_priority,
+ )
+
+ if updated:
+ success_count += 1
+ else:
+ failed_count += 1
+ errors.append({"tool_id": tool_id, "error": f"Plugin {plugin_name} not found for tool {tool.name}"})
+
+ except Exception as e:
+ LOGGER.error(f"Failed to update priority for plugin {plugin_name} on tool {tool_id}: {e}")
+ failed_count += 1
+ errors.append({"tool_id": tool_id, "error": str(e)})
+
+ # Save configuration after all updates
+ if success_count > 0:
+ try:
+ await route_service.save_config()
+ except Exception as e:
+ LOGGER.error(f"Failed to save plugin configuration: {e}")
+ return JSONResponse(
+ status_code=500,
+ content={"success": False, "message": "Failed to save plugin configuration", "error": str(e)},
+ )
+
+ return JSONResponse(
+ content={
+ "success": True,
+ "message": f"Updated priority for {success_count} tools" + (f", {failed_count} failed" if failed_count > 0 else ""),
+ "success_count": success_count,
+ "failed_count": failed_count,
+ "errors": errors if errors else None,
+ }
+ )
+
+ except Exception as e:
+ LOGGER.error(f"Error in bulk update plugin priority: {e}", exc_info=True)
+ return JSONResponse(
+ status_code=500,
+ content={"success": False, "message": str(e)},
+ )
+
+
+@admin_router.post("/tools/{tool_id}/plugins", response_class=HTMLResponse)
+async def add_tool_plugin(
+ request: Request,
+ tool_id: str,
+ db: Session = Depends(get_db),
+):
+ """Quick-add a plugin to a tool.
+
+ Creates a simple name-based routing rule.
+ Accepts form data from HTMX forms.
+ Returns updated plugins UI HTML.
+ """
+ try:
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Parse form data
+ form_data = await request.form()
+ plugin_name = form_data.get("plugin_name")
+ priority = int(form_data.get("priority", 10))
+ hooks = form_data.getlist("hooks") if "hooks" in form_data else None
+ reverse_order_on_post = form_data.get("reverse_order_on_post") == "true"
+
+ # Parse advanced fields
+ config_str = form_data.get("config", "").strip()
+ config = None
+ if config_str:
+ try:
+ config = json.loads(config_str)
+ except json.JSONDecodeError as e:
+ raise HTTPException(status_code=400, detail=f"Invalid JSON in config: {e}")
+
+ override = form_data.get("override") == "true"
+ mode = form_data.get("mode") or None # Convert empty string to None
+
+ if not plugin_name:
+ raise HTTPException(status_code=400, detail="Plugin name is required")
+
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Get plugin route service
+ route_service = get_plugin_route_service()
+
+ # Add simple route
+ await route_service.add_simple_route(
+ entity_type="tool",
+ entity_name=tool.name,
+ plugin_name=plugin_name,
+ priority=priority,
+ hooks=hooks if hooks else None,
+ reverse_order_on_post=reverse_order_on_post,
+ config=config,
+ override=override,
+ mode=mode,
+ )
+
+ # Save configuration
+ await route_service.save_config()
+
+ LOGGER.info(f"Added plugin {plugin_name} to tool {tool.name}")
+
+ # Return updated plugins UI
+ return await get_tool_plugins_ui(request, tool_id, db)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ LOGGER.error(f"Error adding tool plugin: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@admin_router.delete("/tools/{tool_id}/plugins/{plugin_name}", response_class=HTMLResponse)
+async def remove_tool_plugin(
+ request: Request,
+ tool_id: str,
+ plugin_name: str,
+ hook: Optional[str] = Query(None, description="Specific hook to remove (e.g., tool_pre_invoke or tool_post_invoke)"),
+ db: Session = Depends(get_db),
+):
+ """Remove a plugin from a tool.
+
+ Removes the plugin from simple name-based routing rules only.
+ If hook is specified, only removes from that specific hook type.
+ Returns updated plugins UI HTML.
+ """
+ try:
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Get plugin route service
+ route_service = get_plugin_route_service()
+
+ # Remove plugin from entity (optionally for specific hook only)
+ removed = await route_service.remove_plugin_from_entity(
+ entity_type="tool",
+ entity_name=tool.name,
+ plugin_name=plugin_name,
+ hook=hook,
+ )
+
+ if not removed:
+ hook_msg = f" for hook {hook}" if hook else ""
+ raise HTTPException(
+ status_code=404,
+ detail=f"Plugin {plugin_name} not found in simple rules for tool {tool.name}{hook_msg}",
+ )
+
+ # Save configuration
+ await route_service.save_config()
+
+ hook_msg = f" from {hook}" if hook else ""
+ LOGGER.info(f"Removed plugin {plugin_name}{hook_msg} from tool {tool.name}")
+
+ # Return updated plugins UI
+ return await get_tool_plugins_ui(request, tool_id, db)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ LOGGER.error(f"Error removing tool plugin: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@admin_router.post("/tools/{tool_id}/plugins/reverse-post-hooks", response_class=HTMLResponse)
+async def toggle_reverse_post_hooks(
+ request: Request,
+ tool_id: str,
+ db: Session = Depends(get_db),
+):
+ """Toggle reverse_order_on_post for all plugin rules of a tool.
+
+ When enabled, post-hooks execute in reverse order (LIFO - last added runs first).
+ Returns updated plugins UI HTML.
+ """
+ try:
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Get plugin route service
+ route_service = get_plugin_route_service()
+
+ # Toggle reverse_order_on_post for all rules of this tool (save is handled internally by the service)
+ new_state = await route_service.toggle_reverse_post_hooks(
+ entity_type="tool",
+ entity_name=tool.name,
+ )
+
+ LOGGER.info(f"Toggled reverse_order_on_post to {new_state} for tool {tool.name}")
+
+ # Return updated plugins UI
+ return await get_tool_plugins_ui(request, tool_id, db)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ LOGGER.error(f"Error toggling reverse post hooks: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@admin_router.post("/tools/{tool_id}/plugins/{plugin_name}/priority", response_class=HTMLResponse)
+async def change_tool_plugin_priority(
+ request: Request,
+ tool_id: str,
+ plugin_name: str,
+ hook: str = Query(..., description="Hook type (tool_pre_invoke or tool_post_invoke)"),
+ direction: str = Query(..., description="Direction to move: 'up' or 'down'"),
+ db: Session = Depends(get_db),
+):
+ """Change a plugin's priority (move up or down in execution order).
+
+ Moving 'up' decreases priority (runs earlier), 'down' increases priority (runs later).
+ After changing, returns the updated plugins UI.
+ """
+ try:
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Get plugin route service
+ route_service = get_plugin_route_service()
+
+ # Change priority (save is handled internally by the service)
+ success = await route_service.change_plugin_priority(
+ entity_type="tool",
+ entity_name=tool.name,
+ plugin_name=plugin_name,
+ hook=hook,
+ direction=direction,
+ )
+
+ if not success:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Plugin {plugin_name} not found or cannot be moved {direction}",
+ )
+
+ LOGGER.info(f"Changed priority of plugin {plugin_name} ({direction}) for tool {tool.name}")
+
+ # Return updated plugins UI
+ return await get_tool_plugins_ui(request, tool_id, db)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ LOGGER.error(f"Error changing tool plugin priority: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@admin_router.post("/tools/{tool_id}/plugins/{plugin_name}/set-priority", response_class=HTMLResponse)
+async def set_tool_plugin_priority(
+ request: Request,
+ tool_id: str,
+ plugin_name: str,
+ hook: str = Query(..., description="Hook type (tool_pre_invoke or tool_post_invoke)"),
+ db: Session = Depends(get_db),
+):
+ """Set a plugin's priority to an absolute value.
+
+ Accepts form data with 'priority' field.
+ After updating, returns the updated plugins UI.
+ """
+ try:
+ # First-Party
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Parse form data
+ form_data = await request.form()
+ new_priority = int(form_data.get("priority", 10))
+
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Get plugin route service
+ route_service = get_plugin_route_service()
+
+ # Update priority (save is handled internally by the service)
+ success = await route_service.update_plugin_priority(
+ entity_type="tool",
+ entity_name=tool.name,
+ plugin_name=plugin_name,
+ new_priority=new_priority,
+ )
+
+ if not success:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Plugin {plugin_name} not found for tool {tool.name}",
+ )
+
+ LOGGER.info(f"Set priority of plugin {plugin_name} to {new_priority} for tool {tool.name}")
+
+ # Return updated plugins UI
+ return await get_tool_plugins_ui(request, tool_id, db)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ LOGGER.error(f"Error setting tool plugin priority: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@admin_router.get("/tools/{tool_id}/plugins-ui", response_class=HTMLResponse)
+async def get_tool_plugins_ui(
+ request: Request,
+ tool_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get the plugin management UI for a tool (returns HTML fragment for HTMX).
+
+ This endpoint returns an expandable row showing:
+ - Current pre-invoke and post-invoke plugins with execution order
+ - Form to add new plugins
+ - Buttons to remove plugins
+ """
+ try:
+ # First-Party
+ from mcpgateway.plugins.framework import get_plugin_manager
+ from mcpgateway.plugins.framework.hooks.registry import get_hook_registry, HookPhase
+ from mcpgateway.services.plugin_route_service import get_plugin_route_service
+
+ # Get tool from database
+ tool = await _get_entity_by_id(db, "tool", tool_id)
+
+ # Get services
+ route_service = get_plugin_route_service()
+ hook_registry = get_hook_registry()
+
+ # Reload config from disk to see changes from other workers
+ plugin_manager = get_plugin_manager()
+ if plugin_manager:
+ plugin_manager.reload_config()
+
+ # Get pre and post hook types for tools using the registry
+ pre_hook_types = hook_registry.get_hooks_for_entity_type("tool", HookPhase.PRE)
+ post_hook_types = hook_registry.get_hooks_for_entity_type("tool", HookPhase.POST)
+
+ # Get plugins for each hook type (resolver applies correct ordering)
+ pre_hooks = []
+ post_hooks = []
+
+ # Tool has many-to-many with servers, get first if available
+ first_server = tool.servers[0] if tool.servers else None
+
+ # Use first pre-hook type (typically tool_pre_invoke)
+ if pre_hook_types:
+ pre_hooks = await _get_plugins_for_entity_and_hook(
+ route_service,
+ entity_type="tool",
+ entity_name=tool.name,
+ entity_id=str(tool.id),
+ tags=tool.tags or [],
+ server_name=first_server.name if first_server else None,
+ server_id=str(first_server.id) if first_server else None,
+ hook_type=pre_hook_types[0],
+ )
+
+ # Use first post-hook type (typically tool_post_invoke)
+ if post_hook_types:
+ post_hooks = await _get_plugins_for_entity_and_hook(
+ route_service,
+ entity_type="tool",
+ entity_name=tool.name,
+ entity_id=str(tool.id),
+ tags=tool.tags or [],
+ server_name=first_server.name if first_server else None,
+ server_id=str(first_server.id) if first_server else None,
+ hook_type=post_hook_types[0],
+ )
+
+ # Get available plugins from plugin manager (already retrieved and reloaded above)
+ available_plugins = []
+ if plugin_manager:
+ available_plugins = [{"name": name} for name in plugin_manager.get_plugin_names()]
+
+ # Get reverse post-hooks state for this tool
+ reverse_post_hooks = route_service.get_reverse_post_hooks_state(
+ entity_type="tool",
+ entity_name=tool.name,
+ )
+
+ # Calculate next suggested priority (max existing + 10, or 10 if none)
+ all_plugins = pre_hooks + post_hooks
+ max_priority = max((p.get("priority", 0) or 0 for p in all_plugins), default=0)
+ next_priority = max_priority + 10 if max_priority > 0 else 10
+
+ # Get root_path for URL generation
+ root_path = request.scope.get("root_path", "")
+
+ context = {
+ "request": request,
+ "root_path": root_path,
+ "tool_id": tool.id,
+ "tool_name": tool.name,
+ "pre_hooks": pre_hooks,
+ "post_hooks": post_hooks,
+ "available_plugins": available_plugins,
+ "reverse_post_hooks": reverse_post_hooks,
+ "next_priority": next_priority,
+ }
+
+ return request.app.state.templates.TemplateResponse("tool_plugins_partial.html", context)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ LOGGER.error(f"Error loading tool plugins UI: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
##################################################
# MCP Registry Endpoints
##################################################
diff --git a/mcpgateway/main.py b/mcpgateway/main.py
index 20cf10896..c5c3e5393 100644
--- a/mcpgateway/main.py
+++ b/mcpgateway/main.py
@@ -31,6 +31,7 @@
from datetime import datetime
import json
import os as _os # local alias to avoid collisions
+from pathlib import Path
import sys
from typing import Any, AsyncIterator, Dict, List, Optional, Union
from urllib.parse import urlparse, urlunparse
@@ -114,6 +115,7 @@
from mcpgateway.services.import_service import ImportService, ImportValidationError
from mcpgateway.services.logging_service import LoggingService
from mcpgateway.services.metrics import setup_metrics
+from mcpgateway.services.plugin_route_service import init_plugin_route_service
from mcpgateway.services.prompt_service import PromptError, PromptNameConflictError, PromptNotFoundError, PromptService
from mcpgateway.services.resource_service import ResourceError, ResourceNotFoundError, ResourceService, ResourceURIConflictError
from mcpgateway.services.root_service import RootService
@@ -166,6 +168,10 @@
_config_file = _os.getenv("PLUGIN_CONFIG_FILE", settings.plugin_config_file)
plugin_manager: PluginManager | None = PluginManager(_config_file) if _PLUGINS_ENABLED else None
+# Initialize plugin route service if plugins are enabled
+if _PLUGINS_ENABLED:
+ init_plugin_route_service(Path(_config_file))
+
# Initialize services
tool_service = ToolService()
resource_service = ResourceService()
diff --git a/mcpgateway/plugins/framework/__init__.py b/mcpgateway/plugins/framework/__init__.py
index 3a4286bb4..0e50515ed 100644
--- a/mcpgateway/plugins/framework/__init__.py
+++ b/mcpgateway/plugins/framework/__init__.py
@@ -18,10 +18,15 @@
from typing import Optional
# First-Party
-from mcpgateway.plugins.framework.base import Plugin
+from mcpgateway.plugins.framework.base import AttachedHookRef, Plugin
from mcpgateway.plugins.framework.errors import PluginError, PluginViolationError
from mcpgateway.plugins.framework.external.mcp.server import ExternalPluginServer
-from mcpgateway.plugins.framework.hooks.registry import HookRegistry, get_hook_registry
+from mcpgateway.plugins.framework.hooks.registry import (
+ HookMetadata,
+ HookPhase,
+ HookRegistry,
+ get_hook_registry,
+)
from mcpgateway.plugins.framework.loader.config import ConfigLoader
from mcpgateway.plugins.framework.loader.plugin import PluginLoader
from mcpgateway.plugins.framework.manager import PluginManager
@@ -49,17 +54,29 @@
)
from mcpgateway.plugins.framework.hooks.tools import ToolHookType, ToolPostInvokePayload, ToolPostInvokeResult, ToolPreInvokeResult, ToolPreInvokePayload
from mcpgateway.plugins.framework.models import (
+ ConfigMetadata,
+ EntityType,
+ FieldSelection,
GlobalContext,
MCPServerConfig,
+ PluginAttachment,
PluginCondition,
PluginConfig,
PluginContext,
PluginErrorModel,
+ PluginHookRule,
PluginMode,
PluginPayload,
PluginResult,
PluginViolation,
)
+from mcpgateway.plugins.framework.routing import (
+ EvaluationContext,
+ FieldSelector,
+ PolicyEvaluator,
+ RuleBasedResolver,
+ RuleMatchContext,
+)
# Plugin manager singleton (lazy initialization)
_plugin_manager: Optional[PluginManager] = None
@@ -98,11 +115,19 @@ def get_plugin_manager() -> Optional[PluginManager]:
"AgentPostInvokeResult",
"AgentPreInvokePayload",
"AgentPreInvokeResult",
+ "AttachedHookRef",
"ConfigLoader",
+ "ConfigMetadata",
+ "EntityType",
+ "EvaluationContext",
"ExternalPluginServer",
+ "FieldSelection",
+ "FieldSelector",
"get_hook_registry",
"get_plugin_manager",
"GlobalContext",
+ "HookMetadata",
+ "HookPhase",
"HookRegistry",
"HttpAuthCheckPermissionPayload",
"HttpAuthCheckPermissionResult",
@@ -117,11 +142,13 @@ def get_plugin_manager() -> Optional[PluginManager]:
"HttpPreRequestResult",
"MCPServerConfig",
"Plugin",
+ "PluginAttachment",
"PluginCondition",
"PluginConfig",
"PluginContext",
"PluginError",
"PluginErrorModel",
+ "PluginHookRule",
"PluginLoader",
"PluginManager",
"PluginMode",
@@ -129,6 +156,9 @@ def get_plugin_manager() -> Optional[PluginManager]:
"PluginResult",
"PluginViolation",
"PluginViolationError",
+ "PolicyEvaluator",
+ "RuleBasedResolver",
+ "RuleMatchContext",
"PromptHookType",
"PromptPosthookPayload",
"PromptPosthookResult",
diff --git a/mcpgateway/plugins/framework/base.py b/mcpgateway/plugins/framework/base.py
index 1d3e221b9..dc5b325b3 100644
--- a/mcpgateway/plugins/framework/base.py
+++ b/mcpgateway/plugins/framework/base.py
@@ -16,6 +16,7 @@
# First-Party
from mcpgateway.plugins.framework.errors import PluginError
from mcpgateway.plugins.framework.models import (
+ PluginAttachment,
PluginCondition,
PluginConfig,
PluginContext,
@@ -100,6 +101,15 @@ def priority(self) -> int:
"""
return self._config.priority
+ @property
+ def post_priority(self) -> int | None:
+ """Return the plugin's post-hook priority.
+
+ Returns:
+ Plugin's post-hook priority, or None if not set.
+ """
+ return self._config.post_priority
+
@property
def config(self) -> PluginConfig:
"""Return the plugin's configuration.
@@ -317,6 +327,15 @@ def priority(self) -> int:
"""
return self._plugin.priority
+ @property
+ def post_priority(self) -> int | None:
+ """Returns the plugin's post-hook priority.
+
+ Returns:
+ Plugin's post-hook priority, or None if not set.
+ """
+ return self._plugin.post_priority
+
@property
def name(self) -> str:
"""Return the plugin's name.
@@ -594,3 +613,83 @@ def hook(self) -> Callable[[PluginPayload, PluginContext], Awaitable[PluginResul
An awaitable hook function reference.
"""
return self._func
+
+
+class AttachedHookRef:
+ """A HookRef paired with its PluginAttachment configuration.
+
+ This composite object combines:
+ - HookRef: The actual plugin hook implementation (can be HookRef or ExternalHookRef)
+ - PluginAttachment: The routing configuration (priority, field selection, conditions, etc.)
+
+ Used in resource-centric routing to provide plugins with their attachment metadata.
+ Works with both internal (HookRef) and external (ExternalHookRef) plugins.
+
+ Attributes:
+ hook_ref: The HookRef or ExternalHookRef containing the plugin and hook method.
+ attachment: The PluginAttachment configuration for this execution.
+
+ Examples:
+ >>> from mcpgateway.plugins.framework.models import PluginAttachment, FieldSelection
+ >>> # Assuming you have a hook_ref and attachment:
+ >>> # attachment = PluginAttachment(name="pii_filter", priority=10, apply_to=FieldSelection(fields=["args.email"]))
+ >>> # attached = AttachedHookRef(hook_ref, attachment)
+ >>> # attached.hook_ref.name # Plugin name
+ >>> # attached.attachment.priority # Execution priority (10)
+ >>> # attached.attachment.apply_to.fields # Field selection (["args.email"])
+ """
+
+ def __init__(self, hook_ref: HookRef, attachment: Optional[PluginAttachment] = None):
+ """Initialize an attached hook reference.
+
+ Args:
+ hook_ref: The hook reference to execute (HookRef or ExternalHookRef).
+ attachment: The plugin attachment configuration from routing. None for non-routed plugins.
+ """
+ self._hook_ref = hook_ref
+ self._attachment = attachment
+
+ @property
+ def hook_ref(self) -> HookRef:
+ """The underlying HookRef or ExternalHookRef.
+
+ Returns:
+ HookRef object (or subclass).
+ """
+ return self._hook_ref
+
+ @property
+ def attachment(self) -> Optional[PluginAttachment]:
+ """The plugin attachment configuration.
+
+ Returns:
+ PluginAttachment object with priority, field selection, etc., or None if not using routing.
+ """
+ return self._attachment
+
+ @property
+ def name(self) -> str:
+ """Plugin name (convenience accessor).
+
+ Returns:
+ Plugin name from attachment, or plugin name if no attachment.
+ """
+ return self._attachment.name if self._attachment else self._hook_ref.plugin_ref.name
+
+ @property
+ def hook(self) -> Callable[[PluginPayload, PluginContext], Awaitable[PluginResult]] | None:
+ """The hook function (convenience accessor).
+
+ Returns:
+ Hook function from HookRef.
+ """
+ return self._hook_ref.hook
+
+ @property
+ def plugin_ref(self) -> PluginRef:
+ """The plugin reference (convenience accessor).
+
+ Returns:
+ PluginRef from HookRef.
+ """
+ return self._hook_ref.plugin_ref
diff --git a/mcpgateway/plugins/framework/hooks/http.py b/mcpgateway/plugins/framework/hooks/http.py
index 163091097..9412a0471 100644
--- a/mcpgateway/plugins/framework/hooks/http.py
+++ b/mcpgateway/plugins/framework/hooks/http.py
@@ -190,23 +190,25 @@ def _register_http_auth_hooks() -> None:
This is called lazily to avoid circular import issues.
Registers four hook types:
- - HTTP_PRE_REQUEST: Transform headers before authentication (middleware)
- - HTTP_POST_REQUEST: Inspect response after request completion (middleware)
- - HTTP_AUTH_RESOLVE_USER: Custom user authentication (auth layer)
- - HTTP_AUTH_CHECK_PERMISSION: Custom permission checking (RBAC layer)
+ - HTTP_PRE_REQUEST: Transform headers before authentication (middleware) - PRE phase
+ - HTTP_POST_REQUEST: Inspect response after request completion (middleware) - POST phase
+ - HTTP_AUTH_RESOLVE_USER: Custom user authentication (auth layer) - PRE phase (before request)
+ - HTTP_AUTH_CHECK_PERMISSION: Custom permission checking (RBAC layer) - PRE phase (before request)
"""
# Import here to avoid circular dependency at module load time
# First-Party
- from mcpgateway.plugins.framework.hooks.registry import get_hook_registry # pylint: disable=import-outside-toplevel
+ from mcpgateway.plugins.framework.hooks.registry import get_hook_registry, HookPhase # pylint: disable=import-outside-toplevel
registry = get_hook_registry()
# Only register if not already registered (idempotent)
if not registry.is_registered(HttpHookType.HTTP_PRE_REQUEST):
+ # HTTP_PRE_REQUEST and HTTP_POST_REQUEST auto-detect correctly
registry.register_hook(HttpHookType.HTTP_PRE_REQUEST, HttpPreRequestPayload, HttpPreRequestResult)
registry.register_hook(HttpHookType.HTTP_POST_REQUEST, HttpPostRequestPayload, HttpPostRequestResult)
- registry.register_hook(HttpHookType.HTTP_AUTH_RESOLVE_USER, HttpAuthResolveUserPayload, HttpAuthResolveUserResult)
- registry.register_hook(HttpHookType.HTTP_AUTH_CHECK_PERMISSION, HttpAuthCheckPermissionPayload, HttpAuthCheckPermissionResult)
+ # Auth hooks need explicit phase since they don't have _pre_/_post_ in name
+ registry.register_hook(HttpHookType.HTTP_AUTH_RESOLVE_USER, HttpAuthResolveUserPayload, HttpAuthResolveUserResult, HookPhase.PRE)
+ registry.register_hook(HttpHookType.HTTP_AUTH_CHECK_PERMISSION, HttpAuthCheckPermissionPayload, HttpAuthCheckPermissionResult, HookPhase.PRE)
_register_http_auth_hooks()
diff --git a/mcpgateway/plugins/framework/hooks/registry.py b/mcpgateway/plugins/framework/hooks/registry.py
index 177175471..6ba431fb5 100644
--- a/mcpgateway/plugins/framework/hooks/registry.py
+++ b/mcpgateway/plugins/framework/hooks/registry.py
@@ -12,32 +12,92 @@
"""
# Standard
+from enum import Enum
from typing import Dict, Optional, Type, Union
+# Third-Party
+from pydantic import BaseModel
+
# First-Party
from mcpgateway.plugins.framework.models import PluginPayload, PluginResult
+class HookPhase(str, Enum):
+ """Phase when a hook executes.
+
+ Attributes:
+ PRE: Hook executes before the operation (pre-invoke, pre-fetch, etc.).
+ POST: Hook executes after the operation (post-invoke, post-fetch, etc.).
+
+ Examples:
+ >>> HookPhase.PRE
+
+ >>> HookPhase.POST
+
+ """
+
+ PRE = "pre"
+ POST = "post"
+
+
+class HookMetadata(BaseModel):
+ """Metadata for a registered hook.
+
+ Attributes:
+ payload_class: The Pydantic payload model class.
+ result_class: The Pydantic result model class.
+ phase: Whether this is a pre or post hook.
+ entity_type: Optional entity type this hook applies to (tool, prompt, resource, http, agent, etc.).
+
+ Examples:
+ >>> from mcpgateway.plugins.framework import PluginPayload, PluginResult
+ >>> meta = HookMetadata(
+ ... payload_class=PluginPayload,
+ ... result_class=PluginResult,
+ ... phase=HookPhase.PRE,
+ ... entity_type="tool"
+ ... )
+ >>> meta.phase
+
+ >>> meta.entity_type
+ 'tool'
+ """
+
+ payload_class: Type[PluginPayload]
+ result_class: Type[PluginResult]
+ phase: HookPhase
+ entity_type: Optional[str] = None
+
+ class Config:
+ """Pydantic config."""
+
+ arbitrary_types_allowed = True
+
+
class HookRegistry:
"""Global registry for hook type metadata.
This singleton registry maintains mappings between hook type names and their
- associated Pydantic models for payloads and results. It enables dynamic
- serialization/deserialization for external plugins.
+ associated Pydantic models for payloads and results, plus metadata like
+ hook phase (pre/post). It enables dynamic serialization/deserialization
+ for external plugins and proper handling of post-hook priority ordering.
Examples:
>>> from mcpgateway.plugins.framework import PluginPayload, PluginResult
>>> registry = HookRegistry()
- >>> registry.register_hook("test_hook", PluginPayload, PluginResult)
- >>> registry.get_payload_type("test_hook")
+ >>> registry.register_hook("test_pre_hook", PluginPayload, PluginResult, HookPhase.PRE)
+ >>> registry.get_payload_type("test_pre_hook")
- >>> registry.get_result_type("test_hook")
+ >>> registry.get_result_type("test_pre_hook")
+ >>> registry.get_phase("test_pre_hook")
+
+ >>> registry.is_post_hook("test_pre_hook")
+ False
"""
_instance: Optional["HookRegistry"] = None
- _hook_payloads: Dict[str, Type[PluginPayload]] = {}
- _hook_results: Dict[str, Type[PluginResult]] = {}
+ _hook_metadata: Dict[str, HookMetadata] = {}
def __new__(cls) -> "HookRegistry":
"""Ensure singleton pattern for the registry.
@@ -54,21 +114,73 @@ def register_hook(
hook_type: str,
payload_class: Type[PluginPayload],
result_class: Type[PluginResult],
+ phase: Optional[HookPhase] = None,
+ entity_type: Optional[str] = None,
) -> None:
- """Register a hook type with its payload and result classes.
+ """Register a hook type with its payload, result classes, phase, and entity type.
+
+ The phase is auto-detected from the hook_type name if not provided:
+ - Names containing "_pre_" or ending with "_pre" are PRE hooks
+ - Names containing "_post_" or ending with "_post" are POST hooks
+ - Raises PluginError if phase cannot be detected and not explicitly provided
+
+ The entity_type is auto-detected from the hook_type prefix if not provided:
+ - "tool_*" hooks -> entity_type="tool"
+ - "prompt_*" hooks -> entity_type="prompt"
+ - "resource_*" hooks -> entity_type="resource"
+ - "http_*" hooks -> entity_type="http"
+ - "agent_*" hooks -> entity_type="agent"
+ - "server_*" hooks -> entity_type="server"
Args:
- hook_type: The hook type identifier (e.g., "prompt_pre_fetch").
+ hook_type: The hook type identifier (e.g., "tool_pre_invoke").
payload_class: The Pydantic model class for the hook's payload.
result_class: The Pydantic model class for the hook's result.
+ phase: Optional hook phase (PRE or POST). Auto-detected if not provided.
+ entity_type: Optional entity type (tool, prompt, resource, http, agent, server).
+ Auto-detected from hook_type prefix if not provided.
+
+ Raises:
+ ValueError: If phase cannot be auto-detected and not explicitly provided.
Examples:
>>> registry = HookRegistry()
>>> from mcpgateway.plugins.framework import PluginPayload, PluginResult
- >>> registry.register_hook("custom_hook", PluginPayload, PluginResult)
+ >>> # Explicit phase and entity type
+ >>> registry.register_hook("custom_pre", PluginPayload, PluginResult, HookPhase.PRE, "tool")
+ >>> registry.get_phase("custom_pre")
+
+ >>> registry.get_entity_type("custom_pre")
+ 'tool'
+
+ >>> # Auto-detect from name
+ >>> registry.register_hook("tool_pre_invoke", PluginPayload, PluginResult)
+ >>> registry.get_phase("tool_pre_invoke")
+
+ >>> registry.get_entity_type("tool_pre_invoke")
+ 'tool'
"""
- self._hook_payloads[hook_type] = payload_class
- self._hook_results[hook_type] = result_class
+ # Auto-detect phase from hook_type name if not provided
+ if phase is None:
+ if "_pre_" in hook_type or hook_type.endswith("_pre"):
+ phase = HookPhase.PRE
+ elif "_post_" in hook_type or hook_type.endswith("_post"):
+ phase = HookPhase.POST
+ else:
+ # Error if can't detect
+ raise ValueError(
+ f"Cannot auto-detect phase for hook '{hook_type}'. " "Hook name must contain '_pre_', '_post_', or end with '_pre' or '_post', " "or phase must be explicitly provided."
+ )
+
+ # Auto-detect entity_type from hook_type prefix if not provided
+ if entity_type is None:
+ for prefix in ["tool", "prompt", "resource", "http", "agent", "server"]:
+ if hook_type.startswith(f"{prefix}_"):
+ entity_type = prefix
+ break
+
+ # Store in metadata dict
+ self._hook_metadata[hook_type] = HookMetadata(payload_class=payload_class, result_class=result_class, phase=phase, entity_type=entity_type)
def get_payload_type(self, hook_type: str) -> Optional[Type[PluginPayload]]:
"""Get the payload class for a hook type.
@@ -83,7 +195,8 @@ def get_payload_type(self, hook_type: str) -> Optional[Type[PluginPayload]]:
>>> registry = HookRegistry()
>>> registry.get_payload_type("unknown_hook")
"""
- return self._hook_payloads.get(hook_type)
+ metadata = self._hook_metadata.get(hook_type)
+ return metadata.payload_class if metadata else None
def get_result_type(self, hook_type: str) -> Optional[Type[PluginResult]]:
"""Get the result class for a hook type.
@@ -98,7 +211,8 @@ def get_result_type(self, hook_type: str) -> Optional[Type[PluginResult]]:
>>> registry = HookRegistry()
>>> registry.get_result_type("unknown_hook")
"""
- return self._hook_results.get(hook_type)
+ metadata = self._hook_metadata.get(hook_type)
+ return metadata.result_class if metadata else None
def json_to_payload(self, hook_type: str, payload: Union[str, dict]) -> PluginPayload:
"""Convert JSON to the appropriate payload Pydantic model.
@@ -168,7 +282,7 @@ def is_registered(self, hook_type: str) -> bool:
>>> registry.is_registered("unknown")
False
"""
- return hook_type in self._hook_payloads and hook_type in self._hook_results
+ return hook_type in self._hook_metadata
def get_registered_hooks(self) -> list[str]:
"""Get all registered hook types.
@@ -182,7 +296,141 @@ def get_registered_hooks(self) -> list[str]:
>>> isinstance(hooks, list)
True
"""
- return list(self._hook_payloads.keys())
+ return list(self._hook_metadata.keys())
+
+ def get_phase(self, hook_type: str) -> Optional[HookPhase]:
+ """Get the phase (PRE/POST) for a hook type.
+
+ Args:
+ hook_type: The hook type identifier.
+
+ Returns:
+ The hook phase, or None if not registered.
+
+ Examples:
+ >>> registry = HookRegistry()
+ >>> from mcpgateway.plugins.framework import PluginPayload, PluginResult
+ >>> registry.register_hook("test_pre", PluginPayload, PluginResult, HookPhase.PRE)
+ >>> registry.get_phase("test_pre")
+
+ >>> registry.get_phase("unknown")
+ """
+ metadata = self._hook_metadata.get(hook_type)
+ return metadata.phase if metadata else None
+
+ def is_post_hook(self, hook_type: str) -> bool:
+ """Check if a hook is a POST hook.
+
+ Args:
+ hook_type: The hook type identifier.
+
+ Returns:
+ True if hook is a POST hook, False otherwise.
+
+ Examples:
+ >>> registry = HookRegistry()
+ >>> from mcpgateway.plugins.framework import PluginPayload, PluginResult
+ >>> registry.register_hook("test_post", PluginPayload, PluginResult, HookPhase.POST)
+ >>> registry.is_post_hook("test_post")
+ True
+ >>> registry.register_hook("test_pre", PluginPayload, PluginResult, HookPhase.PRE)
+ >>> registry.is_post_hook("test_pre")
+ False
+ """
+ phase = self.get_phase(hook_type)
+ return phase == HookPhase.POST if phase else False
+
+ def is_pre_hook(self, hook_type: str) -> bool:
+ """Check if a hook is a PRE hook.
+
+ Args:
+ hook_type: The hook type identifier.
+
+ Returns:
+ True if hook is a PRE hook, False otherwise.
+
+ Examples:
+ >>> registry = HookRegistry()
+ >>> from mcpgateway.plugins.framework import PluginPayload, PluginResult
+ >>> registry.register_hook("test_pre", PluginPayload, PluginResult, HookPhase.PRE)
+ >>> registry.is_pre_hook("test_pre")
+ True
+ >>> registry.register_hook("test_post", PluginPayload, PluginResult, HookPhase.POST)
+ >>> registry.is_pre_hook("test_post")
+ False
+ """
+ phase = self.get_phase(hook_type)
+ return phase == HookPhase.PRE if phase else False
+
+ def get_metadata(self, hook_type: str) -> Optional[HookMetadata]:
+ """Get the complete metadata for a hook type.
+
+ Args:
+ hook_type: The hook type identifier.
+
+ Returns:
+ The hook metadata, or None if not registered.
+
+ Examples:
+ >>> registry = HookRegistry()
+ >>> from mcpgateway.plugins.framework import PluginPayload, PluginResult
+ >>> registry.register_hook("test", PluginPayload, PluginResult, HookPhase.PRE)
+ >>> meta = registry.get_metadata("test")
+ >>> meta.phase
+
+ """
+ return self._hook_metadata.get(hook_type)
+
+ def get_entity_type(self, hook_type: str) -> Optional[str]:
+ """Get the entity type for a hook.
+
+ Args:
+ hook_type: The hook type identifier.
+
+ Returns:
+ The entity type (tool, prompt, resource, http, etc.), or None if not set.
+
+ Examples:
+ >>> registry = HookRegistry()
+ >>> from mcpgateway.plugins.framework import PluginPayload, PluginResult
+ >>> registry.register_hook("tool_pre_invoke", PluginPayload, PluginResult)
+ >>> registry.get_entity_type("tool_pre_invoke")
+ 'tool'
+ """
+ metadata = self._hook_metadata.get(hook_type)
+ return metadata.entity_type if metadata else None
+
+ def get_hooks_for_entity_type(self, entity_type: str, phase: Optional[HookPhase] = None) -> list[str]:
+ """Get all hook types for a specific entity type and optional phase.
+
+ Args:
+ entity_type: The entity type (tool, prompt, resource, http, agent, server).
+ phase: Optional phase filter (PRE or POST). If None, returns all hooks.
+
+ Returns:
+ List of hook type identifiers matching the criteria.
+
+ Examples:
+ >>> registry = HookRegistry()
+ >>> from mcpgateway.plugins.framework import PluginPayload, PluginResult
+ >>> registry.register_hook("tool_pre_invoke", PluginPayload, PluginResult)
+ >>> registry.register_hook("tool_post_invoke", PluginPayload, PluginResult)
+ >>> registry.register_hook("prompt_pre_fetch", PluginPayload, PluginResult)
+ >>> # Get all tool hooks
+ >>> tool_hooks = registry.get_hooks_for_entity_type("tool")
+ >>> sorted(tool_hooks)
+ ['tool_post_invoke', 'tool_pre_invoke']
+ >>> # Get only tool PRE hooks
+ >>> pre_hooks = registry.get_hooks_for_entity_type("tool", HookPhase.PRE)
+ >>> pre_hooks
+ ['tool_pre_invoke']
+ """
+ hooks = []
+ for hook_name, metadata in self._hook_metadata.items():
+ if metadata.entity_type == entity_type:
+ if phase is None or metadata.phase == phase:
+ hooks.append(hook_name)
+ return hooks
# Global singleton instance
diff --git a/mcpgateway/plugins/framework/manager.py b/mcpgateway/plugins/framework/manager.py
index 09d05dcdc..a57947f99 100644
--- a/mcpgateway/plugins/framework/manager.py
+++ b/mcpgateway/plugins/framework/manager.py
@@ -34,13 +34,15 @@
from typing import Any, Optional, Union
# First-Party
-from mcpgateway.plugins.framework.base import HookRef, Plugin
+from mcpgateway.plugins.framework.base import AttachedHookRef, HookRef, Plugin
from mcpgateway.plugins.framework.errors import convert_exception_to_error, PluginError, PluginViolationError
from mcpgateway.plugins.framework.loader.config import ConfigLoader
from mcpgateway.plugins.framework.loader.plugin import PluginLoader
from mcpgateway.plugins.framework.models import (
Config,
+ EntityType,
GlobalContext,
+ PluginConfig,
PluginContext,
PluginContextTable,
PluginErrorModel,
@@ -49,6 +51,8 @@
PluginResult,
)
from mcpgateway.plugins.framework.registry import PluginInstanceRegistry
+from mcpgateway.plugins.framework.routing import EvaluationContext
+from mcpgateway.plugins.framework.routing.rule_resolver import RuleBasedResolver, RuleMatchContext
from mcpgateway.plugins.framework.utils import payload_matches
# Use standard logging to avoid circular imports (plugins -> services -> plugins)
@@ -102,7 +106,7 @@ def __init__(self, config: Optional[Config] = None, timeout: int = DEFAULT_PLUGI
async def execute(
self,
- hook_refs: list[HookRef],
+ hook_refs: list[AttachedHookRef],
payload: PluginPayload,
global_context: GlobalContext,
hook_type: str,
@@ -112,7 +116,8 @@ async def execute(
"""Execute plugins in priority order with timeout protection.
Args:
- hook_refs: List of hook references to execute, sorted by priority.
+ hook_refs: List of AttachedHookRef to execute, sorted by priority.
+ AttachedHookRef.attachment may be None for non-routed plugins.
payload: The payload to be processed by plugins.
global_context: Shared context for all plugins containing request metadata.
hook_type: The hook type identifier (e.g., "tool_pre_invoke").
@@ -154,7 +159,10 @@ async def execute(
combined_metadata: dict[str, Any] = {}
current_payload: PluginPayload | None = None
- for hook_ref in hook_refs:
+ for attached_hook_ref in hook_refs:
+ # Extract the actual HookRef
+ hook_ref = attached_hook_ref.hook_ref
+
# Skip disabled plugins
if hook_ref.plugin_ref.mode == PluginMode.DISABLED:
continue
@@ -164,13 +172,32 @@ async def execute(
logger.debug("Skipping plugin %s - conditions not met", hook_ref.plugin_ref.name)
continue
+ # Build metadata combining global context metadata + attachment metadata
+ merged_metadata = {} if not global_context.metadata else deepcopy(global_context.metadata)
+
+ # Merge attachment config/metadata if present
+ if attached_hook_ref.attachment and attached_hook_ref.attachment.config:
+ # Add attachment metadata with prefix to avoid conflicts
+ attachment_meta = {
+ "_attachment": {
+ "name": attached_hook_ref.attachment.name,
+ "priority": attached_hook_ref.attachment.priority,
+ "config": attached_hook_ref.attachment.config,
+ }
+ }
+ merged_metadata.update(attachment_meta)
+
tmp_global_context = GlobalContext(
request_id=global_context.request_id,
user=global_context.user,
tenant_id=global_context.tenant_id,
server_id=global_context.server_id,
+ entity_type=global_context.entity_type,
+ entity_id=global_context.entity_id,
+ entity_name=global_context.entity_name,
+ attachment_config=attached_hook_ref.attachment, # Will be None for old system
state={} if not global_context.state else deepcopy(global_context.state),
- metadata={} if not global_context.metadata else deepcopy(global_context.metadata),
+ metadata=merged_metadata,
)
# Get or create local context for this plugin
local_context_key = global_context.request_id + hook_ref.plugin_ref.uuid
@@ -400,6 +427,7 @@ class PluginManager:
- Plugin lifecycle management (initialization, execution, shutdown)
- Context management with automatic cleanup
- Hook execution orchestration
+ - Cached plugin routing resolution
Attributes:
config: The loaded plugin configuration.
@@ -430,7 +458,11 @@ class PluginManager:
_initialized: bool = False
_registry: PluginInstanceRegistry = PluginInstanceRegistry()
_config: Config | None = None
+ _config_path: str = "" # Store config path for reload_config()
_executor: PluginExecutor = PluginExecutor()
+ _resolver: RuleBasedResolver = RuleBasedResolver()
+ # Cache for resolved AttachedHookRefs: (entity_type, entity_name, hook_type) -> list[AttachedHookRef]
+ _routing_cache: dict[tuple[str, str, str], list[AttachedHookRef]] = {}
def __init__(self, config: str = "", timeout: int = DEFAULT_PLUGIN_TIMEOUT):
"""Initialize plugin manager.
@@ -448,6 +480,7 @@ def __init__(self, config: str = "", timeout: int = DEFAULT_PLUGIN_TIMEOUT):
"""
self.__dict__ = self.__shared_state
if config:
+ self._config_path = config # Store for reload_config()
self._config = ConfigLoader.load_config(config)
# Update executor timeouts
@@ -493,6 +526,115 @@ def get_plugin(self, name: str) -> Optional[Plugin]:
plugin_ref = self._registry.get_plugin(name)
return plugin_ref.plugin if plugin_ref else None
+ def get_plugin_names(self) -> list[str]:
+ """Get list of all configured plugin names.
+
+ Returns:
+ List of plugin names from the configuration (includes disabled plugins).
+ """
+ if not self._config or not self._config.plugins:
+ return []
+ return [plugin.name for plugin in self._config.plugins]
+
+ def get_plugins_for_entity_type(self, entity_type: str) -> list:
+ """Get plugins that have hooks for the specified entity type.
+
+ Uses the hook registry to determine which hooks belong to the entity type.
+
+ Args:
+ entity_type: Entity type ('tool', 'prompt', 'resource', 'http', etc.)
+
+ Returns:
+ List of PluginConfig objects that have hooks for this entity type.
+ """
+ if not self._config or not self._config.plugins:
+ return []
+
+ # First-Party
+ from mcpgateway.plugins.framework.hooks.registry import get_hook_registry
+
+ registry = get_hook_registry()
+ entity_hooks = set(registry.get_hooks_for_entity_type(entity_type))
+
+ if not entity_hooks:
+ return []
+
+ return [plugin for plugin in self._config.plugins if any(hook in entity_hooks for hook in plugin.hooks)]
+
+ def get_tool_plugins(self) -> list:
+ """Get all plugins that have tool hooks.
+
+ Returns:
+ List of PluginConfig objects with tool hooks.
+ """
+ return self.get_plugins_for_entity_type("tool")
+
+ def get_plugins_with_hook_info(self, entity_type: str) -> list:
+ """Get plugins for entity type with hook phase information.
+
+ Args:
+ entity_type: Entity type ('tool', 'prompt', 'resource', etc.)
+
+ Returns:
+ List of PluginWithHookInfo objects with hook details.
+ """
+ # First-Party
+ from mcpgateway.plugins.framework.hooks.registry import get_hook_registry
+ from mcpgateway.plugins.framework.models import PluginWithHookInfo
+
+ registry = get_hook_registry()
+ plugins = self.get_plugins_for_entity_type(entity_type)
+ entity_hooks = set(registry.get_hooks_for_entity_type(entity_type))
+
+ result = []
+ for plugin in plugins:
+ # Filter to only hooks for this entity type
+ plugin_entity_hooks = [h for h in plugin.hooks if h in entity_hooks]
+
+ pre_hooks = []
+ post_hooks = []
+ for hook in plugin_entity_hooks:
+ if registry.is_pre_hook(hook):
+ pre_hooks.append(hook)
+ elif registry.is_post_hook(hook):
+ post_hooks.append(hook)
+
+ result.append(PluginWithHookInfo(name=plugin.name, description=plugin.description, pre_hooks=pre_hooks, post_hooks=post_hooks, all_hooks=plugin_entity_hooks))
+
+ return result
+
+ def get_tool_plugins_with_hooks(self) -> list:
+ """Get tool plugins with hook phase information.
+
+ Returns:
+ List of PluginWithHookInfo objects for tools.
+ """
+ return self.get_plugins_with_hook_info("tool")
+
+ def get_prompt_plugins(self) -> list:
+ """Get all plugins that have prompt hooks.
+
+ Returns:
+ List of PluginConfig objects with prompt hooks.
+ """
+ return self.get_plugins_for_entity_type("prompt")
+
+ def get_resource_plugins(self) -> list:
+ """Get all plugins that have resource hooks.
+
+ Returns:
+ List of PluginConfig objects with resource hooks.
+ """
+ return self.get_plugins_for_entity_type("resource")
+
+ def get_http_plugins(self) -> list:
+ """Get all plugins that have HTTP-level hooks.
+
+ Returns:
+ List of PluginConfig objects with HTTP hooks.
+ """
+ return self.get_plugins_for_entity_type("http")
+
async def initialize(self) -> None:
"""Initialize the plugin manager and load all configured plugins.
@@ -549,8 +691,9 @@ async def shutdown(self) -> None:
This method:
1. Shuts down all registered plugins
2. Clears the plugin registry
- 3. Cleans up stored contexts
- 4. Resets initialization state
+ 3. Clears routing cache
+ 4. Cleans up stored contexts
+ 5. Resets initialization state
Examples:
>>> manager = PluginManager("plugins/config.yaml")
@@ -564,6 +707,9 @@ async def shutdown(self) -> None:
# Shutdown all plugins
await self._registry.shutdown()
+ # Clear routing cache
+ self.clear_routing_cache()
+
# Clear context store
# Reset state
@@ -580,10 +726,15 @@ async def invoke_hook(
) -> tuple[PluginResult, PluginContextTable | None]:
"""Invoke a set of plugins configured for the hook point in priority order.
+ Automatically uses resource-centric routing if:
+ - enable_plugin_routing is True in config
+ - global_context has entity_type and entity_name set
+
Args:
hook_type: The type of hook to execute.
payload: The plugin payload for which the plugins will analyze and modify.
global_context: Shared context for all plugins with request metadata.
+ Include entity_type and entity_name for resource-centric routing.
local_contexts: Optional existing contexts from previous hook executions.
violations_as_exceptions: Raise violations as exceptions rather than as returns.
@@ -596,21 +747,360 @@ async def invoke_hook(
>>> manager = PluginManager("plugins/config.yaml")
>>> # In async context:
>>> # await manager.initialize()
- >>> # payload = ResourcePreFetchPayload("file:///data.txt")
- >>> # context = GlobalContext(request_id="123", server_id="srv1")
- >>> # result, contexts = await manager.resource_pre_fetch(payload, context)
- >>> # if result.continue_processing:
- >>> # # Use modified payload
- >>> # uri = result.modified_payload.uri
+ >>> # With resource-centric routing:
+ >>> # context = GlobalContext(
+ >>> # request_id="123",
+ >>> # server_id="srv1",
+ >>> # entity_type="tool",
+ >>> # entity_name="my_tool"
+ >>> # )
+ >>> # result, contexts = await manager.invoke_hook("tool_pre_invoke", payload, context)
"""
- # Get plugins configured for this hook
- hook_refs = self._registry.get_hook_refs_for_hook(hook_type=hook_type)
+ # Determine which plugin resolution system to use
+ use_routing = self._config and self._config.plugin_settings.enable_plugin_routing and global_context.entity_type and global_context.entity_name
+
+ if use_routing:
+ # New resource-centric routing system (returns list[AttachedHookRef])
+ # Pass payload for enhanced runtime filtering (creates plugin instances as needed)
+ attached_refs = await self._resolve_with_routing(hook_type, global_context, payload)
+ else:
+ # Old condition-based system (returns list[HookRef], wrap them)
+ hook_refs = self._registry.get_hook_refs_for_hook(hook_type=hook_type)
+ # Wrap in AttachedHookRef with attachment=None
+ attached_refs = [AttachedHookRef(hook_ref, attachment=None) for hook_ref in hook_refs]
# Execute plugins
- result = await self._executor.execute(hook_refs, payload, global_context, hook_type, local_contexts, violations_as_exceptions)
+ result = await self._executor.execute(attached_refs, payload, global_context, hook_type, local_contexts, violations_as_exceptions)
return result
+ async def _resolve_with_routing(
+ self,
+ hook_type: str,
+ global_context: GlobalContext,
+ payload: Optional[PluginPayload] = None,
+ ) -> list[AttachedHookRef]:
+ """Resolve plugins using the resource-centric routing system with caching.
+
+ Uses two-level resolution:
+ 1. Static resolution (cached): Match rules, look up HookRefs, create AttachedHookRefs
+ - Creates plugin instances with merged configs as needed
+ 2. Runtime filtering: Evaluate 'when' clauses from PluginAttachments with actual payload
+
+ Args:
+ hook_type: The type of hook to execute.
+ global_context: Shared context with entity_type and entity_name.
+ payload: Optional plugin payload for enhanced runtime filtering.
+
+ Returns:
+ List of AttachedHookRef objects (HookRef + PluginAttachment) sorted by priority,
+ filtered by runtime 'when' clause evaluation.
+ """
+ if not self._config or not global_context.entity_type or not global_context.entity_name:
+ return []
+
+ # Map string entity_type to EntityType enum
+ try:
+ entity_type = EntityType(global_context.entity_type)
+ except ValueError:
+ logger.warning(f"Unknown entity_type: {global_context.entity_type}. Falling back to registry.")
+ # Return empty list since we can't do routing without valid entity_type
+ return []
+
+ # Check cache first
+ # Include infrastructure filters in cache key since rules can match based on these
+ cache_key = (
+ global_context.entity_type,
+ global_context.entity_name,
+ hook_type,
+ global_context.server_name,
+ global_context.server_id,
+ global_context.gateway_id,
+ )
+
+ if cache_key in self._routing_cache:
+ static_refs = self._routing_cache[cache_key]
+ logger.debug(f"Using cached routing for {global_context.entity_type}:{global_context.entity_name} " f"hook={hook_type} ({len(static_refs)} refs)")
+ else:
+ # Perform static resolution (no 'when' evaluation, creates instances as needed)
+ static_refs = await self._resolve_static(hook_type, global_context, entity_type)
+
+ # Cache the result
+ self._routing_cache[cache_key] = static_refs
+ logger.debug(f"Cached routing for {global_context.entity_type}:{global_context.entity_name} " f"hook={hook_type} ({len(static_refs)} refs)")
+
+ # Apply runtime filtering (evaluate 'when' clauses from attachments with actual payload)
+ filtered_refs = self._filter_attachments_runtime(static_refs, global_context, payload)
+
+ return filtered_refs
+
+ def _get_base_plugin_config_dict(self, plugin_name: str) -> dict:
+ """Get base configuration dict for a plugin by name from registry.
+
+ Args:
+ plugin_name: Name of the plugin.
+
+ Returns:
+ Base plugin config dict, or empty dict if not found.
+ """
+ plugin_ref = self._registry.get_plugin(plugin_name)
+ if plugin_ref and plugin_ref.plugin.config:
+ # plugin.config is a PluginConfig model, get the config dict from it
+ return plugin_ref.plugin.config.config or {}
+ return {}
+
+ def _get_plugin_config(self, plugin_name: str) -> Optional[PluginConfig]:
+ """Get full PluginConfig for a plugin by name.
+
+ Args:
+ plugin_name: Name of the plugin.
+
+ Returns:
+ PluginConfig or None if not found.
+ """
+ if not self._config or not self._config.plugins:
+ return None
+
+ for plugin_config in self._config.plugins:
+ if plugin_config.name == plugin_name:
+ return plugin_config
+
+ return None
+
+ async def _resolve_static(
+ self,
+ hook_type: str,
+ global_context: GlobalContext,
+ entity_type: EntityType,
+ ) -> list[AttachedHookRef]:
+ """Resolve AttachedHookRefs statically (no 'when' evaluation).
+
+ Creates plugin instances with merged configs as needed.
+
+ Args:
+ hook_type: The type of hook to execute.
+ global_context: Shared context with entity info.
+ entity_type: Entity type enum.
+
+ Returns:
+ List of AttachedHookRef objects with 'when' clauses preserved for runtime eval.
+ """
+ # Build rule match context for resolver
+ match_context = RuleMatchContext(
+ name=global_context.entity_name or "",
+ entity_type=global_context.entity_type or "",
+ entity_id=global_context.entity_id,
+ tags=global_context.tags or [],
+ metadata=global_context.metadata or {},
+ server_name=global_context.server_name,
+ server_id=global_context.server_id,
+ gateway_id=global_context.gateway_id,
+ payload={}, # Will be populated during runtime filtering if needed
+ )
+
+ # Get rules from config
+ rules = self._config.routes if self._config else []
+
+ # Get merge strategy from plugin settings
+ merge_strategy = "most_specific" # default
+ if self._config and self._config.plugin_settings:
+ merge_strategy = self._config.plugin_settings.rule_merge_strategy
+
+ # Use resolver to get sorted plugin attachments (with 'when' clauses preserved)
+ plugin_attachments = self._resolver.resolve_for_entity(
+ rules=rules,
+ context=match_context,
+ hook_type=hook_type,
+ eval_context=None, # Don't evaluate 'when' clauses during static resolution
+ merge_strategy=merge_strategy,
+ )
+
+ # Convert PluginAttachment + HookRef -> AttachedHookRef
+ # with config merging and instance key creation
+ # First-Party
+ from mcpgateway.plugins.framework.utils import deep_merge, hash_config
+
+ attached_refs: list[AttachedHookRef] = []
+ for attachment in plugin_attachments:
+ # Merge configs and create instance key
+ base_config = self._get_base_plugin_config_dict(attachment.name)
+
+ if attachment.override:
+ # Replace base config entirely
+ merged_config = attachment.config
+ else:
+ # Deep merge rule config with base config
+ merged_config = deep_merge(base_config, attachment.config)
+
+ # Create instance key that includes hooks and mode overrides
+ # This ensures different instances are created when hooks or mode differ
+ instance_data = {
+ "config": merged_config,
+ "hooks": attachment.hooks if attachment.hooks else None,
+ "mode": attachment.mode if attachment.mode else None,
+ }
+ config_hash = hash_config(instance_data)
+ instance_key = f"{attachment.name}:{config_hash}"
+
+ # Store instance key in attachment for caching
+ attachment.instance_key = instance_key
+
+ # Check if instance exists, if not create it
+ if not self._registry.get_plugin(instance_key):
+ # Get base plugin config to create new instance
+ base_plugin_config = self._get_plugin_config(attachment.name)
+ if base_plugin_config:
+ try:
+ # Build update dict with all available overrides from PluginAttachment
+ updates: dict[str, Any] = {"config": merged_config}
+ if attachment.hooks:
+ updates["hooks"] = attachment.hooks
+ if attachment.mode:
+ updates["mode"] = attachment.mode
+
+ # Create new PluginConfig with all overrides
+ # Pydantic v2: use model_copy with update
+ new_config = base_plugin_config.model_copy(update=updates)
+
+ # Instantiate plugin with merged config (async)
+ plugin = await self._loader.load_and_instantiate_plugin(new_config)
+
+ if plugin:
+ # Register with instance key
+ self._registry.register(plugin, instance_key)
+ logger.info(f"Created plugin instance {instance_key} with merged config")
+ else:
+ logger.warning(f"Failed to instantiate plugin {instance_key}, falling back to base")
+ instance_key = attachment.name
+ except Exception as e:
+ logger.error(f"Error instantiating plugin {instance_key}: {e}, falling back to base")
+ instance_key = attachment.name
+ else:
+ logger.warning(f"Base plugin config not found for {attachment.name}, falling back to base")
+ instance_key = attachment.name
+
+ # Look up plugin hook by instance key
+ hook_ref = self._registry.get_plugin_hook_by_name(instance_key, hook_type)
+ if hook_ref:
+ # Create composite object pairing HookRef with its attachment config
+ attached_ref = AttachedHookRef(hook_ref, attachment)
+ attached_refs.append(attached_ref)
+ else:
+ logger.warning(
+ f"Plugin '{attachment.name}' (instance: {instance_key}) configured for {global_context.entity_type}:{global_context.entity_name} " f"but not found in registry. Skipping."
+ )
+
+ return attached_refs
+
+ def _filter_attachments_runtime(
+ self,
+ attached_refs: list[AttachedHookRef],
+ global_context: GlobalContext,
+ payload: Optional[PluginPayload] = None,
+ ) -> list[AttachedHookRef]:
+ """Filter AttachedHookRefs at runtime by evaluating 'when' clauses.
+
+ Extracts args, payload dict, and other data from the actual payload for
+ enhanced 'when' clause evaluation.
+
+ Args:
+ attached_refs: Pre-resolved AttachedHookRefs from cache.
+ global_context: Runtime context for evaluation.
+ payload: Optional plugin payload for extracting args/payload dict.
+
+ Returns:
+ Filtered list of AttachedHookRefs.
+ """
+ # First-Party
+ from mcpgateway.plugins.framework.routing.evaluator import PolicyEvaluator
+
+ filtered = []
+ evaluator = PolicyEvaluator()
+
+ # Extract data from payload for evaluation context
+ args_dict = {}
+ payload_dict = {}
+ tags_list = []
+
+ if payload:
+ # Extract args if available (tools, prompts, agents)
+ if hasattr(payload, "args") and payload.args:
+ args_dict = payload.args if isinstance(payload.args, dict) else {}
+
+ # Extract tags if available
+ if hasattr(payload, "tags") and payload.tags:
+ tags_list = payload.tags if isinstance(payload.tags, list) else []
+
+ # Convert payload to dict for full access
+ try:
+ payload_dict = payload.model_dump() if hasattr(payload, "model_dump") else {}
+ except Exception as e:
+ logger.debug(f"Could not convert payload to dict: {e}")
+ payload_dict = {}
+
+ for attached_ref in attached_refs:
+ # Check if attachment has a 'when' clause
+ if attached_ref.attachment and attached_ref.attachment.when:
+ # Build evaluation context from global_context + payload
+ eval_context = EvaluationContext(
+ name=global_context.entity_name or "",
+ entity_type=global_context.entity_type or "",
+ entity_id=global_context.entity_id,
+ tags=tags_list,
+ metadata=global_context.metadata or {},
+ server_name=None, # TODO: Resolve server name from server_id
+ server_id=global_context.server_id,
+ gateway_id=None, # TODO: Get gateway_id if available
+ args=args_dict,
+ payload=payload_dict,
+ user=global_context.user,
+ tenant_id=global_context.tenant_id,
+ )
+
+ try:
+ if not evaluator.evaluate(attached_ref.attachment.when, eval_context):
+ logger.debug(f"Skipping plugin {attached_ref.hook_ref.plugin_ref.name}: " f"when clause '{attached_ref.attachment.when}' evaluated to False")
+ continue
+ except Exception as e:
+ logger.error(f"Failed to evaluate when clause for plugin {attached_ref.hook_ref.plugin_ref.name}: {e}. " "Skipping plugin.")
+ continue
+
+ filtered.append(attached_ref)
+
+ return filtered
+
+ def clear_routing_cache(self):
+ """Clear the plugin routing resolution cache.
+
+ Use this when configuration changes at runtime or plugins are reloaded.
+
+ Examples:
+ >>> manager = PluginManager()
+ >>> manager.clear_routing_cache()
+ """
+ self._routing_cache.clear()
+ logger.info("Cleared plugin routing cache")
+
+ def reload_config(self):
+ """Reload plugin configuration from disk and update manager state.
+
+ This reloads the configuration from the stored config path, updates the executor
+ with the fresh config, and clears the routing cache. Use this when the config
+ file has been modified externally (e.g., by another worker process).
+
+ Examples:
+ >>> manager = PluginManager()
+ >>> manager.reload_config()
+ """
+ if not self._config_path:
+ logger.warning("Cannot reload config: no config path stored")
+ return
+
+ self._config = ConfigLoader.load_config(self._config_path)
+ self._executor.config = self._config
+ self.clear_routing_cache()
+ logger.info("Reloaded plugin configuration from %s", self._config_path)
+
async def invoke_hook_for_plugin(
self,
name: str,
diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py
index d6644abc3..4b4d5b3a4 100644
--- a/mcpgateway/plugins/framework/models.py
+++ b/mcpgateway/plugins/framework/models.py
@@ -11,6 +11,7 @@
# Standard
from enum import Enum
+import logging
import os
from pathlib import Path
from typing import Any, Generic, Optional, Self, TypeAlias, TypeVar
@@ -18,6 +19,7 @@
# Third-Party
from pydantic import (
BaseModel,
+ ConfigDict,
Field,
field_serializer,
field_validator,
@@ -37,6 +39,7 @@
URL,
)
+logger = logging.getLogger(__name__)
T = TypeVar("T")
@@ -557,12 +560,15 @@ class PluginConfig(BaseModel):
tags (list[str]): a list of tags for making the plugin searchable.
mode (bool): whether the plugin is active.
priority (int): indicates the order in which the plugin is run. Lower = higher priority. Default: 100.
+ post_priority (Optional[int]): Optional custom priority for post-hooks (enables wrapping behavior).
conditions (Optional[list[PluginCondition]]): the conditions on which the plugin is run.
applied_to (Optional[list[AppliedTo]]): the tools, fields, that the plugin is applied to.
config (dict[str, Any]): the plugin specific configurations.
mcp (Optional[MCPClientConfig]): Client-side MCP configuration (gateway connecting to plugin).
"""
+ model_config = ConfigDict(use_enum_values=True)
+
name: str
description: Optional[str] = None
author: Optional[str] = None
@@ -572,7 +578,8 @@ class PluginConfig(BaseModel):
hooks: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
mode: PluginMode = PluginMode.ENFORCE
- priority: int = 100 # Lower = higher priority
+ priority: Optional[int] = 100 # Lower = higher priority
+ post_priority: Optional[int] = None # Optional custom priority for post-hooks
conditions: list[PluginCondition] = Field(default_factory=list) # When to apply
applied_to: Optional[AppliedTo] = None # Fields to apply to.
config: Optional[dict[str, Any]] = None
@@ -729,6 +736,9 @@ class PluginSettings(BaseModel):
fail_on_plugin_error (bool): error when there is a plugin connectivity or ignore.
enable_plugin_api (bool): enable or disable plugins globally.
plugin_health_check_interval (int): health check interval check.
+ auto_reverse_post_hooks (bool): automatically reverse plugin order on post-hooks for wrapping behavior.
+ enable_plugin_routing (bool): enable resource-centric plugin routing configuration.
+ rule_merge_strategy (str): strategy for merging matching rules - "most_specific" (default) or "merge_all".
"""
parallel_execution_within_band: bool = False
@@ -736,6 +746,423 @@ class PluginSettings(BaseModel):
fail_on_plugin_error: bool = False
enable_plugin_api: bool = False
plugin_health_check_interval: int = 60
+ auto_reverse_post_hooks: bool = False
+ enable_plugin_routing: bool = False
+ rule_merge_strategy: str = "most_specific" # "most_specific" or "merge_all"
+
+
+# ============================================================================
+# PLUGIN ROUTING MODELS (Resource-Centric Configuration)
+# ============================================================================
+
+
+class EntityType(str, Enum):
+ """Types of entities that can have plugins attached.
+
+ Attributes:
+ TOOL: MCP tool entity.
+ PROMPT: MCP prompt entity.
+ RESOURCE: MCP resource entity.
+ AGENT: LLM agent entity.
+ VIRTUAL_SERVER: Virtual server entity (composed from catalog items).
+ MCP_SERVER: MCP server/gateway entity (external MCP server).
+
+ Examples:
+ >>> EntityType.TOOL
+
+ >>> EntityType.TOOL.value
+ 'tool'
+ >>> EntityType('prompt')
+
+ """
+
+ TOOL = "tool"
+ PROMPT = "prompt"
+ RESOURCE = "resource"
+ AGENT = "agent"
+ VIRTUAL_SERVER = "virtual_server"
+ MCP_SERVER = "mcp_server"
+
+
+class FieldSelection(BaseModel):
+ """Field selection for scoping plugin execution to specific fields.
+
+ Allows plugins to process only specific fields in payloads using JSONPath-like
+ notation. Supports dot notation, array indexing, and wildcards.
+
+ Attributes:
+ fields: Shorthand for input_fields (pre-hook only).
+ input_fields: Field paths to process on pre-hook (request).
+ output_fields: Field paths to process on post-hook (response).
+
+ Examples:
+ >>> # Simple field selection
+ >>> fs = FieldSelection(fields=["args.email", "args.phone"])
+ >>> fs.fields
+ ['args.email', 'args.phone']
+
+ >>> # Different fields for input and output
+ >>> fs2 = FieldSelection(
+ ... input_fields=["args.user_id"],
+ ... output_fields=["result.customer.ssn"]
+ ... )
+ >>> fs2.input_fields
+ ['args.user_id']
+ >>> fs2.output_fields
+ ['result.customer.ssn']
+
+ >>> # Array and wildcard support
+ >>> fs3 = FieldSelection(fields=["args.customers[*].email"])
+ >>> fs3.fields
+ ['args.customers[*].email']
+ """
+
+ fields: Optional[list[str]] = None
+ input_fields: Optional[list[str]] = None
+ output_fields: Optional[list[str]] = None
+
+
+class PluginAttachment(BaseModel):
+ """Plugin attachment configuration for resource-centric routing.
+
+ Represents HOW a plugin is attached to an entity (tool/prompt/resource/agent).
+ The plugin definition (PluginConfig) declares WHAT the plugin is.
+
+ Attributes:
+ name: Plugin name (references PluginConfig.name).
+ priority: Execution priority (lower = runs first).
+ post_priority: Optional custom priority for post-hooks (enables wrapping behavior).
+ scope: Entity types this plugin applies to (for gateway/server-level plugins).
+ hooks: Override plugin's declared hooks (use specific hooks only).
+ when: Runtime condition (transferred from rule, not set in config).
+ apply_to: Field selection for scoping to specific fields.
+ override: If True, replace inherited config instead of merging.
+ mode: Override plugin's default execution mode.
+ config: Plugin-specific configuration overrides/extensions.
+
+ Examples:
+ >>> # Basic attachment
+ >>> pa = PluginAttachment(name="pii_filter", priority=10)
+ >>> pa.name
+ 'pii_filter'
+ >>> pa.priority
+ 10
+
+ >>> # With field selection
+ >>> pa3 = PluginAttachment(
+ ... name="pii_filter",
+ ... priority=10,
+ ... apply_to=FieldSelection(fields=["args.email"])
+ ... )
+ >>> pa3.apply_to.fields
+ ['args.email']
+
+ >>> # Server-level with scope
+ >>> pa4 = PluginAttachment(
+ ... name="security_check",
+ ... priority=5,
+ ... scope=[EntityType.TOOL, EntityType.PROMPT]
+ ... )
+ >>> len(pa4.scope)
+ 2
+
+ >>> # With post-hook priority for wrapping
+ >>> pa5 = PluginAttachment(
+ ... name="transaction",
+ ... priority=10,
+ ... post_priority=30
+ ... )
+ >>> pa5.post_priority
+ 30
+ """
+
+ model_config = ConfigDict(use_enum_values=True)
+
+ name: str
+ priority: Optional[int] = None # If None, assigned based on list position
+ post_priority: Optional[int] = None
+ hooks: Optional[list[str]] = None
+ when: Optional[str] = None # Transferred from rule, not set in config
+ apply_to: Optional[FieldSelection] = None
+ override: bool = False
+ mode: Optional[PluginMode] = None
+ config: dict[str, Any] = Field(default_factory=dict)
+ instance_key: Optional[str] = None # Unique instance key (name:config_hash), set during resolution
+
+ @model_validator(mode="after")
+ def warn_when_in_config(self) -> Self:
+ """Warn if 'when' is set directly on plugin attachment in config.
+
+ The 'when' field should be defined at the rule level, not on individual plugins.
+ The resolver transfers the rule's 'when' to plugins during resolution.
+
+ Returns:
+ The validated attachment.
+ """
+ if self.when:
+ logger.warning(
+ f"Plugin attachment '{self.name}' has 'when' clause set directly. "
+ f"This is not recommended - define 'when' at the rule level instead. "
+ f"The resolver will transfer rule 'when' clauses to plugins automatically."
+ )
+ return self
+
+
+class PluginHookRule(BaseModel):
+ """Plugin hook rule for declarative, flat rule-based routing.
+
+ Defines WHEN and WHERE plugins should be attached using exact matches,
+ tag-based matching, and complex expressions. Replaces hierarchical cascading.
+
+ Attributes:
+ entities: Entity types this rule applies to (tools, prompts, resources, agents).
+ If None, applies to HTTP-level hooks (before entity resolution).
+ name: Exact entity name match(es) - fast path with hash lookup.
+ Can be single string or list of strings.
+ tags: Tag-based matching - fast path with set intersection.
+ hooks: Hook type filter(s) - applies only to specific hooks.
+ Examples: ["tool_pre_invoke", "tool_post_invoke"], ["http_pre_request"]
+ when: Complex policy expression - flexible path with expression evaluation.
+ server_name: Server name filter(s) - applies only to entities on these servers.
+ server_id: Server ID filter(s) - applies only to entities on these servers.
+ gateway_id: Gateway ID filter(s) - applies only to entities on these gateways.
+ priority: Rule priority (lower = runs first). Default: list order.
+ reverse_order_on_post: If True, reverse plugin order for post-hooks (wrapping).
+ plugins: Plugins to attach when rule matches.
+ metadata: Rule metadata for governance, auditing, documentation.
+
+ Examples:
+ >>> # Simple tag-based rule
+ >>> rule = PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... tags=["customer", "pii"],
+ ... plugins=[
+ ... PluginAttachment(name="pii_filter", priority=10),
+ ... PluginAttachment(name="audit_logger", priority=20)
+ ... ]
+ ... )
+ >>> rule.tags
+ ['customer', 'pii']
+ >>> len(rule.plugins)
+ 2
+
+ >>> # Exact name match
+ >>> rule2 = PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... name="process_payment",
+ ... plugins=[PluginAttachment(name="fraud_detector", priority=5)]
+ ... )
+ >>> rule2.name
+ 'process_payment'
+
+ >>> # Multiple names
+ >>> rule3 = PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... name=["create_user", "update_user", "delete_user"],
+ ... reverse_order_on_post=True,
+ ... plugins=[PluginAttachment(name="user_validator", priority=10)]
+ ... )
+ >>> len(rule3.name)
+ 3
+
+ >>> # Complex expression
+ >>> rule4 = PluginHookRule(
+ ... entities=[EntityType.RESOURCE],
+ ... when="payload.uri.endswith('.env') or payload.uri.endswith('.secrets')",
+ ... plugins=[PluginAttachment(name="secret_redactor", priority=1)]
+ ... )
+ >>> rule4.when
+ "payload.uri.endswith('.env') or payload.uri.endswith('.secrets')"
+
+ >>> # HTTP-level rule (no entities)
+ >>> rule5 = PluginHookRule(
+ ... when="payload.method == 'POST'",
+ ... plugins=[PluginAttachment(name="rate_limiter", priority=10)]
+ ... )
+ >>> rule5.entities is None
+ True
+
+ >>> # Server filtering
+ >>> rule6 = PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... server_name="production-api",
+ ... tags=["pii"],
+ ... plugins=[PluginAttachment(name="pii_filter", priority=10)]
+ ... )
+ >>> rule6.server_name
+ 'production-api'
+
+ >>> # With metadata
+ >>> rule7 = PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... tags=["customer"],
+ ... plugins=[PluginAttachment(name="audit_logger", priority=10)],
+ ... metadata={"reason": "GDPR compliance", "ticket": "SEC-1234"}
+ ... )
+ >>> rule7.metadata['reason']
+ 'GDPR compliance'
+
+ >>> # Hook type filtering
+ >>> rule8 = PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... hooks=["tool_pre_invoke"],
+ ... tags=["customer"],
+ ... plugins=[PluginAttachment(name="pre_validator", priority=10)]
+ ... )
+ >>> rule8.hooks
+ ['tool_pre_invoke']
+
+ >>> # HTTP-level with hook filter
+ >>> rule9 = PluginHookRule(
+ ... hooks=["http_pre_request"],
+ ... plugins=[PluginAttachment(name="auth_checker", priority=5)]
+ ... )
+ >>> rule9.hooks
+ ['http_pre_request']
+ >>> rule9.entities is None
+ True
+ """
+
+ model_config = ConfigDict(use_enum_values=True)
+ entities: Optional[list[EntityType]] = None # None = HTTP-level
+ name: Optional[str | list[str]] = None # Exact match (fast path)
+ tags: Optional[list[str]] = None # Tag match (fast path)
+ hooks: Optional[list[str]] = None # Hook type filter (e.g., ["tool_pre_invoke"])
+ when: Optional[str] = None # Expression (flexible path)
+ server_name: Optional[str | list[str]] = None # Server filter
+ server_id: Optional[str | list[str]] = None # Server ID filter
+ gateway_id: Optional[str | list[str]] = None # Gateway filter
+ priority: Optional[int] = None # Rule priority (lower = first)
+ reverse_order_on_post: bool = False # Reverse plugin order for post-hooks
+ plugins: list[PluginAttachment] = Field(default_factory=list)
+ metadata: dict[str, Any] = Field(default_factory=dict)
+
+ @model_validator(mode="after")
+ def validate_rule(self) -> Self:
+ """Validate the plugin hook rule has valid configuration.
+
+ Checks:
+ 1. Plugins list is not empty
+ 2. At least one matching criterion is specified (name, tags, when, or entities)
+ 3. 'when' expression has valid syntax (if specified)
+
+ Returns:
+ The validated rule.
+
+ Raises:
+ ValueError: If validation fails.
+ """
+ # Check plugins list is not empty
+ if not self.plugins:
+ raise ValueError("PluginHookRule must have at least one plugin in the 'plugins' list")
+
+ # Check at least one matching criterion exists
+ has_entities = self.entities is not None and len(self.entities) > 0
+ has_name = self.name is not None
+ has_tags = self.tags is not None and len(self.tags) > 0
+ has_hooks = self.hooks is not None and len(self.hooks) > 0
+ has_when = self.when is not None and len(self.when.strip()) > 0
+ has_server_name = self.server_name is not None
+ has_server_id = self.server_id is not None
+ has_gateway_id = self.gateway_id is not None
+
+ # At least one matching criterion must be set
+ if not (has_entities or has_name or has_tags or has_hooks or has_when or has_server_name or has_server_id or has_gateway_id):
+ raise ValueError(
+ "PluginHookRule must have at least one matching criterion: "
+ "'entities', 'name', 'tags', 'hooks', 'when', 'server_name', 'server_id', or 'gateway_id'. "
+ "A rule must specify what it applies to."
+ )
+
+ # Entity-level rules CAN match all entities of a type (catch-all rules are valid)
+ # e.g., entities: [tool] without other criteria applies to ALL tools
+ # This allows baseline plugins; more specific rules can override via priority
+
+ # Validate 'when' expression syntax if present
+ if self.when:
+ try:
+ # Import here to avoid circular dependency
+ # First-Party
+ from mcpgateway.plugins.framework.routing.evaluator import PolicyEvaluator
+
+ evaluator = PolicyEvaluator()
+ # Try to parse the expression (validates syntax, doesn't evaluate)
+ evaluator._parse_expression(self.when)
+ except SyntaxError as e:
+ raise ValueError(f"Invalid 'when' expression syntax: {self.when}. Error: {e}") from e
+ except Exception as e:
+ raise ValueError(f"Failed to validate 'when' expression: {self.when}. Error: {e}") from e
+
+ # Assign default priorities to plugins based on list position
+ # This enables implicit priority through order
+ for i, plugin in enumerate(self.plugins):
+ if plugin.priority is None:
+ # Assign priority based on position (0, 1, 2, ...)
+ plugin.priority = i
+
+ return self
+
+
+class ConfigMetadata(BaseModel):
+ """Top-level configuration metadata.
+
+ Used for config-wide attributes like tenant_id, environment, region.
+ Especially useful when using separate config files per tenant.
+
+ Attributes:
+ tenant_id: Tenant identifier.
+ environment: Environment (production, staging, development).
+ region: Cloud region or datacenter.
+ custom: Additional custom metadata.
+
+ Examples:
+ >>> metadata = ConfigMetadata(
+ ... tenant_id="tenant-acme",
+ ... environment="production",
+ ... region="us-east-1"
+ ... )
+ >>> metadata.tenant_id
+ 'tenant-acme'
+ >>> metadata.environment
+ 'production'
+ """
+
+ tenant_id: Optional[str] = None
+ environment: Optional[str] = None
+ region: Optional[str] = None
+ custom: dict[str, Any] = Field(default_factory=dict)
+
+
+class PluginWithHookInfo(BaseModel):
+ """Plugin information enriched with hook phase details.
+
+ Used for UI display to show which hooks are pre/post for a given entity type.
+
+ Attributes:
+ name: Plugin name
+ description: Plugin description
+ pre_hooks: List of pre-phase hooks (e.g., tool_pre_invoke)
+ post_hooks: List of post-phase hooks (e.g., tool_post_invoke)
+ all_hooks: All hooks for the entity type
+
+ Examples:
+ >>> info = PluginWithHookInfo(
+ ... name="TestPlugin",
+ ... pre_hooks=["tool_pre_invoke"],
+ ... post_hooks=["tool_post_invoke"],
+ ... all_hooks=["tool_pre_invoke", "tool_post_invoke"]
+ ... )
+ >>> info.name
+ 'TestPlugin'
+ >>> len(info.pre_hooks)
+ 1
+ """
+
+ name: str
+ description: Optional[str] = None
+ pre_hooks: list[str] = Field(default_factory=list)
+ post_hooks: list[str] = Field(default_factory=list)
+ all_hooks: list[str] = Field(default_factory=list)
class Config(BaseModel):
@@ -746,12 +1173,20 @@ class Config(BaseModel):
plugin_dirs (list[str]): The directories in which to look for plugins.
plugin_settings (PluginSettings): global settings for plugins.
server_settings (Optional[MCPServerConfig]): Server-side MCP configuration (when plugins run as server).
+ metadata (Optional[ConfigMetadata]): Config-wide metadata (tenant_id, environment, region, etc.).
+ routes (list[PluginHookRule]): Flat rule-based plugin routing (NEW declarative approach).
"""
+ model_config = ConfigDict(use_enum_values=True)
+
plugins: Optional[list[PluginConfig]] = []
plugin_dirs: list[str] = []
- plugin_settings: PluginSettings
+ plugin_settings: PluginSettings = Field(default_factory=PluginSettings)
server_settings: Optional[MCPServerConfig] = None
+ # Config-wide metadata
+ metadata: Optional[ConfigMetadata] = None
+ # NEW: Flat rule-based plugin routing (declarative approach)
+ routes: list[PluginHookRule] = Field(default_factory=list)
class PluginResult(BaseModel, Generic[T]):
@@ -800,6 +1235,11 @@ class GlobalContext(BaseModel):
user (str): user ID associated with the request.
tenant_id (str): tenant ID.
server_id (str): server ID.
+ entity_type (Optional[str]): entity type for resource-centric routing (e.g., "tool", "prompt", "resource").
+ entity_id (Optional[str]): unique entity ID for resource-centric routing (e.g., database ID).
+ entity_name (Optional[str]): entity name for resource-centric routing (e.g., "my_tool").
+ attachment_config (Optional[PluginAttachment]): The plugin attachment configuration for the current plugin execution.
+ Contains priority, field selection (apply_to), conditional execution (when), and plugin-specific config overrides.
metadata (Optional[dict[str,Any]]): a global shared metadata across plugins (Read-only from plugin's perspective).
state (Optional[dict[str,Any]]): a global shared state across plugins.
@@ -819,12 +1259,34 @@ class GlobalContext(BaseModel):
'123'
>>> c.server_id
'srv1'
+ >>> c2 = GlobalContext(request_id="123", entity_type="tool", entity_id="tool-123", entity_name="my_tool")
+ >>> c2.entity_type
+ 'tool'
+ >>> c2.entity_id
+ 'tool-123'
+ >>> c2.entity_name
+ 'my_tool'
+ >>> # With attachment config for field selection
+ >>> from mcpgateway.plugins.framework.models import PluginAttachment, FieldSelection
+ >>> attachment = PluginAttachment(name="pii_filter", priority=10, apply_to=FieldSelection(fields=["args.email"]))
+ >>> c3 = GlobalContext(request_id="456", attachment_config=attachment)
+ >>> c3.attachment_config.name
+ 'pii_filter'
+ >>> c3.attachment_config.apply_to.fields
+ ['args.email']
"""
request_id: str
user: Optional[str] = None
tenant_id: Optional[str] = None
server_id: Optional[str] = None
+ server_name: Optional[str] = None
+ gateway_id: Optional[str] = None
+ entity_type: Optional[str] = None
+ entity_id: Optional[str] = None
+ entity_name: Optional[str] = None
+ tags: list[str] = Field(default_factory=list)
+ attachment_config: Optional["PluginAttachment"] = None
state: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
diff --git a/mcpgateway/plugins/framework/registry.py b/mcpgateway/plugins/framework/registry.py
index 28c5259dc..9092d6d64 100644
--- a/mcpgateway/plugins/framework/registry.py
+++ b/mcpgateway/plugins/framework/registry.py
@@ -67,21 +67,25 @@ def __init__(self) -> None:
self._hooks_by_name: dict[str, dict[str, HookRef]] = {}
self._priority_cache: dict[str, list[HookRef]] = {}
- def register(self, plugin: Plugin) -> None:
+ def register(self, plugin: Plugin, instance_key: Optional[str] = None) -> None:
"""Register a plugin instance.
Args:
plugin: plugin to be registered.
+ instance_key: Optional unique key for this instance (e.g., "name:config_hash").
+ If None, uses plugin.name as key (backward compatibility).
Raises:
ValueError: if plugin is already registered.
"""
- if plugin.name in self._plugins:
- raise ValueError(f"Plugin {plugin.name} already registered")
+ key = instance_key or plugin.name
+
+ if key in self._plugins:
+ raise ValueError(f"Plugin instance {key} already registered")
plugin_ref = PluginRef(plugin)
- self._plugins[plugin.name] = plugin_ref
+ self._plugins[key] = plugin_ref
plugin_hooks = {}
@@ -98,7 +102,8 @@ def register(self, plugin: Plugin) -> None:
plugin_hooks[hook_type] = hook_ref
# Invalidate priority cache for this hook
self._priority_cache.pop(hook_type, None)
- self._hooks_by_name[plugin.name] = plugin_hooks
+ # Index hooks by instance key, not just plugin.name (supports config-specific instances)
+ self._hooks_by_name[key] = plugin_hooks
logger.info(f"Registered plugin: {plugin.name} with hooks: {list(plugin.hooks)}")
diff --git a/mcpgateway/plugins/framework/routing/__init__.py b/mcpgateway/plugins/framework/routing/__init__.py
new file mode 100644
index 000000000..9d2857cfb
--- /dev/null
+++ b/mcpgateway/plugins/framework/routing/__init__.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+"""Location: ./mcpgateway/plugins/framework/routing/__init__.py
+Copyright 2025
+SPDX-License-Identifier: Apache-2.0
+Authors: Teryl Taylor
+
+Plugin Routing Package.
+Provides resource-centric plugin routing with conditional execution,
+field selection, and extensible entity types.
+"""
+
+# Import routing models from framework.models
+from mcpgateway.plugins.framework.models import (
+ ConfigMetadata,
+ EntityType,
+ FieldSelection,
+ PluginAttachment,
+)
+
+# Import routing components
+from mcpgateway.plugins.framework.routing.evaluator import (
+ EvaluationContext,
+ PolicyEvaluator,
+)
+from mcpgateway.plugins.framework.routing.field_selector import FieldSelector
+from mcpgateway.plugins.framework.routing.rule_resolver import (
+ RuleBasedResolver,
+ RuleMatchContext,
+)
+
+__all__ = [
+ "ConfigMetadata",
+ "EntityType",
+ "EvaluationContext",
+ "FieldSelection",
+ "FieldSelector",
+ "PluginAttachment",
+ "RuleBasedResolver",
+ "RuleMatchContext",
+ "PolicyEvaluator",
+]
diff --git a/mcpgateway/plugins/framework/routing/evaluator.py b/mcpgateway/plugins/framework/routing/evaluator.py
new file mode 100644
index 000000000..cde7ddbed
--- /dev/null
+++ b/mcpgateway/plugins/framework/routing/evaluator.py
@@ -0,0 +1,425 @@
+# -*- coding: utf-8 -*-
+"""Location: ./mcpgateway/plugins/framework/routing/evaluator.py
+Copyright 2025
+SPDX-License-Identifier: Apache-2.0
+Authors: Teryl Taylor
+
+Policy Expression Evaluator.
+Evaluates 'when' clauses for conditional plugin execution using simpleeval
+with AST caching for performance.
+
+Supports expressions like:
+- args.size > 1000
+- contains(agent.tags, "customer-facing")
+- entity.metadata.risk_level == "high"
+- (entity.type == "tool" and args.count > 10) or contains(entity.tags, "admin")
+"""
+
+# Standard
+import ast
+from functools import lru_cache
+import logging
+from typing import Any, Optional
+
+# Third-Party
+from pydantic import BaseModel, Field
+from simpleeval import DEFAULT_FUNCTIONS, DEFAULT_OPERATORS, SimpleEval
+
+logger = logging.getLogger(__name__)
+
+
+class EvaluationContext(BaseModel):
+ """Context for when expression evaluation in plugin routing rules.
+
+ Contains all variables available in 'when' clause expressions.
+
+ Attributes:
+ name: Entity name (e.g., "create_customer", "my_tool").
+ entity_type: Entity type ("tool", "prompt", "resource").
+ entity_id: Optional entity ID.
+ tags: Entity tags for matching (e.g., ["customer", "pii"]).
+ metadata: Entity metadata dict (e.g., {"risk_level": "high"}).
+ server_name: Name of the server this entity belongs to.
+ server_id: ID of the server this entity belongs to.
+ gateway_id: ID of the gateway processing the request.
+ args: Tool/prompt/resource arguments (convenience accessor).
+ payload: Full payload dict for accessing any field (uri, method, headers, result, etc.).
+ user: User making the request.
+ tenant_id: Tenant ID for multi-tenancy.
+
+ Examples:
+ >>> ctx = EvaluationContext(
+ ... name="create_customer",
+ ... entity_type="tool",
+ ... tags=["customer", "pii"],
+ ... metadata={"risk_level": "high"},
+ ... server_name="production-api",
+ ... args={"email": "test@example.com"}
+ ... )
+ >>> ctx.name
+ 'create_customer'
+ >>> ctx.tags
+ ['customer', 'pii']
+
+ >>> # Use in when expression: "metadata.get('risk_level') == 'high'"
+ >>> ctx.metadata["risk_level"]
+ 'high'
+
+ >>> # Use in when expression: "'customer' in tags"
+ >>> 'customer' in ctx.tags
+ True
+
+ >>> # Convenience accessor for common case
+ >>> ctx.args["email"]
+ 'test@example.com'
+
+ >>> # Full payload access for other fields
+ >>> ctx2 = EvaluationContext(
+ ... name="file.env",
+ ... entity_type="resource",
+ ... payload={"uri": "file:///.env", "metadata": {"size": 1024}}
+ ... )
+ >>> ctx2.payload["uri"]
+ 'file:///.env'
+
+ >>> # HTTP payload example
+ >>> ctx3 = EvaluationContext(
+ ... name="api_endpoint",
+ ... entity_type="http",
+ ... payload={"method": "POST", "path": "/api/users", "headers": {"content-type": "application/json"}}
+ ... )
+ >>> ctx3.payload["method"]
+ 'POST'
+ """
+
+ # Entity context
+ name: str = ""
+ entity_type: str = ""
+ entity_id: Optional[str] = None
+ tags: list[str] = Field(default_factory=list)
+ metadata: dict[str, Any] = Field(default_factory=dict)
+
+ # Infrastructure context
+ server_name: Optional[str] = None
+ server_id: Optional[str] = None
+ gateway_id: Optional[str] = None
+
+ # Request context
+ args: dict[str, Any] = Field(default_factory=dict) # Convenience accessor (common in tools/prompts)
+ payload: dict[str, Any] = Field(default_factory=dict) # Full payload access (any field)
+ user: Optional[str] = None
+ tenant_id: Optional[str] = None
+ agent: dict[str, Any] = Field(default_factory=dict) # Agent context for A2A scenarios
+
+
+class PolicyEvaluator:
+ """Evaluates policy expressions for conditional plugin execution.
+
+ Uses simpleeval for safe expression evaluation with AST caching for performance.
+ Expressions are compiled once and cached using LRU cache.
+
+ Supports:
+ - Comparisons: ==, !=, <, >, <=, >=
+ - Logical: and, or, not
+ - Membership: in, contains (custom operator)
+ - Existence: is_defined() function
+ - Dot notation: metadata.risk_level, payload.uri
+ - Literals: strings, numbers, booleans, lists
+ - Parentheses: (a and b) or c
+
+ Examples:
+ >>> evaluator = PolicyEvaluator()
+ >>> ctx = EvaluationContext(args={"size": 1500})
+ >>> evaluator.evaluate("args.size > 1000", ctx)
+ True
+ >>> evaluator.evaluate("args.size <= 1000", ctx)
+ False
+
+ >>> # Test contains function with tags
+ >>> ctx2 = EvaluationContext(
+ ... tags=["production", "customer-facing"]
+ ... )
+ >>> evaluator.evaluate('contains(tags, "production")', ctx2)
+ True
+ >>> evaluator.evaluate('contains(tags, "staging")', ctx2)
+ False
+
+ >>> # Test is_defined function (Note: is_defined doesn't work as expected due to
+ >>> # simpleeval evaluating arguments before function call. Use 'in' operator instead)
+ >>> ctx3 = EvaluationContext(args={"user_query": "test"})
+ >>> evaluator.evaluate("is_defined(args.user_query)", ctx3)
+ True
+ >>> evaluator.evaluate('"user_query" in args', ctx3)
+ True
+ >>> evaluator.evaluate('"missing" in args', ctx3)
+ False
+
+ >>> # Test equality with entity_type
+ >>> ctx4 = EvaluationContext(entity_type="tool", name="customer_support")
+ >>> evaluator.evaluate('entity_type == "tool"', ctx4)
+ True
+
+ >>> # Test logical operators with parentheses
+ >>> ctx5 = EvaluationContext(
+ ... tags=["production"],
+ ... args={"size": 1500}
+ ... )
+ >>> evaluator.evaluate(
+ ... 'contains(tags, "production") and args.size > 1000',
+ ... ctx5
+ ... )
+ True
+
+ >>> # Test 'in' operator with name
+ >>> ctx6 = EvaluationContext(name="create_user")
+ >>> evaluator.evaluate('name in ["create_user", "update_user"]', ctx6)
+ True
+
+ >>> # Test payload access for resources
+ >>> ctx7 = EvaluationContext(
+ ... name="secrets.env",
+ ... entity_type="resource",
+ ... payload={"uri": "file:///.env", "metadata": {"size": 1024}}
+ ... )
+ >>> evaluator.evaluate('payload.uri.endswith(".env")', ctx7)
+ True
+
+ >>> # Test payload access for HTTP hooks
+ >>> ctx8 = EvaluationContext(
+ ... entity_type="http",
+ ... payload={"method": "POST", "path": "/api/users"}
+ ... )
+ >>> evaluator.evaluate('payload.method == "POST"', ctx8)
+ True
+ """
+
+ def __init__(self):
+ """Initialize the policy evaluator with custom operators and functions."""
+ self.evaluator = SimpleEval()
+ self._setup_custom_operators()
+ self._setup_custom_functions()
+ self._setup_custom_nodes()
+
+ def _setup_custom_operators(self):
+ """Setup custom operators for the evaluator."""
+ # simpleeval doesn't support adding custom infix operators to AST
+ # so we just keep the default operators
+ self.evaluator.operators = DEFAULT_OPERATORS.copy()
+
+ def _setup_custom_nodes(self):
+ """Setup custom AST node handlers for the evaluator."""
+ # Enable list literals: [1, 2, 3]
+ self.evaluator.nodes[ast.List] = lambda node: [self.evaluator._eval(x) for x in node.elts] # pylint: disable=protected-access
+ # Enable tuple literals: (1, 2, 3)
+ self.evaluator.nodes[ast.Tuple] = lambda node: tuple(self.evaluator._eval(x) for x in node.elts) # pylint: disable=protected-access
+
+ def _setup_custom_functions(self):
+ """Setup custom functions for the evaluator."""
+ functions = DEFAULT_FUNCTIONS.copy()
+
+ # Add 'is_defined' function to check if a variable exists and is not None
+ # Usage: is_defined(args.user_query)
+ def is_defined_func(value):
+ """Check if value is defined (not None)."""
+ return value is not None
+
+ # Add 'contains' function: contains(list, value)
+ # Usage: contains(entity.tags, "production")
+ def contains_func(container, value):
+ """Check if container contains value."""
+ if container is None:
+ return False
+ if isinstance(container, (list, tuple, set)):
+ return value in container
+ if isinstance(container, str):
+ return str(value) in container
+ if isinstance(container, dict):
+ return value in container
+ return False
+
+ functions["is_defined"] = is_defined_func
+ functions["contains"] = contains_func
+ self.evaluator.functions = functions
+
+ @lru_cache(maxsize=1000)
+ def _parse_expression(self, expression: str) -> ast.Expression:
+ """Parse and cache expression AST.
+
+ Uses LRU cache to store compiled ASTs for repeated expressions.
+ This provides significant performance improvement for frequently
+ evaluated expressions.
+
+ Args:
+ expression: The expression string to parse.
+
+ Returns:
+ Parsed AST Expression node.
+
+ Raises:
+ SyntaxError: If expression has invalid syntax.
+ """
+ try:
+ # Parse expression and extract the expression node
+ parsed = ast.parse(expression.strip(), mode="eval")
+ return parsed
+ except SyntaxError as e:
+ logger.error(f"Failed to parse expression '{expression}': {e}")
+ raise
+
+ def evaluate(self, expression: str, context: EvaluationContext) -> bool:
+ """Evaluate a policy expression against a context.
+
+ Expressions are parsed once and cached for performance. The same
+ expression evaluated multiple times will use the cached AST.
+
+ Args:
+ expression: The policy expression to evaluate.
+ context: The evaluation context with variables.
+
+ Returns:
+ True if the expression evaluates to True, False otherwise.
+
+ Raises:
+ ValueError: If the expression is invalid or evaluation fails.
+
+ Examples:
+ >>> evaluator = PolicyEvaluator()
+ >>> ctx = EvaluationContext(args={"count": 5})
+ >>> evaluator.evaluate("args.count > 3", ctx)
+ True
+ >>> evaluator.evaluate("args.count < 3", ctx)
+ False
+ """
+ if not expression or not expression.strip():
+ return True
+
+ try:
+ # Parse expression (cached)
+ parsed_ast = self._parse_expression(expression)
+
+ # Set context on evaluator (simpleeval uses .names attribute)
+ self.evaluator.names = {
+ # Entity-related fields (also available as entity.*)
+ "name": context.name,
+ "entity_type": context.entity_type,
+ "entity_id": context.entity_id,
+ "tags": context.tags,
+ "metadata": context.metadata,
+ "server_name": context.server_name,
+ "server_id": context.server_id,
+ "gateway_id": context.gateway_id,
+ # Grouped entity namespace for cleaner expressions
+ "entity": {
+ "name": context.name,
+ "type": context.entity_type,
+ "id": context.entity_id,
+ "tags": context.tags,
+ "metadata": context.metadata,
+ },
+ # Request context
+ "args": context.args,
+ "payload": context.payload,
+ "user": context.user,
+ "tenant_id": context.tenant_id,
+ "agent": context.agent,
+ }
+
+ # Evaluate using pre-parsed AST (pass the expression body, not the Module)
+ result = self.evaluator.eval(expr="", previously_parsed=parsed_ast.body)
+
+ # Convert result to boolean
+ return bool(result)
+
+ except Exception as e:
+ logger.error(f"Failed to evaluate expression '{expression}': {e}")
+ raise ValueError(f"Failed to evaluate expression '{expression}': {e}") from e
+
+ def compile_expression(self, expression: str) -> Optional[ast.Expression]:
+ """Pre-compile an expression for later evaluation.
+
+ This can be used to compile expressions when loading configuration,
+ allowing even faster evaluation at runtime.
+
+ Args:
+ expression: The expression to compile.
+
+ Returns:
+ Compiled AST Expression, or None if expression is empty.
+
+ Raises:
+ ValueError: If expression has invalid syntax.
+
+ Examples:
+ >>> evaluator = PolicyEvaluator()
+ >>> compiled = evaluator.compile_expression("args.size > 1000")
+ >>> compiled is not None
+ True
+ """
+ if not expression or not expression.strip():
+ return None
+
+ try:
+ return self._parse_expression(expression)
+ except SyntaxError as e:
+ raise ValueError(f"Invalid expression syntax '{expression}': {e}") from e
+
+ def evaluate_compiled(self, compiled_expr: ast.Expression, context: EvaluationContext) -> bool:
+ """Evaluate a pre-compiled expression.
+
+ This is faster than evaluate() when the expression has been
+ pre-compiled using compile_expression().
+
+ Args:
+ compiled_expr: Pre-compiled AST Expression.
+ context: The evaluation context.
+
+ Returns:
+ True if the expression evaluates to True, False otherwise.
+
+ Examples:
+ >>> evaluator = PolicyEvaluator()
+ >>> compiled = evaluator.compile_expression("args.count > 5")
+ >>> ctx = EvaluationContext(args={"count": 10})
+ >>> evaluator.evaluate_compiled(compiled, ctx)
+ True
+ """
+ if compiled_expr is None:
+ return True
+
+ try:
+ # Set context on evaluator
+ self.evaluator.names = {
+ "name": context.name,
+ "entity_type": context.entity_type,
+ "entity_id": context.entity_id,
+ "tags": context.tags,
+ "metadata": context.metadata,
+ "server_name": context.server_name,
+ "server_id": context.server_id,
+ "gateway_id": context.gateway_id,
+ "args": context.args,
+ "payload": context.payload,
+ "user": context.user,
+ "tenant_id": context.tenant_id,
+ }
+
+ # Evaluate
+ result = self.evaluator.eval(expr="", previously_parsed=compiled_expr.body)
+ return bool(result)
+
+ except Exception as e:
+ logger.error(f"Failed to evaluate compiled expression: {e}")
+ raise ValueError(f"Failed to evaluate compiled expression: {e}") from e
+
+ def clear_cache(self):
+ """Clear the expression cache.
+
+ Useful for testing or if memory usage becomes a concern.
+
+ Examples:
+ >>> evaluator = PolicyEvaluator()
+ >>> evaluator.evaluate("args.count > 5", EvaluationContext(args={"count": 10}))
+ True
+ >>> evaluator.clear_cache()
+ """
+ self._parse_expression.cache_clear()
diff --git a/mcpgateway/plugins/framework/routing/field_selector.py b/mcpgateway/plugins/framework/routing/field_selector.py
new file mode 100644
index 000000000..af4b2fc8b
--- /dev/null
+++ b/mcpgateway/plugins/framework/routing/field_selector.py
@@ -0,0 +1,372 @@
+# -*- coding: utf-8 -*-
+"""Location: ./mcpgateway/plugins/framework/routing/field_selector.py
+Copyright 2025
+SPDX-License-Identifier: Apache-2.0
+Authors: Teryl Taylor
+
+Field Selection and Scoping.
+Extracts specific fields from payloads for plugin processing using dot notation
+and JSONPath-like syntax, then merges processed fields back into the original payload.
+
+Supports:
+- Dot notation: args.user_query, result.customer.ssn
+- Array indexing: args.customers[0].email
+- Wildcard arrays: args.customers[*].email (all email fields in array)
+- Wildcard dicts: args.metadata.*.value (all values in dict)
+"""
+
+# Standard
+import logging
+import re
+from typing import Any, Optional
+
+# First-Party
+from mcpgateway.plugins.framework.models import FieldSelection
+
+logger = logging.getLogger(__name__)
+
+
+class FieldSelector:
+ """Extracts and merges specific fields from/to payloads.
+
+ All methods are static - no need to instantiate this class.
+
+ Examples:
+ >>> # Simple field extraction
+ >>> payload = {"args": {"query": "test", "limit": 10}}
+ >>> extracted = FieldSelector.extract_fields(payload, ["args.query"])
+ >>> extracted
+ {'args': {'query': 'test'}}
+
+ >>> # Nested field extraction
+ >>> payload2 = {
+ ... "args": {
+ ... "filters": {
+ ... "email": "john@example.com",
+ ... "phone": "555-1234"
+ ... },
+ ... "limit": 10
+ ... }
+ ... }
+ >>> extracted2 = FieldSelector.extract_fields(
+ ... payload2,
+ ... ["args.filters.email", "args.filters.phone"]
+ ... )
+ >>> extracted2
+ {'args': {'filters': {'email': 'john@example.com', 'phone': '555-1234'}}}
+
+ >>> # Array wildcard extraction
+ >>> payload3 = {
+ ... "args": {
+ ... "customers": [
+ ... {"name": "Alice", "email": "alice@example.com"},
+ ... {"name": "Bob", "email": "bob@example.com"}
+ ... ]
+ ... }
+ ... }
+ >>> extracted3 = FieldSelector.extract_fields(
+ ... payload3,
+ ... ["args.customers[*].email"]
+ ... )
+ >>> extracted3
+ {'args': {'customers': [{'email': 'alice@example.com'}, {'email': 'bob@example.com'}]}}
+
+ >>> # Merge processed fields back
+ >>> original = {"args": {"query": "sensitive", "limit": 10}}
+ >>> processed = {"args": {"query": "[REDACTED]"}}
+ >>> merged = FieldSelector.merge_fields(original, processed, ["args.query"])
+ >>> merged
+ {'args': {'query': '[REDACTED]', 'limit': 10}}
+
+ >>> # Multiple field extraction
+ >>> payload4 = {
+ ... "name": "my_tool",
+ ... "args": {"user_query": "test", "email": "user@example.com", "limit": 5}
+ ... }
+ >>> extracted4 = FieldSelector.extract_fields(
+ ... payload4,
+ ... ["args.user_query", "args.email"]
+ ... )
+ >>> extracted4
+ {'args': {'user_query': 'test', 'email': 'user@example.com'}}
+ """
+
+ @staticmethod
+ def extract_fields(payload: dict[str, Any], field_paths: list[str]) -> dict[str, Any]:
+ """Extract specific fields from payload.
+
+ Args:
+ payload: The full payload dict.
+ field_paths: List of dot-notation field paths to extract.
+
+ Returns:
+ New dict containing only the specified fields.
+
+ Examples:
+ >>> payload = {"args": {"a": 1, "b": 2}, "name": "test"}
+ >>> FieldSelector.extract_fields(payload, ["args.a"])
+ {'args': {'a': 1}}
+ """
+ result: dict[str, Any] = {}
+
+ for path in field_paths:
+ FieldSelector._extract_path(payload, path, result)
+
+ return result
+
+ @staticmethod
+ def merge_fields(
+ original: dict[str, Any],
+ processed: dict[str, Any],
+ field_paths: list[str],
+ ) -> dict[str, Any]:
+ """Merge processed fields back into original payload.
+
+ Args:
+ original: Original payload dict.
+ processed: Processed payload dict (only contains specified fields).
+ field_paths: List of field paths that were processed.
+
+ Returns:
+ New dict with processed fields merged back into original.
+
+ Examples:
+ >>> original = {"args": {"query": "test", "limit": 10}}
+ >>> processed = {"args": {"query": "REDACTED"}}
+ >>> FieldSelector.merge_fields(original, processed, ["args.query"])
+ {'args': {'query': 'REDACTED', 'limit': 10}}
+ """
+ # Start with a deep copy of original
+ # Standard
+ import copy
+
+ result = copy.deepcopy(original)
+
+ # Merge processed fields back
+ for path in field_paths:
+ FieldSelector._merge_path(result, processed, path)
+
+ return result
+
+ @staticmethod
+ def apply_field_selection(
+ payload: dict[str, Any],
+ field_selection: Optional[FieldSelection],
+ is_input: bool = True,
+ ) -> tuple[dict[str, Any], Optional[list[str]]]:
+ """Apply field selection to payload.
+
+ Args:
+ payload: The payload to filter.
+ field_selection: Field selection configuration.
+ is_input: True for input (pre-hook), False for output (post-hook).
+
+ Returns:
+ Tuple of (filtered_payload, field_paths_used).
+ If no field selection, returns (original_payload, None).
+
+ Examples:
+ >>> from mcpgateway.plugins.framework.models import FieldSelection
+ >>> fs = FieldSelection(input_fields=["args.query"])
+ >>> payload = {"args": {"query": "test", "limit": 10}}
+ >>> filtered, paths = FieldSelector.apply_field_selection(payload, fs, is_input=True)
+ >>> filtered
+ {'args': {'query': 'test'}}
+ >>> paths
+ ['args.query']
+ """
+ if field_selection is None:
+ return payload, None
+
+ # Determine which field list to use
+ if is_input:
+ # For input (pre-hook): use input_fields if specified, else fields
+ field_paths = field_selection.input_fields or field_selection.fields
+ else:
+ # For output (post-hook): use output_fields if specified, else fields
+ field_paths = field_selection.output_fields or field_selection.fields
+
+ # If no fields specified, return original payload
+ if not field_paths:
+ return payload, None
+
+ # Extract specified fields
+ filtered = FieldSelector.extract_fields(payload, field_paths)
+ return filtered, field_paths
+
+ @staticmethod
+ def _extract_path(source: dict[str, Any], path: str, result: dict[str, Any]):
+ """Extract a single path from source into result.
+
+ Handles dot notation, array indexing, and wildcards.
+ """
+ parts = FieldSelector._parse_path(path)
+ FieldSelector._extract_parts(source, parts, result, parts)
+
+ @staticmethod
+ def _extract_parts(
+ source: Any,
+ parts: list[str],
+ result: Any,
+ full_parts: list[str],
+ current_depth: int = 0,
+ ):
+ """Recursively extract parts from source into result."""
+ if current_depth >= len(parts):
+ return
+
+ part = parts[current_depth]
+ is_last = current_depth == len(parts) - 1
+
+ # Handle array wildcard: [*]
+ match = re.match(r"^(.+)\[\*\]$", part)
+ if match:
+ key = match.group(1)
+ if isinstance(source, dict) and key in source:
+ if isinstance(source[key], list):
+ # Create array in result if not exists
+ if not isinstance(result, dict):
+ return
+ if key not in result:
+ result[key] = []
+ # Process each array element
+ for item in source[key]:
+ if is_last:
+ # Last part: copy entire item
+ result[key].append(item if not isinstance(item, dict) else {})
+ else:
+ # More parts: recurse into each item
+ result_item = {}
+ result[key].append(result_item)
+ FieldSelector._extract_parts(item, parts, result_item, full_parts, current_depth + 1)
+ return
+
+ # Handle array index: [0], [1], etc.
+ match2 = re.match(r"^(.+)\[(\d+)\]$", part)
+ if match2:
+ key = match2.group(1)
+ index = int(match2.group(2))
+ if isinstance(source, dict) and key in source:
+ if isinstance(source[key], list) and len(source[key]) > index:
+ if not isinstance(result, dict):
+ return
+ if key not in result:
+ result[key] = []
+ # Pad array if needed
+ while len(result[key]) <= index:
+ result[key].append({})
+ if is_last:
+ result[key][index] = source[key][index]
+ else:
+ FieldSelector._extract_parts(
+ source[key][index],
+ parts,
+ result[key][index],
+ full_parts,
+ current_depth + 1,
+ )
+ return
+
+ # Handle simple key
+ if isinstance(source, dict) and part in source:
+ if not isinstance(result, dict):
+ return
+ if is_last:
+ # Last part: copy value
+ result[part] = source[part]
+ else:
+ # More parts: recurse
+ if part not in result:
+ result[part] = {}
+ FieldSelector._extract_parts(source[part], parts, result[part], full_parts, current_depth + 1)
+
+ @staticmethod
+ def _merge_path(target: dict[str, Any], source: dict[str, Any], path: str):
+ """Merge a single path from source into target."""
+ parts = FieldSelector._parse_path(path)
+ FieldSelector._merge_parts(target, source, parts, 0)
+
+ @staticmethod
+ def _merge_parts(target: Any, source: Any, parts: list[str], current_depth: int):
+ """Recursively merge parts from source into target."""
+ if current_depth >= len(parts):
+ return
+
+ part = parts[current_depth]
+ is_last = current_depth == len(parts) - 1
+
+ # Handle array wildcard
+ match = re.match(r"^(.+)\[\*\]$", part)
+ if match:
+ key = match.group(1)
+ if isinstance(source, dict) and key in source and isinstance(target, dict) and key in target:
+ if isinstance(source[key], list) and isinstance(target[key], list):
+ # Merge each array element
+ for i, item in enumerate(source[key]):
+ if i < len(target[key]):
+ if is_last:
+ target[key][i] = item
+ else:
+ FieldSelector._merge_parts(target[key][i], item, parts, current_depth + 1)
+ return
+
+ # Handle array index
+ match2 = re.match(r"^(.+)\[(\d+)\]$", part)
+ if match2:
+ key = match2.group(1)
+ index = int(match2.group(2))
+ if isinstance(source, dict) and key in source and isinstance(target, dict) and key in target:
+ if isinstance(source[key], list) and isinstance(target[key], list) and len(source[key]) > index and len(target[key]) > index:
+ if is_last:
+ target[key][index] = source[key][index]
+ else:
+ FieldSelector._merge_parts(target[key][index], source[key][index], parts, current_depth + 1)
+ return
+
+ # Handle simple key
+ if isinstance(source, dict) and part in source and isinstance(target, dict):
+ if is_last:
+ target[part] = source[part]
+ else:
+ if part not in target:
+ target[part] = {}
+ FieldSelector._merge_parts(target[part], source[part], parts, current_depth + 1)
+
+ @staticmethod
+ def _parse_path(path: str) -> list[str]:
+ """Parse a dot-notation path into parts.
+
+ Handles array notation like args.customers[0].email or args.items[*].name.
+
+ Examples:
+ >>> FieldSelector._parse_path("args.query")
+ ['args', 'query']
+ >>> FieldSelector._parse_path("args.customers[0].email")
+ ['args', 'customers[0]', 'email']
+ >>> FieldSelector._parse_path("args.items[*].name")
+ ['args', 'items[*]', 'name']
+ """
+ # Split by dots, but keep array notation with the key
+ # e.g., "args.customers[0].email" -> ["args", "customers[0]", "email"]
+ parts = []
+ current = ""
+ in_bracket = False
+
+ for char in path:
+ if char == "[":
+ in_bracket = True
+ current += char
+ elif char == "]":
+ in_bracket = False
+ current += char
+ elif char == "." and not in_bracket:
+ if current:
+ parts.append(current)
+ current = ""
+ else:
+ current += char
+
+ if current:
+ parts.append(current)
+
+ return parts
diff --git a/mcpgateway/plugins/framework/routing/rule_resolver.py b/mcpgateway/plugins/framework/routing/rule_resolver.py
new file mode 100644
index 000000000..a184d1096
--- /dev/null
+++ b/mcpgateway/plugins/framework/routing/rule_resolver.py
@@ -0,0 +1,481 @@
+# -*- coding: utf-8 -*-
+"""Location: ./mcpgateway/plugins/framework/routing/rule_resolver.py
+Copyright 2025
+SPDX-License-Identifier: Apache-2.0
+Authors: Claude Code
+
+Rule-Based Plugin Resolver.
+Implements flat, declarative rule matching for plugin routing using
+exact matches (fast path), tag-based matching, and complex expressions.
+"""
+
+# Standard
+import logging
+from typing import Any, Optional
+
+# Third-Party
+from pydantic import BaseModel
+
+# First-Party
+from mcpgateway.plugins.framework.hooks.registry import get_hook_registry
+from mcpgateway.plugins.framework.models import (
+ EntityType,
+ PluginAttachment,
+ PluginHookRule,
+)
+from mcpgateway.plugins.framework.routing.evaluator import (
+ EvaluationContext,
+ PolicyEvaluator,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class RuleMatchContext(BaseModel):
+ """Context for rule matching.
+
+ Contains information about the entity being matched against rules.
+
+ Attributes:
+ name: Entity name.
+ entity_type: Entity type (tool, prompt, resource, agent, http).
+ entity_id: Optional entity ID.
+ tags: Entity tags.
+ metadata: Entity metadata.
+ server_name: Server name.
+ server_id: Server ID.
+ gateway_id: Gateway ID.
+ payload: Full payload dict for accessing any field.
+ user: User making the request.
+ tenant_id: Tenant ID.
+
+ Examples:
+ >>> ctx = RuleMatchContext(
+ ... name="create_customer",
+ ... entity_type="tool",
+ ... tags=["customer", "pii"],
+ ... metadata={"risk_level": "high"},
+ ... server_name="api-server",
+ ... payload={"args": {"email": "test@example.com"}}
+ ... )
+ >>> ctx.name
+ 'create_customer'
+ >>> ctx.tags
+ ['customer', 'pii']
+ """
+
+ name: str
+ entity_type: str
+ entity_id: Optional[str] = None
+ tags: list[str] = []
+ metadata: dict[str, Any] = {}
+ server_name: Optional[str] = None
+ server_id: Optional[str] = None
+ gateway_id: Optional[str] = None
+ payload: dict[str, Any] = {}
+ user: Optional[str] = None
+ tenant_id: Optional[str] = None
+
+
+class RuleBasedResolver:
+ """Resolves plugins using flat, declarative rule-based matching with caching.
+
+ Uses two-level caching strategy:
+ 1. Static resolution (cached): Match rules by name/tags/server, return plugin attachments
+ 2. Runtime filtering: Evaluate 'when' clauses on cached plugins with request context
+
+ The cache key is (entity_type, entity_name, hook_type) and stores a list of
+ PluginAttachments. Rule-level 'when' clauses are transferred to each plugin
+ attachment and evaluated at runtime, not during resolution.
+
+ Examples:
+ >>> from mcpgateway.plugins.framework.models import EntityType, PluginAttachment, PluginHookRule
+ >>> resolver = RuleBasedResolver()
+
+ >>> # Define rules
+ >>> rules = [
+ ... PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... tags=["customer", "pii"],
+ ... plugins=[
+ ... PluginAttachment(name="pii_filter", priority=10),
+ ... PluginAttachment(name="audit_logger", priority=20)
+ ... ]
+ ... ),
+ ... PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... name="process_payment",
+ ... plugins=[
+ ... PluginAttachment(name="fraud_detector", priority=5)
+ ... ]
+ ... )
+ ... ]
+
+ >>> # Match entity against rules
+ >>> ctx = RuleMatchContext(
+ ... name="create_customer",
+ ... entity_type="tool",
+ ... tags=["customer", "pii"],
+ ... server_name="api-server"
+ ... )
+ >>> attachments = resolver.resolve_for_entity(rules, ctx)
+ >>> len(attachments)
+ 2
+ >>> attachments[0].name
+ 'pii_filter'
+ """
+
+ def __init__(self):
+ """Initialize the rule-based resolver.
+
+ Note: Caching is handled by PluginManager, not here.
+ """
+ self.evaluator = PolicyEvaluator()
+
+ def resolve_for_entity(
+ self,
+ rules: list[PluginHookRule],
+ context: RuleMatchContext,
+ hook_type: Optional[str] = None,
+ eval_context: Optional[EvaluationContext] = None,
+ merge_strategy: str = "most_specific",
+ ) -> list[PluginAttachment]:
+ """Resolve plugins for an entity with optional runtime filtering.
+
+ This method performs:
+ 1. Static matching: Match rules by name/tags/server (no 'when' evaluation)
+ 2. Runtime filtering (optional): Evaluate 'when' clauses with context
+
+ Note: Caching is handled by PluginManager, not here.
+
+ Args:
+ rules: List of plugin hook rules to evaluate.
+ context: Context about the entity being matched.
+ hook_type: Optional hook type (for logging/debugging).
+ eval_context: Optional evaluation context for 'when' clause filtering.
+ merge_strategy: Strategy for merging matching rules - "most_specific" (default) or "merge_all".
+
+ Returns:
+ List of PluginAttachments to apply to the entity, sorted by priority.
+
+ Examples:
+ >>> resolver = RuleBasedResolver()
+ >>> rules = [
+ ... PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... tags=["pii"],
+ ... plugins=[PluginAttachment(name="pii_filter", priority=10)]
+ ... )
+ ... ]
+ >>> ctx = RuleMatchContext(
+ ... name="get_customer",
+ ... entity_type="tool",
+ ... tags=["pii"]
+ ... )
+ >>> attachments = resolver.resolve_for_entity(rules, ctx)
+ >>> len(attachments)
+ 1
+ >>> attachments[0].name
+ 'pii_filter'
+
+ >>> # With runtime filtering
+ >>> rule_with_when = PluginHookRule(
+ ... entities=[EntityType.TOOL],
+ ... when="args.size > 1000",
+ ... plugins=[PluginAttachment(name="size_validator", priority=10)]
+ ... )
+ >>> eval_ctx = EvaluationContext(args={"size": 1500})
+ >>> attachments_filtered = resolver.resolve_for_entity(
+ ... [rule_with_when], ctx, eval_context=eval_ctx
+ ... )
+ >>> len(attachments_filtered)
+ 1
+ """
+ # Perform static resolution (no 'when' evaluation)
+ static_plugins = self._resolve_static(rules, context, hook_type, merge_strategy)
+
+ # Apply runtime filtering (evaluate 'when' clauses) if context provided
+ if eval_context:
+ filtered_plugins = self._filter_runtime(static_plugins, eval_context)
+ return filtered_plugins
+
+ return static_plugins
+
+ def _resolve_static(
+ self,
+ rules: list[PluginHookRule],
+ context: RuleMatchContext,
+ hook_type: Optional[str] = None,
+ merge_strategy: str = "most_specific",
+ ) -> list[PluginAttachment]:
+ """Resolve plugins statically (no 'when' clause evaluation).
+
+ Transfers rule-level 'when' clauses to plugin attachments for runtime evaluation.
+ Implements reverse_order_on_post for symmetric hook wrapping.
+
+ Args:
+ rules: List of plugin hook rules to evaluate.
+ context: Context about the entity being matched.
+ hook_type: Optional hook type to check for POST hooks and hook filtering.
+
+ Returns:
+ List of PluginAttachments sorted by priority, with 'when' clauses transferred.
+ """
+ matching_rules: list[tuple[PluginHookRule, int]] = []
+
+ # Find all matching rules with their specificity (skip 'when' evaluation)
+ for rule in rules:
+ # Filter by hooks if specified
+ if rule.hooks and hook_type:
+ if hook_type not in rule.hooks:
+ continue
+
+ if self._rule_matches_static(rule, context):
+ specificity = self._calculate_specificity(rule)
+ matching_rules.append((rule, specificity))
+
+ # Sort by priority (explicit priority first, then specificity)
+ matching_rules.sort(
+ key=lambda x: (
+ x[0].priority if x[0].priority is not None else 999999,
+ -x[1], # Higher specificity first
+ )
+ )
+
+ # Apply merge strategy
+ if matching_rules and merge_strategy == "most_specific":
+ # Filter: Keep only rules with the highest specificity level
+ # This ensures more specific rules (e.g., with name filters) take precedence
+ # over less specific rules (e.g., only infrastructure filters)
+ max_specificity = max(specificity for _, specificity in matching_rules)
+ matching_rules = [(rule, spec) for rule, spec in matching_rules if spec == max_specificity]
+ # else: merge_strategy == "merge_all" -> use all matching rules
+
+ # Check if this is a POST hook for reverse ordering
+ is_post_hook = False
+ if hook_type:
+ registry = get_hook_registry()
+ is_post_hook = registry.is_post_hook(hook_type)
+
+ # Merge plugins from matching rules
+ merged_attachments: list[PluginAttachment] = []
+
+ # Note: We DO NOT deduplicate by plugin name here because the same plugin
+ # can appear multiple times with different configurations, creating different instances.
+ # The PluginManager will create separate instances based on config hashes.
+
+ for rule, _ in matching_rules:
+ for plugin_attachment in rule.plugins:
+
+ # Transfer rule-level 'when' to plugin attachment if not already set
+ if rule.when and not plugin_attachment.when:
+ # Create a copy with rule's 'when' clause for runtime evaluation
+ attachment_with_when = PluginAttachment(
+ name=plugin_attachment.name,
+ priority=plugin_attachment.priority,
+ post_priority=plugin_attachment.post_priority,
+ scope=plugin_attachment.scope,
+ hooks=plugin_attachment.hooks,
+ when=rule.when, # Transfer from rule for runtime evaluation
+ apply_to=plugin_attachment.apply_to,
+ override=plugin_attachment.override,
+ mode=plugin_attachment.mode,
+ config=plugin_attachment.config,
+ )
+ merged_attachments.append(attachment_with_when)
+ else:
+ merged_attachments.append(plugin_attachment)
+
+ # Sort final list by plugin priority
+ merged_attachments.sort(key=lambda p: p.priority)
+
+ # Apply reverse ordering for POST hooks if any rule requested it
+ if is_post_hook:
+ # Collect reverse_order_on_post settings from all matching rules
+ reverse_settings = [rule.reverse_order_on_post for rule, _ in matching_rules]
+ should_reverse = any(reverse_settings)
+
+ # Warn if settings are inconsistent across matching rules
+ if should_reverse and not all(reverse_settings):
+ logger.warning(
+ f"Inconsistent reverse_order_on_post settings for entity '{context.name}': "
+ f"some rules have reverse_order_on_post=True while others don't. "
+ f"All plugins will be reversed because at least one rule requested it. "
+ f"Consider setting reverse_order_on_post consistently across all matching rules."
+ )
+
+ if should_reverse:
+ merged_attachments = list(reversed(merged_attachments))
+ logger.debug(f"Reversed plugin order for POST hook '{hook_type}' to create symmetric wrapping: " f"{[p.name for p in merged_attachments]}")
+
+ return merged_attachments
+
+ def _filter_runtime(
+ self,
+ static_plugins: list[PluginAttachment],
+ context: EvaluationContext,
+ ) -> list[PluginAttachment]:
+ """Filter plugins at runtime by evaluating 'when' clauses.
+
+ Args:
+ static_plugins: Pre-resolved plugins from cache (with 'when' transferred from rules).
+ context: Evaluation context for 'when' clauses.
+
+ Returns:
+ Filtered list of plugins.
+ """
+ filtered = []
+
+ for plugin in static_plugins:
+ # Evaluate 'when' clause if present (transferred from rule)
+ if plugin.when:
+ try:
+ if not self.evaluator.evaluate(plugin.when, context):
+ logger.debug(f"Skipping plugin {plugin.name}: " f"when clause '{plugin.when}' evaluated to False")
+ continue
+ except Exception as e:
+ logger.error(f"Failed to evaluate when clause for plugin {plugin.name}: {e}. " "Skipping plugin.")
+ continue
+
+ filtered.append(plugin)
+
+ return filtered
+
+ def _rule_matches_static(self, rule: PluginHookRule, context: RuleMatchContext) -> bool:
+ """Check if a rule matches using static criteria only (no 'when' evaluation).
+
+ Uses fast-path matching for name, tags, and infrastructure filters.
+ Does NOT evaluate 'when' expressions - those are deferred to runtime.
+
+ Args:
+ rule: The rule to evaluate.
+ context: The entity context.
+
+ Returns:
+ True if the rule matches statically, False otherwise.
+ """
+ # Check entity type match (None = HTTP-level)
+ if rule.entities is not None:
+ entity_type_enum = self._get_entity_type_enum(context.entity_type)
+ if entity_type_enum not in rule.entities:
+ return False
+
+ # Check infrastructure filters
+ if not self._infrastructure_matches(rule, context):
+ return False
+
+ # FAST PATH: Exact name match
+ if rule.name is not None:
+ if isinstance(rule.name, list):
+ if context.name not in rule.name:
+ return False
+ elif context.name != rule.name:
+ return False
+
+ # FAST PATH: Tag match (set intersection)
+ if rule.tags:
+ rule_tags_set = set(rule.tags)
+ context_tags_set = set(context.tags)
+ if not rule_tags_set.intersection(context_tags_set):
+ return False
+
+ # Skip 'when' evaluation - that happens at runtime
+ return True
+
+ def _infrastructure_matches(self, rule: PluginHookRule, context: RuleMatchContext) -> bool:
+ """Check if infrastructure filters match.
+
+ Args:
+ rule: The rule with infrastructure filters.
+ context: The entity context.
+
+ Returns:
+ True if infrastructure filters match, False otherwise.
+ """
+ # Check server_name filter
+ if rule.server_name is not None:
+ if isinstance(rule.server_name, list):
+ if context.server_name not in rule.server_name:
+ return False
+ elif context.server_name != rule.server_name:
+ return False
+
+ # Check server_id filter
+ if rule.server_id is not None:
+ if isinstance(rule.server_id, list):
+ if context.server_id not in rule.server_id:
+ return False
+ elif context.server_id != rule.server_id:
+ return False
+
+ # Check gateway_id filter
+ if rule.gateway_id is not None:
+ if isinstance(rule.gateway_id, list):
+ if context.gateway_id not in rule.gateway_id:
+ return False
+ elif context.gateway_id != rule.gateway_id:
+ return False
+
+ return True
+
+ def _calculate_specificity(self, rule: PluginHookRule) -> int:
+ """Calculate specificity score for a rule.
+
+ Higher scores indicate more specific rules.
+ - Exact name match: 1000
+ - Tag match: 100
+ - Hook type filter: 50
+ - When expression: 10
+ - Entity type only: 0
+
+ Args:
+ rule: The rule to score.
+
+ Returns:
+ Specificity score.
+ """
+ score = 0
+
+ # Exact name match is most specific
+ if rule.name is not None:
+ score += 1000
+
+ # Tag match is medium specificity
+ if rule.tags:
+ score += 100
+
+ # Hook type filter is medium-low specificity
+ if rule.hooks:
+ score += 50
+
+ # When expression is lower specificity
+ if rule.when:
+ score += 10
+
+ return score
+
+ def _get_entity_type_enum(self, entity_type: str) -> EntityType:
+ """Convert entity type string to EntityType enum.
+
+ Args:
+ entity_type: Entity type string.
+
+ Returns:
+ EntityType enum value.
+
+ Raises:
+ ValueError: If entity type is invalid.
+ """
+ type_map = {
+ "tool": EntityType.TOOL,
+ "prompt": EntityType.PROMPT,
+ "resource": EntityType.RESOURCE,
+ "agent": EntityType.AGENT,
+ "virtual_server": EntityType.VIRTUAL_SERVER,
+ "mcp_server": EntityType.MCP_SERVER,
+ }
+
+ if entity_type not in type_map:
+ raise ValueError(f"Invalid entity type: {entity_type}")
+
+ return type_map[entity_type]
diff --git a/mcpgateway/plugins/framework/utils.py b/mcpgateway/plugins/framework/utils.py
index 0d40e01ac..1e3eb8e4e 100644
--- a/mcpgateway/plugins/framework/utils.py
+++ b/mcpgateway/plugins/framework/utils.py
@@ -11,7 +11,9 @@
# Standard
from functools import cache
+import hashlib
import importlib
+import json
from types import ModuleType
from typing import Any, Optional
@@ -438,3 +440,73 @@ def payload_matches(
# if index < len(conditions) - 1:
# current_result = True
# return current_result
+
+
+def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
+ """Deep merge two dictionaries, with override values taking precedence.
+
+ Recursively merges nested dictionaries. Lists and other types are replaced entirely.
+
+ Args:
+ base: Base configuration dictionary.
+ override: Override configuration dictionary (takes precedence).
+
+ Returns:
+ New dictionary with deep-merged values.
+
+ Examples:
+ >>> base = {"a": 1, "b": {"x": 10, "y": 20}, "c": [1, 2]}
+ >>> override = {"b": {"y": 30, "z": 40}, "d": 4}
+ >>> result = deep_merge(base, override)
+ >>> result == {"a": 1, "b": {"x": 10, "y": 30, "z": 40}, "c": [1, 2], "d": 4}
+ True
+ >>> # Base values preserved when not overridden
+ >>> deep_merge({"timeout": 30, "retry": 3}, {"timeout": 60})
+ {'timeout': 60, 'retry': 3}
+ >>> # Nested merge
+ >>> deep_merge({"db": {"host": "localhost", "port": 5432}}, {"db": {"port": 3306}})
+ {'db': {'host': 'localhost', 'port': 3306}}
+ """
+ # Standard
+ import copy
+
+ result = copy.deepcopy(base)
+
+ for key, value in override.items():
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
+ # Recursively merge nested dicts
+ result[key] = deep_merge(result[key], value)
+ else:
+ # Override (or add new key)
+ result[key] = copy.deepcopy(value)
+
+ return result
+
+
+def hash_config(config: dict[str, Any]) -> str:
+ """Create a stable hash of a configuration dictionary.
+
+ Uses JSON serialization with sorted keys to ensure deterministic hashing.
+
+ Args:
+ config: Configuration dictionary to hash.
+
+ Returns:
+ SHA256 hash of the config as hex string.
+
+ Examples:
+ >>> config1 = {"timeout": 30, "retry": 3}
+ >>> config2 = {"retry": 3, "timeout": 30} # Different order
+ >>> hash_config(config1) == hash_config(config2)
+ True
+ >>> config3 = {"timeout": 60, "retry": 3}
+ >>> hash_config(config1) == hash_config(config3)
+ False
+ >>> # Empty config
+ >>> hash_config({})
+ 'bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f'
+ """
+ # Serialize with sorted keys for stability
+ config_json = json.dumps(config, sort_keys=True, default=str)
+ # Use SHA1 for shorter hashes (collision risk is acceptable here)
+ return hashlib.sha1(config_json.encode()).hexdigest()
diff --git a/mcpgateway/services/plugin_route_service.py b/mcpgateway/services/plugin_route_service.py
new file mode 100644
index 000000000..df520dedf
--- /dev/null
+++ b/mcpgateway/services/plugin_route_service.py
@@ -0,0 +1,977 @@
+# -*- coding: utf-8 -*-
+"""Service for managing plugin routing rules.
+
+Provides an abstraction layer for plugin route management that currently
+uses YAML storage but can be migrated to database storage in the future.
+"""
+
+# Standard
+from contextlib import contextmanager
+import fcntl
+import logging
+from pathlib import Path
+from typing import Any, Optional
+
+# Third-Party
+from sqlalchemy.orm import Session
+import yaml
+
+# First-Party
+from mcpgateway.plugins.framework.models import (
+ Config,
+ EntityType,
+ PluginAttachment,
+ PluginHookRule,
+)
+from mcpgateway.plugins.framework.routing.rule_resolver import (
+ RuleBasedResolver,
+ RuleMatchContext,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class PluginRouteService:
+ """Service for managing plugin routing rules.
+
+ Provides methods to:
+ - Get plugins that apply to an entity
+ - Get entities that a plugin applies to
+ - Add/remove simple routing rules
+ - Save configuration to YAML
+
+ This abstraction layer allows for future migration to database storage.
+ """
+
+ def __init__(self, config_path: Path):
+ """Initialize the plugin route service.
+
+ Args:
+ config_path: Path to the plugin configuration YAML file.
+ """
+ self.config_path = config_path
+ self.resolver = RuleBasedResolver()
+
+ @property
+ def config(self) -> Optional[Config]:
+ """Get config from PluginManager (single source of truth).
+
+ Returns:
+ Plugin configuration from PluginManager, or None if not available.
+ """
+ # First-Party
+ from mcpgateway.plugins.framework import get_plugin_manager
+
+ plugin_manager = get_plugin_manager()
+ return plugin_manager.config if plugin_manager else None
+
+ @contextmanager
+ def _config_write_lock(self):
+ """Context manager for exclusive write access to config file.
+
+ Acquires file lock, reloads config from disk to get latest state,
+ yields for modifications, then caller must save before exit.
+
+ This ensures multi-worker safety:
+ 1. Lock prevents concurrent writes
+ 2. Reload gets latest disk state (including other workers' changes)
+ 3. Modifications happen on fresh state
+ 4. Save persists changes atomically
+
+ Usage:
+ with self._config_write_lock():
+ # modify self.config
+ await self.save_config()
+ """
+ lock_file = self.config_path.with_suffix(".lock")
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(lock_file, "w") as lock_fd:
+ try:
+ # Acquire exclusive lock (blocks until available)
+ fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
+ logger.info(f"Acquired config write lock for {self.config_path}")
+
+ # Reload PluginManager's config from disk to get latest state (critical for multi-worker!)
+ # First-Party
+ from mcpgateway.plugins.framework import get_plugin_manager
+
+ plugin_manager = get_plugin_manager()
+ if plugin_manager:
+ plugin_manager.reload_config()
+ logger.info(f"Reloaded config from disk: {len(self.config.routes) if self.config else 0} routes")
+
+ # Yield to caller for modifications
+ yield
+
+ finally:
+ # Release lock
+ fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
+ logger.info(f"Released config write lock for {self.config_path}")
+
+ async def get_routes_for_entity(
+ self,
+ entity_type: str,
+ entity_name: str,
+ entity_id: Optional[str] = None,
+ tags: Optional[list[str]] = None,
+ server_name: Optional[str] = None,
+ server_id: Optional[str] = None,
+ gateway_id: Optional[str] = None,
+ hook_type: Optional[str] = None,
+ ) -> list[PluginAttachment]:
+ """Get ordered list of plugins that apply to an entity.
+
+ Args:
+ entity_type: Type of entity (tool, prompt, resource, etc.)
+ entity_name: Name of the entity
+ entity_id: Optional entity ID
+ tags: Optional list of entity tags
+ server_name: Optional server name for infrastructure filtering
+ server_id: Optional server ID
+ gateway_id: Optional gateway ID
+ hook_type: Optional hook type to filter by
+
+ Returns:
+ List of PluginAttachment objects in execution order.
+ """
+ if not self.config or not self.config.routes:
+ return []
+
+ # Create context for rule matching
+ context = RuleMatchContext(
+ name=entity_name,
+ entity_type=entity_type,
+ entity_id=entity_id,
+ tags=tags or [],
+ server_name=server_name,
+ server_id=server_id,
+ gateway_id=gateway_id,
+ )
+
+ # Get merge strategy from config
+ merge_strategy = "most_specific"
+ if self.config.plugin_settings:
+ merge_strategy = self.config.plugin_settings.rule_merge_strategy
+
+ # Resolve plugins using the rule resolver
+ plugins = self.resolver.resolve_for_entity(
+ rules=self.config.routes,
+ context=context,
+ hook_type=hook_type,
+ merge_strategy=merge_strategy,
+ )
+
+ return plugins
+
+ async def get_entities_for_plugin(
+ self,
+ plugin_name: str,
+ db: Session,
+ ) -> dict[str, list[str]]:
+ """Get all entities a plugin applies to (by scanning rules).
+
+ Args:
+ plugin_name: Name of the plugin
+ db: Database session (for future DB-based resolution)
+
+ Returns:
+ Dictionary mapping entity types to entity names.
+ Example: {"tools": ["create_customer", "update_customer"], "prompts": [...]}
+ """
+ if not self.config or not self.config.routes:
+ return {}
+
+ entities: dict[str, list[str]] = {}
+
+ # Scan all rules for this plugin
+ for rule in self.config.routes:
+ # Check if this rule includes the plugin
+ plugin_in_rule = any(p.name == plugin_name for p in rule.plugins)
+ if not plugin_in_rule:
+ continue
+
+ # Add entities from this rule
+ if rule.entities:
+ for entity_type in rule.entities:
+ entity_type_str = entity_type.value
+ if entity_type_str not in entities:
+ entities[entity_type_str] = []
+
+ # If rule has specific names, add them
+ if rule.name:
+ if isinstance(rule.name, list):
+ entities[entity_type_str].extend(rule.name)
+ else:
+ entities[entity_type_str].append(rule.name)
+ else:
+ # Rule matches all entities of this type (via tags/when/catch-all)
+ # Mark as "ALL" or fetch from DB in future
+ if "ALL" not in entities[entity_type_str]:
+ entities[entity_type_str].append("ALL")
+
+ return entities
+
+ async def get_matching_rules(
+ self,
+ entity_type: str,
+ entity_name: str,
+ tags: Optional[list[str]] = None,
+ ) -> list[dict[str, Any]]:
+ """Get rules that match an entity (for display in UI).
+
+ Args:
+ entity_type: Type of entity
+ entity_name: Name of entity
+ tags: Optional entity tags
+
+ Returns:
+ List of rule dictionaries with metadata.
+ """
+ if not self.config or not self.config.routes:
+ return []
+
+ matching = []
+ context = RuleMatchContext(
+ name=entity_name,
+ entity_type=entity_type,
+ tags=tags or [],
+ )
+
+ for i, rule in enumerate(self.config.routes):
+ # Check if rule matches (basic check - no when evaluation)
+ if rule.entities:
+ entity_type_enum = EntityType(entity_type) if entity_type in [e.value for e in EntityType] else None
+ if not entity_type_enum or entity_type_enum not in rule.entities:
+ continue
+
+ # Check name filter
+ if rule.name:
+ if isinstance(rule.name, list):
+ if entity_name not in rule.name:
+ continue
+ elif entity_name != rule.name:
+ continue
+
+ # Check tag filter
+ if rule.tags and tags:
+ rule_tags_set = set(rule.tags)
+ entity_tags_set = set(tags)
+ if not rule_tags_set.intersection(entity_tags_set):
+ continue
+
+ # This rule matches
+ matching.append(
+ {
+ "index": i,
+ "entities": [e.value for e in rule.entities] if rule.entities else None,
+ "name": rule.name,
+ "tags": rule.tags,
+ "hooks": rule.hooks,
+ "when": rule.when,
+ "plugins": [p.name for p in rule.plugins],
+ "priority": rule.priority,
+ }
+ )
+
+ return matching
+
+ async def add_simple_route(
+ self,
+ entity_type: str,
+ entity_name: str,
+ plugin_name: str,
+ priority: int = 10,
+ hooks: Optional[list[str]] = None,
+ reverse_order_on_post: bool = False,
+ config: Optional[dict] = None,
+ when: Optional[str] = None,
+ override: bool = False,
+ mode: Optional[str] = None,
+ ) -> None:
+ """Quick-add: Create or update a simple name-based rule.
+
+ Creates ONE rule per entity with multiple plugins:
+ - entities: [tool]
+ name: create_customer
+ hooks: [tool_pre_invoke, tool_post_invoke]
+ plugins:
+ - name: pii_filter
+ priority: 10
+ - name: audit_logger
+ priority: 20
+
+ If a simple rule already exists for this entity, adds the plugin to that rule.
+ This ensures one consolidated rule per entity (not one rule per plugin per entity).
+
+ Thread-safe: Uses file locking to prevent concurrent write conflicts.
+
+ Args:
+ entity_type: Type of entity (tool, prompt, resource, etc.)
+ entity_name: Name of entity to attach plugin to
+ plugin_name: Name of plugin to attach
+ priority: Plugin priority (default: 10)
+ hooks: Optional list of specific hooks to target. If None, defaults to
+ both pre and post hooks for the entity type.
+ reverse_order_on_post: If True, reverse plugin order for post-hooks (wrapping behavior)
+ config: Optional plugin-specific configuration (JSON dict)
+ when: Optional runtime condition expression
+ override: If True, replace inherited config instead of merging
+ mode: Optional execution mode override (normal, passthrough, observe)
+ """
+ with self._config_write_lock():
+ if not self.config:
+ raise RuntimeError("Config not loaded")
+
+ logger.info(f"Adding route: {plugin_name} -> {entity_type}:{entity_name} (current routes: {len(self.config.routes)})")
+
+ entity_type_enum = EntityType(entity_type)
+
+ # Default to both hooks if none specified
+ if not hooks:
+ hooks = self._get_default_hooks(entity_type)
+
+ # Look for existing simple rule for this entity (one rule per entity approach)
+ # A "simple rule" is: exact entity name match, no tags, no when clause
+ existing_simple_rule = None
+ for rule in self.config.routes:
+ if not rule.entities or entity_type_enum not in rule.entities:
+ continue
+ if rule.name != entity_name:
+ continue
+ if rule.tags or rule.when:
+ continue # Skip complex rules - only looking for simple rules
+
+ # Found existing simple rule for this entity
+ existing_simple_rule = rule
+ break
+
+ if existing_simple_rule:
+ # Found a simple rule for this entity - add or update plugin in it
+ plugin_in_rule = None
+ for p in existing_simple_rule.plugins:
+ if p.name == plugin_name:
+ plugin_in_rule = p
+ break
+
+ if plugin_in_rule:
+ # Plugin already exists in this rule - update all configuration
+ plugin_in_rule.priority = priority
+ if config is not None:
+ plugin_in_rule.config = config
+ if when is not None:
+ plugin_in_rule.when = when
+ plugin_in_rule.override = override
+ if mode is not None:
+ plugin_in_rule.mode = mode
+ logger.info(f"Updated plugin configuration in existing rule: {plugin_name} -> {entity_type}:{entity_name}")
+ else:
+ # Plugin doesn't exist - add it to the rule
+ plugin_attachment = PluginAttachment(
+ name=plugin_name,
+ priority=priority,
+ config=config if config is not None else {},
+ when=when,
+ override=override,
+ mode=mode,
+ )
+ existing_simple_rule.plugins.append(plugin_attachment)
+ logger.info(f"Added plugin to existing rule: {plugin_name} -> {entity_type}:{entity_name} (now {len(existing_simple_rule.plugins)} plugins)")
+
+ # Merge hooks if needed
+ if hooks:
+ existing_hooks = set(existing_simple_rule.hooks) if existing_simple_rule.hooks else set()
+ new_hooks = set(hooks)
+ merged_hooks = existing_hooks | new_hooks
+ existing_simple_rule.hooks = list(merged_hooks)
+
+ # Update reverse_order_on_post if specified
+ if reverse_order_on_post:
+ existing_simple_rule.reverse_order_on_post = reverse_order_on_post
+ else:
+ # No existing simple rule for this entity - create new one
+ plugin_attachment = PluginAttachment(
+ name=plugin_name,
+ priority=priority,
+ config=config if config is not None else {},
+ when=when,
+ override=override,
+ mode=mode,
+ )
+ new_rule = PluginHookRule(
+ entities=[entity_type_enum],
+ name=entity_name,
+ hooks=hooks,
+ reverse_order_on_post=reverse_order_on_post,
+ plugins=[plugin_attachment],
+ )
+
+ self.config.routes.append(new_rule)
+ logger.info(f"Created new simple rule: {plugin_name} -> {entity_type}:{entity_name} hooks={hooks}")
+
+ # Save changes within the lock
+ await self.save_config()
+ logger.info(f"Saved config with {len(self.config.routes)} routes")
+
+ def _get_default_hooks(self, entity_type: str) -> list[str]:
+ """Get default hooks for an entity type (both pre and post).
+
+ Args:
+ entity_type: Type of entity (e.g., "tool")
+
+ Returns:
+ List of default hook types for the entity.
+ """
+ hook_pairs = {
+ "tool": ["tool_pre_invoke", "tool_post_invoke"],
+ "prompt": ["prompt_pre_invoke", "prompt_post_invoke"],
+ "resource": ["resource_pre_read", "resource_post_read"],
+ }
+ return hook_pairs.get(entity_type, [])
+
+ async def remove_plugin_from_entity(
+ self,
+ entity_type: str,
+ entity_name: str,
+ plugin_name: str,
+ hook: Optional[str] = None,
+ ) -> bool:
+ """Remove plugin from entity's simple rule.
+
+ With consolidated rules (one rule per entity), this removes the plugin
+ from the entity's rule. If the rule has no plugins left, removes the rule.
+
+ When a specific hook is provided:
+ - Removes that hook from the rule's hooks list
+ - If hooks list becomes empty, removes the entire rule
+
+ When no hook is provided:
+ - Removes the plugin from the rule's plugins list
+ - If no plugins left in the rule, removes the entire rule
+
+ Thread-safe: Uses file locking to prevent concurrent write conflicts.
+
+ Args:
+ entity_type: Type of entity
+ entity_name: Name of entity
+ plugin_name: Name of plugin to remove
+ hook: Optional specific hook to remove (e.g., "tool_pre_invoke").
+ If None, removes plugin from all hooks.
+
+ Returns:
+ True if plugin was removed, False if not found.
+ """
+ with self._config_write_lock():
+ if not self.config or not self.config.routes:
+ return False
+
+ entity_type_enum = EntityType(entity_type)
+ modified = False
+ rules_to_remove = []
+
+ for i, rule in enumerate(self.config.routes):
+ # Only modify simple name-based rules
+ if not rule.entities or entity_type_enum not in rule.entities:
+ continue
+
+ # Must have exact name match
+ if rule.name != entity_name:
+ continue
+
+ # Must not have tags or when (simple rule only)
+ if rule.tags or rule.when:
+ continue
+
+ # Found the simple rule for this entity
+ if hook:
+ # Remove specific hook from the hooks list
+ if rule.hooks and hook in rule.hooks:
+ rule.hooks.remove(hook)
+ modified = True
+ logger.info(f"Removed hook {hook} from rule for {entity_type}:{entity_name}")
+
+ # If no hooks left, mark rule for removal
+ if not rule.hooks:
+ rules_to_remove.append(i)
+ logger.info("Rule has no hooks left, will be removed")
+ else:
+ # No specific hook - remove the plugin from the rule's plugins list
+ plugins_before = len(rule.plugins)
+ rule.plugins = [p for p in rule.plugins if p.name != plugin_name]
+ plugins_after = len(rule.plugins)
+
+ if plugins_before > plugins_after:
+ modified = True
+ logger.info(f"Removed plugin {plugin_name} from rule for {entity_type}:{entity_name} ({plugins_after} plugins remaining)")
+
+ # If no plugins left in the rule, mark rule for removal
+ if not rule.plugins:
+ rules_to_remove.append(i)
+ logger.info("Rule has no plugins left, will be removed")
+
+ # Only process one matching rule per entity
+ break
+
+ # Remove marked rules (in reverse order to maintain indices)
+ for i in reversed(rules_to_remove):
+ del self.config.routes[i]
+
+ if modified:
+ hook_msg = f" from hook {hook}" if hook else ""
+ logger.info(f"Removed {plugin_name}{hook_msg} from {entity_type}:{entity_name}")
+ await self.save_config()
+
+ return modified
+
+ def _get_other_hooks(self, entity_type: str, current_hook: str) -> list[str]:
+ """Get the other hook types for an entity type.
+
+ Args:
+ entity_type: Type of entity (e.g., "tool")
+ current_hook: The hook being removed
+
+ Returns:
+ List of other hook types for the entity.
+ """
+ # Map entity types to their hook pairs
+ hook_pairs = {
+ "tool": ["tool_pre_invoke", "tool_post_invoke"],
+ "prompt": ["prompt_pre_invoke", "prompt_post_invoke"],
+ "resource": ["resource_pre_read", "resource_post_read"],
+ }
+
+ hooks = hook_pairs.get(entity_type, [])
+ return [h for h in hooks if h != current_hook]
+
+ async def change_plugin_priority(
+ self,
+ entity_type: str,
+ entity_name: str,
+ plugin_name: str,
+ hook: str,
+ direction: str,
+ ) -> bool:
+ """Change a plugin's priority (move up or down in execution order).
+
+ Works across multiple rules for the same entity - each plugin may be in its own rule.
+
+ Thread-safe: Uses file locking to prevent concurrent write conflicts.
+
+ Args:
+ entity_type: Type of entity (e.g., "tool")
+ entity_name: Name of entity
+ plugin_name: Name of plugin to move
+ hook: Hook type (e.g., "tool_pre_invoke")
+ direction: "up" (run earlier, lower priority) or "down" (run later, higher priority)
+
+ Returns:
+ True if priority was changed, False if plugin not found or can't be moved.
+ """
+ with self._config_write_lock():
+ if not self.config or not self.config.routes:
+ logger.warning("No config or routes available")
+ return False
+
+ entity_type_enum = EntityType(entity_type)
+
+ # Collect all plugins for this entity across all matching rules
+ # Each entry: (rule_index, plugin_index, plugin_attachment, priority)
+ entity_plugins: list[tuple[int, int, PluginAttachment]] = []
+
+ for rule_idx, rule in enumerate(self.config.routes):
+ # Only check simple name-based rules
+ if not rule.entities or entity_type_enum not in rule.entities:
+ continue
+
+ # Must have exact name match
+ if rule.name != entity_name:
+ continue
+
+ # Must not have tags or when (simple rule only)
+ if rule.tags or rule.when:
+ continue
+
+ # Check hook filter - rule must apply to this hook
+ if rule.hooks and hook not in rule.hooks:
+ continue
+
+ # Collect all plugins from this rule
+ for plugin_idx, plugin in enumerate(rule.plugins):
+ # Ensure priority is set
+ if plugin.priority is None:
+ plugin.priority = 10
+ entity_plugins.append((rule_idx, plugin_idx, plugin))
+
+ logger.info(f"Found {len(entity_plugins)} plugins for {entity_type}:{entity_name} hook={hook}")
+
+ if not entity_plugins:
+ logger.warning(f"No plugins found for {entity_type}:{entity_name}")
+ return False
+
+ # Sort by priority
+ entity_plugins.sort(key=lambda x: x[2].priority or 0)
+
+ # Find the target plugin
+ target_idx = None
+ for i, (rule_idx, plugin_idx, plugin) in enumerate(entity_plugins):
+ if plugin.name == plugin_name:
+ target_idx = i
+ break
+
+ if target_idx is None:
+ logger.warning(f"Plugin {plugin_name} not found in entity plugins list")
+ return False
+
+ # Only one plugin - can't move
+ if len(entity_plugins) == 1:
+ logger.info(f"Only one plugin for {entity_type}:{entity_name}, cannot move")
+ return False
+
+ if direction == "up":
+ if target_idx == 0:
+ logger.info(f"Plugin {plugin_name} is already first")
+ return False # Already first
+ # Swap with previous plugin
+ swap_idx = target_idx - 1
+ elif direction == "down":
+ if target_idx == len(entity_plugins) - 1:
+ logger.info(f"Plugin {plugin_name} is already last")
+ return False # Already last
+ # Swap with next plugin
+ swap_idx = target_idx + 1
+ else:
+ return False # Invalid direction
+
+ # Get the two plugins to swap
+ target_rule_idx, target_plugin_idx, target_plugin = entity_plugins[target_idx]
+ swap_rule_idx, swap_plugin_idx, swap_plugin = entity_plugins[swap_idx]
+
+ # Swap their priorities
+ target_priority = target_plugin.priority
+ swap_priority = swap_plugin.priority
+
+ # If priorities are equal, adjust them to make the swap work
+ if target_priority == swap_priority:
+ if direction == "up":
+ target_plugin.priority = swap_priority - 1
+ else:
+ target_plugin.priority = swap_priority + 1
+ else:
+ target_plugin.priority = swap_priority
+ swap_plugin.priority = target_priority
+
+ logger.info(f"Swapped priority of {plugin_name} ({target_priority} -> {target_plugin.priority}) " f"with {swap_plugin.name} ({swap_priority} -> {swap_plugin.priority})")
+
+ # Save changes within the lock
+ await self.save_config()
+ return True
+
+ async def update_plugin_priority(
+ self,
+ entity_type: str,
+ entity_name: str,
+ plugin_name: str,
+ new_priority: int,
+ ) -> bool:
+ """Update a plugin's priority to an absolute value.
+
+ Works across multiple rules for the same entity - updates all instances of the plugin.
+
+ Thread-safe: Uses file locking to prevent concurrent write conflicts.
+
+ Args:
+ entity_type: Type of entity (e.g., "tool")
+ entity_name: Name of entity
+ plugin_name: Name of plugin to update
+ new_priority: New priority value to set
+
+ Returns:
+ True if priority was updated, False if plugin not found.
+ """
+ with self._config_write_lock():
+ if not self.config or not self.config.routes:
+ logger.warning("No config or routes available")
+ return False
+
+ entity_type_enum = EntityType(entity_type)
+ updated = False
+
+ # Find all rules matching this entity
+ for rule in self.config.routes:
+ # Only check simple name-based rules
+ if not rule.entities or entity_type_enum not in rule.entities:
+ continue
+
+ # Must have exact name match
+ if rule.name != entity_name:
+ continue
+
+ # Must not have tags or when (simple rule only)
+ if rule.tags or rule.when:
+ continue
+
+ # Update the plugin priority in this rule
+ for plugin in rule.plugins:
+ if plugin.name == plugin_name:
+ old_priority = plugin.priority
+ plugin.priority = new_priority
+ logger.info(f"Updated priority for {plugin_name} on {entity_type}:{entity_name}: {old_priority} -> {new_priority}")
+ updated = True
+
+ if not updated:
+ logger.warning(f"Plugin {plugin_name} not found for {entity_type}:{entity_name}")
+ return False
+
+ # Save changes within the lock
+ await self.save_config()
+ return True
+
+ async def toggle_reverse_post_hooks(
+ self,
+ entity_type: str,
+ entity_name: str,
+ ) -> bool:
+ """Toggle reverse_order_on_post for all rules of an entity.
+
+ Thread-safe: Uses file locking to prevent concurrent write conflicts.
+
+ Args:
+ entity_type: Type of entity (e.g., "tool")
+ entity_name: Name of entity
+
+ Returns:
+ The new state (True if now reversed, False if normal order).
+ """
+ with self._config_write_lock():
+ if not self.config or not self.config.routes:
+ return False
+
+ # Note: use_enum_values=True causes rule.entities to contain strings, not enums
+ # Find all rules for this entity and check current state
+ matching_rules = []
+ current_state = False
+
+ for rule in self.config.routes:
+ if not rule.entities or entity_type not in rule.entities:
+ continue
+ if rule.name != entity_name:
+ continue
+ if rule.tags or rule.when:
+ continue
+
+ matching_rules.append(rule)
+ # Use first rule's state as the current state
+ if not matching_rules[1:]: # First rule
+ current_state = rule.reverse_order_on_post or False
+
+ # Toggle to opposite state
+ new_state = not current_state
+
+ # Update all matching rules
+ for rule in matching_rules:
+ rule.reverse_order_on_post = new_state
+
+ logger.info(f"Toggled reverse_order_on_post to {new_state} for {entity_type}:{entity_name} ({len(matching_rules)} rules)")
+
+ # Save changes within the lock
+ await self.save_config()
+ return new_state
+
+ def get_reverse_post_hooks_state(
+ self,
+ entity_type: str,
+ entity_name: str,
+ ) -> bool:
+ """Get the current reverse_order_on_post state for an entity.
+
+ Args:
+ entity_type: Type of entity (e.g., "tool")
+ entity_name: Name of entity
+
+ Returns:
+ True if reverse order is enabled, False otherwise.
+ """
+ if not self.config or not self.config.routes:
+ return False
+
+ # Note: use_enum_values=True causes rule.entities to contain strings, not enums
+ for rule in self.config.routes:
+ if not rule.entities or entity_type not in rule.entities:
+ continue
+ if rule.name != entity_name:
+ continue
+ if rule.tags or rule.when:
+ continue
+
+ # Return state from first matching rule
+ return rule.reverse_order_on_post or False
+
+ return False
+
+ async def get_rule(self, index: int) -> Optional[PluginHookRule]:
+ """Get a single routing rule by index.
+
+ Args:
+ index: Index of the rule in the routes list.
+
+ Returns:
+ The PluginHookRule at the given index, or None if not found.
+ """
+ if not self.config or not self.config.routes:
+ return None
+
+ if index < 0 or index >= len(self.config.routes):
+ return None
+
+ return self.config.routes[index]
+
+ async def add_or_update_rule(
+ self,
+ rule: PluginHookRule,
+ index: Optional[int] = None,
+ ) -> int:
+ """Add a new routing rule or update an existing one.
+
+ Thread-safe: Uses file locking to prevent concurrent write conflicts.
+
+ Args:
+ rule: The PluginHookRule to add or update.
+ index: If provided, updates the rule at this index. Otherwise, adds a new rule.
+
+ Returns:
+ The index of the added/updated rule.
+
+ Raises:
+ ValueError: If the index is out of range.
+ """
+ with self._config_write_lock():
+ if not self.config:
+ self.config = Config(routes=[])
+
+ if index is not None:
+ # Update existing rule
+ if index < 0 or index >= len(self.config.routes):
+ raise ValueError(f"Rule index {index} is out of range")
+ self.config.routes[index] = rule
+ logger.info(f"Updated routing rule at index {index}: {rule.name if hasattr(rule, 'name') else 'unnamed'}")
+ result_index = index
+ else:
+ # Add new rule
+ self.config.routes.append(rule)
+ new_index = len(self.config.routes) - 1
+ logger.info(f"Added new routing rule at index {new_index}: {rule.name if hasattr(rule, 'name') else 'unnamed'}")
+ result_index = new_index
+
+ await self.save_config()
+ return result_index
+
+ async def delete_rule(self, index: int) -> bool:
+ """Delete a routing rule by index.
+
+ Thread-safe: Uses file locking to prevent concurrent write conflicts.
+
+ Args:
+ index: Index of the rule to delete.
+
+ Returns:
+ True if the rule was deleted, False otherwise.
+
+ Raises:
+ ValueError: If the index is out of range.
+ """
+ with self._config_write_lock():
+ if not self.config or not self.config.routes:
+ return False
+
+ if index < 0 or index >= len(self.config.routes):
+ raise ValueError(f"Rule index {index} is out of range")
+
+ deleted_rule = self.config.routes.pop(index)
+ logger.info(f"Deleted routing rule at index {index}: {deleted_rule.name if hasattr(deleted_rule, 'name') else 'unnamed'}")
+
+ await self.save_config()
+ return True
+
+ async def save_config(self) -> None:
+ """Save configuration to YAML file.
+
+ Performs atomic write with backup to prevent data loss.
+ Also clears the PluginManager routing cache to ensure changes take effect immediately.
+ """
+ if not self.config:
+ logger.warning("No config to save")
+ return
+
+ try:
+ # Create backup
+ backup_path = self.config_path.with_suffix(".yaml.bak")
+ if self.config_path.exists():
+ # Standard
+ import shutil
+
+ shutil.copy2(self.config_path, backup_path)
+
+ # Convert config to dict for YAML serialization
+ # use_enum_values=True in Config model ensures enums are converted to strings
+ data = self.config.model_dump(by_alias=True, exclude_none=True)
+
+ # Write to temp file first (atomic write)
+ temp_path = self.config_path.with_suffix(".yaml.tmp")
+ with open(temp_path, "w", encoding="utf-8") as f:
+ # Use safe_dump to prevent Python object serialization
+ yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
+
+ # Atomic rename
+ temp_path.replace(self.config_path)
+
+ logger.info(f"Saved plugin config to {self.config_path}")
+
+ # Reload PluginManager config so changes take effect immediately
+ # (PluginRouteService uses PluginManager's config via property, so no separate reload needed)
+ # First-Party
+ from mcpgateway.plugins.framework import get_plugin_manager
+
+ plugin_manager = get_plugin_manager()
+ if plugin_manager:
+ plugin_manager.reload_config()
+ logger.info("Reloaded PluginManager config after save")
+
+ except Exception as e:
+ logger.error(f"Failed to save plugin config: {e}")
+ # Restore from backup if it exists
+ if backup_path.exists():
+ # Standard
+ import shutil
+
+ shutil.copy2(backup_path, self.config_path)
+ logger.info("Restored from backup")
+ raise
+
+
+# Global instance (initialized in main.py)
+_plugin_route_service: Optional[PluginRouteService] = None
+
+
+def init_plugin_route_service(config_path: Path) -> None:
+ """Initialize the global plugin route service.
+
+ Args:
+ config_path: Path to plugin configuration file.
+ """
+ global _plugin_route_service
+ logger.info(f"=== INIT PLUGIN ROUTE SERVICE === path={config_path}")
+ _plugin_route_service = PluginRouteService(config_path)
+
+
+def get_plugin_route_service() -> PluginRouteService:
+ """Get the global plugin route service instance.
+
+ Returns:
+ PluginRouteService instance.
+
+ Raises:
+ RuntimeError: If service not initialized.
+ """
+ if _plugin_route_service is None:
+ raise RuntimeError("PluginRouteService not initialized. Call init_plugin_route_service() first.")
+ return _plugin_route_service
diff --git a/mcpgateway/services/prompt_service.py b/mcpgateway/services/prompt_service.py
index 456ed2461..964b9e5ea 100644
--- a/mcpgateway/services/prompt_service.py
+++ b/mcpgateway/services/prompt_service.py
@@ -746,34 +746,6 @@ async def get_prompt(
else:
prompt_id_int = prompt_id
- if self._plugin_manager:
- if not request_id:
- request_id = uuid.uuid4().hex
- global_context = GlobalContext(request_id=request_id, user=user, server_id=server_id, tenant_id=tenant_id)
- pre_result, context_table = await self._plugin_manager.invoke_hook(
- PromptHookType.PROMPT_PRE_FETCH,
- payload=PromptPrehookPayload(prompt_id=str(prompt_id), args=arguments),
- global_context=global_context,
- local_contexts=None,
- violations_as_exceptions=True,
- )
-
- # Use modified payload if provided
- if pre_result.modified_payload:
- payload = pre_result.modified_payload
- # Re-parse the modified prompt_id
- if isinstance(payload.prompt_id, int):
- prompt_id_int = payload.prompt_id
- prompt_name = None
- elif isinstance(payload.prompt_id, str):
- try:
- prompt_id_int = int(payload.prompt_id)
- prompt_name = None
- except ValueError:
- prompt_name = payload.prompt_id
- prompt_id_int = None
- arguments = payload.args
-
# Find prompt by ID or name
if prompt_id_int is not None:
prompt = db.execute(select(DbPrompt).where(DbPrompt.id == prompt_id_int).where(DbPrompt.is_active)).scalar_one_or_none()
@@ -796,6 +768,33 @@ async def get_prompt(
raise PromptNotFoundError(f"Prompt not found: {search_key}")
+ # Now that we have the prompt, invoke plugin hooks with entity context
+ context_table = None
+ if self._plugin_manager:
+ if not request_id:
+ request_id = uuid.uuid4().hex
+ global_context = GlobalContext(
+ request_id=request_id,
+ user=user,
+ server_id=server_id,
+ tenant_id=tenant_id,
+ entity_type="prompt",
+ entity_id=str(prompt.id),
+ entity_name=prompt.name,
+ )
+ pre_result, context_table = await self._plugin_manager.invoke_hook(
+ PromptHookType.PROMPT_PRE_FETCH,
+ payload=PromptPrehookPayload(prompt_id=str(prompt.id), args=arguments),
+ global_context=global_context,
+ local_contexts=None,
+ violations_as_exceptions=True,
+ )
+
+ # Use modified payload if provided
+ if pre_result.modified_payload:
+ payload = pre_result.modified_payload
+ arguments = payload.args
+
if not arguments:
result = PromptResult(
messages=[
diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py
index 97ea6e250..d1b39f031 100644
--- a/mcpgateway/services/resource_service.py
+++ b/mcpgateway/services/resource_service.py
@@ -806,6 +806,7 @@ async def read_resource(self, db: Session, resource_id: Union[int, str], request
# Original resource fetching logic
logger.info(f"Fetching resource: {resource_id} (URI: {uri})")
# Check for template
+ resource = None
if uri is not None and "{" in uri and "}" in uri:
content = await self._read_template_resource(uri)
else:
@@ -821,6 +822,12 @@ async def read_resource(self, db: Session, resource_id: Union[int, str], request
content = resource.content
+ # Populate entity context now that we have the resource
+ if plugin_eligible and resource:
+ global_context.entity_type = "resource"
+ global_context.entity_id = str(resource.id)
+ global_context.entity_name = resource.name
+
# Call post-fetch hooks if plugin manager is available
if plugin_eligible:
# Create post-fetch payload
diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py
index dee42c271..350236d03 100644
--- a/mcpgateway/services/tool_service.py
+++ b/mcpgateway/services/tool_service.py
@@ -1135,7 +1135,14 @@ async def invoke_tool(self, db: Session, name: str, arguments: Dict[str, Any], r
# Use gateway_id if available, otherwise use a generic server identifier
gateway_id = getattr(tool, "gateway_id", "unknown")
server_id = gateway_id if isinstance(gateway_id, str) else "unknown"
- global_context = GlobalContext(request_id=request_id, server_id=server_id, tenant_id=None)
+ global_context = GlobalContext(
+ request_id=request_id,
+ server_id=server_id,
+ tenant_id=None,
+ entity_type="tool",
+ entity_id=str(tool.id),
+ entity_name=tool.name,
+ )
start_time = time.monotonic()
success = False
diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js
index fefd09f6e..96a63bb94 100644
--- a/mcpgateway/static/admin.js
+++ b/mcpgateway/static/admin.js
@@ -5494,6 +5494,15 @@ function showTab(tabName) {
const panel = safeGetElement(`${tabName}-panel`);
if (panel) {
panel.classList.remove("hidden");
+
+ // Initialize Alpine.js for the panel if it hasn't been initialized yet
+ if (typeof Alpine !== 'undefined' && panel.__x === undefined) {
+ // Remove x-ignore attribute if present so Alpine can initialize
+ if (panel.hasAttribute('x-ignore')) {
+ panel.removeAttribute('x-ignore');
+ }
+ Alpine.initTree(panel);
+ }
} else {
console.error(`Panel ${tabName}-panel not found`);
return;
@@ -5692,7 +5701,7 @@ function showTab(tabName) {
if (tabName === "plugins") {
const pluginsPanel = safeGetElement("plugins-panel");
- if (pluginsPanel && pluginsPanel.innerHTML.trim() === "") {
+ if (pluginsPanel) {
const rootPath = window.ROOT_PATH || "";
fetchWithTimeout(
`${rootPath}/admin/plugins/partial`,
diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html
index 3d1b8feb5..579860858 100644
--- a/mcpgateway/templates/admin.html
+++ b/mcpgateway/templates/admin.html
@@ -48,6 +48,26 @@
+
+
+