diff --git a/.gitignore b/.gitignore index 49bbf3ac5..e598dd7de 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,7 @@ e2e/langfuse/.env.langfuse-keys # AI assistant configuration .cursor/ .tessl/ +.mcp.json + +# Developer-specific setup documentation +LOCAL_SETUP.md diff --git a/components/runners/claude-code-runner/.mcp.json b/components/runners/claude-code-runner/.mcp.json index 331e83d21..c6086c875 100644 --- a/components/runners/claude-code-runner/.mcp.json +++ b/components/runners/claude-code-runner/.mcp.json @@ -9,6 +9,14 @@ "JIRA_SSL_VERIFY": "true", "READ_ONLY_MODE": "true" } + }, + "mcp-ambient": { + "command": "mcp-ambient-server", + "args": [], + "env": { + "BACKEND_API_URL": "${BACKEND_API_URL}", + "BOT_TOKEN": "${BOT_TOKEN}" + } } } } diff --git a/components/runners/claude-code-runner/Dockerfile b/components/runners/claude-code-runner/Dockerfile index fe7a65092..71ffd03f6 100644 --- a/components/runners/claude-code-runner/Dockerfile +++ b/components/runners/claude-code-runner/Dockerfile @@ -16,6 +16,10 @@ WORKDIR /app COPY runner-shell /app/runner-shell RUN cd /app/runner-shell && pip install --no-cache-dir . +# Copy and install mcp-ambient-server package +COPY mcp-ambient-server /app/mcp-ambient-server +RUN cd /app/mcp-ambient-server && pip install --no-cache-dir . + # Copy claude-runner specific files COPY claude-code-runner /app/claude-runner diff --git a/components/runners/claude-code-runner/pyproject.toml b/components/runners/claude-code-runner/pyproject.toml index e52e89e6f..afb1ccec8 100644 --- a/components/runners/claude-code-runner/pyproject.toml +++ b/components/runners/claude-code-runner/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "claude-agent-sdk>=0.1.4", "langfuse>=3.0.0", "mcp-atlassian>=0.11.9", + "mcp-ambient-server>=0.1.0", ] [tool.uv] diff --git a/components/runners/mcp-ambient-server/.gitignore b/components/runners/mcp-ambient-server/.gitignore new file mode 100644 index 000000000..afed478b4 --- /dev/null +++ b/components/runners/mcp-ambient-server/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/components/runners/mcp-ambient-server/README.md b/components/runners/mcp-ambient-server/README.md new file mode 100644 index 000000000..cc9d48f91 --- /dev/null +++ b/components/runners/mcp-ambient-server/README.md @@ -0,0 +1,163 @@ +# MCP Server for Ambient Code Platform + +A Model Context Protocol (MCP) server that provides read-only access to the Ambient Code Platform backend API. This server runs as a sidecar in Claude runner pods and allows Claude to browse projects, sessions, and workspace files through native MCP tools. + +## Features + +- **13 Read-Only Tools** organized into 4 categories: + - **Project Management**: List projects, get project details, check access + - **Session Browsing**: List and inspect agentic sessions + - **Workspace Access**: Browse and read files from session workspaces + - **Cluster Info**: Get cluster and workflow information + +- **Secure Authentication**: Uses user bearer tokens for RBAC-enforced API access +- **Error Handling**: Informative error messages with status code mapping +- **Path Validation**: Prevents path traversal attacks on file access + +## Architecture + +**Deployment**: Sidecar container in Claude runner pods +**Communication**: MCP over stdio +**Authentication**: User bearer token from `BOT_TOKEN` environment variable +**Backend API**: Internal Kubernetes service DNS + +## Available Tools + +### Project Management + +- `list_projects()` - List all accessible projects +- `get_project(project_name)` - Get project details +- `check_project_access(project_name)` - Check user permissions + +### Session Browsing + +- `list_sessions(project_name)` - List agentic sessions in a project +- `get_session(project_name, session_name)` - Get session details +- `get_session_k8s_resources(project_name, session_name)` - Get K8s resources +- `list_session_workspace(project_name, session_name)` - List workspace files + +### Workspace File Access + +- `get_workspace_file(project_name, session_name, path)` - Get file contents + +### Workflow & Cluster Info + +- `list_ootb_workflows()` - List out-of-the-box workflows +- `get_workflow_metadata(project_name, session_name)` - Get workflow metadata +- `get_cluster_info()` - Get cluster information +- `get_health()` - Get backend health status + +## Installation + +```bash +# Install with uv (preferred) +uv pip install . + +# Or with pip +pip install . +``` + +## Usage + +### As MCP Server (in runner pods) + +The server is automatically started when included in the runner's `.mcp.json` configuration: + +```json +{ + "mcpServers": { + "mcp-ambient": { + "command": "mcp-ambient-server", + "args": [], + "env": { + "BACKEND_API_URL": "${BACKEND_API_URL}", + "BOT_TOKEN": "${BOT_TOKEN}" + } + } + } +} +``` + +### Manual Testing (with port-forward) + +```bash +# Port-forward backend API +kubectl port-forward -n ambient-code svc/vteam-backend 8080:8080 + +# Export credentials +export BACKEND_API_URL=http://localhost:8080/api +export BOT_TOKEN=$(kubectl get secret -n test-project session-xyz-runner-token -o jsonpath='{.data.token}' | base64 -d) + +# Run server +mcp-ambient-server +``` + +## Configuration + +The server requires two environment variables: + +- `BACKEND_API_URL` - Backend API base URL (default: `http://vteam-backend.ambient-code.svc.cluster.local:8080/api`) +- `BOT_TOKEN` - User bearer token for authentication (required) + +## Development + +### Setup + +```bash +# Create virtual environment +uv venv +source .venv/bin/activate # or `. .venv/bin/activate` + +# Install dependencies +uv pip install -e ".[dev]" +``` + +### Running Tests + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=mcp_ambient_server --cov-report=html + +# Run specific test file +pytest tests/test_client.py +``` + +### Linting and Formatting + +```bash +# Format code +black src/ tests/ + +# Run linter +ruff check src/ tests/ +``` + +## Security + +- **Read-Only Operations**: All tools use GET requests only +- **Token Security**: Tokens are never logged (only token length) +- **Input Validation**: Project/session names and file paths are validated +- **Path Traversal Protection**: Blocks `..` in file paths +- **RBAC Enforcement**: All API calls respect user permissions + +## Error Handling + +The server maps HTTP status codes to informative error messages: + +- `401` - "Authentication failed. BOT_TOKEN may be invalid or expired." +- `403` - "Access denied. User does not have permission for this resource." +- `404` - "Resource not found" +- `500+` - "Backend API error: {message}" +- Network errors - "Cannot reach backend API. Check cluster connectivity." +- Timeouts - "Request timed out after 30s." + +## Integration with Runner + +This MCP server is designed to run as a sidecar in Claude runner pods. The operator sets the required environment variables (`BACKEND_API_URL`, `BOT_TOKEN`) automatically when creating runner pods. + +## License + +Part of the Ambient Code Platform project. diff --git a/components/runners/mcp-ambient-server/pyproject.toml b/components/runners/mcp-ambient-server/pyproject.toml new file mode 100644 index 000000000..353a4e832 --- /dev/null +++ b/components/runners/mcp-ambient-server/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "mcp-ambient-server" +version = "0.1.0" +description = "MCP server for Ambient Code Platform API" +readme = "README.md" +requires-python = ">=3.11" +authors = [ + { name = "Ambient Code Platform" } +] +dependencies = [ + "mcp>=1.1.0", + "httpx>=0.27.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.1.0", + "pytest-httpx>=0.30.0", +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project.scripts] +mcp-ambient-server = "mcp_ambient_server.server:main" diff --git a/components/runners/mcp-ambient-server/src/mcp_ambient_server/__init__.py b/components/runners/mcp-ambient-server/src/mcp_ambient_server/__init__.py new file mode 100644 index 000000000..c0441d388 --- /dev/null +++ b/components/runners/mcp-ambient-server/src/mcp_ambient_server/__init__.py @@ -0,0 +1,3 @@ +"""MCP server for Ambient Code Platform API.""" + +__version__ = "0.1.0" diff --git a/components/runners/mcp-ambient-server/src/mcp_ambient_server/client.py b/components/runners/mcp-ambient-server/src/mcp_ambient_server/client.py new file mode 100644 index 000000000..ac7ae385e --- /dev/null +++ b/components/runners/mcp-ambient-server/src/mcp_ambient_server/client.py @@ -0,0 +1,362 @@ +"""HTTP client for Ambient Code Platform backend API.""" + +import os +import logging +from typing import Any, Optional +import httpx + + +logger = logging.getLogger(__name__) + + +class APIClient: + """Async HTTP client for Ambient Code backend API. + + Handles authentication, error handling, and type-safe requests to the + backend API running in the same Kubernetes cluster. + """ + + def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None): + """Initialize API client with authentication. + + Args: + base_url: Backend API base URL (default from BACKEND_API_URL env) + token: Bearer token (default from BOT_TOKEN env) + + Raises: + ValueError: If BOT_TOKEN is not set + """ + self.base_url = base_url or os.getenv( + "BACKEND_API_URL", + "http://vteam-backend.ambient-code.svc.cluster.local:8080/api", + ) + self.token = token or os.getenv("BOT_TOKEN", "") + + if not self.token: + raise ValueError("BOT_TOKEN environment variable must be set") + + logger.info( + "Initialized API client: base_url=%s, token_len=%d", + self.base_url, + len(self.token), + ) + + self.client = httpx.AsyncClient( + base_url=self.base_url, + headers={"Authorization": f"Bearer {self.token}"}, + timeout=30.0, + ) + + async def close(self): + """Close the HTTP client.""" + await self.client.aclose() + + async def _handle_response( + self, response: httpx.Response, resource_type: str = "resource" + ) -> dict[str, Any]: + """Handle HTTP response with error mapping. + + Args: + response: HTTP response object + resource_type: Type of resource for error messages + + Returns: + Parsed JSON response + + Raises: + Exception: With informative error message based on status code + """ + if response.status_code >= 200 and response.status_code < 300: + return response.json() + + # Map status codes to user-friendly error messages + if response.status_code == 401: + raise Exception( + "Authentication failed. BOT_TOKEN may be invalid or expired." + ) + elif response.status_code == 403: + raise Exception( + f"Access denied. User does not have permission for this {resource_type}." + ) + elif response.status_code == 404: + raise Exception(f"{resource_type.capitalize()} not found") + elif response.status_code >= 500: + error_msg = "Unknown backend error" + try: + error_data = response.json() + error_msg = error_data.get("error", error_msg) + except Exception: + pass + raise Exception(f"Backend API error: {error_msg}") + else: + raise Exception(f"Request failed with status {response.status_code}") + + # Project Management + + async def create_project( + self, name: str, display_name: str = "", description: str = "" + ) -> dict[str, Any]: + """Create a new project. + + Args: + name: Project name (required, must be valid Kubernetes namespace name) + display_name: Display name (optional, used on OpenShift) + description: Project description (optional, used on OpenShift) + + Returns: + Created project object with name, namespace, status + + Raises: + Exception: If project creation fails (conflict, forbidden, etc.) + """ + payload = {"name": name} + if display_name: + payload["displayName"] = display_name + if description: + payload["description"] = description + + try: + response = await self.client.post("/projects", json=payload) + return await self._handle_response(response, "project") + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + async def list_projects(self) -> list[dict[str, Any]]: + """List all projects accessible by the user. + + Returns: + List of project objects + """ + try: + response = await self.client.get("/projects") + data = await self._handle_response(response, "projects") + return data.get("items", []) + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + async def get_project(self, project_name: str) -> dict[str, Any]: + """Get project details. + + Args: + project_name: Name of the project + + Returns: + Project object + """ + try: + response = await self.client.get(f"/projects/{project_name}") + return await self._handle_response(response, "project") + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + async def check_project_access(self, project_name: str) -> dict[str, Any]: + """Check user's access permissions for a project. + + Args: + project_name: Name of the project + + Returns: + Access information object + """ + try: + response = await self.client.get(f"/projects/{project_name}/access") + return await self._handle_response(response, "access information") + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + # Session Browsing + + async def list_sessions(self, project_name: str) -> list[dict[str, Any]]: + """List agentic sessions in a project. + + Args: + project_name: Name of the project + + Returns: + List of session objects + """ + try: + response = await self.client.get( + f"/projects/{project_name}/agentic-sessions" + ) + data = await self._handle_response(response, "sessions") + return data.get("items", []) + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + async def get_session(self, project_name: str, session_name: str) -> dict[str, Any]: + """Get agentic session details. + + Args: + project_name: Name of the project + session_name: Name of the session + + Returns: + Session object + """ + try: + response = await self.client.get( + f"/projects/{project_name}/agentic-sessions/{session_name}" + ) + return await self._handle_response(response, "session") + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + async def get_session_k8s_resources( + self, project_name: str, session_name: str + ) -> dict[str, Any]: + """Get Kubernetes resources for a session. + + Args: + project_name: Name of the project + session_name: Name of the session + + Returns: + Kubernetes resources object + """ + try: + response = await self.client.get( + f"/projects/{project_name}/agentic-sessions/{session_name}/k8s-resources" + ) + return await self._handle_response(response, "k8s resources") + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + async def list_session_workspace( + self, project_name: str, session_name: str + ) -> list[dict[str, Any]]: + """List files in session workspace. + + Args: + project_name: Name of the project + session_name: Name of the session + + Returns: + List of workspace file objects + """ + try: + response = await self.client.get( + f"/projects/{project_name}/agentic-sessions/{session_name}/workspace" + ) + data = await self._handle_response(response, "workspace") + return data.get("files", []) + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + # Workspace File Access + + async def get_workspace_file( + self, project_name: str, session_name: str, path: str + ) -> dict[str, Any]: + """Get contents of a file in session workspace. + + Args: + project_name: Name of the project + session_name: Name of the session + path: Path to the file (relative to workspace root) + + Returns: + File content object + """ + # Validate path to prevent path traversal attacks + if ".." in path: + raise ValueError("Path cannot contain '..' components") + + try: + response = await self.client.get( + f"/projects/{project_name}/agentic-sessions/{session_name}/workspace/{path}" + ) + return await self._handle_response(response, "file") + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + # Workflow & Cluster Info + + async def list_ootb_workflows(self) -> list[dict[str, Any]]: + """List out-of-the-box workflows. + + Returns: + List of workflow objects + """ + try: + response = await self.client.get("/workflows/ootb") + data = await self._handle_response(response, "workflows") + return data.get("workflows", []) + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + async def get_workflow_metadata( + self, project_name: str, session_name: str + ) -> dict[str, Any]: + """Get workflow metadata for a session. + + Args: + project_name: Name of the project + session_name: Name of the session + + Returns: + Workflow metadata object + """ + try: + response = await self.client.get( + f"/projects/{project_name}/agentic-sessions/{session_name}/workflow/metadata" + ) + return await self._handle_response(response, "workflow metadata") + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + async def get_cluster_info(self) -> dict[str, Any]: + """Get cluster information. + + Returns: + Cluster info object + """ + try: + response = await self.client.get("/cluster-info") + return await self._handle_response(response, "cluster info") + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") + + async def get_health(self) -> dict[str, Any]: + """Get backend health status. + + Returns: + Health status object + """ + try: + # Health endpoint is at root, not under /api - use full URL + health_url = self.base_url.replace("/api", "") + "/health" + async with httpx.AsyncClient( + headers={"Authorization": f"Bearer {self.token}"}, + timeout=30.0, + ) as client: + response = await client.get(health_url) + return await self._handle_response(response, "health status") + except httpx.ConnectError: + raise Exception("Cannot reach backend API. Check cluster connectivity.") + except httpx.TimeoutException: + raise Exception("Request timed out after 30s. Backend may be overloaded.") diff --git a/components/runners/mcp-ambient-server/src/mcp_ambient_server/server.py b/components/runners/mcp-ambient-server/src/mcp_ambient_server/server.py new file mode 100644 index 000000000..1fe55a411 --- /dev/null +++ b/components/runners/mcp-ambient-server/src/mcp_ambient_server/server.py @@ -0,0 +1,333 @@ +"""MCP server for Ambient Code Platform API. + +This server provides read-only access to the ACP backend API through MCP tools. +It runs as a sidecar in Claude runner pods and uses the user's bearer token for authentication. +""" + +import logging +import json +import sys +from mcp.server.fastmcp import FastMCP +from mcp_ambient_server.client import APIClient + + +# Configure logging to stderr (stdout is used for MCP protocol) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, +) + +logger = logging.getLogger(__name__) + +# Initialize FastMCP server +mcp = FastMCP("ambient-code") + +# Initialize API client (will be set in main()) +api_client: APIClient = None + + +def format_json(data: dict) -> str: + """Format dict as pretty JSON string. + + Args: + data: Dictionary to format + + Returns: + Formatted JSON string + """ + return json.dumps(data, indent=2) + + +# ============================================================================ +# Project Management Tools +# ============================================================================ + + +@mcp.tool() +async def create_project( + name: str, display_name: str = "", description: str = "" +) -> str: + """Create a new project in the Ambient Code Platform. + + Args: + name: Project name (required, must be valid Kubernetes namespace name) + display_name: Display name (optional, used on OpenShift) + description: Project description (optional) + + Returns: + JSON object with created project details (name, namespace, status) + """ + try: + project = await api_client.create_project(name, display_name, description) + return format_json(project) + except Exception as e: + logger.error(f"Failed to create project {name}: {e}") + return f"Error creating project: {str(e)}" + + +@mcp.tool() +async def list_projects() -> str: + """List all projects accessible by the user. + + Returns: + JSON array of project objects with name, displayName, description, status + """ + try: + projects = await api_client.list_projects() + return format_json({"projects": projects, "count": len(projects)}) + except Exception as e: + logger.error(f"Failed to list projects: {e}") + return f"Error listing projects: {str(e)}" + + +@mcp.tool() +async def get_project(project_name: str) -> str: + """Get detailed information about a specific project. + + Args: + project_name: Name of the project to retrieve + + Returns: + JSON object with project details + """ + try: + project = await api_client.get_project(project_name) + return format_json(project) + except Exception as e: + logger.error(f"Failed to get project {project_name}: {e}") + return f"Error getting project: {str(e)}" + + +@mcp.tool() +async def check_project_access(project_name: str) -> str: + """Check user's access permissions for a project. + + Args: + project_name: Name of the project to check access for + + Returns: + JSON object with access information and permissions + """ + try: + access_info = await api_client.check_project_access(project_name) + return format_json(access_info) + except Exception as e: + logger.error(f"Failed to check access for project {project_name}: {e}") + return f"Error checking project access: {str(e)}" + + +# ============================================================================ +# Session Browsing Tools +# ============================================================================ + + +@mcp.tool() +async def list_sessions(project_name: str) -> str: + """List all agentic sessions in a project. + + Args: + project_name: Name of the project + + Returns: + JSON array of session objects with name, status, phase, timestamps + """ + try: + sessions = await api_client.list_sessions(project_name) + return format_json({"sessions": sessions, "count": len(sessions)}) + except Exception as e: + logger.error(f"Failed to list sessions in project {project_name}: {e}") + return f"Error listing sessions: {str(e)}" + + +@mcp.tool() +async def get_session(project_name: str, session_name: str) -> str: + """Get detailed information about a specific agentic session. + + Args: + project_name: Name of the project + session_name: Name of the session + + Returns: + JSON object with session details including spec, status, repos, workflow + """ + try: + session = await api_client.get_session(project_name, session_name) + return format_json(session) + except Exception as e: + logger.error(f"Failed to get session {project_name}/{session_name}: {e}") + return f"Error getting session: {str(e)}" + + +@mcp.tool() +async def get_session_k8s_resources(project_name: str, session_name: str) -> str: + """Get Kubernetes resources associated with a session (pods, jobs, services). + + Args: + project_name: Name of the project + session_name: Name of the session + + Returns: + JSON object with Kubernetes resource information + """ + try: + resources = await api_client.get_session_k8s_resources( + project_name, session_name + ) + return format_json(resources) + except Exception as e: + logger.error( + f"Failed to get k8s resources for {project_name}/{session_name}: {e}" + ) + return f"Error getting Kubernetes resources: {str(e)}" + + +@mcp.tool() +async def list_session_workspace(project_name: str, session_name: str) -> str: + """List all files in a session's workspace. + + Args: + project_name: Name of the project + session_name: Name of the session + + Returns: + JSON array of file objects with name, size, type, modified time + """ + try: + files = await api_client.list_session_workspace(project_name, session_name) + return format_json({"files": files, "count": len(files)}) + except Exception as e: + logger.error(f"Failed to list workspace for {project_name}/{session_name}: {e}") + return f"Error listing workspace: {str(e)}" + + +# ============================================================================ +# Workspace File Access Tools +# ============================================================================ + + +@mcp.tool() +async def get_workspace_file(project_name: str, session_name: str, path: str) -> str: + """Get contents of a specific file from a session's workspace. + + Args: + project_name: Name of the project + session_name: Name of the session + path: Path to the file (relative to workspace root, no '..' allowed) + + Returns: + File contents or JSON object with file metadata and content + """ + try: + file_data = await api_client.get_workspace_file( + project_name, session_name, path + ) + return format_json(file_data) + except ValueError as e: + return f"Invalid path: {str(e)}" + except Exception as e: + logger.error( + f"Failed to get workspace file {project_name}/{session_name}/{path}: {e}" + ) + return f"Error getting workspace file: {str(e)}" + + +# ============================================================================ +# Workflow & Cluster Info Tools +# ============================================================================ + + +@mcp.tool() +async def list_ootb_workflows() -> str: + """List all out-of-the-box (OOTB) workflows available in the platform. + + Returns: + JSON array of workflow objects with name, description, agents + """ + try: + workflows = await api_client.list_ootb_workflows() + return format_json({"workflows": workflows, "count": len(workflows)}) + except Exception as e: + logger.error(f"Failed to list OOTB workflows: {e}") + return f"Error listing workflows: {str(e)}" + + +@mcp.tool() +async def get_workflow_metadata(project_name: str, session_name: str) -> str: + """Get metadata about the workflow assigned to a session. + + Args: + project_name: Name of the project + session_name: Name of the session + + Returns: + JSON object with workflow metadata including name, agents, status + """ + try: + metadata = await api_client.get_workflow_metadata(project_name, session_name) + return format_json(metadata) + except Exception as e: + logger.error( + f"Failed to get workflow metadata for {project_name}/{session_name}: {e}" + ) + return f"Error getting workflow metadata: {str(e)}" + + +@mcp.tool() +async def get_cluster_info() -> str: + """Get information about the Kubernetes/OpenShift cluster. + + Returns: + JSON object with cluster details like version, platform, capabilities + """ + try: + cluster_info = await api_client.get_cluster_info() + return format_json(cluster_info) + except Exception as e: + logger.error(f"Failed to get cluster info: {e}") + return f"Error getting cluster info: {str(e)}" + + +@mcp.tool() +async def get_health() -> str: + """Get health status of the backend API. + + Returns: + JSON object with health status information + """ + try: + health = await api_client.get_health() + return format_json(health) + except Exception as e: + logger.error(f"Failed to get health status: {e}") + return f"Error getting health status: {str(e)}" + + +# ============================================================================ +# Main Entry Point +# ============================================================================ + + +def main(): + """Initialize the MCP server and start listening on stdio.""" + global api_client + + try: + # Initialize API client + api_client = APIClient() + logger.info("API client initialized successfully") + + # Run MCP server on stdio + logger.info("Starting MCP server for Ambient Code Platform...") + mcp.run(transport="stdio") + + except ValueError as e: + logger.error(f"Failed to initialize API client: {e}") + sys.exit(1) + except Exception as e: + logger.error(f"Unexpected error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/components/runners/mcp-ambient-server/tests/test_client.py b/components/runners/mcp-ambient-server/tests/test_client.py new file mode 100644 index 000000000..51a57c561 --- /dev/null +++ b/components/runners/mcp-ambient-server/tests/test_client.py @@ -0,0 +1,395 @@ +"""Unit tests for API client.""" + +import pytest +import pytest_asyncio +import httpx +from unittest.mock import AsyncMock, Mock, patch +from mcp_ambient_server.client import APIClient + + +@pytest.fixture +def mock_env(monkeypatch): + """Mock environment variables for testing.""" + monkeypatch.setenv("BOT_TOKEN", "test-token-12345") + monkeypatch.setenv("BACKEND_API_URL", "http://test-backend:8080/api") + + +@pytest_asyncio.fixture +async def api_client(mock_env): + """Create API client for testing.""" + client = APIClient() + yield client + await client.close() + + +def mock_httpx_response(status_code: int, json_data: dict = None): + """Create a mock httpx response.""" + response = Mock() + response.status_code = status_code + if json_data is not None: + response.json = Mock(return_value=json_data) + return response + + +@pytest.mark.asyncio +async def test_client_initialization_success(mock_env): + """Test successful client initialization with env vars.""" + client = APIClient() + assert client.base_url == "http://test-backend:8080/api" + assert client.token == "test-token-12345" + await client.close() + + +@pytest.mark.asyncio +async def test_client_initialization_missing_token(monkeypatch): + """Test client initialization fails without BOT_TOKEN.""" + monkeypatch.delenv("BOT_TOKEN", raising=False) + with pytest.raises(ValueError, match="BOT_TOKEN"): + APIClient() + + +@pytest.mark.asyncio +async def test_list_projects_success(api_client): + """Test successful project listing.""" + mock_response = mock_httpx_response( + 200, + { + "items": [ + {"metadata": {"name": "project1"}}, + {"metadata": {"name": "project2"}}, + ] + }, + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + projects = await api_client.list_projects() + assert len(projects) == 2 + assert projects[0]["metadata"]["name"] == "project1" + + +@pytest.mark.asyncio +async def test_get_project_not_found(api_client): + """Test getting non-existent project returns 404.""" + mock_response = AsyncMock() + mock_response.status_code = 404 + + with patch.object(api_client.client, "get", return_value=mock_response): + with pytest.raises(Exception, match="not found"): + await api_client.get_project("nonexistent") + + +@pytest.mark.asyncio +async def test_authentication_error(api_client): + """Test 401 response raises authentication error.""" + mock_response = AsyncMock() + mock_response.status_code = 401 + + with patch.object(api_client.client, "get", return_value=mock_response): + with pytest.raises(Exception, match="Authentication failed"): + await api_client.list_projects() + + +@pytest.mark.asyncio +async def test_authorization_error(api_client): + """Test 403 response raises access denied error.""" + mock_response = AsyncMock() + mock_response.status_code = 403 + + with patch.object(api_client.client, "get", return_value=mock_response): + with pytest.raises(Exception, match="Access denied"): + await api_client.get_project("forbidden-project") + + +@pytest.mark.asyncio +async def test_server_error(api_client): + """Test 500 response raises backend error.""" + mock_response = AsyncMock() + mock_response.status_code = 500 + mock_response.json.return_value = {"error": "Internal server error"} + + with patch.object(api_client.client, "get", return_value=mock_response): + with pytest.raises(Exception, match="Backend API error"): + await api_client.list_projects() + + +@pytest.mark.asyncio +async def test_connection_error(api_client): + """Test connection error raises connectivity message.""" + with patch.object( + api_client.client, + "get", + side_effect=httpx.ConnectError("Connection refused"), + ): + with pytest.raises(Exception, match="Cannot reach backend API"): + await api_client.list_projects() + + +@pytest.mark.asyncio +async def test_timeout_error(api_client): + """Test timeout error raises timeout message.""" + with patch.object( + api_client.client, + "get", + side_effect=httpx.TimeoutException("Timeout"), + ): + with pytest.raises(Exception, match="timed out"): + await api_client.list_projects() + + +@pytest.mark.asyncio +async def test_path_traversal_protection(api_client): + """Test that path traversal attempts are blocked.""" + with pytest.raises(ValueError, match="cannot contain"): + await api_client.get_workspace_file( + "test-project", "test-session", "../etc/passwd" + ) + + +@pytest.mark.asyncio +async def test_list_sessions_success(api_client): + """Test successful session listing.""" + mock_response = mock_httpx_response( + 200, + { + "items": [ + {"metadata": {"name": "session1"}}, + {"metadata": {"name": "session2"}}, + ] + }, + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + sessions = await api_client.list_sessions("test-project") + assert len(sessions) == 2 + + +@pytest.mark.asyncio +async def test_get_workspace_file_success(api_client): + """Test successful workspace file retrieval.""" + mock_response = mock_httpx_response( + 200, + { + "content": "file contents", + "path": "README.md", + }, + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + file_data = await api_client.get_workspace_file( + "test-project", "test-session", "README.md" + ) + assert file_data["path"] == "README.md" + assert file_data["content"] == "file contents" + + +@pytest.mark.asyncio +async def test_create_project_success(api_client): + """Test successful project creation.""" + mock_response = mock_httpx_response(201, {"metadata": {"name": "test-proj"}}) + + with patch.object( + api_client.client, "post", new_callable=AsyncMock, return_value=mock_response + ): + project = await api_client.create_project( + "test-proj", "Test Project", "Description" + ) + assert project["metadata"]["name"] == "test-proj" + + +@pytest.mark.asyncio +async def test_check_project_access_success(api_client): + """Test successful project access check.""" + mock_response = mock_httpx_response( + 200, {"allowed": True, "permissions": ["read", "write"]} + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + access = await api_client.check_project_access("test-project") + assert access["allowed"] is True + + +@pytest.mark.asyncio +async def test_get_session_success(api_client): + """Test successful session retrieval.""" + mock_response = mock_httpx_response( + 200, + { + "metadata": {"name": "session1"}, + "spec": {"prompt": "test"}, + "status": {"phase": "Running"}, + }, + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + session = await api_client.get_session("test-project", "session1") + assert session["metadata"]["name"] == "session1" + assert session["status"]["phase"] == "Running" + + +@pytest.mark.asyncio +async def test_get_session_k8s_resources_success(api_client): + """Test successful K8s resources retrieval.""" + mock_response = mock_httpx_response( + 200, + { + "job": {"name": "job1", "status": "Running"}, + "pods": [{"name": "pod1", "phase": "Running"}], + }, + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + resources = await api_client.get_session_k8s_resources( + "test-project", "session1" + ) + assert resources["job"]["name"] == "job1" + assert len(resources["pods"]) == 1 + + +@pytest.mark.asyncio +async def test_list_ootb_workflows_success(api_client): + """Test successful OOTB workflows listing.""" + mock_response = mock_httpx_response( + 200, + { + "workflows": [ + {"name": "workflow1", "description": "Test workflow"}, + {"name": "workflow2", "description": "Another workflow"}, + ] + }, + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + workflows = await api_client.list_ootb_workflows() + assert len(workflows) == 2 + assert workflows[0]["name"] == "workflow1" + + +@pytest.mark.asyncio +async def test_get_workflow_metadata_success(api_client): + """Test successful workflow metadata retrieval.""" + mock_response = mock_httpx_response( + 200, + { + "name": "rfe-workflow", + "agents": ["PM", "Architect", "Staff Engineer"], + "status": "active", + }, + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + metadata = await api_client.get_workflow_metadata("test-project", "session1") + assert metadata["name"] == "rfe-workflow" + assert len(metadata["agents"]) == 3 + + +@pytest.mark.asyncio +async def test_get_cluster_info_success(api_client): + """Test successful cluster info retrieval.""" + mock_response = mock_httpx_response( + 200, + { + "isOpenShift": False, + "version": "1.28", + "vertexEnabled": False, + }, + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + cluster_info = await api_client.get_cluster_info() + assert cluster_info["isOpenShift"] is False + assert cluster_info["version"] == "1.28" + + +@pytest.mark.asyncio +async def test_create_project_conflict(api_client): + """Test project creation with conflict error.""" + mock_response = AsyncMock() + mock_response.status_code = 409 + + with patch.object(api_client.client, "post", return_value=mock_response): + with pytest.raises(Exception, match="failed with status 409"): + await api_client.create_project("existing-project") + + +@pytest.mark.asyncio +async def test_list_sessions_empty(api_client): + """Test listing sessions in empty project.""" + mock_response = mock_httpx_response(200, {"items": []}) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + sessions = await api_client.list_sessions("test-project") + assert len(sessions) == 0 + + +@pytest.mark.asyncio +async def test_get_session_not_found(api_client): + """Test getting non-existent session.""" + mock_response = AsyncMock() + mock_response.status_code = 404 + + with patch.object(api_client.client, "get", return_value=mock_response): + with pytest.raises(Exception, match="not found"): + await api_client.get_session("test-project", "nonexistent") + + +@pytest.mark.asyncio +async def test_list_session_workspace_success(api_client): + """Test successful workspace listing.""" + mock_response = mock_httpx_response( + 200, + { + "files": [ + {"name": "file1.txt", "size": 100}, + {"name": "file2.md", "size": 200}, + ] + }, + ) + + with patch.object( + api_client.client, "get", new_callable=AsyncMock, return_value=mock_response + ): + files = await api_client.list_session_workspace("test-project", "session1") + assert len(files) == 2 + assert files[0]["name"] == "file1.txt" + + +@pytest.mark.asyncio +async def test_check_project_access_forbidden(api_client): + """Test project access check when forbidden.""" + mock_response = AsyncMock() + mock_response.status_code = 403 + + with patch.object(api_client.client, "get", return_value=mock_response): + with pytest.raises(Exception, match="Access denied"): + await api_client.check_project_access("forbidden-project") + + +@pytest.mark.asyncio +async def test_get_workflow_metadata_not_found(api_client): + """Test workflow metadata for non-existent session.""" + mock_response = AsyncMock() + mock_response.status_code = 404 + + with patch.object(api_client.client, "get", return_value=mock_response): + with pytest.raises(Exception, match="not found"): + await api_client.get_workflow_metadata("test-project", "nonexistent") diff --git a/e2e/cypress/e2e/vteam.cy.ts b/e2e/cypress/e2e/vteam.cy.ts index 21d740b65..93f077d51 100644 --- a/e2e/cypress/e2e/vteam.cy.ts +++ b/e2e/cypress/e2e/vteam.cy.ts @@ -81,5 +81,59 @@ describe('vTeam E2E Tests', () => { expect(response.body.isOpenShift).to.eq(false) // kind is vanilla k8s }) }) + + it('should verify MCP server can reach backend endpoints', () => { + // Test backend endpoints that MCP server uses are accessible + cy.request('/api/cluster-info').then((response) => { + expect(response.status).to.eq(200) + expect(response.body).to.have.property('isOpenShift') + }) + + cy.request('/api/workflows/ootb').then((response) => { + expect(response.status).to.eq(200) + expect(response.body).to.have.property('workflows') + }) + }) + + it('should verify project endpoints work with auth', () => { + // Test authenticated endpoints that MCP uses + cy.request({ + url: '/api/projects', + headers: {'Authorization': `Bearer ${Cypress.env('TEST_TOKEN')}`} + }).then((response) => { + expect(response.status).to.eq(200) + expect(response.body).to.have.property('items') + }) + }) + + it('should create project and verify via API (like MCP would)', () => { + const projectName = `e2e-mcp-${Date.now()}` + + // Create via UI + cy.visit('/projects') + cy.contains('button', 'New Workspace').click() + cy.get('#name').type(projectName) + cy.contains('button', 'Create Workspace').click() + cy.url({ timeout: 15000 }).should('include', `/projects/${projectName}`) + + // Verify via API (like MCP would do) + cy.request({ + url: `/api/projects/${projectName}`, + headers: {'Authorization': `Bearer ${Cypress.env('TEST_TOKEN')}`} + }).then((response) => { + expect(response.status).to.eq(200) + expect(response.body.metadata.name).to.eq(projectName) + }) + + // Verify session list endpoint works + cy.request({ + url: `/api/projects/${projectName}/agentic-sessions`, + headers: {'Authorization': `Bearer ${Cypress.env('TEST_TOKEN')}`} + }).then((response) => { + expect(response.status).to.eq(200) + expect(response.body).to.have.property('items') + expect(response.body.items).to.be.an('array') + }) + }) })