From d800a8faa119158b4ed57974802239be97ae02b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Jan 2026 19:51:12 +0000 Subject: [PATCH 1/2] Add backend adapter pattern for multi-gateway UI support Introduces a backend adapter layer that allows the demo UI to work with multiple gateway implementations: - mcp-proxy (Python gateway, default) - agentgateway (Rust gateway) New file: demo/ui/backend.py - GatewayBackend abstract base class defining the interface - MCPProxyBackend: adapter for current Python mcp-proxy gateway - Uses JSON-RPC over /mcp/ with session management - Health check via /status - OAuth via /oauth/connect - AgentGatewayBackend: adapter for Rust agentgateway - Uses /config for health, /registry for tool definitions - Supports loading registry from gateway backend - Helper functions for registry format conversion between backends - load_registry_from_file() extracted from main.py Updated: demo/ui/main.py - Import and initialize backend based on GATEWAY_BACKEND env var - Replace direct gateway calls with backend adapter methods: - check_gateway_health() -> gateway_backend.check_health() - call_tool() -> gateway_backend.call_tool() - OAuth connect -> gateway_backend.connect_oauth() - Add "From Gateway" option in registry selector for agentgateway - Auto-load registry from agentgateway backend on startup - Display backend name in status badge (header) Configuration: - GATEWAY_BACKEND: "mcp-proxy" (default) or "agentgateway" - GATEWAY_URL: Backend URL (defaults vary by backend type) This enables the same UI codebase to be used with either the Python mcp-proxy or Rust agentgateway backend implementations. --- demo/ui/backend.py | 516 +++++++++++++++++++++++++++++++++++++++++++++ demo/ui/main.py | 182 ++++++---------- 2 files changed, 586 insertions(+), 112 deletions(-) create mode 100644 demo/ui/backend.py diff --git a/demo/ui/backend.py b/demo/ui/backend.py new file mode 100644 index 00000000..04794bc6 --- /dev/null +++ b/demo/ui/backend.py @@ -0,0 +1,516 @@ +"""Backend adapters for different MCP Gateway implementations. + +This module provides an abstraction layer that allows the UI to work with +different backend gateway implementations: +- MCPProxyBackend: The Python-based mcp-proxy gateway +- AgentGatewayBackend: The Rust-based agentgateway + +Usage: + Set GATEWAY_BACKEND environment variable to "mcp-proxy" or "agentgateway" + Default is "mcp-proxy" for backward compatibility. +""" + +import os +import json +import logging +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + + +class GatewayBackend(ABC): + """Abstract interface for gateway backends.""" + + def __init__(self, gateway_url: str): + self.gateway_url = gateway_url + + @abstractmethod + async def check_health(self) -> bool: + """Check if the gateway is healthy and reachable.""" + pass + + @abstractmethod + async def call_tool(self, tool_name: str, arguments: dict) -> dict: + """Execute a tool on the gateway.""" + pass + + @abstractmethod + async def list_tools(self) -> list[dict]: + """List available tools from the gateway.""" + pass + + @abstractmethod + async def get_registry(self) -> Optional[dict]: + """Get the current registry/configuration from the gateway.""" + pass + + @abstractmethod + async def connect_oauth(self, server_url: str, access_token: str) -> bool: + """Establish OAuth connection for a remote server.""" + pass + + @property + @abstractmethod + def backend_name(self) -> str: + """Human-readable name of this backend.""" + pass + + +class MCPProxyBackend(GatewayBackend): + """Backend adapter for the Python mcp-proxy gateway. + + Uses JSON-RPC over HTTP at /mcp/ endpoint with session management. + """ + + @property + def backend_name(self) -> str: + return "MCP Proxy (Python)" + + async def check_health(self) -> bool: + """Check gateway health via /status endpoint.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get(f"{self.gateway_url}/status", timeout=2.0) + return resp.status_code == 200 + except Exception: + return False + + async def call_tool(self, tool_name: str, arguments: dict) -> dict: + """Call a tool via MCP JSON-RPC protocol.""" + mcp_headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream" + } + + try: + async with httpx.AsyncClient() as client: + # Initialize session + init_resp = await client.post( + f"{self.gateway_url}/mcp/", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "demo-ui", "version": "1.0.0"} + } + }, + headers=mcp_headers, + timeout=30.0 + ) + session_id = init_resp.headers.get("mcp-session-id", "") + + # Send initialized notification + await client.post( + f"{self.gateway_url}/mcp/", + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + headers={**mcp_headers, "Mcp-Session-Id": session_id}, + timeout=5.0 + ) + + # Call the tool + resp = await client.post( + f"{self.gateway_url}/mcp/", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments} + }, + headers={**mcp_headers, "Mcp-Session-Id": session_id}, + timeout=60.0 + ) + return resp.json() + except Exception as e: + return {"error": str(e)} + + async def list_tools(self) -> list[dict]: + """List tools via MCP JSON-RPC protocol.""" + mcp_headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream" + } + + try: + async with httpx.AsyncClient() as client: + # Initialize session + init_resp = await client.post( + f"{self.gateway_url}/mcp/", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "demo-ui", "version": "1.0.0"} + } + }, + headers=mcp_headers, + timeout=30.0 + ) + session_id = init_resp.headers.get("mcp-session-id", "") + + # Send initialized notification + await client.post( + f"{self.gateway_url}/mcp/", + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + headers={**mcp_headers, "Mcp-Session-Id": session_id}, + timeout=5.0 + ) + + # List tools + resp = await client.post( + f"{self.gateway_url}/mcp/", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }, + headers={**mcp_headers, "Mcp-Session-Id": session_id}, + timeout=30.0 + ) + result = resp.json() + return result.get("result", {}).get("tools", []) + except Exception as e: + logger.error(f"Failed to list tools: {e}") + return [] + + async def get_registry(self) -> Optional[dict]: + """MCP Proxy doesn't have a registry endpoint - returns None. + + The UI loads registries from local JSON files for mcp-proxy. + """ + return None + + async def connect_oauth(self, server_url: str, access_token: str) -> bool: + """Establish OAuth connection via /oauth/connect endpoint.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self.gateway_url}/oauth/connect", + json={"server_url": server_url, "token": access_token}, + timeout=30.0 + ) + if resp.status_code == 200: + logger.info(f"Gateway connection established for {server_url}") + return True + else: + logger.warning(f"Gateway connection failed: {resp.text}") + return False + except Exception as e: + logger.warning(f"Failed to establish gateway connection: {e}") + return False + + +class AgentGatewayBackend(GatewayBackend): + """Backend adapter for the Rust agentgateway. + + Uses REST API at /config endpoint and MCP via SSE transport. + """ + + @property + def backend_name(self) -> str: + return "Agent Gateway (Rust)" + + async def check_health(self) -> bool: + """Check gateway health via /config endpoint.""" + try: + async with httpx.AsyncClient() as client: + # agentgateway uses /config endpoint + resp = await client.get(f"{self.gateway_url}/config", timeout=2.0) + return resp.status_code == 200 + except Exception: + return False + + async def call_tool(self, tool_name: str, arguments: dict) -> dict: + """Call a tool via agentgateway's MCP endpoint. + + Agentgateway exposes MCP over SSE, similar to mcp-proxy but may + have different endpoint paths depending on configuration. + """ + # Try the standard MCP path first (port 3000 default for MCP in agentgateway) + mcp_url = self.gateway_url + + mcp_headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream" + } + + try: + async with httpx.AsyncClient() as client: + # Initialize session - agentgateway uses similar MCP protocol + init_resp = await client.post( + f"{mcp_url}/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "demo-ui", "version": "1.0.0"} + } + }, + headers=mcp_headers, + timeout=30.0 + ) + session_id = init_resp.headers.get("mcp-session-id", "") + + # Send initialized notification + await client.post( + f"{mcp_url}/mcp", + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + headers={**mcp_headers, "Mcp-Session-Id": session_id}, + timeout=5.0 + ) + + # Call the tool + resp = await client.post( + f"{mcp_url}/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments} + }, + headers={**mcp_headers, "Mcp-Session-Id": session_id}, + timeout=60.0 + ) + return resp.json() + except Exception as e: + return {"error": str(e)} + + async def list_tools(self) -> list[dict]: + """List tools via agentgateway's MCP endpoint.""" + mcp_url = self.gateway_url + + mcp_headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream" + } + + try: + async with httpx.AsyncClient() as client: + # Initialize session + init_resp = await client.post( + f"{mcp_url}/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "demo-ui", "version": "1.0.0"} + } + }, + headers=mcp_headers, + timeout=30.0 + ) + session_id = init_resp.headers.get("mcp-session-id", "") + + # Send initialized notification + await client.post( + f"{mcp_url}/mcp", + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + headers={**mcp_headers, "Mcp-Session-Id": session_id}, + timeout=5.0 + ) + + # List tools + resp = await client.post( + f"{mcp_url}/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }, + headers={**mcp_headers, "Mcp-Session-Id": session_id}, + timeout=30.0 + ) + result = resp.json() + return result.get("result", {}).get("tools", []) + except Exception as e: + logger.error(f"Failed to list tools: {e}") + return [] + + async def get_registry(self) -> Optional[dict]: + """Get registry from agentgateway's /registry endpoint.""" + try: + async with httpx.AsyncClient() as client: + resp = await client.get(f"{self.gateway_url}/registry", timeout=10.0) + if resp.status_code == 200: + return resp.json() + elif resp.status_code == 404: + # No registry configured + return None + else: + logger.warning(f"Failed to fetch registry: {resp.status_code}") + return None + except Exception as e: + logger.error(f"Error fetching registry: {e}") + return None + + async def connect_oauth(self, server_url: str, access_token: str) -> bool: + """Establish OAuth connection. + + Agentgateway may handle OAuth differently - this is a placeholder + that can be extended based on actual agentgateway OAuth implementation. + """ + # For now, agentgateway handles OAuth at the route/policy level + # This may need to be updated based on agentgateway's actual OAuth flow + logger.info(f"OAuth connection for agentgateway: {server_url}") + return True + + +def get_backend(gateway_url: Optional[str] = None) -> GatewayBackend: + """Factory function to get the appropriate backend based on configuration. + + Environment variables: + GATEWAY_BACKEND: "mcp-proxy" (default) or "agentgateway" + GATEWAY_URL: URL of the gateway (default varies by backend) + """ + backend_type = os.environ.get("GATEWAY_BACKEND", "mcp-proxy").lower() + + if gateway_url is None: + if backend_type == "agentgateway": + gateway_url = os.environ.get("GATEWAY_URL", "http://localhost:15000") + else: + gateway_url = os.environ.get("GATEWAY_URL", "http://localhost:8080") + + if backend_type == "agentgateway": + logger.info(f"Using AgentGateway backend at {gateway_url}") + return AgentGatewayBackend(gateway_url) + else: + logger.info(f"Using MCPProxy backend at {gateway_url}") + return MCPProxyBackend(gateway_url) + + +# Registry helpers that work with both backends + +def load_registry_from_file(path: str) -> dict: + """Load a registry JSON file and resolve schema references and source inheritance. + + This is used for local registry files (mcp-proxy style). + """ + try: + with open(path) as f: + registry = json.load(f) + + tools = registry.get("tools", []) + schemas = registry.get("schemas", {}) + + # Build lookup by name + tools_by_name = {t.get("name"): t for t in tools} + + # Resolve $ref in inputSchema for each tool + for tool in tools: + input_schema = tool.get("inputSchema", {}) + if isinstance(input_schema, dict) and "$ref" in input_schema: + ref = input_schema["$ref"] + if ref.startswith("#/schemas/"): + schema_name = ref.split("/")[-1] + if schema_name in schemas: + tool["inputSchema"] = schemas[schema_name].copy() + + # Inherit inputSchema from source for virtual tools + for tool in tools: + source_name = tool.get("source") + if source_name and "inputSchema" not in tool: + # Find the root source (follow chain) + source_tool = tools_by_name.get(source_name) + while source_tool and source_tool.get("source"): + source_tool = tools_by_name.get(source_tool["source"]) + + if source_tool and "inputSchema" in source_tool: + tool["inputSchema"] = source_tool["inputSchema"].copy() + + return registry + except Exception as e: + return {"error": str(e), "tools": []} + + +def convert_agentgateway_registry(ag_registry: dict) -> dict: + """Convert agentgateway registry format to mcp-proxy format for UI compatibility. + + Agentgateway registry format (from Rust): + { + "schema_version": "1.0", + "tools": [ + { + "name": "tool_name", + "source": {"target": "backend", "tool": "original_name"}, + "description": "...", + "input_schema": {...}, + "defaults": {...}, + "hide_fields": [...], + "output_schema": {...}, + "version": "1.0.0", + "metadata": {...} + } + ] + } + + MCP-proxy registry format: + { + "tools": [ + { + "name": "tool_name", + "source": "original_name", # string, not object + "description": "...", + "inputSchema": {...}, # camelCase + "defaults": {...}, + "hide_fields": [...], + "outputSchema": {...}, # camelCase + "server": {"command": "...", "url": "..."} # backend info + } + ] + } + """ + if not ag_registry: + return {"tools": []} + + converted_tools = [] + for tool in ag_registry.get("tools", []): + converted = { + "name": tool.get("name"), + "description": tool.get("description"), + } + + # Convert source object to string + server info + source = tool.get("source", {}) + if isinstance(source, dict): + converted["source"] = source.get("tool") + # Store target as virtual server reference + converted["server"] = {"target": source.get("target")} + elif isinstance(source, str): + converted["source"] = source + + # Convert snake_case to camelCase for schemas + if tool.get("input_schema"): + converted["inputSchema"] = tool["input_schema"] + if tool.get("output_schema"): + converted["outputSchema"] = tool["output_schema"] + + # Copy other fields as-is + if tool.get("defaults"): + converted["defaults"] = tool["defaults"] + if tool.get("hide_fields"): + converted["hide_fields"] = tool["hide_fields"] + if tool.get("version"): + converted["version"] = tool["version"] + if tool.get("metadata"): + converted["metadata"] = tool["metadata"] + + converted_tools.append(converted) + + return {"tools": converted_tools} diff --git a/demo/ui/main.py b/demo/ui/main.py index 9f6499d5..2693659e 100644 --- a/demo/ui/main.py +++ b/demo/ui/main.py @@ -2,6 +2,10 @@ A web interface for exploring MCP Gateway tool registries, testing tools interactively, and chatting with an AI agent. + +Supports multiple gateway backends via GATEWAY_BACKEND env var: +- "mcp-proxy" (default): Python-based mcp-proxy gateway +- "agentgateway": Rust-based agentgateway """ import os @@ -29,13 +33,21 @@ discover_oauth_metadata, register_client, OAuthFlow, store_pending_flow, get_pending_flow ) +from backend import ( + get_backend, load_registry_from_file, convert_agentgateway_registry, + GatewayBackend +) # Configuration SCRIPT_DIR = Path(__file__).parent GATEWAY_URL = os.environ.get("GATEWAY_URL", "http://localhost:8080") +GATEWAY_BACKEND_TYPE = os.environ.get("GATEWAY_BACKEND", "mcp-proxy").lower() REGISTRIES_DIR = Path(os.environ.get("REGISTRIES_DIR", SCRIPT_DIR.parent / "registries")) ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "") +# Initialize backend +gateway_backend: GatewayBackend = get_backend(GATEWAY_URL) + # FastHTML app setup with dark theme hdrs = Theme.slate.headers(mode='dark') + [ # HTMX for interactivity @@ -60,41 +72,7 @@ def load_registry(path: str) -> dict: """Load a registry JSON file and resolve schema references and source inheritance.""" - try: - with open(path) as f: - registry = json.load(f) - - tools = registry.get("tools", []) - schemas = registry.get("schemas", {}) - - # Build lookup by name - tools_by_name = {t.get("name"): t for t in tools} - - # Resolve $ref in inputSchema for each tool - for tool in tools: - input_schema = tool.get("inputSchema", {}) - if isinstance(input_schema, dict) and "$ref" in input_schema: - ref = input_schema["$ref"] - if ref.startswith("#/schemas/"): - schema_name = ref.split("/")[-1] - if schema_name in schemas: - tool["inputSchema"] = schemas[schema_name].copy() - - # Inherit inputSchema from source for virtual tools - for tool in tools: - source_name = tool.get("source") - if source_name and "inputSchema" not in tool: - # Find the root source (follow chain) - source_tool = tools_by_name.get(source_name) - while source_tool and source_tool.get("source"): - source_tool = tools_by_name.get(source_tool["source"]) - - if source_tool and "inputSchema" in source_tool: - tool["inputSchema"] = source_tool["inputSchema"].copy() - - return registry - except Exception as e: - return {"error": str(e), "tools": []} + return load_registry_from_file(path) def list_registries() -> list: @@ -148,12 +126,7 @@ def get_unique_servers() -> list: async def check_gateway_health() -> bool: """Check if gateway is healthy.""" - try: - async with httpx.AsyncClient() as client: - resp = await client.get(f"{GATEWAY_URL}/status", timeout=2.0) - return resp.status_code == 200 - except Exception: - return False + return await gateway_backend.check_health() async def call_tool(tool_name: str, arguments: dict) -> dict: @@ -163,55 +136,7 @@ async def call_tool(tool_name: str, arguments: dict) -> dict: tool_name: Name of the tool to call arguments: Tool arguments """ - # Common headers for MCP requests - mcp_headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream" - } - - try: - async with httpx.AsyncClient() as client: - # Initialize session - init_resp = await client.post( - f"{GATEWAY_URL}/mcp/", - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "demo-ui", "version": "1.0.0"} - } - }, - headers=mcp_headers, - timeout=30.0 - ) - session_id = init_resp.headers.get("mcp-session-id", "") - - # Send initialized notification - await client.post( - f"{GATEWAY_URL}/mcp/", - json={"jsonrpc": "2.0", "method": "notifications/initialized"}, - headers={**mcp_headers, "Mcp-Session-Id": session_id}, - timeout=5.0 - ) - - # Call the tool - resp = await client.post( - f"{GATEWAY_URL}/mcp/", - json={ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments} - }, - headers={**mcp_headers, "Mcp-Session-Id": session_id}, - timeout=60.0 - ) - return resp.json() - except Exception as e: - return {"error": str(e)} + return await gateway_backend.call_tool(tool_name, arguments) # Routes @@ -223,6 +148,19 @@ async def index(request: Request): # Load default registry if not loaded registries = list_registries() + + # For agentgateway, try to load registry from backend first + if not current_registry_path and GATEWAY_BACKEND_TYPE == "agentgateway": + try: + ag_registry = await gateway_backend.get_registry() + if ag_registry: + current_registry = convert_agentgateway_registry(ag_registry) + current_registry_path = "(from gateway)" + logger.info("Loaded registry from agentgateway") + except Exception as e: + logger.warning(f"Failed to load registry from agentgateway: {e}") + + # Fall back to local registry files if not current_registry_path and registries: # Prefer showcase.json as default, otherwise use first available default_reg = "showcase.json" if "showcase.json" in registries else registries[0] @@ -243,14 +181,27 @@ def get_tool_oauth_status(tool: dict) -> tuple[bool, bool]: return (False, False) # Registry selector options - registry_options = [ + registry_options = [] + + # Add "From Gateway" option for agentgateway backend + if GATEWAY_BACKEND_TYPE == "agentgateway": + registry_options.append( + Option( + "From Gateway", + value="__gateway__", + selected=(current_registry_path == "(from gateway)") + ) + ) + + # Add local registry file options + registry_options.extend([ Option( reg, value=reg, - selected=(reg == Path(current_registry_path).name if current_registry_path else False) + selected=(reg == Path(current_registry_path).name if current_registry_path and current_registry_path != "(from gateway)" else False) ) for reg in registries - ] + ]) # Header bar header = Div( @@ -278,7 +229,10 @@ def get_tool_oauth_status(tool: dict) -> tuple[bool, bool]: ), Div( Span(cls=f"status-indicator {'status-online' if gateway_healthy else 'status-offline'}"), - Span("Gateway" + (" Connected" if gateway_healthy else " Offline"), cls="status-text"), + Span( + gateway_backend.backend_name + (" Connected" if gateway_healthy else " Offline"), + cls="status-text" + ), cls="status-badge" ), cls="header-controls" @@ -386,13 +340,27 @@ def get_tool_oauth_status(tool: dict) -> tuple[bool, bool]: @app.post("/registry/load") async def load_registry_route(registry: str): - """Load a different registry file.""" + """Load a different registry file or from gateway.""" global current_registry, current_registry_path - path = REGISTRIES_DIR / registry - if path.exists(): - current_registry_path = str(path) - current_registry = load_registry(current_registry_path) + if registry == "__gateway__": + # Load from agentgateway backend + try: + ag_registry = await gateway_backend.get_registry() + if ag_registry: + current_registry = convert_agentgateway_registry(ag_registry) + current_registry_path = "(from gateway)" + logger.info("Loaded registry from agentgateway") + else: + logger.warning("No registry available from gateway") + except Exception as e: + logger.error(f"Failed to load registry from gateway: {e}") + else: + # Load from local file + path = REGISTRIES_DIR / registry + if path.exists(): + current_registry_path = str(path) + current_registry = load_registry(current_registry_path) # Use HX-Redirect header for HTMX to do a client-side redirect response = Response(status_code=200) @@ -707,19 +675,9 @@ async def oauth_callback(request: Request, code: str = None, state: str = None, # Establish connection to OAuth backend via gateway access_token = token_data.get("access_token") if access_token: - try: - async with httpx.AsyncClient() as client: - resp = await client.post( - f"{GATEWAY_URL}/oauth/connect", - json={"server_url": flow.server_url, "token": access_token}, - timeout=30.0 - ) - if resp.status_code == 200: - logger.info(f"Gateway connection established for {flow.server_url}") - else: - logger.warning(f"Gateway connection failed: {resp.text}") - except Exception as e: - logger.warning(f"Failed to establish gateway connection: {e}") + success = await gateway_backend.connect_oauth(flow.server_url, access_token) + if not success: + logger.warning(f"Gateway connection may have failed for {flow.server_url}") return Div( H2("Connected!"), From 8fae248a943707dd9d8b5d831cd791f11a7229b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Jan 2026 23:07:27 +0000 Subject: [PATCH 2/2] Add multi-backend testing documentation for demo UI Documents how to test the UI with both MCP Proxy (Python) and Agent Gateway (Rust) backends, including: - Required repositories and branches for each backend - Environment variable configuration - Feature differences between backends - Architecture overview of the backend adapter pattern --- demo/ui/README.md | 193 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 demo/ui/README.md diff --git a/demo/ui/README.md b/demo/ui/README.md new file mode 100644 index 00000000..0e20008f --- /dev/null +++ b/demo/ui/README.md @@ -0,0 +1,193 @@ +# MCP Gateway Demo UI + +A FastHTML web interface for exploring tool registries and testing MCP tools. The UI supports multiple backend gateways through a configurable adapter pattern. + +## Supported Backends + +The UI can work with two different MCP gateway implementations: + +### 1. MCP Proxy (Python) - Default + +**Repository**: `jakemannix/mcp-proxy` +**Branch**: `mcp-gateway-prototype` (or `feature/tool-versioning`) +**Default URL**: `http://localhost:8080` + +The Python-based gateway from this repository. Uses JSON-RPC over HTTP at the `/mcp/` endpoint with session management. + +**Endpoints used**: +- `GET /status` - Health check +- `POST /mcp/` - MCP JSON-RPC (initialize, tools/list, tools/call) +- `POST /oauth/connect` - OAuth token connection + +### 2. Agent Gateway (Rust) + +**Repository**: `jakemannix/agentgateway` +**Branch**: `feature/virtual_tools_and_registry` +**Default URL**: `http://localhost:15000` + +A Rust-based MCP gateway implementation with similar virtual tools and registry functionality. + +**Endpoints used**: +- `GET /config` - Health check +- `GET /registry` - Fetch registry from gateway +- `POST /mcp` - MCP JSON-RPC (note: no trailing slash) + +## Configuration + +Set environment variables to select and configure the backend: + +```bash +# Select backend: "mcp-proxy" (default) or "agentgateway" +export GATEWAY_BACKEND=mcp-proxy + +# Override gateway URL (optional - defaults based on backend) +export GATEWAY_URL=http://localhost:8080 +``` + +Default URLs by backend: +- `mcp-proxy`: `http://localhost:8080` +- `agentgateway`: `http://localhost:15000` + +## Testing with MCP Proxy Backend + +### 1. Build and start the gateway + +```bash +cd /path/to/mcp-proxy +git checkout mcp-gateway-prototype # or feature/tool-versioning + +# Install dependencies +uv sync + +# Start gateway with demo registry +uv run mcp-proxy --named-server-config demo/registries/showcase.json --port 8080 +``` + +### 2. Start the UI + +```bash +# In another terminal +cd demo/ui +uv run python main.py + +# Or use docker-compose +cd demo +docker compose up --build +``` + +### 3. Access the UI + +- Gateway: http://localhost:8080 +- UI: http://localhost:5001 + +The UI will show "MCP Proxy (Python)" in the status badge when connected. + +## Testing with Agent Gateway Backend + +### 1. Build and start agentgateway + +```bash +cd /path/to/agentgateway +git checkout feature/virtual_tools_and_registry + +# Build the Rust gateway +cargo build --release + +# Start with a registry config +./target/release/agentgateway --config path/to/config.yaml +``` + +Note: agentgateway configuration format differs from mcp-proxy. See the agentgateway documentation for config file structure. + +### 2. Start the UI with agentgateway backend + +```bash +cd /path/to/mcp-proxy/demo/ui + +# Configure for agentgateway +export GATEWAY_BACKEND=agentgateway +export GATEWAY_URL=http://localhost:15000 + +uv run python main.py +``` + +### 3. Access the UI + +- Gateway: http://localhost:15000 (or your configured port) +- UI: http://localhost:5001 + +The UI will show "Agent Gateway (Rust)" in the status badge when connected. + +## Feature Differences by Backend + +| Feature | MCP Proxy | Agent Gateway | +|---------|-----------|---------------| +| Registry loading | Local JSON files | From gateway (`/registry`) or local files | +| Health endpoint | `/status` | `/config` | +| MCP endpoint | `/mcp/` | `/mcp` | +| OAuth flow | `/oauth/connect` | Handled at route/policy level | +| Registry format | Native | Converted (snake_case → camelCase) | + +## Registry Loading + +The UI supports multiple ways to load tool registries: + +1. **Sample Registry** - Built-in demo registry for testing +2. **Local JSON files** - Upload or select from filesystem +3. **From Gateway** (agentgateway only) - Fetch live registry via `/registry` endpoint + +When using agentgateway, the "From Gateway" option fetches the registry directly from the running gateway and converts it to the UI's expected format. + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ +│ Demo UI │────▶│ Backend Adapter │ +│ (FastHTML) │ │ (backend.py) │ +└─────────────────┘ └──────────────────┘ + │ + ┌──────────┴──────────┐ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ MCPProxyBackend│ │AgentGatewayBackend│ + │ (Python) │ │(Rust) │ + └─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ mcp-proxy │ │ agentgateway │ + │ localhost:8080 │ │ localhost:15000│ + └─────────────────┘ └─────────────────┘ +``` + +The `backend.py` module provides: +- `GatewayBackend` - Abstract base class defining the interface +- `MCPProxyBackend` - Implementation for Python mcp-proxy +- `AgentGatewayBackend` - Implementation for Rust agentgateway +- `get_backend()` - Factory function using environment variables +- `convert_agentgateway_registry()` - Format conversion utility + +## Troubleshooting + +### UI shows "Gateway Offline" + +1. Verify the gateway is running at the expected URL +2. Check `GATEWAY_URL` environment variable +3. Test the health endpoint directly: + ```bash + # For mcp-proxy + curl http://localhost:8080/status + + # For agentgateway + curl http://localhost:15000/config + ``` + +### Tools not appearing + +1. Ensure a registry is loaded (select from dropdown or upload) +2. For agentgateway, try "From Gateway" option if registry endpoint is available +3. Check gateway logs for backend connection errors + +### OAuth not working + +OAuth flow is currently only implemented for the mcp-proxy backend. For agentgateway, OAuth is typically handled at the infrastructure level (routes/policies).