diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7b84559 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.venv/ +venv/ +__pycache__/ +*.pyc +.git/ +.github/ +.pytest_cache/ +htmlcov/ +.coverage +*.egg-info/ +dist/ +build/ +.env +.env.* +!.env.example +*.log +.mypy_cache/ +.ruff_cache/ +.serena/ +tests/ +docs/ diff --git a/.env.example b/.env.example index fef6104..f90b51a 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,49 @@ -# CyberArk Privilege Cloud Configuration -# Copy this file to .env and fill in your actual values +# CyberArk Privilege Cloud MCP Server Configuration +# Copy this file to .env and fill in your actual values. +# +# WHY TWO SETS OF CREDENTIALS? +# The MCP server needs two separate credential pairs: +# 1. Service account (CYBERARK_CLIENT_ID/SECRET) - A CyberArk Identity user marked +# as "OAuth 2.0 confidential client". Used to obtain a PCloud platform token for +# all API calls. All users share this service account's PCloud permissions. +# 2. OIDC app (CYBERARK_OAUTH_CLIENT_ID/SECRET) - Auto-generated credentials from +# the OIDC app's Trust tab. Used for the OAuth authorization flow (DCR, +# /token proxy) so end-users authenticate with their own identity. +# See docs/CYBERARK_IDENTITY_SETUP.md for full setup instructions. -# Required: Service account credentials -CYBERARK_CLIENT_ID=your-service-account-username -CYBERARK_CLIENT_SECRET=your-service-account-password +# ============================================================ +# OAuth Per-User Mode (Recommended) +# ============================================================ +# Triggers OAuth mode; each user authenticates with their own identity. -# Optional: Log level (default: INFO) -CYBERARK_LOG_LEVEL=INFO \ No newline at end of file +# Required: CyberArk Identity tenant URL +CYBERARK_IDENTITY_TENANT_URL=https://your-tenant.id.cyberark.cloud + +# Service account credentials (must be marked "OAuth 2.0 confidential client" in CyberArk Identity). +# Used for: PCloud platform token (all API calls). +CYBERARK_CLIENT_ID=mcp-service@cyberark.cloud.XXXX +CYBERARK_CLIENT_SECRET=service-account-password +# ============================================================ +# Required: OIDC App Credentials (from Trust tab in CyberArk Identity) +# ============================================================ +# These are the OIDC app credentials used for DCR (Dynamic Client Registration) +# and JWT audience validation. Found on the Trust tab of your OIDC app in CyberArk Identity. +CYBERARK_OAUTH_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +CYBERARK_OAUTH_CLIENT_SECRET=oidc-app-client-secret + +# ============================================================ +# Transport & Server Settings (Optional) +# ============================================================ + +# Transport protocol: stdio (default), sse, or streamable-http +# MCP_TRANSPORT=streamable-http + +# Server bind settings (HTTP transports only) +# MCP_HOST=127.0.0.1 +# MCP_PORT=8000 + +# Public URL for OAuth metadata (defaults to http://{host}:{port}) +# MCP_SERVER_URL=https://mcp.example.com + +# Log level +# CYBERARK_LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index 413293c..40171c1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,30 +3,14 @@ __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 -MANIFEST # Virtual environments venv/ -env/ -ENV/ -env.bak/ -venv.bak/ +.venv/ # Environment variables and secrets .env @@ -37,14 +21,10 @@ venv.bak/ *.p12 *.pfx secrets.json -config.json credentials.json # CyberArk specific secrets cyberark_credentials.* -*_credentials.* -api_keys.* -tokens.* # Testing .coverage @@ -61,26 +41,20 @@ htmlcov/ # OS .DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db Thumbs.db # Logs *.log logs/ -debug.log -error.log + +# PoC files +poc_* # MCP *.mcp.json -mcp_server_config.json # Claude Desktop configs with credentials claude_desktop_config.json -*config*windows.json # Temporary files *.tmp @@ -91,7 +65,3 @@ claude_desktop_config.json # Backup files *.bak *.backup -*~ - -# serena -.serena/cache/* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 7a218c6..8400194 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ **BEFORE CODING**: 1. **Always read this entire CLAUDE.md file first** - Contains critical patterns and constraints -2. **Check current test status** - All changes must maintain 210+ passing tests +2. **Check current test status** - All changes must maintain 269+ passing tests 3. **Follow existing patterns** - Simplified architecture patterns are established and documented 4. **Use official SDK** - All CyberArk operations MUST use ark-sdk-python (never direct HTTP) 5. **MANDATORY: Use context7 MCP tools for ALL API documentation** - Before working with any library or API, use context7 MCP server tools to get up-to-date documentation @@ -96,9 +96,9 @@ Use context7 resolve-library-id and get-library-docs tools: **Purpose**: MCP server for CyberArk Privilege Cloud integration, enabling AI assistants to securely manage privileged accounts. -**Current Status**: ✅ **MODERNIZED MCP ARCHITECTURE** - Production ready with FastMCP lifespan management, context injection, typed response models, and complete PCloud service coverage -**Last Updated**: January 31, 2026 -**Recent Achievement**: Modernized MCP server architecture leveraging mcp SDK v1.26.0 features. Implemented lifespan management for proper server lifecycle, context injection for dependency access in tools, and Pydantic response models for typed returns. Added 50+ new tests bringing total to 213 passing tests with zero regression. +**Current Status**: ✅ **SERVICE ACCOUNT TOKEN BRIDGE COMPLETE** - OAuth mode verifies user identity via OIDC JWT, then uses a shared service account platform token for all PCloud API calls. +**Last Updated**: February 27, 2026 +**Recent Achievement**: Technical debt cleanup — removed dead code (session_manager.py, token_auth.py, HTTP bypasses, unused server methods), simplified AppContext to use `is_oauth` flag, fixed token_verifier exception handling, updated all documentation. 276 passing tests with zero regression. ## Architecture @@ -113,7 +113,7 @@ The server provides 53 MCP tools for comprehensive CyberArk operations, built on - **Safe Management Tools (10 tools)**: Complete CRUD plus member management - `list_safes`, `get_safe_details`, `add_safe`, `update_safe`, `delete_safe`, `list_safe_members`, `get_safe_member_details`, `add_safe_member`, `update_safe_member`, `remove_safe_member` - **Platform Management Tools (12 tools)**: Complete lifecycle plus statistics - `list_platforms`, `get_platform_details`, `import_platform_package`, `export_platform`, `duplicate_target_platform`, `activate_target_platform`, `deactivate_target_platform`, `delete_target_platform`, `get_platform_statistics`, `get_target_platform_statistics` - **Applications Management Tools (8 tools)**: Complete application lifecycle - `list_applications`, `get_application_details`, `add_application`, `delete_application`, `list_application_auth_methods`, `get_application_auth_method_details`, `add_application_auth_method`, `delete_application_auth_method`, `get_applications_stats` -- **Session Monitoring Tools (5 tools)**: Privileged session monitoring and analytics - `list_sessions`, `list_sessions_by_filter`, `get_session_details`, `list_session_activities`, `count_sessions`, `get_session_statistics` +- **Session Monitoring Tools (6 tools)**: Privileged session monitoring and analytics - `list_sessions`, `list_sessions_by_filter`, `get_session_details`, `list_session_activities`, `count_sessions`, `get_session_statistics` > **Complete PCloud Coverage**: All tools provide comprehensive coverage of CyberArk's 5 PCloud services with enterprise-grade CRUD operations, advanced analytics, member management, and privileged session monitoring. Built on official ark-sdk-python library with 210+ passing tests and zero regression. @@ -274,7 +274,7 @@ The codebase underwent a systematic simplification process achieving **~27% code - **Simplified Testing**: Cleaner test patterns with reduced mocking complexity **Performance & Reliability**: -- **Zero Functional Regression**: All 210+ tests passing with complete functionality coverage +- **Zero Functional Regression**: All 261+ tests passing with complete functionality coverage - **Preserved SDK Integration**: Official ark-sdk-python patterns maintained - **Graceful Error Handling**: Centralized error management with consistent logging - **Backward Compatibility**: No breaking changes to MCP tool interfaces @@ -309,38 +309,52 @@ async def your_new_tool( **🤖 MANDATORY PATTERN: Lifespan Management** ```python # Server lifecycle is managed via app_lifespan context manager -# Access server through ctx.request_context.lifespan_context.server +# Dual mode: OAuth (is_oauth=True) or legacy (is_oauth=False) @dataclass class AppContext: - server: CyberArkMCPServer + server: Optional[CyberArkMCPServer] = None + is_oauth: bool = False @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: cyberark_server = CyberArkMCPServer.from_environment() - try: + if is_oauth_mode(): + yield AppContext(server=cyberark_server, is_oauth=True) + else: yield AppContext(server=cyberark_server) - finally: - # Cleanup resources - pass ``` **🤖 FILE STRUCTURE GUIDE**: -- `sdk_auth.py` - Authentication only, no business logic -- `server.py` - Business logic with @handle_sdk_errors decorator -- `mcp_server.py` - MCP tools with lifespan management and context injection -- `models.py` - Pydantic response models for typed returns -- `exceptions.py` - Custom exceptions only +- `sdk_auth.py` - Service account authentication (legacy mode) +- `token_verifier.py` - JWT verification via CyberArk Identity JWKS (MCP TokenVerifier protocol) +- `server.py` - Business logic with @handle_sdk_errors decorator + `from_token()` factory +- `mcp_server.py` - MCP tools with dual-mode lifespan, context injection, Streamable HTTP transport, RFC 8414 metadata route +- `exceptions.py` - Custom exceptions: CyberArkAPIError, SDK compatibility layer ### Testing Validation ✅ **VERIFIED** -- **210+ tests passing** - Zero functionality regression with comprehensive expansion -- **Test Coverage Maintained** - All expansion preserved existing test patterns +- **261+ tests passing** - Zero functionality regression across all phases +- **Test Coverage Maintained** - 16 token verifier + 14 OAuth integration tests added - **Integration Tests Updated** - MCP tool parameter passing verified for all 53 tools - **Performance Baseline** - No degradation in execution performance ## Configuration -**Required Environment Variables**: -- `CYBERARK_CLIENT_ID` - OAuth service account username +**Required Environment Variables** (OAuth per-user mode): +- `CYBERARK_IDENTITY_TENANT_URL` - CyberArk Identity tenant URL (e.g., `https://abc1234.id.cyberark.cloud`) +- `CYBERARK_CLIENT_ID` - Service account login name (for PCloud platform token access) +- `CYBERARK_CLIENT_SECRET` - Service account password +- `CYBERARK_OAUTH_CLIENT_ID` - OIDC app client ID from Trust tab (for DCR and /token proxy) +- `CYBERARK_OAUTH_CLIENT_SECRET` - OIDC app client secret from Trust tab (injected server-side by /token proxy, never exposed via DCR) + +**Optional Environment Variables**: +- `CYBERARK_OIDC_APP_ID` - OIDC app name in URL paths (default: `mcpprivilegecloud`) +- `MCP_TRANSPORT` - Transport protocol: `stdio`, `sse`, or `streamable-http` (default: `stdio`) +- `MCP_HOST` - Server bind host (default: `127.0.0.1`) +- `MCP_PORT` - Server bind port (default: `8000`) +- `MCP_SERVER_URL` - Public URL for metadata (default: `http://{host}:{port}`) + +**Legacy Environment Variables** (service account mode -- fallback): +- `CYBERARK_CLIENT_ID` - Service account login name - `CYBERARK_CLIENT_SECRET` - Service account password *See README.md for complete configuration details* @@ -379,9 +393,10 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: ### Entry Points #### Standardized Execution Methods (Recommended) -- **`uvx mcp-privilege-cloud`** - Primary production execution method +- **`uvx mcp-privilege-cloud`** - Primary production execution (stdio by default) - **`uv run mcp-privilege-cloud`** - Development execution with dependency management - **`python -m mcp_privilege_cloud`** - Standard Python module execution +- **Transport**: Controlled by `MCP_TRANSPORT` env var (default: `stdio`). Set to `streamable-http` for HTTP on `MCP_HOST`:`MCP_PORT`. #### Legacy Entry Points (Deprecated) - **`run_server.py`** - Legacy multiplatform entry point (removed in SDK migration) @@ -389,14 +404,19 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: ## Testing Strategy -**Test Files**: 160+ total tests across 9 test files +**Test Files**: 261+ total tests across 15 test files - `tests/test_core_functionality.py` - Authentication, server core, platform management (comprehensive error handling) -- `tests/test_account_operations.py` - Account lifecycle management with CRUD operations +- `tests/test_account_operations.py` - Account lifecycle management with CRUD operations - `tests/test_applications_service.py` - Applications service testing with authentication methods - `tests/test_mcp_integration.py` - MCP tool wrappers and integration testing - `tests/test_integration_tools.py` - End-to-end integration tests for all tools - `tests/test_enhanced_error_handling.py` - Enhanced error handling validation - `tests/test_enhanced_error_messages.py` - Error message consistency testing +- `tests/test_transport.py` - Streamable HTTP transport configuration tests +- `tests/test_token_verifier.py` - CyberArkTokenVerifier JWT verification tests +- `tests/test_oauth_integration.py` - Full OAuth integration: dual-mode, execute_tool, lifespan +- `tests/test_oauth_metadata.py` - RFC 8414 `/.well-known/oauth-authorization-server` endpoint tests +- `tests/test_env_var_resolution.py` - Environment variable priority chain tests (DCR) - Additional test files for comprehensive coverage of all 53 tools **Key Commands**: @@ -462,7 +482,7 @@ async def get_account_password(account_id: str) -> Dict[str, Any]: 2. **NEVER bypass patterns** - Always use @handle_sdk_errors decorator 3. **ALWAYS follow TDD** - Write failing test first, then implementation 4. **SDK-only operations** - Never create direct HTTP requests -5. **Preserve test coverage** - All 210+ tests must continue passing +5. **Preserve test coverage** - All 261+ tests must continue passing 6. **Use existing models** - Leverage ark-sdk-python model classes **🔍 Mandatory Context7 Workflow**: @@ -473,17 +493,17 @@ async def get_account_password(account_id: str) -> Dict[str, Any]: - get-library-docs with the resolved ID 2. Write failing test using current patterns 3. Implement using up-to-date SDK methods -4. Verify all 210+ tests still pass +4. Verify all 261+ tests still pass ``` ## References - **README.md** - Complete setup and configuration documentation -- **ARCHITECTURE.md** - System architecture and component details +- **docs/ARCHITECTURE.md** - System architecture and component details - **DEVELOPMENT.md** - Development workflows and procedures -- **INSTRUCTIONS.md** - Development workflow and coding standards - **docs/API_REFERENCE.md** - Complete tool specifications and examples - **docs/TESTING.md** - Comprehensive testing guidelines and procedures +- **docs/CYBERARK_IDENTITY_SETUP.md** - CyberArk Identity OAuth app configuration guide --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23c6b68 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.12-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir uv + +WORKDIR /app + +# Copy dependency files first for layer caching +COPY pyproject.toml uv.lock ./ + +# Install dependencies (without the project itself) +RUN uv sync --frozen --no-install-project + +# Copy source and files needed by hatchling build +COPY src/ src/ +COPY README.md ./ + +# Install the project +RUN uv sync --frozen + +FROM python:3.12-slim + +RUN useradd --create-home --no-log-init appuser + +WORKDIR /app + +# Copy the entire virtual environment and project from builder +COPY --from=builder /app /app + +ENV PATH="/app/.venv/bin:$PATH" + +USER appuser + +EXPOSE 8000 + +ENTRYPOINT ["mcp-privilege-cloud"] diff --git a/README.md b/README.md index c0a9f93..7ce353c 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,11 @@ claude mcp add cyberark-privilege-cloud \ - **Password Management**: `change_account_password`, `set_next_password`, `verify_account_password`, `reconcile_account_password` - **Advanced Search**: `filter_accounts_by_platform_group`, `filter_accounts_by_environment`, `filter_accounts_by_management_status`, `group_accounts_by_safe`, `group_accounts_by_platform`, `analyze_account_distribution`, `search_accounts_by_pattern`, `count_accounts_by_criteria` -**Safe Management (11 tools):** +**Safe Management (10 tools):** - **Core Operations**: `list_safes`, `get_safe_details`, `add_safe`, `update_safe`, `delete_safe` - **Member Management**: `list_safe_members`, `get_safe_member_details`, `add_safe_member`, `update_safe_member`, `remove_safe_member` -**Platform Management (12 tools):** +**Platform Management (10 tools):** - **Core Operations**: `list_platforms`, `get_platform_details`, `import_platform_package`, `export_platform` - **Lifecycle Management**: `duplicate_target_platform`, `activate_target_platform`, `deactivate_target_platform`, `delete_target_platform` - **Statistics**: `get_platform_statistics`, `get_target_platform_statistics` @@ -119,20 +119,84 @@ claude mcp add cyberark-privilege-cloud \ ## Configuration -The MCP server requires two environment variables for authentication: +The server supports two authentication modes. It auto-detects which mode to use based on the environment variables present. -| Variable | Description | -|----------|-------------| -| `CYBERARK_CLIENT_ID` | Your Service User username | -| `CYBERARK_CLIENT_SECRET` | Your Service User password | +### OAuth Per-User Mode (Recommended) + +Each connecting user authenticates with their own CyberArk Identity credentials via OAuth. The server verifies user identity from the OIDC JWT, then uses a **service account platform token** for all PCloud API calls. Requires an OAuth2 app configured in CyberArk Identity (see [CyberArk Identity Setup](docs/CYBERARK_IDENTITY_SETUP.md)). + +| Variable | Required | Description | +|----------|----------|-------------| +| `CYBERARK_IDENTITY_TENANT_URL` | Yes | CyberArk Identity tenant URL (e.g., `https://abc1234.id.cyberark.cloud`) | +| `CYBERARK_CLIENT_ID` | Yes | Service account login name (used for PCloud platform token) | +| `CYBERARK_CLIENT_SECRET` | Yes | Service account password | +| `CYBERARK_OAUTH_CLIENT_ID` | Yes | OIDC app client ID from Trust tab (used for DCR and JWT audience validation) | +| `CYBERARK_OAUTH_CLIENT_SECRET` | Yes | OIDC app client secret from Trust tab (injected server-side in /token proxy) | +| `MCP_TRANSPORT` | No | Transport protocol: `stdio`, `sse`, or `streamable-http` (default: `stdio`) | +| `MCP_HOST` | No | Server bind host (default: `127.0.0.1`) | +| `MCP_PORT` | No | Server bind port (default: `8000`) | + +### Legacy Service Account Mode + +A single shared service account authenticates all requests. Simpler setup but all operations run under one identity. + +| Variable | Required | Description | +|----------|----------|-------------| +| `CYBERARK_CLIENT_ID` | Yes | Your Service User username | +| `CYBERARK_CLIENT_SECRET` | Yes | Your Service User password | +| `MCP_TRANSPORT` | No | Transport protocol: `stdio`, `sse`, or `streamable-http` (default: `stdio`) | **For Claude Desktop/Claude Code**: Pass these directly in the configuration (see [Client Integration](#client-integration)). No `.env` file is needed. **For local development/testing**: Create a `.env` file in the project root directory: ```bash +# OAuth per-user mode (recommended) +# Service account — for PCloud API access via platform token +CYBERARK_CLIENT_ID=mcp-service@cyberark.cloud.XXXX +CYBERARK_CLIENT_SECRET=service-user-password +# OIDC app — from Trust tab, for DCR +CYBERARK_OAUTH_CLIENT_ID=your-oidc-app-client-id +CYBERARK_OAUTH_CLIENT_SECRET=your-oidc-app-client-secret +CYBERARK_IDENTITY_TENANT_URL=https://abc1234.id.cyberark.cloud + +# OR legacy service account mode CYBERARK_CLIENT_ID=your-service-user-username CYBERARK_CLIENT_SECRET=your-service-user-password + +# Transport: stdio (default), sse, or streamable-http +# MCP_TRANSPORT=streamable-http +``` + +## Reverse Proxy Deployment + +When deploying behind a reverse proxy with OAuth enabled, you **must** configure the proxy to strip trailing slashes from request paths. MCP clients (e.g. Copilot Studio) POST to `/mcp/` (trailing slash), which causes a 307 redirect to `/mcp`. HTTP clients strip the `Authorization` header on redirect, breaking OAuth Bearer token authentication. + +Also set `MCP_SERVER_URL` to the public URL of your server so that OAuth discovery metadata contains reachable URLs. + +**Traefik example** (dynamic config): +```yaml +http: + middlewares: + strip-trailing-slash: + replacePathRegex: + regex: "^(/.+?)/$" + replacement: "${1}" + routers: + mcp: + rule: "Host(`mcp.example.com`)" + entryPoints: + - web-secure + service: mcp + middlewares: + - strip-trailing-slash + tls: + certResolver: myresolver + services: + mcp: + loadBalancer: + servers: + - url: "http://backend:8000" ``` ## Troubleshooting @@ -143,6 +207,7 @@ CYBERARK_CLIENT_SECRET=your-service-user-password | Authentication failed | Verify Service User credentials in CyberArk Identity | | Permission errors | Ensure the Service User has appropriate Identity roles and safe permissions | | Connection issues | Verify you're using the `.cloud` domain (not `.com`) | +| OAuth 401 behind reverse proxy | Ensure the proxy strips trailing slashes (see Reverse Proxy Deployment above) | | `uvx` not found | Install uv: `curl -LsSf https://astral.sh/uv/install.sh \| sh` | **Verify MCP server manually:** @@ -186,7 +251,8 @@ Configure with command `uv run mcp-privilege-cloud` and your credentials. ## Documentation - **[API Reference](docs/API_REFERENCE.md)** - Complete tool specifications and parameters -- **[Architecture](ARCHITECTURE.md)** - System design and components +- **[Architecture](docs/ARCHITECTURE.md)** - System design and components +- **[CyberArk Identity Setup](docs/CYBERARK_IDENTITY_SETUP.md)** - OAuth app configuration guide - **[Development Guide](DEVELOPMENT.md)** - Contributing and development workflows - **[Testing Guide](docs/TESTING.md)** - Detailed testing instructions diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..62dc215 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +# IMPORTANT: When deploying behind a reverse proxy (e.g. Traefik, nginx), +# configure the proxy to strip trailing slashes from request paths. +# MCP clients like Copilot Studio POST to /mcp/ (trailing slash), which +# causes a 307 redirect to /mcp. HTTP clients strip the Authorization +# header on redirect, breaking OAuth Bearer token authentication. +# +# Traefik example (dynamic config): +# middlewares: +# strip-trailing-slash: +# replacePathRegex: +# regex: "^(/.+?)/$" +# replacement: "${1}" +services: + mcp-server: + build: . + ports: + - "${MCP_PORT:-8000}:8000" + environment: + MCP_TRANSPORT: streamable-http + MCP_HOST: "0.0.0.0" + MCP_PORT: "8000" + MCP_SERVER_URL: ${MCP_SERVER_URL:-} + CYBERARK_IDENTITY_TENANT_URL: ${CYBERARK_IDENTITY_TENANT_URL:-} + CYBERARK_CLIENT_ID: ${CYBERARK_CLIENT_ID:-} + CYBERARK_CLIENT_SECRET: ${CYBERARK_CLIENT_SECRET:-} + CYBERARK_OAUTH_CLIENT_ID: ${CYBERARK_OAUTH_CLIENT_ID:-} + CYBERARK_OAUTH_CLIENT_SECRET: ${CYBERARK_OAUTH_CLIENT_SECRET:-} + env_file: + - path: .env + required: false + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/mcp')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + restart: unless-stopped diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index cdd93cb..46761f3 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -22,8 +22,8 @@ Comprehensive API reference for the CyberArk Privilege Cloud MCP Server. This gu The CyberArk Privilege Cloud MCP Server provides **53 enterprise-grade tools** for comprehensive privileged account management through the Model Context Protocol (MCP). All tools follow consistent patterns built on the official ark-sdk-python library for authentication, parameter validation, and error handling. ### Core Capabilities -- **Complete Account Lifecycle**: Create, read, update, delete accounts with advanced search and password management (17 tools) -- **Comprehensive Safe Operations**: Full CRUD operations plus member management with granular permissions (11 tools) +- **Complete Account Lifecycle**: Create, read, update, delete accounts with advanced search and password management (18 tools) +- **Comprehensive Safe Operations**: Full CRUD operations plus member management with granular permissions (10 tools) - **Platform Management**: Complete platform lifecycle including statistics, import/export, and target platform operations (10 tools) - **Applications Management**: Full application lifecycle with authentication method management and statistics (9 tools) - **Advanced Analytics**: Account filtering, grouping, distribution analysis, and environment categorization @@ -39,34 +39,56 @@ The CyberArk Privilege Cloud MCP Server provides **53 enterprise-grade tools** f ## Authentication -### OAuth 2.0 Configuration -All tools require proper authentication configuration through environment variables: +The server supports two authentication modes, auto-detected from environment variables. + +### OAuth Per-User Mode (Recommended) + +Each user authenticates with their own CyberArk Identity credentials via OAuth. The server verifies user identity from the OIDC JWT, then uses a shared service account platform token for all PCloud API calls. ```bash -# Required Environment Variables -CYBERARK_CLIENT_ID=service-account-username # OAuth service account +# Required for OAuth per-user mode +CYBERARK_IDENTITY_TENANT_URL=https://abc1234.id.cyberark.cloud +CYBERARK_CLIENT_ID=mcp-service@cyberark.cloud.XXXX CYBERARK_CLIENT_SECRET=service-account-password +CYBERARK_OAUTH_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +CYBERARK_OAUTH_CLIENT_SECRET=oidc-app-client-secret -# Optional Configuration +# Optional +MCP_HOST=127.0.0.1 # Server bind host +MCP_PORT=8000 # Server bind port CYBERARK_LOG_LEVEL=INFO # Logging level ``` +**Service Account Token Bridge Flow**: +1. MCP client sends Bearer token in request +2. `CyberArkTokenVerifier` validates JWT signature via JWKS +3. User identity (from JWT `sub` claim) is logged for audit +4. Tool executes via shared service account platform token + +### Legacy Service Account Mode + +A single shared service account authenticates all requests. + +```bash +CYBERARK_CLIENT_ID=service-account-username +CYBERARK_CLIENT_SECRET=service-account-password +``` + ### Token Management -- **Expiration**: 15 minutes with automatic refresh -- **Caching**: Secure in-memory token caching -- **Concurrency**: Thread-safe token refresh with double-checked locking +- **OAuth Mode**: JWT verification via JWKS; shared service account platform token for API calls +- **Legacy Mode**: 15-minute token expiration with automatic refresh - **Error Recovery**: Automatic retry on 401 authentication errors ## Tool Categories The server provides 53 enterprise-grade tools organized across all 5 CyberArk PCloud services: -### Account Management Tools (17 tools) +### Account Management Tools (18 tools) **Core Operations**: `list_accounts`, `get_account_details`, `search_accounts`, `create_account`, `update_account`, `delete_account` **Password Management**: `change_account_password`, `set_next_password`, `verify_account_password`, `reconcile_account_password` **Advanced Search**: `filter_accounts_by_platform_group`, `filter_accounts_by_environment`, `filter_accounts_by_management_status`, `group_accounts_by_safe`, `group_accounts_by_platform`, `analyze_account_distribution`, `search_accounts_by_pattern`, `count_accounts_by_criteria` -### Safe Management Tools (11 tools) +### Safe Management Tools (10 tools) **Core Operations**: `list_safes`, `get_safe_details`, `add_safe`, `update_safe`, `delete_safe` **Member Management**: `list_safe_members`, `get_safe_member_details`, `add_safe_member`, `update_safe_member`, `remove_safe_member` @@ -80,12 +102,13 @@ The server provides 53 enterprise-grade tools organized across all 5 CyberArk PC **Auth Methods**: `list_application_auth_methods`, `get_application_auth_method_details`, `add_application_auth_method`, `delete_application_auth_method` **Statistics**: `get_applications_stats` -### Additional Tools -**Health Monitoring**: `health_check` - Comprehensive system status verification +### Session Monitoring Tools (6 tools) +**Session Management**: `list_sessions`, `list_sessions_by_filter`, `get_session_details`, `count_sessions` +**Activity Tracking**: `list_session_activities`, `get_session_statistics` ## Account Management Tools -**🤖 LLM REFERENCE**: This section documents core account tools. The server provides 17 total account management tools including: `update_account`, `delete_account`, `filter_accounts_by_platform_group`, `filter_accounts_by_environment`, `filter_accounts_by_management_status`, `group_accounts_by_safe`, `group_accounts_by_platform`, `analyze_account_distribution`, `search_accounts_by_pattern`, `count_accounts_by_criteria`. For complete specifications of all tools, refer to `src/mcp_privilege_cloud/mcp_server.py` implementations using ArkPCloudAccountsService. +**🤖 LLM REFERENCE**: This section documents core account tools. The server provides 18 total account management tools including: `update_account`, `delete_account`, `filter_accounts_by_platform_group`, `filter_accounts_by_environment`, `filter_accounts_by_management_status`, `group_accounts_by_safe`, `group_accounts_by_platform`, `analyze_account_distribution`, `search_accounts_by_pattern`, `count_accounts_by_criteria`. For complete specifications of all tools, refer to `src/mcp_privilege_cloud/mcp_server.py` implementations using ArkPCloudAccountsService. ### `list_accounts` @@ -651,48 +674,6 @@ await client.call_tool("reconcile_account_password", { } ``` -## Health Monitoring Tools - -### `health_check` - -**Description**: Perform a comprehensive health check of the CyberArk connection and system status. - -**API Endpoint**: Multiple endpoints for comprehensive validation - -**Parameters**: None - -**Returns**: Health status object with system information - -**Example Usage**: -```python -# Perform health check -await client.call_tool("health_check", {}) -``` - -**Response Examples**: - -**Healthy System**: -```json -{ - "status": "healthy", - "timestamp": "2025-06-28T10:30:00Z", - "safes_accessible": 12, - "authentication": "valid", - "api_connectivity": "operational" -} -``` - -**System Issues**: -```json -{ - "status": "unhealthy", - "timestamp": "2025-06-28T10:30:00Z", - "error": "Authentication failed: Invalid credentials", - "authentication": "failed", - "api_connectivity": "unreachable" -} -``` - ## Error Handling ### Standard Error Responses diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3eda0db..60fa5ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,33 +12,52 @@ The CyberArk Privilege Cloud MCP Server follows a **simplified, streamlined arch ## Core Components -The project is structured around four main modules leveraging the official ark-sdk-python: +The project is structured around these core modules leveraging the official ark-sdk-python: ``` src/mcp_privilege_cloud/ -├── sdk_auth.py # Official SDK authentication wrapper -├── server.py # Core CyberArk API integration via SDK -├── mcp_server.py # MCP protocol implementation -└── exceptions.py # Custom exception handling +├── sdk_auth.py # Legacy service account authentication +├── token_verifier.py # JWT verification via CyberArk Identity JWKS +├── server.py # Core CyberArk API integration via SDK +├── mcp_server.py # MCP protocol implementation (Streamable HTTP) +└── exceptions.py # Custom exception handling ``` -### 1. SDK Authentication Module (`sdk_auth.py`) +### Authentication Architecture (Dual-Mode) -**Purpose**: Official CyberArk SDK authentication wrapper for enterprise-grade security +The server supports two authentication modes: + +**OAuth Per-User Mode** (recommended for multi-user deployments): +- Users authenticate via CyberArk Identity OAuth Authorization Code flow +- Each user's JWT is verified against the JWKS endpoint by `CyberArkTokenVerifier` +- A shared service account platform token is used for all PCloud API calls +- User identity from the JWT is logged for audit purposes + +**Legacy Service Account Mode** (single shared identity): +- A single service account authenticates via `CYBERARK_CLIENT_ID`/`CYBERARK_CLIENT_SECRET` +- All requests share one `CyberArkMCPServer` instance +- Authentication handled by `CyberArkSDKAuthenticator` in `sdk_auth.py` + +### 1. Token Verifier (`token_verifier.py`) + +**Purpose**: MCP SDK `TokenVerifier` protocol implementation for CyberArk Identity JWTs + +**Key Features**: +- **JWKS Validation**: Verifies JWT signatures against CyberArk Identity `/oauth2/certs` +- **MCP Protocol Compliance**: Returns `AccessToken` for MCP auth middleware +- **Claim Validation**: Enforces `exp`, `iss`, `sub`, `aud` with RS256 algorithm +- **Graceful Failures**: Returns `None` on any verification failure (no exceptions) + +### 2. Legacy SDK Authentication (`sdk_auth.py`) + +**Purpose**: Service account authentication wrapper (legacy mode) **Key Features**: - **Official SDK Integration**: Uses ark-sdk-python for authenticated API access - **Automatic Token Management**: SDK handles token lifecycle automatically -- **Enterprise Security**: CyberArk-tested authentication patterns - **Environment Configuration**: Seamless integration with existing credential management -- **Future-Proof Design**: Automatic compatibility with SDK updates -**Implementation Details**: -- Wraps ark-sdk-python authentication client -- Provides consistent interface for server methods -- Leverages SDK's built-in token management and error handling - -### 2. Server Module (`server.py`) +### 3. Server Module (`server.py`) **Purpose**: Core business logic using official ark-sdk-python services @@ -55,13 +74,13 @@ src/mcp_privilege_cloud/ - Direct SDK method invocation with enhanced error handling - Response normalization while preserving data integrity -### 3. MCP Integration (`mcp_server.py`) +### 4. MCP Integration (`mcp_server.py`) **Purpose**: Model Context Protocol implementation with enhanced SDK-powered tools **Key Features**: - **FastMCP Server**: MCP protocol implementation -- **Comprehensive Tool Suite**: 53 enterprise-grade action tools for complete CyberArk PCloud operations across all 5 services (18+10+12+8+5) +- **Comprehensive Tool Suite**: 53 enterprise-grade action tools for complete CyberArk PCloud operations across all 5 services (18+10+10+9+6) - **SDK-Powered Reliability**: All tools leverage official ark-sdk-python services - **Parameter Validation**: Enhanced input validation and type checking - **Cross-Platform Support**: Windows encoding compatibility @@ -74,8 +93,20 @@ src/mcp_privilege_cloud/ ## API Integration Architecture -### SDK-Enhanced Authentication Flow +### Authentication Flows + +**OAuth Per-User Mode:** +``` +MCP Client → Bearer Token → CyberArkTokenVerifier (JWKS) → AccessToken + ↓ +execute_tool() → get_access_token() → verify user identity (audit log) + ↓ + Shared CyberArkMCPServer (service account) + ↓ + SDK Services → CyberArk PCloud API +``` +**Legacy Service Account Mode:** ``` Client Request → MCP Tool → Server Method → SDK Auth → ark-sdk-python → CyberArk Identity ↓ ↓ @@ -129,26 +160,39 @@ Server Method → SDK Service → CyberArk API → SDK Response → MCP Response ### Configuration Management -**Environment Variables**: +**OAuth Per-User Mode Environment Variables** (recommended): +- `CYBERARK_IDENTITY_TENANT_URL` - CyberArk Identity tenant URL (e.g., `https://abc1234.id.cyberark.cloud`) +- `CYBERARK_CLIENT_ID` - Service account login name (for PCloud platform token) +- `CYBERARK_CLIENT_SECRET` - Service account password +- `CYBERARK_OAUTH_CLIENT_ID` - OIDC app client ID from Trust tab (for DCR) +- `CYBERARK_OAUTH_CLIENT_SECRET` - OIDC app client secret from Trust tab (for DCR) +- `MCP_TRANSPORT` - Transport protocol: `stdio`, `sse`, or `streamable-http` (default: `stdio`) +- `MCP_HOST` - Server bind host (default: `127.0.0.1`) +- `MCP_PORT` - Server bind port (default: `8000`) +- `MCP_SERVER_URL` - Public URL for metadata (default: `http://{host}:{port}`) + +**Legacy Service Account Mode Environment Variables**: - `CYBERARK_CLIENT_ID` - OAuth service account username - `CYBERARK_CLIENT_SECRET` - Service account password +**Mode Detection**: The server automatically selects OAuth mode when `CYBERARK_IDENTITY_TENANT_URL` is set; otherwise falls back to legacy mode. + **Security Principles**: - Never log sensitive information (tokens, passwords) - Environment variable-based configuration only -- OAuth token caching with automatic refresh +- JWT verification via JWKS for per-user tokens - Principle of least privilege for service accounts ## Tool Architecture -The server exposes 45 enterprise-grade MCP tools organized by functionality across all 5 PCloud services, all powered by ark-sdk-python: +The server exposes 53 enterprise-grade MCP tools organized by functionality across all 5 PCloud services, all powered by ark-sdk-python: -**Account Management Tools (17 tools)**: +**Account Management Tools (18 tools)**: - Core Operations: `list_accounts`, `get_account_details`, `search_accounts`, `create_account`, `update_account`, `delete_account` - Password Management: `change_account_password`, `set_next_password`, `verify_account_password`, `reconcile_account_password` - Advanced Search: `filter_accounts_by_platform_group`, `filter_accounts_by_environment`, `filter_accounts_by_management_status`, `group_accounts_by_safe`, `group_accounts_by_platform`, `analyze_account_distribution`, `search_accounts_by_pattern`, `count_accounts_by_criteria` -**Safe Management Tools (11 tools)**: +**Safe Management Tools (10 tools)**: - Core Operations: `list_safes`, `get_safe_details`, `add_safe`, `update_safe`, `delete_safe` - Member Management: `list_safe_members`, `get_safe_member_details`, `add_safe_member`, `update_safe_member`, `remove_safe_member` @@ -162,6 +206,10 @@ The server exposes 45 enterprise-grade MCP tools organized by functionality acro - Auth Methods: `list_application_auth_methods`, `get_application_auth_method_details`, `add_application_auth_method`, `delete_application_auth_method` - Statistics: `get_applications_stats` +**Session Monitoring Tools (6 tools)**: +- Session Management: `list_sessions`, `list_sessions_by_filter`, `get_session_details`, `count_sessions` +- Activity Tracking: `list_session_activities`, `get_session_statistics` + All tools follow consistent SDK-powered patterns: - Direct SDK service method invocation - Enhanced parameter validation with SDK models @@ -186,10 +234,10 @@ async def server_method(self, *args, **kwargs): **2. Streamlined Tool Execution**: ```python -async def execute_tool(tool_name: str, *args, **kwargs): - server_instance = get_server() - server_method = getattr(server_instance, tool_name) - return await server_method(*args, **kwargs) +async def execute_tool(tool_name: str, ctx=None, **kwargs): + app_ctx = ctx.request_context.lifespan_context + server_method = getattr(app_ctx.server, tool_name) + return await server_method(**kwargs) ``` **3. Simplified Service Management**: diff --git a/docs/CLAUDE_AI_OAUTH_INVESTIGATION.md b/docs/CLAUDE_AI_OAUTH_INVESTIGATION.md new file mode 100644 index 0000000..3694a50 --- /dev/null +++ b/docs/CLAUDE_AI_OAUTH_INVESTIGATION.md @@ -0,0 +1,177 @@ +# Claude.ai Remote MCP OAuth Integration — Investigation Report + +## Goal + +Connect the MCP Privilege Cloud server as a **Remote MCP Server** in claude.ai using OAuth 2.1 with CyberArk Identity as the authorization server. + +## Background + +Claude.ai supports adding Remote MCP Servers that require OAuth authentication. The MCP spec (2025-03-26) defines a discovery and authorization flow based on: + +- **RFC 9728** — OAuth 2.0 Protected Resource Metadata (`/.well-known/oauth-protected-resource`) +- **RFC 8414** — OAuth 2.0 Authorization Server Metadata (`/.well-known/oauth-authorization-server`) +- **RFC 7591** — OAuth 2.0 Dynamic Client Registration (`POST /register`) +- **OAuth 2.1** — Authorization Code + PKCE + +Our server delegates authentication to CyberArk Identity (an external authorization server) rather than being its own authorization server. This creates a mismatch because: + +1. FastMCP only creates `/.well-known/oauth-authorization-server` when acting as its own auth server (via `auth_server_provider`) +2. CyberArk Identity serves `/.well-known/openid-configuration` (OIDC) but NOT `/.well-known/oauth-authorization-server` (RFC 8414) + +## What We Implemented + +### 1. RFC 8414 Authorization Server Metadata Endpoint (WORKING) + +**Problem**: claude.ai fetches `/.well-known/oauth-authorization-server` from the MCP server and gets 404. + +**Solution**: Added a `custom_route` on FastMCP that fetches CyberArk Identity's `/.well-known/openid-configuration`, caches it, and maps it to RFC 8414 format. + +``` +GET https://mcp.ams.iosharp.com/.well-known/oauth-authorization-server +→ 200 OK with authorization_endpoint, token_endpoint, registration_endpoint, etc. +``` + +**Key detail — app-specific endpoints**: CyberArk Identity's tenant-level OIDC discovery returns generic endpoints (`/Oauth/Openid`, `/Oauth/GetToken`) that don't bind to a specific OIDC app. These fail with a generic error page. The correct endpoints include the app ID in the path: + +- `/OAuth2/Authorize/{app_id}` (not `/Oauth/Openid`) +- `/OAuth2/Token/{app_id}` (not `/Oauth/GetToken`) + +### 2. Protected Resource Metadata — `authorization_servers` Fix (WORKING) + +**Problem**: FastMCP populates `authorization_servers` in `/.well-known/oauth-protected-resource` from `AuthSettings.issuer_url`. We initially set this to CyberArk Identity's tenant URL, so claude.ai fetched `/.well-known/oauth-authorization-server` from CyberArk Identity (which doesn't serve it) rather than from our server. + +**Solution**: Set `issuer_url` to our own server URL (`MCP_SERVER_URL`). This makes `authorization_servers` point to our server, where we serve the proxied metadata. + +```python +kwargs["auth"] = AuthSettings( + issuer_url=AnyHttpUrl(server_url), # our server, NOT tenant_url + resource_server_url=AnyHttpUrl(server_url), +) +``` + +### 3. Dynamic Client Registration Proxy (WORKING) + +**Problem**: claude.ai requires RFC 7591 Dynamic Client Registration to obtain `client_id` (and optionally `client_secret`) before starting the OAuth flow. Without a `registration_endpoint` in the metadata, claude.ai can't proceed. + +**Solution**: Added a `/register` custom route that returns pre-configured CyberArk Identity OIDC app credentials from environment variables. + +``` +POST https://mcp.ams.iosharp.com/register +← 201 Created { "client_id": "...", "client_secret": "...", "token_endpoint_auth_method": "client_secret_post" } +``` + +The DCR response uses: +- `CYBERARK_CLIENT_ID` env var as `client_id` (falls back to `CYBERARK_OIDC_APP_ID`) +- `CYBERARK_CLIENT_SECRET` env var as `client_secret` (if set; otherwise public client) + +### 4. Custom OIDC App in CyberArk Identity (PARTIALLY WORKING) + +**Problem**: The built-in `__idaptive_cybr_user_oidc` app doesn't allow adding redirect URIs, so claude.ai's callback URL (`https://claude.ai/api/mcp/auth_callback`) couldn't be registered. + +**Solution**: Created a custom OIDC app `mcpprivilegecloud` in CyberArk Identity with the claude.ai callback as an allowed redirect URI. + +The OIDC app ID is configurable via `CYBERARK_OIDC_APP_ID` env var (default: `mcpprivilegecloud`). + +## What Worked End-to-End + +The following steps complete successfully: + +| Step | Endpoint | Status | +|------|----------|--------| +| 1. Initial request | `POST /mcp` | 401 Unauthorized (correct) | +| 2. Protected resource discovery | `GET /.well-known/oauth-protected-resource` | 200 OK | +| 3. Authorization server discovery | `GET /.well-known/oauth-authorization-server` | 200 OK | +| 4. Dynamic Client Registration | `POST /register` | 201 Created | +| 5. Redirect to CyberArk Identity | Browser → `/OAuth2/Authorize/mcpprivilegecloud` | 302 → login page | +| 6. User authentication | CyberArk Identity login form | User authenticates successfully | +| 7. Authorization code issuance | CyberArk Identity → callback | **FAILS: `invalid_client` / `invalid client creds`** | + +## Where It Fails — The Blocking Issue + +After the user successfully authenticates with CyberArk Identity, the authorization server returns an error instead of an authorization code: + +``` +https://claude.ai/api/mcp/auth_callback?error=invalid_client&error_description=invalid%20client%20creds +``` + +This means CyberArk Identity **authenticated the user** but **rejected the OAuth client** when trying to issue the authorization code. + +### Root Cause Analysis + +CyberArk Identity's OIDC app configuration does not appear to support the standard OAuth 2.1 authorization code flow as expected by MCP clients. Specifically: + +1. **The built-in `__idaptive_cybr_user_oidc` app** — Cannot modify redirect URIs, so it can't support claude.ai's callback URL. + +2. **Custom OIDC apps** — Can be created, but CyberArk Identity returns `invalid_client` after user authentication regardless of: + - Client ID type set to "Anything" + - App set to "Confidential" with matching `client_id` and `client_secret` + - Redirect URI properly registered + - All standard OAuth parameters present (response_type, code_challenge, scope, etc.) + +3. **The `invalid client creds` error** occurs AFTER user authentication, during the authorization code issuance step. This suggests CyberArk Identity's OIDC implementation has specific requirements for client validation that differ from standard OAuth 2.1. + +### Possible Explanations + +- CyberArk Identity may require a specific client authentication method during the authorization request (not just token exchange) +- The OIDC app may need additional configuration beyond what's visible in the admin UI (e.g., API-level settings, specific OAuth profile, or trust relationships) +- The `client_id` format expected by CyberArk Identity for custom apps may differ from what we're sending +- CyberArk Identity may not fully support the public/PKCE flow that MCP clients expect, or may require additional parameters + +## Files Modified + +| File | Purpose | +|------|---------| +| `src/mcp_privilege_cloud/mcp_server.py` | Added `_fetch_oidc_discovery()`, `_build_oauth_metadata()`, `_build_dcr_response()`, `_register_oauth_routes()` with custom routes for `/.well-known/oauth-authorization-server` and `/register` | +| `src/mcp_privilege_cloud/token_verifier.py` | Made `CYBERARK_OIDC_APP_ID` configurable via env var (default: `mcpprivilegecloud`) | +| `tests/test_oauth_metadata.py` | 16 tests covering OIDC discovery fetching/caching, metadata building, DCR response, route registration | +| `CLAUDE.md` | Updated status and test file listings | + +## Environment Variables (New/Changed) + +| Variable | Purpose | Default | +|----------|---------|---------| +| `CYBERARK_OIDC_APP_ID` | OIDC app ID for URL paths and token audience validation | `mcpprivilegecloud` | +| `CYBERARK_CLIENT_ID` | OAuth client_id returned in DCR response | Falls back to `CYBERARK_OIDC_APP_ID` | +| `CYBERARK_CLIENT_SECRET` | OAuth client_secret returned in DCR (confidential client) | None (public client) | + +## Likely Resolution: DCR Client ID Fix + +**Update (2026-02-26)**: CyberArk Identity OAuth2 Client apps do NOT provide their own client_id/client_secret. Instead, the `client_id` and `client_secret` come from a **service user** marked as "OAuth 2.0 confidential client". The service user's login name IS the `client_id` (format: `@cyberark.cloud.`), and the password IS the `client_secret`. + +This means `CYBERARK_CLIENT_ID` was already the correct value for DCR all along. The `invalid_client` error from Step 7 may instead be caused by: +- Missing trusted DNS domains (most likely) +- Client ID Type misconfiguration on the OAuth2 Client app +- Service user not properly marked as OAuth 2.0 confidential client + +Code changes made: +- `CYBERARK_OAUTH_CLIENT_ID`/`CYBERARK_OAUTH_CLIENT_SECRET` added as optional overrides (for cases where different credentials are needed for DCR vs legacy mode) +- DCR priority chain: `CYBERARK_OAUTH_CLIENT_ID` > `CYBERARK_CLIENT_ID` > `CYBERARK_OIDC_APP_ID` +- Token verifier audience uses same priority chain +- `DEPLOY_ENV` removed as dead code + +**To verify**: Ensure `CYBERARK_CLIENT_ID` is a service user marked as "OAuth 2.0 confidential client", trusted DNS domains are configured, then test the full claude.ai OAuth flow. + +## Other Next Steps + +1. **CyberArk Identity investigation** — If the DCR fix doesn't fully resolve the issue, determine the exact OIDC app configuration required: + - CyberArk support engagement + - Reviewing CyberArk Identity API documentation for OIDC app configuration + - Testing with CyberArk Identity's own OAuth playground/test tools + +2. **Alternative: Token proxy approach** — Instead of delegating the OAuth flow to CyberArk Identity, our MCP server could act as its own authorization server (using FastMCP's `auth_server_provider`) that: + - Handles the OAuth flow with claude.ai directly + - Authenticates users against CyberArk Identity on the backend + - Issues its own tokens to the MCP client + - This is the "Third-Party Authorization Flow" described in the MCP spec + +3. **Alternative: mcp-remote proxy** — Use the community `mcp-remote` tool as a bridge, which handles OAuth complexity for providers that don't fully support Dynamic Client Registration. + +## References + +- [MCP Authorization Specification (2025-03-26)](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) +- [Building custom connectors via remote MCP servers (claude.ai)](https://support.claude.com/en/articles/11503834-building-custom-connectors-via-remote-mcp-servers) +- [RFC 8414 — OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) +- [RFC 7591 — OAuth 2.0 Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) +- [RFC 9728 — OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) +- [Claude OAuth requires DCR — GitHub Issue](https://github.com/anthropics/claude-code/issues/2527) +- [Evolving OAuth Client Registration in MCP](https://blog.modelcontextprotocol.io/posts/client_registration/) diff --git a/docs/CYBERARK_IDENTITY_SETUP.md b/docs/CYBERARK_IDENTITY_SETUP.md new file mode 100644 index 0000000..1673110 --- /dev/null +++ b/docs/CYBERARK_IDENTITY_SETUP.md @@ -0,0 +1,255 @@ +# CyberArk Identity Setup Guide + +This guide explains how to configure CyberArk Identity for use with the MCP Privilege Cloud server in OAuth per-user mode. + +## Prerequisites + +- CyberArk Identity administrator access +- CyberArk Privilege Cloud tenant + +## Part A: Create a Service User (OAuth Confidential Client) + +CyberArk Identity OAuth2 apps do NOT provide their own client_id/client_secret. Instead, credentials come from a **service user** marked as an OAuth 2.0 confidential client. + +### Step 1: Create the Service User + +1. Navigate to **Core Services** > **Users** > **Add User** +2. Fill in: + - Login name (e.g., `mcp-service@cyberark.cloud.XXXX`) + - Display name + - Password +3. In Status checklist, select **"Is OAuth confidential client"** + - This auto-selects: "Is Service User", "Password never expires" +4. Click **Create User** + +### Step 2: Assign Roles + +1. Navigate to **Core Services** > **Roles** > select the required role +2. Add the service user as a member +3. Typical roles: Privilege Cloud Administrator, Safe Management, etc. + +### Step 3: Note Credentials + +- Login name = `CYBERARK_CLIENT_ID` (e.g., `mcp-service@cyberark.cloud.3240`) +- Password = `CYBERARK_CLIENT_SECRET` +- These credentials are used **only** for PCloud API access via `/oauth2/platformtoken` (client_credentials grant) +- They are NOT used for the OAuth authorization flow — that uses the OIDC app credentials from Part B + +## Part B: Create the OIDC Application + +The OIDC app defines the OAuth/OIDC endpoints, redirect URIs, token settings, and issues the JWTs that the MCP server validates on every request. + +### Step 1: Create the App + +1. Navigate to **Apps & Widgets** > **Add Web Apps** > **Custom** > select the **OpenID Connect** template +2. Name the app `mcpprivilegecloud` (this is the default `CYBERARK_OIDC_APP_ID`) + - This name becomes the **ServiceName** and appears in OIDC URL paths (e.g., `/OAuth2/Authorize/mcpprivilegecloud`) + +### Step 2: Configure General Usage Tab + +- **Client ID Type**: Set to **"Anything"** + - "Anything" supports BOTH PKCE (claude.ai) and confidential (Copilot Studio) clients + - "List" = PKCE only, "Confidential" = secret required + - If only using PKCE clients (claude.ai): "List" also works + +### Step 3: Configure Trust Tab + +Add redirect URIs for each MCP client: + +| Client | Redirect URI | +|--------|-------------| +| claude.ai | `https://claude.ai/api/mcp/auth_callback` | +| Copilot Studio | (Copilot Studio's callback URL) | +| Local development | `http://localhost:8000/oauth/callback` | + +**Important**: After saving the Trust tab, note the auto-generated credentials: +- **Client ID** (UUID format, e.g., `c21840a7-...`) = `CYBERARK_OAUTH_CLIENT_ID` +- **Client Secret** = `CYBERARK_OAUTH_CLIENT_SECRET` + +These are different from the service user credentials in Part A. + +### Step 4: Configure Tokens Tab + +**Signing Algorithm**: Must be **RS256** (the only algorithm the MCP server accepts for JWT signature verification). + +**Token Lifetime**: 1 hour (3600s) recommended. + +**Scopes**: Configure at least `openid profile`: + +| Scope | Required | Why | +|-------|----------|-----| +| `openid` | Yes | Produces the `sub` claim (user identity) — the MCP server requires this claim and rejects tokens without it | +| `profile` | Recommended | Adds `unique_name` and display name claims for richer audit logging | + +**Required JWT Claims**: The MCP server validates these claims on every request. All are standard OIDC claims produced automatically by CyberArk Identity when the scopes above are configured: + +| Claim | Set By | Validated Against | +|-------|--------|-------------------| +| `sub` | `openid` scope | Must be non-empty (used as user identity for audit logging) | +| `exp` | Always present | Must not be expired | +| `iss` | Always present | Must match `{tenant_url}/{app_name}/` (e.g., `https://abc1234.id.cyberark.cloud/mcpprivilegecloud/`) | +| `aud` | Set to the `client_id` used in the authorization request | Must match `CYBERARK_OAUTH_CLIENT_ID` (see [JWT Validation](#jwt-validation) below) | + +### Step 5: Add Trusted DNS Domains (Required for PKCE Clients) + +CyberArk Identity requires PKCE clients to have their domain added to trusted DNS domains: + +1. Navigate to **Settings** > **Authentication** > **Security Settings** > **API Security** +2. Under **Trusted DNS Domains for API Calls**, add: + - `claude.ai` (for claude.ai integration) + - Any other MCP client domains that will use the OAuth flow +3. Save the settings + +**Without this step, CyberArk Identity will return `invalid_client` errors during the authorization code flow.** + +### Step 6: Assign Users/Roles + +1. Navigate to the application's **Permissions** tab +2. Add the users or roles that should have access to the MCP server +3. Users must also have appropriate **Privilege Cloud** permissions (safe access, platform admin, etc.) + +### Step 7: Verify OIDC Discovery + +Verify the OIDC discovery endpoint is accessible for your app: + +```bash +curl https://YOUR_TENANT.id.cyberark.cloud/mcpprivilegecloud/.well-known/openid-configuration +``` + +This should return JSON with `authorization_endpoint`, `token_endpoint`, and `jwks_uri` that include the app ID in the path (e.g., `/OAuth2/Authorize/mcpprivilegecloud`). + +## Part C: MCP Server Environment Variables + +### OAuth Per-User Mode (Recommended) + +```bash +# Required: triggers OAuth mode +CYBERARK_IDENTITY_TENANT_URL=https://abc1234.id.cyberark.cloud + +# Service account (Part A) -- for PCloud API access via platform token +CYBERARK_CLIENT_ID=mcp-service@cyberark.cloud.XXXX +CYBERARK_CLIENT_SECRET=service-user-password + +# OIDC app (Part B, Step 3) -- injected server-side in /token proxy, never exposed via DCR +CYBERARK_OAUTH_CLIENT_ID=your-oidc-app-client-id +CYBERARK_OAUTH_CLIENT_SECRET=your-oidc-app-client-secret +``` + +### Legacy Service Account Mode + +Uses the service account credentials directly for all PCloud API calls (no OAuth): + +```bash +CYBERARK_CLIENT_ID=mcp-service@cyberark.cloud.XXXX +CYBERARK_CLIENT_SECRET=service-user-password +``` + +## How It Works + +1. **User connects** to the MCP server via an MCP client (claude.ai, Copilot Studio, etc.) +2. **MCP client** calls DCR (`/register`) and receives the `client_id` (public client, no secret) +3. **MCP client** redirects to CyberArk Identity for user authentication (authorization_code flow with PKCE) +4. **MCP server** receives the Bearer JWT token with each request +5. **CyberArkTokenVerifier** validates the JWT signature against the JWKS endpoint (`/OAuth2/Keys/mcpprivilegecloud`, RS256) +6. **execute_tool()** verifies user identity from the OIDC JWT, then routes API calls through the service account's platform token +7. **Tools execute** under the service account's CyberArk permissions, with the authenticated user's identity logged for audit + +**Architecture note**: All API calls use a single shared service account platform token. The OIDC JWT is used solely for identity verification and audit logging -- it is not used for PCloud API authorization. + +## JWT Validation + +The MCP server validates every incoming Bearer JWT against CyberArk Identity's JWKS endpoint. Understanding these checks helps diagnose authentication failures. + +**Signature**: RS256 only, verified against keys from `{tenant}/{app_name}/.well-known/openid-configuration` → `jwks_uri`. Keys are cached after first fetch. + +**Required claims** (token is rejected if any are missing): + +| Claim | Validation Rule | Common Failure | +|-------|----------------|----------------| +| `sub` | Must be non-empty | Missing when `openid` scope is not configured on the app | +| `exp` | Must be in the future | Token has expired — check token lifetime in Tokens tab | +| `iss` | Must equal `{tenant_url}/{app_name}/` | Wrong app name or tenant URL in `CYBERARK_IDENTITY_TENANT_URL` | +| `aud` | Must match a configured audience value | See audience resolution below | + +**Audience resolution**: The server accepts these values as a valid `aud` claim: + +1. `CYBERARK_OAUTH_CLIENT_ID` — the Trust tab client ID (Part B, Step 3) +2. Fallback: `CYBERARK_OIDC_APP_ID` (default: `mcpprivilegecloud`) + +CyberArk Identity sets `aud` to the `client_id` used in the authorization request. When DCR returns `CYBERARK_OAUTH_CLIENT_ID`, tokens will have that UUID as the audience. + +**Debugging token issues**: Set `CYBERARK_LOG_LEVEL=DEBUG` to see the actual `iss`, `aud`, and `sub` claims from rejected tokens. You can also decode a token manually: + +```bash +# Decode JWT payload (no verification) to inspect claims +echo "" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool +``` + +## OIDC App Configuration Reference + +The following details describe the expected configuration of the CyberArk Identity OIDC app created in Part B. + +| Setting | Value | +|---------|-------| +| App Name | `MCP Privilege Cloud` (display name) | +| App Type | Web (OpenID Connect) | +| ServiceName (URL slug) | `mcpprivilegecloud` | +| Client ID Type | Anything (supports PKCE + confidential clients) | +| Signing Algorithm | RS256 | +| Scopes | `openid profile` minimum | +| State | Active | +| OIDC Discovery | `https://{tenant}/mcpprivilegecloud/.well-known/openid-configuration` | +| JWKS Endpoint | `https://{tenant}/OAuth2/Keys/mcpprivilegecloud` | +| Authorization Endpoint | `https://{tenant}/OAuth2/Authorize/mcpprivilegecloud` | +| Token Endpoint | `https://{tenant}/OAuth2/Token/mcpprivilegecloud` | + +### Tenant-Level OIDC Discovery + +The tenant-level discovery document (at `/.well-known/openid-configuration`) is NOT used by the MCP server — it exposes generic endpoints that don't resolve the app context. The MCP server uses the app-specific discovery at `/{app_name}/.well-known/openid-configuration` instead. + +For reference, the tenant-level discovery exposes: + +| Field | Value | +|-------|-------| +| `issuer` | `https://{tenant}/` | +| `authorization_endpoint` | `https://{tenant}/Oauth/Openid` | +| `token_endpoint` | `https://{tenant}/Oauth/GetToken` | +| `jwks_uri` | `https://{tenant}/Oauth/Keys` | +| `userinfo_endpoint` | `https://{tenant}/Oauth/UserInfo` | +| `scopes_supported` | `openid`, `profile`, `email`, `address`, `phone` | +| `id_token_signing_alg_values_supported` | `RS256` | +| `response_types_supported` | `code`, `id_token`, `id_token token`, `code id_token`, `code token`, `code id_token token` | +| `code_challenge_methods_supported` | `plain`, `S256` | + +## Security Limitations + +> **Important**: All authenticated users share the same service account's PCloud permissions. The OIDC JWT verifies *who* the user is, but API calls are executed using the service account's platform token. This means: +> +> - Any authenticated user can perform any operation the service account is authorized for +> - Per-user permission enforcement is **not** supported — CyberArk PCloud does not accept OIDC tokens for API authorization +> - The service account's roles and safe permissions define the ceiling for all users +> - User identity is logged for audit purposes only +> +> **Recommendation**: Grant the service account the minimum PCloud permissions required, and restrict which users can authenticate by limiting the OIDC app's assigned users/roles in CyberArk Identity (Part B, Step 6). + +## Security Considerations + +- JWTs are verified against the JWKS endpoint on every request (keys are cached) +- All PCloud API calls use a shared service account platform token (not per-user tokens) +- The OIDC JWT establishes user identity for audit logging only +- Service user credentials are stored server-side and never exposed to end users +- DCR (`/register`) returns public client only (`token_endpoint_auth_method: "none"`) -- secrets are injected server-side by the `/token` proxy +- Token verification uses RS256 signature validation via CyberArk Identity's public keys + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| `invalid_client` during auth | Verify trusted DNS domains include the MCP client's domain (Part B, Step 5) and that Client ID Type is set to "Anything" | +| "Token verification failed" | Set `CYBERARK_LOG_LEVEL=DEBUG` to see actual vs expected claims. Verify `CYBERARK_IDENTITY_TENANT_URL` is correct | +| "Token missing required 'sub' claim" | Ensure `openid` scope is configured on the Tokens tab (Part B, Step 4) | +| "JWKS connection failed" | Verify `CYBERARK_IDENTITY_TENANT_URL` is reachable and the app's OIDC discovery endpoint responds | +| Server starts in legacy mode | Ensure `CYBERARK_IDENTITY_TENANT_URL` is set | +| Copilot Studio auth fails | Ensure `CYBERARK_OAUTH_CLIENT_SECRET` is set and Client ID Type is "Anything" or "Confidential" | +| JWT `aud` claim mismatch | Verify `CYBERARK_OAUTH_CLIENT_ID` matches the Trust tab client ID (decode the token to check — see [JWT Validation](#jwt-validation)) | +| JWT `iss` claim mismatch | The issuer must be `{tenant_url}/{app_name}/` — verify the app name matches `CYBERARK_OIDC_APP_ID` (default: `mcpprivilegecloud`) | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a6e54b2..cb5c10d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -46,25 +46,25 @@ This comprehensive guide provides everything developers need to contribute to th 3. **Verify setup**: ```bash # Test basic functionality - python -c " - from src.mcp_privilege_cloud.server import CyberArkMCPServer + python3 -c " + from mcp_privilege_cloud.server import CyberArkMCPServer import asyncio server = CyberArkMCPServer.from_environment() health = asyncio.run(server.health_check()) print('Health:', health['status']) " - + # Run tests - pytest + uv run pytest ``` ### Development Tools -The project includes several entry points for development: +The project includes these entry points for development: -- **`run_server.py`** - Multiplatform MCP server launcher -- **`debug_platform_api.py`** - Platform API debugging script -- **`test_mvp.py`** - MVP functionality verification +- **`uv run mcp-privilege-cloud`** - Development server execution +- **`python -m mcp_privilege_cloud`** - Standard Python module execution +- **`uvx mcp-privilege-cloud`** - Production execution ## Architecture @@ -166,39 +166,12 @@ Follow this systematic approach for adding new action tools: ```python # src/mcp_privilege_cloud/mcp_server.py @mcp.tool() - async def new_action_tool(param1: str) -> Dict[str, Any]: + async def new_action_tool( + param1: str, + ctx: Optional[Context[ServerSession, AppContext]] = None + ) -> Dict[str, Any]: """MCP tool wrapper with parameter validation.""" - server = CyberArkMCPServer.from_environment() - return await server.new_action_tool(param1) - ``` - -### Adding New Resources - -For read-only data access, use the resource system: - -1. **Create Resource Class**: - ```python - # src/mcp_privilege_cloud/resources/ - class NewDataResource(ResourceBase): - """Resource for accessing new data type.""" - - async def read(self) -> str: - # Return JSON data - pass - ``` - -2. **Register Resource**: - ```python - # src/mcp_privilege_cloud/mcp_server.py in setup_resources() - resource_registry.register_resource("new-data", NewDataResource) - resource_registry.register_resource("new-data/{item_id}", NewDataItemResource) - ``` - -3. **Test Integration**: - ```bash - # Test with MCP Inspector - python run_server.py - # Connect Inspector and validate resource access + return await execute_tool("new_action_tool", ctx=ctx, param1=param1) ``` ## Code Quality Standards @@ -266,36 +239,47 @@ The test suite is organized into focused categories: ``` tests/ -├── test_core_functionality.py # Auth, server core, platforms (64+ tests) -├── test_account_operations.py # Account lifecycle (35+ tests) -├── test_mcp_integration.py # MCP action tools (15+ tests) -├── test_integration.py # End-to-end tests (10+ tests) -└── test_resources.py # MCP resource tests (24+ tests) +├── conftest.py # Shared fixtures +├── test_core_functionality.py # Auth, server core, platforms +├── test_applications_service.py # Applications service +├── test_mcp_integration.py # MCP tool wrappers +├── test_integration_tools.py # End-to-end integration +├── test_enhanced_error_handling.py # Error handling validation +├── test_enhanced_error_messages.py # Error message consistency +├── test_token_verifier.py # JWT verification +├── test_token_bridge.py # Token bridge tests +├── test_oauth_integration.py # OAuth integration +├── test_oauth_metadata.py # RFC 8414 metadata +├── test_transport.py # Transport configuration +├── test_env_var_resolution.py # Env var priority chain +├── test_pcloud_url_resolution.py # PCloud URL resolution +├── test_context_injection.py # Context injection +├── test_lifespan.py # Lifespan management +├── test_response_models.py # Response models +└── test_typed_tools.py # Typed tool validation ``` ### Running Tests ```bash -# All tests (148+ total) -pytest +# All tests (292 total) +uv run pytest # Specific test categories -pytest tests/test_core_functionality.py # Core functionality -pytest tests/test_account_operations.py # Account operations -pytest tests/test_mcp_integration.py # MCP action tools -pytest tests/test_resources.py # MCP resources +uv run pytest tests/test_core_functionality.py # Core functionality +uv run pytest tests/test_mcp_integration.py # MCP action tools +uv run pytest tests/test_oauth_integration.py # OAuth integration # By markers -pytest -m auth # Authentication tests -pytest -m integration # Integration tests only -pytest -k platform # Platform management tests -pytest -k resource # Resource access tests +uv run pytest -m auth # Authentication tests +uv run pytest -m integration # Integration tests only +uv run pytest -k platform # Platform management tests # With coverage -pytest --cov=src/mcp_privilege_cloud +uv run pytest --cov=src/mcp_privilege_cloud # Verbose output with detailed failures -pytest -v --tb=short +uv run pytest -v --tb=short ``` ### Test Patterns @@ -359,10 +343,10 @@ async def test_api_error_handling(mock_server, mocker): pytest --cov=src/mcp_privilege_cloud # Check style compliance - flake8 src/ + ruff check src/ # Verify MCP integration - python run_server.py # Test with Inspector + uv run mcp-privilege-cloud # Test with Inspector ``` 4. **Submit Pull Request**: @@ -396,14 +380,11 @@ async def test_api_error_handling(mock_server, mocker): #### Python Environment ```bash # Check Python version and environment -python --version -which python -pip list | grep mcp - -# Recreate virtual environment if needed -rm -rf venv -python -m venv venv -source venv/bin/activate +python3 --version +which python3 +uv pip list | grep mcp + +# Reinstall dependencies if needed uv sync ``` @@ -414,7 +395,7 @@ ls -la src/mcp_privilege_cloud/ python -c "import sys; print(sys.path)" # Test imports directly -python -c "from src.mcp_privilege_cloud.server import CyberArkMCPServer" +python3 -c "from mcp_privilege_cloud.server import CyberArkMCPServer" ``` ### API Integration Issues @@ -422,12 +403,11 @@ python -c "from src.mcp_privilege_cloud.server import CyberArkMCPServer" #### Authentication Problems ```bash # Test authentication separately -python -c " -from src.mcp_privilege_cloud.sdk_auth import CyberArkSDKAuthenticator -import asyncio -auth = CyberArkAuthenticator.from_environment() -result = asyncio.run(auth.get_auth_header()) -print('Auth header obtained:', bool(result)) +python3 -c " +from mcp_privilege_cloud.sdk_auth import CyberArkSDKAuthenticator +auth = CyberArkSDKAuthenticator.from_environment() +client = auth.get_authenticated_client() +print('Authenticated:', bool(client)) " ``` @@ -623,14 +603,10 @@ python -m mcp_privilege_cloud # Module execution #### Tool Validation ```bash -# Verify tool definitions directly +# Verify tool definitions via MCP Inspector python -c " -from mcp_privilege_cloud.server import CyberArkMCPServer -server = CyberArkMCPServer.from_environment() -tools = server.get_available_tools() -print(f'Available tools ({len(tools)}):') -for tool in tools: - print(f'- {tool}') +from mcp_privilege_cloud.mcp_server import mcp +print(f'MCP server: {mcp.name}') " ``` @@ -642,9 +618,9 @@ for tool in tools: export CYBERARK_LOG_LEVEL=DEBUG # Test with minimal operations -python -c " +python3 -c " import time, asyncio -from src.mcp_privilege_cloud.server import CyberArkMCPServer +from mcp_privilege_cloud.server import CyberArkMCPServer server = CyberArkMCPServer.from_environment() start = time.time() health = asyncio.run(server.health_check()) @@ -658,9 +634,9 @@ Quick diagnostic commands for common issues: ```bash # Complete environment verification -python -c " +python3 -c " import os, sys -from src.mcp_privilege_cloud.server import CyberArkMCPServer +from mcp_privilege_cloud.server import CyberArkMCPServer print('Python:', sys.version) print('Environment variables set:', bool(os.getenv('CYBERARK_CLIENT_ID'))) try: @@ -671,15 +647,10 @@ except Exception as e: " # MCP server validation -python -c " -from src.mcp_privilege_cloud.mcp_server import mcp +python3 -c " +from mcp_privilege_cloud.mcp_server import mcp print(f'MCP server: {mcp.name}') -print(f'Action tools available: {len(list(mcp.list_tools()))}') -print(f'Resource endpoints available: {len(list(mcp.list_resources()))}') " - -# Platform management debug -python debug_platform_api.py ``` For additional support, review the [project documentation](docs/development/) or create an issue with detailed error information and environment details. \ No newline at end of file diff --git a/docs/MCP_SERVER_MODERNIZATION_PLAN.md b/docs/MCP_SERVER_MODERNIZATION_PLAN.md deleted file mode 100644 index 60e3c12..0000000 --- a/docs/MCP_SERVER_MODERNIZATION_PLAN.md +++ /dev/null @@ -1,198 +0,0 @@ -# MCP Server Modernization Plan (TDD Approach) - -**Status:** APPROVED - All Phases -**Created:** 2026-01-31 -**Scope:** Full modernization (Phases 1-4) - -## Overview - -Modernize the CyberArk MCP server to leverage current `mcp` SDK (v1.26.0) features including typed return values, lifespan management, and structured output. - -## TDD Workflow Per Phase - -Each phase follows: **RED** (failing test) → **GREEN** (minimal implementation) → **REFACTOR** (improve) - ---- - -## Phase 1: Response Models (High Priority) - -### 1.1 Create Response Model Definitions - -**RED - Write failing tests first:** -- Create `tests/test_response_models.py` with model validation tests -- Test JSON schema generation for MCP compatibility - -**GREEN - Implement models:** -- Create `src/mcp_privilege_cloud/models.py` with Pydantic models -- Models: AccountDetails, AccountSummary, SafeDetails, SafeMember, PlatformDetails, ApplicationDetails, SessionDetails - -**Files:** -- CREATE: `src/mcp_privilege_cloud/models.py` -- CREATE: `tests/test_response_models.py` - -### 1.2 Update Tool Return Types - -**RED - Test typed returns:** -- Create `tests/test_typed_tools.py` with typed return tests - -**GREEN - Update tool signatures:** -- Update high-use tools to return Pydantic models - -**Files:** -- MODIFY: `src/mcp_privilege_cloud/mcp_server.py` - ---- - -## Phase 2: Lifespan Management (Medium Priority) - -### 2.1 Implement Lifespan Context - -**RED - Test lifespan initialization:** -- Create `tests/test_lifespan.py` with lifecycle tests - -**GREEN - Implement lifespan:** -- Create `AppContext` dataclass -- Implement `app_lifespan()` async context manager -- Pass to `FastMCP(lifespan=app_lifespan)` - -**Files:** -- MODIFY: `src/mcp_privilege_cloud/mcp_server.py` -- CREATE: `tests/test_lifespan.py` - ---- - -## Phase 3: Context Injection (Medium Priority) - -### 3.1 Update Tools to Use Context - -**RED - Test context access:** -- Create `tests/test_context_injection.py` - -**GREEN - Update tool signatures:** -- Add `ctx: Context[ServerSession, AppContext]` parameter -- Access server via `ctx.request_context.lifespan_context.server` - -**Files:** -- MODIFY: `src/mcp_privilege_cloud/mcp_server.py` -- CREATE: `tests/test_context_injection.py` - ---- - -## Phase 4: Remove Legacy Code (Low Priority) - -### 4.1 Remove Manual Dict Conversion - -- Delete `_convert_to_dict()` function -- Delete `execute_tool()` wrapper -- Tools call server methods directly via context - -### 4.2 Remove Legacy Globals - -- Remove global `server` variable -- Remove `get_server()` function -- Remove `reset_server()` function -- Update `conftest.py` fixtures - ---- - -## Implementation Order (TDD Cycles) - -### Cycle 1: Models Foundation -1. Write `test_response_models.py` with model tests (RED) -2. Create `models.py` with Pydantic models (GREEN) -3. Run tests, verify pass -4. Refactor model organization if needed - -### Cycle 2: Lifespan Management -1. Write lifespan initialization/cleanup tests (RED) -2. Implement `app_lifespan()` and `AppContext` (GREEN) -3. Run tests, verify lifecycle works - -### Cycle 3: Context Injection -1. Write context access tests (RED) -2. Update pilot tools to use Context (GREEN) -3. Run tests, verify context flows correctly - -### Cycle 4: Update High-Use Tools -1. Write typed return tests (RED) -2. Update tools to return typed models (GREEN) -3. Run full test suite, verify no regression - -### Cycle 5: Cleanup -1. Remove `_convert_to_dict()` (GREEN) -2. Remove legacy globals (REFACTOR) -3. Final test run: all 160+ tests pass - ---- - -## Files Summary - -### New Files - -| File | Purpose | -|------|---------| -| `src/mcp_privilege_cloud/models.py` | Pydantic response models | -| `tests/test_response_models.py` | Model validation tests | -| `tests/test_lifespan.py` | Lifespan management tests | -| `tests/test_context_injection.py` | Context DI tests | -| `tests/test_typed_tools.py` | Typed return tests | - -### Modified Files - -| File | Changes | -|------|---------| -| `src/mcp_privilege_cloud/mcp_server.py` | Lifespan, Context, typed returns | -| `tests/conftest.py` | New fixtures for lifespan/context | - ---- - -## Verification Plan - -### After Each TDD Cycle - -```bash -# Run all tests -uv run pytest - -# Check coverage didn't drop -uv run pytest --cov=src/mcp_privilege_cloud --cov-report=term-missing - -# Verify MCP server still starts -uv run mcp-privilege-cloud & -# (verify no startup errors, then kill) -``` - -### Final Verification - -```bash -# Full test suite -uv run pytest -v - -# Coverage report -uv run pytest --cov=src/mcp_privilege_cloud --cov-report=html - -# MCP Inspector test -npx @modelcontextprotocol/inspector uv run mcp-privilege-cloud -# Verify tools show output schemas -``` - ---- - -## Risk Mitigation - -| Risk | Mitigation | -|------|------------| -| Breaking existing tests | Run full suite after each change | -| SDK response format changes | Models use `populate_by_name` for alias flexibility | -| Context not available in tests | Create mock context fixtures in conftest.py | -| Lifespan errors on startup | Keep fallback to lazy init during transition | - ---- - -## Rollback Plan - -If issues arise: -1. Models are additive - can coexist with `Any` returns -2. Lifespan is optional - can remove from FastMCP constructor -3. Context is optional - can fall back to global pattern -4. Git feature branch allows easy revert diff --git a/docs/TESTING.md b/docs/TESTING.md index b44c361..33e7e7d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,7 +4,7 @@ Testing guide for LLM development of the CyberArk Privilege Cloud MCP Server. Fo ## Current Test Status ✅ **VERIFIED** -**Test Suite Results**: **144/144 tests passing** (100% success rate) +**Test Suite Results**: **269 tests passing** **Code Coverage**: Comprehensive coverage across all 53 tools **Integration Testing**: All 53 MCP tools verified across all services with proper parameter passing @@ -23,15 +23,6 @@ uv run pytest --cov=src/mcp_privilege_cloud # Test categories pytest -m unit # Unit tests only pytest -m integration # Integration tests only - -# MCP Inspector CLI Testing (for LLMs) -# Requires: npm install -g @modelcontextprotocol/inspector -python test_mcp_cli.py health_check # Server health check -python test_mcp_cli.py list_tools # List all 53 tools -python test_mcp_cli.py call_tool list_accounts # Test specific tool -python test_mcp_cli.py capabilities # Discover tool capabilities -python test_mcp_cli.py validation_suite # Run comprehensive validation -python test_mcp_cli.py generate_report # Full test report ``` ## Test Structure @@ -69,19 +60,19 @@ The test suite is organized into specialized test files with comprehensive cover - **Password Management**: Complete password operations (change, set, verify, reconcile) - **Account Analytics**: Distribution analysis, pattern searching, criteria-based counting - **Grouping Operations**: Safe-based and platform-based account organization -- **Error Handling**: Comprehensive error scenarios across all 17 account management tools +- **Error Handling**: Comprehensive error scenarios across all 18 account management tools - **Special Character Handling**: Account data validation and security testing #### `tests/test_mcp_integration.py` (18+ tests) **TestMCPAccountTools** - Complete account management MCP tools (enhanced coverage) -- All 17 account management tool wrappers +- All 18 account management tool wrappers - Advanced search and analytics tool testing - Password management MCP tool wrappers (change, set, verify, reconcile) - Parameter passing and validation across expanded tool set - Error handling and response formatting **TestMCPSafeTools** - Safe management MCP tools integration (enhanced coverage) -- All 11 safe management tools including member management +- All 10 safe management tools including member management - Safe CRUD operations and member management workflows - Permission and access validation testing @@ -95,28 +86,54 @@ The test suite is organized into specialized test files with comprehensive cover - Authentication method management workflows - Application statistics and lifecycle operations -#### `tests/test_tools.py` (9+ tests) -- Direct tool function testing -- Parameter validation for all tools -- Error handling for tool operations -- Raw API data verification - -#### `tests/test_integration_tools.py` (2+ tests) +#### `tests/test_integration_tools.py` - Tool integration testing with server methods - End-to-end tool workflow validation -#### Legacy Test Files (Removed) -- `tests/test_resources.py` - Removed (resources converted to tools) -- `tests/test_integration_old.py` - Removed (legacy resource integration tests) +#### `tests/test_applications_service.py` +- Applications service CRUD operations +- Authentication method management workflows + +#### `tests/test_context_injection.py` +- Context injection and dual-mode lifespan testing + +#### `tests/test_token_verifier.py` +- CyberArkTokenVerifier JWT verification tests +- JWKS endpoint validation + +#### `tests/test_token_bridge.py` +- Service account token bridge testing + +#### `tests/test_oauth_integration.py` +- Full OAuth integration: dual-mode, execute_tool, lifespan + +#### `tests/test_oauth_metadata.py` +- RFC 8414 `/.well-known/oauth-authorization-server` endpoint tests + +#### `tests/test_transport.py` +- Streamable HTTP transport configuration tests + +#### `tests/test_lifespan.py` +- App lifespan context manager testing + +#### `tests/test_env_var_resolution.py` +- Environment variable priority chain tests + +#### `tests/test_pcloud_url_resolution.py` +- PCloud URL resolution and subdomain override tests -The testing architecture has been simplified by converting to a tool-based architecture while maintaining comprehensive coverage for all tool functionality. +#### `tests/test_enhanced_error_handling.py` +- Enhanced error handling validation + +#### `tests/test_enhanced_error_messages.py` +- Error message consistency testing ### Test Coverage Metrics -- **Total Tests**: 144+ tests across 6 test files -- **Target Coverage**: Minimum 80% code coverage maintained across 45-tool expansion +- **Total Tests**: 269 tests across 16 test files +- **Target Coverage**: Minimum 80% code coverage maintained across 53 tools - **Mock Strategy**: All external CyberArk API dependencies are mocked using official SDK patterns - **Test Types**: Unit, integration, MCP tools tests across all 5 PCloud services -- **Service Coverage**: Complete testing for ArkPCloudAccountsService, ArkPCloudSafesService, ArkPCloudPlatformsService, ArkPCloudApplicationsService +- **Service Coverage**: Complete testing for ArkPCloudAccountsService, ArkPCloudSafesService, ArkPCloudPlatformsService, ArkPCloudApplicationsService, ArkSMService ## Testing Strategy @@ -138,12 +155,13 @@ The testing architecture has been simplified by converting to a tool-based archi - **Performance Tests**: Response times, concurrent operations, memory usage ### Key Test Files for LLM Development -- `tests/test_core_functionality.py` - Authentication, server core, platform management (88+ tests) -- `tests/test_account_operations.py` - Complete account lifecycle management (85+ tests) -- `tests/test_mcp_integration.py` - MCP tool wrappers and integration across all 5 services (18+ tests) -- `tests/test_integration.py` - End-to-end integration tests with enhanced platform operations (25+ tests) -- `tests/test_performance.py` - Performance and optimization tests (11+ tests) -- `tests/test_resources.py` - MCP resource implementation tests (42+ tests) +- `tests/test_core_functionality.py` - Authentication, server core, platform management +- `tests/test_account_operations.py` - Complete account lifecycle management +- `tests/test_mcp_integration.py` - MCP tool wrappers and integration across all 5 services +- `tests/test_integration_tools.py` - End-to-end integration tests +- `tests/test_token_verifier.py` - CyberArkTokenVerifier JWT verification tests +- `tests/test_oauth_integration.py` - Full OAuth integration: dual-mode, execute_tool, lifespan +- `tests/test_transport.py` - Streamable HTTP transport configuration tests ### Performance Testing ```bash @@ -167,119 +185,14 @@ pytest -k platform # Platform management tests pytest -k account # Account management tests ``` -## MCP Inspector CLI Testing - -### Overview - -The `test_mcp_cli.py` script provides a single-file solution for programmatic MCP server testing using the `@modelcontextprotocol/inspector` CLI. Designed specifically for LLM integration and ad-hoc validation testing. - -### Prerequisites - -```bash -# Install MCP inspector (one-time setup) -npm install @modelcontextprotocol/inspector - -# Ensure environment variables are set -cp .env.example .env -# Edit .env with your CyberArk credentials -``` - -### Command Line Usage - -```bash -# Server health and connectivity -python test_mcp_cli.py health_check - -# Tool discovery and validation -python test_mcp_cli.py list_tools # List all 53 available tools -python test_mcp_cli.py capabilities # Detailed tool capabilities analysis - -# Tool execution testing -python test_mcp_cli.py call_tool list_accounts -python test_mcp_cli.py call_tool list_safes -python test_mcp_cli.py call_tool get_platform_statistics - -# Comprehensive validation -python test_mcp_cli.py validation_suite # Run complete test suite -python test_mcp_cli.py generate_report # Full diagnostic report -``` - -### Python API for LLMs - -```python -from test_mcp_cli import MCPTester - -# Initialize tester -tester = MCPTester(timeout=60, debug=True) - -# Basic operations -tools = tester.list_tools() -health = tester.test_server_health() -capabilities = tester.discover_tool_capabilities() - -# Tool execution -result = tester.call_tool("list_accounts", search="test") -platforms = tester.call_tool("list_platforms") - -# Comprehensive testing -validation = tester.run_basic_validation_suite() -report = tester.generate_test_report() -``` - -### Test Functions - -**Core Testing Functions:** -- `list_tools()` - Discovers all 45 available MCP tools -- `call_tool(name, **kwargs)` - Executes specific tools with parameters -- `test_server_health()` - Comprehensive server health assessment -- `discover_tool_capabilities()` - Detailed tool analysis and categorization +## MCP Inspector Testing -**Validation Functions:** -- `run_basic_validation_suite()` - Tests core functionality across all services -- `generate_test_report()` - Human-readable comprehensive test report - -### Error Handling and Diagnostics - -The testing script provides detailed error categorization: -- **Authentication Issues**: Invalid credentials, token problems -- **Permission Issues**: Insufficient CyberArk permissions -- **Network Issues**: Connectivity, timeout problems -- **Tool Issues**: Invalid parameters, tool execution failures - -### Integration with Existing Tests +For interactive testing of the MCP server against a live CyberArk environment, use the MCP Inspector: ```bash -# Run all testing approaches together -uv run pytest # Unit/integration tests (144 tests) -python test_mcp_cli.py validation_suite # MCP CLI validation -python test_mcp_cli.py generate_report # Comprehensive report -``` - -### LLM Usage Examples - -**Health Check:** -```python -tester = MCPTester() -health = tester.test_server_health() -if health["overall_status"] == "healthy": - print("✅ Server ready for operations") -else: - print("❌ Issues found:", health["issues"]) -``` - -**Tool Discovery:** -```python -capabilities = tester.discover_tool_capabilities() -print(f"Total tools: {capabilities['total_tools']}") -for category, tools in capabilities["tools_by_category"].items(): - print(f"{category}: {len(tools)} tools") -``` +# Run MCP Inspector +npx @modelcontextprotocol/inspector -**Validation Suite:** -```python -validation = tester.run_basic_validation_suite() -print(f"Success rate: {validation['success_rate']:.1f}%") -print(f"Tests passed: {validation['tests_passed']}/{validation['tests_passed'] + validation['tests_failed']}") -``` - -This approach enables LLMs to perform comprehensive, real-time validation of the MCP server functionality with actual CyberArk environments. \ No newline at end of file +# Run all pytest tests (269 tests) +uv run pytest +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6e75fd6..0e1e7ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ dependencies = [ "pydantic>=2.5.0", "pydantic-settings>=2.1.0", "ark-sdk-python @ git+https://github.com/cyberark/ark-sdk-python.git@v2.1.0", + "uvicorn>=0.30.0", + "PyJWT>=2.8.0", + "cryptography>=41.0.0", ] [project.optional-dependencies] diff --git a/pytest.ini b/pytest.ini index f35cf61..d10228f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,7 @@ [pytest] asyncio_mode = auto testpaths = tests +pythonpath = tests python_files = test_*.py python_classes = Test* python_functions = test_* diff --git a/src/mcp_privilege_cloud/exceptions.py b/src/mcp_privilege_cloud/exceptions.py index 771b8ae..91098e3 100644 --- a/src/mcp_privilege_cloud/exceptions.py +++ b/src/mcp_privilege_cloud/exceptions.py @@ -14,10 +14,8 @@ ArkPCloudException ) from ark_sdk_python.models.auth.exceptions import ArkAuthException - _SDK_AVAILABLE = True except ImportError: # Fallback classes if SDK not available - _SDK_AVAILABLE = False class ArkServiceException(Exception): # type: ignore[no-redef] """Fallback class when SDK is not available""" @@ -40,11 +38,6 @@ def __init__(self, message: str, status_code: Optional[int] = None): self.status_code = status_code -class AuthenticationError(Exception): - """Raised when authentication fails - Legacy compatibility exception""" - pass - - # SDK exception compatibility functions def is_sdk_exception(exception: Exception) -> bool: """Check if an exception is from ark-sdk-python""" @@ -69,7 +62,6 @@ def convert_sdk_exception(exception: Exception) -> CyberArkAPIError: # Re-export SDK exceptions for direct use __all__ = [ "CyberArkAPIError", - "AuthenticationError", "ArkServiceException", "ArkPCloudException", "ArkAuthException", diff --git a/src/mcp_privilege_cloud/mcp_server.py b/src/mcp_privilege_cloud/mcp_server.py index 966a218..5183634 100644 --- a/src/mcp_privilege_cloud/mcp_server.py +++ b/src/mcp_privilege_cloud/mcp_server.py @@ -14,35 +14,23 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Literal -# Import BaseModel for Pydantic model detection -from pydantic import BaseModel +import httpx +from dotenv import load_dotenv +from pydantic import AnyHttpUrl, BaseModel from mcp.server.fastmcp import FastMCP, Context from mcp.server.session import ServerSession +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.settings import AuthSettings +from starlette.requests import Request +from starlette.responses import JSONResponse, Response -# Add the src directory to Python path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - -try: - from .server import CyberArkMCPServer -except ImportError: - # Fallback for direct execution - from mcp_privilege_cloud.server import CyberArkMCPServer - -# Load environment variables from .env file -try: - from dotenv import load_dotenv - load_dotenv() -except ImportError: - # python-dotenv not available, try manual loading - env_file = os.path.join(os.path.dirname(__file__), '..', '..', '.env') - if os.path.exists(env_file): - with open(env_file) as f: - for line in f: - if '=' in line and not line.strip().startswith('#'): - key, value = line.strip().split('=', 1) - if key and value: - os.environ[key] = value +from starlette.types import ASGIApp, Receive, Scope, Send + +from .server import CyberArkMCPServer +from .token_verifier import CyberArkTokenVerifier, CYBERARK_OIDC_APP_ID + +load_dotenv() # Configure logging logging.basicConfig( @@ -51,56 +39,400 @@ ) logger = logging.getLogger(__name__) +# Streamable HTTP transport configuration +MCP_HOST = os.getenv("MCP_HOST", "127.0.0.1") +MCP_PORT = int(os.getenv("MCP_PORT", "8000")) + + +def is_oauth_mode() -> bool: + """Check if OAuth per-user mode is configured via environment variables.""" + return bool(os.getenv("CYBERARK_IDENTITY_TENANT_URL")) + + +def get_access_token() -> Optional[AccessToken]: + """Get the access token from the current MCP auth context. + + Returns None if no auth context is available (legacy mode). + """ + try: + from mcp.server.auth.middleware.auth_context import ( + get_access_token as _get_access_token, + ) + return _get_access_token() + except Exception: + return None + @dataclass class AppContext: """Application context with typed dependencies for lifespan management.""" - server: CyberArkMCPServer + server: Optional[CyberArkMCPServer] = None + is_oauth: bool = False @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Manage application lifecycle with type-safe context. - Initializes CyberArkMCPServer on startup and cleans up resources on shutdown. - The yielded AppContext provides typed access to the server instance. + In OAuth mode: creates a service account CyberArkMCPServer and verifies + user identity from OIDC JWTs on each request. + In legacy mode: creates a single CyberArkMCPServer from env vars. """ - logger.info("Initializing CyberArk MCP Server via lifespan...") + oauth = is_oauth_mode() + mode = "OAuth service account bridge" if oauth else "legacy service account" + logger.info("Initializing in %s mode...", mode) + cyberark_server = CyberArkMCPServer.from_environment() logger.info("CyberArk MCP Server initialized successfully") try: - yield AppContext(server=cyberark_server) + yield AppContext(server=cyberark_server, is_oauth=oauth) finally: - # Cleanup resources on shutdown if hasattr(cyberark_server, '_executor'): - logger.info("Shutting down executor...") cyberark_server._executor.shutdown(wait=True) logger.info("CyberArk MCP Server shutdown complete") -# Initialize the MCP server with lifespan management -mcp = FastMCP("CyberArk Privilege Cloud MCP Server", lifespan=app_lifespan) +_oidc_discovery_cache: Optional[dict] = None +_oidc_discovery_ts: float = 0.0 +_OIDC_CACHE_TTL = 3600 # 1 hour + + +def _get_oauth_credentials() -> tuple: + """Resolve OAuth credentials for the CyberArk Identity token endpoint. + + Injected server-side in /token proxy -- never exposed to clients. + Client ID: CYBERARK_OAUTH_CLIENT_ID > CYBERARK_OIDC_APP_ID + Secret: CYBERARK_OAUTH_CLIENT_SECRET only (no legacy fallback) + """ + client_id = os.getenv("CYBERARK_OAUTH_CLIENT_ID") or CYBERARK_OIDC_APP_ID + client_secret = os.getenv("CYBERARK_OAUTH_CLIENT_SECRET") or "" + if not client_secret and os.getenv("CYBERARK_OAUTH_CLIENT_ID"): + logger.warning( + "CYBERARK_OAUTH_CLIENT_ID is set but CYBERARK_OAUTH_CLIENT_SECRET is not; " + "the /token proxy will forward requests without a client secret" + ) + return client_id, client_secret -# Server instance will be created lazily by tools (legacy pattern, kept for backwards compatibility) -server: Optional[CyberArkMCPServer] = None -def get_server() -> CyberArkMCPServer: - """Get or create the server instance lazily.""" - global server - if server is None: +def _build_dcr_response(body: dict) -> dict: + """Build RFC 7591 Dynamic Client Registration response. + + Returns pre-configured CyberArk Identity OAuth2 app client_id + so MCP clients can complete the OAuth flow. Secrets are NEVER returned; + they are injected server-side by the /token proxy. + + Client ID priority: CYBERARK_OAUTH_CLIENT_ID > CYBERARK_OIDC_APP_ID + """ + client_id = os.getenv("CYBERARK_OAUTH_CLIENT_ID") + if not client_id: + logger.warning( + "CYBERARK_OAUTH_CLIENT_ID not set; falling back to OIDC app ID '%s'. " + "Set CYBERARK_OAUTH_CLIENT_ID to the auto-generated client ID " + "from the CyberArk Identity app Trust tab.", + CYBERARK_OIDC_APP_ID, + ) + client_id = CYBERARK_OIDC_APP_ID + + return { + "client_id": client_id, + "client_name": body.get("client_name", "MCP Client"), + "redirect_uris": body.get("redirect_uris", []), + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + + +async def _fetch_oidc_discovery(tenant_url: str) -> dict: + """Fetch and cache OIDC discovery from CyberArk Identity tenant. + + Cache expires after _OIDC_CACHE_TTL seconds to pick up key rotations. + """ + import time + + global _oidc_discovery_cache, _oidc_discovery_ts + if _oidc_discovery_cache is not None and (time.time() - _oidc_discovery_ts) < _OIDC_CACHE_TTL: + return _oidc_discovery_cache + + url = f"{tenant_url.rstrip('/')}/{CYBERARK_OIDC_APP_ID}/.well-known/openid-configuration" + async with httpx.AsyncClient() as client: + resp = await client.get(url) + resp.raise_for_status() + _oidc_discovery_cache = resp.json() + _oidc_discovery_ts = time.time() + logger.info("Fetched OIDC discovery from %s", tenant_url) + return _oidc_discovery_cache + + +def _build_oauth_metadata(oidc_config: dict, server_url: str) -> dict: + """Build RFC 8414 authorization server metadata from OIDC discovery. + + All endpoints are served through our server (same-origin) to avoid + cross-origin rejection by clients like Copilot Studio. The /authorize + route redirects to CyberArk Identity, and /token proxies token requests. + + Args: + oidc_config: OIDC discovery response from CyberArk Identity. + server_url: The MCP endpoint URL (e.g. https://host/mcp). Used as the + issuer to match what the client derives from the well-known URL path + per RFC 8414 section 3.3. + """ + from urllib.parse import urlparse + + # Match the URL normalization used by MCP SDK's AuthSettings / AnyHttpUrl + issuer = str(AnyHttpUrl(server_url)) + + # All endpoints at the server root (same-origin as issuer) + parsed = urlparse(issuer) + server_base = f"{parsed.scheme}://{parsed.netloc}" + + metadata = { + "issuer": issuer, + "authorization_endpoint": f"{server_base}/authorize", + "token_endpoint": f"{server_base}/token", + "registration_endpoint": f"{server_base}/register", + "response_types_supported": oidc_config.get("response_types_supported", ["code"]), + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["none"], + "code_challenge_methods_supported": oidc_config.get("code_challenge_methods_supported", ["S256"]), + } + # jwks_uri is REQUIRED per RFC 8414 for authorization_code grants + jwks_uri = oidc_config.get("jwks_uri") + if jwks_uri: + metadata["jwks_uri"] = jwks_uri + scopes = oidc_config.get("scopes_supported") + if scopes is not None: + metadata["scopes_supported"] = scopes + return metadata + + +def _register_oauth_routes(mcp_server: FastMCP) -> None: + """Register OAuth discovery and DCR routes on the FastMCP server.""" + + async def _serve_oauth_metadata(request: Request) -> Response: + """Serve RFC 8414 metadata by proxying CyberArk Identity OIDC discovery. + + Handles both base path and path-suffixed forms per RFC 8414 section 3.1: + - /.well-known/oauth-authorization-server + - /.well-known/oauth-authorization-server/{path} + """ + if request.method == "OPTIONS": + return Response(headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }) + + tenant_url = os.environ["CYBERARK_IDENTITY_TENANT_URL"].rstrip("/") + try: + oidc_config = await _fetch_oidc_discovery(tenant_url) + except Exception: + logger.exception("Failed to fetch OIDC discovery from %s", tenant_url) + return JSONResponse( + {"error": "Failed to fetch OIDC discovery"}, + status_code=502, + ) + + server_url = os.getenv("MCP_SERVER_URL") or f"http://{MCP_HOST}:{MCP_PORT}" + mcp_endpoint_url = server_url.rstrip("/") + "/mcp" + metadata = _build_oauth_metadata(oidc_config, mcp_endpoint_url) + return JSONResponse(metadata, headers={ + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": "*", + }) + + @mcp_server.custom_route("/.well-known/oauth-authorization-server", methods=["GET", "OPTIONS"]) + async def oauth_metadata_base(request: Request) -> Response: + return await _serve_oauth_metadata(request) + + @mcp_server.custom_route("/.well-known/oauth-authorization-server/{path:path}", methods=["GET", "OPTIONS"]) + async def oauth_metadata_path(request: Request) -> Response: + return await _serve_oauth_metadata(request) + + @mcp_server.custom_route("/register", methods=["POST", "OPTIONS"]) + async def dynamic_client_registration(request: Request) -> Response: + """RFC 7591 Dynamic Client Registration proxy. + + Returns the pre-configured CyberArk Identity OIDC app client ID + so MCP clients (e.g. claude.ai) can obtain credentials automatically. + """ + if request.method == "OPTIONS": + return Response(headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }) + + try: + body = await request.json() + except Exception: + body = {} + + registration_response = _build_dcr_response(body) + + logger.info( + "DCR: registered client_name=%s redirect_uris=%s", + registration_response["client_name"], + registration_response["redirect_uris"], + ) + + return JSONResponse( + registration_response, + status_code=201, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + @mcp_server.custom_route("/authorize", methods=["GET", "OPTIONS"]) + async def authorize_proxy(request: Request) -> Response: + """Redirect to CyberArk Identity authorization endpoint. + + Proxies the authorization request so all OAuth endpoints appear + same-origin in the AS metadata. Passes all query parameters through. + """ + if request.method == "OPTIONS": + return Response(headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }) + + tenant_url = os.environ["CYBERARK_IDENTITY_TENANT_URL"].rstrip("/") try: - server = CyberArkMCPServer.from_environment() - logger.info("Successfully initialized CyberArk MCP Server") - except ValueError as e: - logger.error(f"Failed to initialize server: {e}") - raise - return server - -def reset_server() -> None: - """Reset the global server instance. Used for testing to ensure clean state.""" - global server - server = None + oidc_config = await _fetch_oidc_discovery(tenant_url) + except Exception: + logger.exception("Failed to fetch OIDC discovery for /authorize redirect") + return JSONResponse({"error": "Failed to resolve authorization endpoint"}, status_code=502) + + target = oidc_config["authorization_endpoint"] + qs = str(request.url.query) + redirect_url = f"{target}?{qs}" if qs else target + # CyberArk Identity validates redirect_uri against the app's registered URIs + logger.info( + "Redirecting /authorize → %s (redirect_uri=%s)", + oidc_config["authorization_endpoint"], + request.query_params.get("redirect_uri", ""), + ) + return Response(status_code=302, headers={ + "Location": redirect_url, + "Access-Control-Allow-Origin": "*", + }) + + @mcp_server.custom_route("/token", methods=["POST", "OPTIONS"]) + async def token_proxy(request: Request) -> Response: + """Reverse-proxy token requests to CyberArk Identity token endpoint. + + Proxies the token request so all OAuth endpoints appear same-origin + in the AS metadata. Injects server-side client_id and client_secret + so secrets never leave the server (clients use token_endpoint_auth_method=none). + """ + from urllib.parse import parse_qs, urlencode + + if request.method == "OPTIONS": + return Response(headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }) + + tenant_url = os.environ["CYBERARK_IDENTITY_TENANT_URL"].rstrip("/") + try: + oidc_config = await _fetch_oidc_discovery(tenant_url) + except Exception: + logger.exception("Failed to fetch OIDC discovery for /token proxy") + return JSONResponse({"error": "Failed to resolve token endpoint"}, status_code=502) + + target = oidc_config["token_endpoint"] + body = await request.body() + + # Parse form body, inject server-side credentials, re-encode + params = parse_qs(body.decode("utf-8"), keep_blank_values=True) + flat_params = {k: v[0] for k, v in params.items()} + client_id, client_secret = _get_oauth_credentials() + flat_params["client_id"] = client_id + flat_params["client_secret"] = client_secret + forwarded_body = urlencode(flat_params) + + async with httpx.AsyncClient() as client: + resp = await client.post( + target, + content=forwarded_body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + grant_type = flat_params.get("grant_type", "") + if resp.status_code >= 400: + # Log error body for debugging but never log secrets + safe_body = resp.text + for sensitive_key in ("client_secret", "code", "code_verifier"): + # Redact any echoed sensitive values from error responses + if sensitive_key in flat_params: + safe_body = safe_body.replace(flat_params[sensitive_key], "[REDACTED]") + logger.warning( + "Token proxy error: %s → %d (grant_type=%s): %s", + target, resp.status_code, grant_type, safe_body, + ) + else: + logger.info( + "Token proxy: %s → %d (grant_type=%s)", target, resp.status_code, grant_type, + ) + # CORS * is acceptable here: the auth code + PKCE code_verifier prevent + # abuse, and MCP SDK clients need cross-origin access to the token endpoint. + return Response( + content=resp.content, + status_code=resp.status_code, + headers={ + "Content-Type": resp.headers.get("content-type", "application/json"), + "Access-Control-Allow-Origin": "*", + }, + ) + + +def create_mcp_server() -> FastMCP: + """Create and configure the FastMCP server instance. + + In OAuth mode: configures token_verifier and AuthSettings. + In legacy mode: no auth configuration. + """ + kwargs: Dict[str, Any] = { + "lifespan": app_lifespan, + "host": MCP_HOST, + "port": MCP_PORT, + } + + if is_oauth_mode(): + tenant_url = os.environ["CYBERARK_IDENTITY_TENANT_URL"] + server_url = os.getenv("MCP_SERVER_URL") or f"http://{MCP_HOST}:{MCP_PORT}" + + # The MCP endpoint path (/mcp) must be included in the URLs so that: + # 1. authorization_servers in protected resource metadata matches the + # issuer the client derives from /.well-known/oauth-authorization-server/mcp + # 2. resource in protected resource metadata matches the actual endpoint + # 3. Protected resource route is at /.well-known/oauth-protected-resource/mcp + # This is required by RFC 8414 section 3.3 (issuer identity validation). + mcp_endpoint_url = server_url.rstrip("/") + "/mcp" + + kwargs["token_verifier"] = CyberArkTokenVerifier( + identity_tenant_url=tenant_url, + ) + kwargs["auth"] = AuthSettings( + issuer_url=AnyHttpUrl(mcp_endpoint_url), + resource_server_url=AnyHttpUrl(mcp_endpoint_url), + ) + logger.info("OAuth auth configured (tenant: %s)", tenant_url) + else: + kwargs["token_verifier"] = None + kwargs["auth"] = None + + return FastMCP("CyberArk Privilege Cloud MCP Server", **kwargs) + + +# Initialize the MCP server +mcp = create_mcp_server() + +# Register OAuth discovery and DCR routes in OAuth mode +if is_oauth_mode(): + _register_oauth_routes(mcp) def _convert_to_dict(obj: Any) -> Any: """Convert Pydantic models to dictionaries for MCP boundary. @@ -129,17 +461,30 @@ async def execute_tool( This function serves as the MCP boundary layer, converting Pydantic models returned by server methods to dictionaries for MCP client consumption. + In OAuth mode: verifies user identity from OIDC JWT, then routes API calls + through the shared service account server. + In legacy mode: uses the shared server from lifespan context. + Args: tool_name: The server method name to call ctx: Optional MCP context with lifespan_context containing the server **kwargs: Parameters to pass to the server method """ try: - # Get server from context if available, otherwise use legacy get_server() - if ctx is not None and hasattr(ctx, 'request_context'): - server_instance = ctx.request_context.lifespan_context.server - else: - server_instance = get_server() + if ctx is None or not hasattr(ctx, 'request_context'): + raise RuntimeError("No server context available") + + app_ctx = ctx.request_context.lifespan_context + + # In OAuth mode, verify user identity before allowing access + if app_ctx.is_oauth: + access_token = get_access_token() + if access_token is not None: + logger.info("Authenticated user: %s", access_token.client_id) + else: + raise PermissionError("OAuth mode requires authentication") + + server_instance = app_ctx.server server_method = getattr(server_instance, tool_name) result = await server_method(**kwargs) @@ -197,34 +542,33 @@ async def create_account( @mcp.tool() async def change_account_password( account_id: str, - new_password: Optional[str] = None + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Change the password for an existing account in CyberArk Privilege Cloud. - - This operation initiates an immediate password change for the specified account. - If no new password is provided, the Central Password Manager (CPM) will generate - a new password according to the platform's password policy. - + + This operation initiates an immediate CPM-managed password change for the specified account. + The Central Password Manager (CPM) will generate a new password according to the platform's + password policy. + Args: account_id: The unique ID of the account to change password for (required) - new_password: Optional new password. If not provided, CPM will generate one automatically - + Returns: Password change response containing status, timestamps, and account metadata - + Security Notes: - This operation requires appropriate permissions for password management - Password changes are audited and logged in CyberArk - - Use CPM-generated passwords when possible for better security compliance """ - return await execute_tool("change_account_password", account_id=account_id, new_password=new_password) + return await execute_tool("change_account_password", ctx=ctx, account_id=account_id) @mcp.tool() async def set_next_password( account_id: str, new_password: str, - change_immediately: bool = True + change_immediately: bool = True, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Set the next password for an existing account in CyberArk Privilege Cloud. @@ -245,11 +589,12 @@ async def set_next_password( - Password changes are audited and logged in CyberArk - Use strong passwords that comply with your organization's policy """ - return await execute_tool("set_next_password", account_id=account_id, new_password=new_password, change_immediately=change_immediately) + return await execute_tool("set_next_password", ctx=ctx, account_id=account_id, new_password=new_password, change_immediately=change_immediately) @mcp.tool() async def verify_account_password( - account_id: str + account_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Verify the password for an existing account in CyberArk Privilege Cloud. @@ -269,11 +614,12 @@ async def verify_account_password( - Password verifications are audited and logged in CyberArk - This operation does not expose the actual password, only verification status """ - return await execute_tool("verify_account_password", account_id=account_id) + return await execute_tool("verify_account_password", ctx=ctx, account_id=account_id) @mcp.tool() async def reconcile_account_password( - account_id: str + account_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Reconcile the password for an existing account in CyberArk Privilege Cloud. @@ -294,7 +640,7 @@ async def reconcile_account_password( - This operation may take longer to complete as it involves communication with target systems - The operation synchronizes credentials between vault and target without exposing passwords """ - return await execute_tool("reconcile_account_password", account_id=account_id) + return await execute_tool("reconcile_account_password", ctx=ctx, account_id=account_id) @mcp.tool() async def update_account( @@ -304,7 +650,8 @@ async def update_account( user_name: Optional[str] = None, platform_account_properties: Optional[Dict[str, Any]] = None, secret_management: Optional[Dict[str, Any]] = None, - remote_machines_access: Optional[Dict[str, Any]] = None + remote_machines_access: Optional[Dict[str, Any]] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Update an existing account in CyberArk Privilege Cloud. @@ -334,13 +681,14 @@ async def update_account( update_account("123_456", name="updated-web-server", address="web02.corp.com", platform_account_properties={"Port": "8080"}) """ - return await execute_tool("update_account", account_id=account_id, name=name, address=address, + return await execute_tool("update_account", ctx=ctx, account_id=account_id, name=name, address=address, user_name=user_name, platform_account_properties=platform_account_properties, secret_management=secret_management, remote_machines_access=remote_machines_access) @mcp.tool() async def delete_account( - account_id: str + account_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Delete an existing account from CyberArk Privilege Cloud. @@ -367,7 +715,7 @@ async def delete_account( Example: delete_account("123_456") # Permanently removes account with ID 123_456 """ - return await execute_tool("delete_account", account_id=account_id) + return await execute_tool("delete_account", ctx=ctx, account_id=account_id) @@ -382,7 +730,8 @@ async def delete_account( @mcp.tool() async def import_platform_package( - platform_package_file: str + platform_package_file: str, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Import a platform package ZIP file to CyberArk Privilege Cloud to add new platform types. @@ -404,13 +753,14 @@ async def import_platform_package( Note: Requires Privilege Cloud Administrator role for platform management operations """ - return await execute_tool("import_platform_package", platform_package_file=platform_package_file) + return await execute_tool("import_platform_package", ctx=ctx, platform_package_file=platform_package_file) @mcp.tool() async def export_platform( platform_id: str, - output_folder: str + output_folder: str, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Export a platform configuration package from CyberArk Privilege Cloud. @@ -432,14 +782,15 @@ async def export_platform( Note: Exports platform configuration as a ZIP package to the specified folder """ - return await execute_tool("export_platform", platform_id=platform_id, output_folder=output_folder) + return await execute_tool("export_platform", ctx=ctx, platform_id=platform_id, output_folder=output_folder) @mcp.tool() async def duplicate_target_platform( target_platform_id: int, name: str, - description: Optional[str] = None + description: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Duplicate/clone an existing target platform in CyberArk Privilege Cloud. @@ -462,12 +813,13 @@ async def duplicate_target_platform( Note: Creates a copy of an existing target platform with new name and optional description """ - return await execute_tool("duplicate_target_platform", target_platform_id=target_platform_id, name=name, description=description) + return await execute_tool("duplicate_target_platform", ctx=ctx, target_platform_id=target_platform_id, name=name, description=description) @mcp.tool() async def activate_target_platform( - target_platform_id: int + target_platform_id: int, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Activate/enable a target platform in CyberArk Privilege Cloud. @@ -488,12 +840,13 @@ async def activate_target_platform( Note: Activates the platform making it available for account creation and management """ - return await execute_tool("activate_target_platform", target_platform_id=target_platform_id) + return await execute_tool("activate_target_platform", ctx=ctx, target_platform_id=target_platform_id) @mcp.tool() async def deactivate_target_platform( - target_platform_id: int + target_platform_id: int, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Deactivate/disable a target platform in CyberArk Privilege Cloud. @@ -515,12 +868,13 @@ async def deactivate_target_platform( Note: Deactivates the platform preventing new account creation with this platform """ - return await execute_tool("deactivate_target_platform", target_platform_id=target_platform_id) + return await execute_tool("deactivate_target_platform", ctx=ctx, target_platform_id=target_platform_id) @mcp.tool() async def delete_target_platform( - target_platform_id: int + target_platform_id: int, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """ Delete a target platform from CyberArk Privilege Cloud. @@ -542,10 +896,12 @@ async def delete_target_platform( Warning: This operation permanently removes the platform and cannot be undone """ - return await execute_tool("delete_target_platform", target_platform_id=target_platform_id) + return await execute_tool("delete_target_platform", ctx=ctx, target_platform_id=target_platform_id) @mcp.tool() -async def get_platform_statistics() -> Any: +async def get_platform_statistics( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """ Calculate comprehensive platform statistics from CyberArk Privilege Cloud. @@ -571,10 +927,12 @@ async def get_platform_statistics() -> Any: Note: Statistics are calculated from all visible platforms based on user permissions """ - return await execute_tool("get_platform_statistics") + return await execute_tool("get_platform_statistics", ctx=ctx) @mcp.tool() -async def get_target_platform_statistics() -> Any: +async def get_target_platform_statistics( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """ Calculate comprehensive target platform statistics from CyberArk Privilege Cloud. @@ -601,7 +959,7 @@ async def get_target_platform_statistics() -> Any: Note: Statistics are calculated from all visible target platforms based on user permissions """ - return await execute_tool("get_target_platform_statistics") + return await execute_tool("get_target_platform_statistics", ctx=ctx) # Data access tools - return raw API data @@ -637,7 +995,8 @@ async def search_accounts( safe_name: Optional[str] = None, username: Optional[str] = None, address: Optional[str] = None, - platform_id: Optional[str] = None + platform_id: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """Search for accounts with various criteria. @@ -651,12 +1010,14 @@ async def search_accounts( Returns: List of matching account objects with exact API fields """ - return await execute_tool("search_accounts", query=query, safe_name=safe_name, + return await execute_tool("search_accounts", ctx=ctx, query=query, safe_name=safe_name, username=username, address=address, platform_id=platform_id) # Advanced Account Search and Filtering Tools @mcp.tool() -async def filter_accounts_by_platform_group(platform_group: str) -> Any: +async def filter_accounts_by_platform_group(platform_group: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Filter accounts by platform type grouping (Windows, Linux, Database, etc.). Args: @@ -665,10 +1026,12 @@ async def filter_accounts_by_platform_group(platform_group: str) -> Any: Returns: List of accounts matching the platform group with exact API fields """ - return await execute_tool("filter_accounts_by_platform_group", platform_group=platform_group) + return await execute_tool("filter_accounts_by_platform_group", ctx=ctx, platform_group=platform_group) @mcp.tool() -async def filter_accounts_by_environment(environment: str) -> Any: +async def filter_accounts_by_environment(environment: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Filter accounts by environment (production, staging, development, etc.). Args: @@ -677,10 +1040,12 @@ async def filter_accounts_by_environment(environment: str) -> Any: Returns: List of accounts in the specified environment with exact API fields """ - return await execute_tool("filter_accounts_by_environment", environment=environment) + return await execute_tool("filter_accounts_by_environment", ctx=ctx, environment=environment) @mcp.tool() -async def filter_accounts_by_management_status(auto_managed: bool = True) -> Any: +async def filter_accounts_by_management_status(auto_managed: bool = True, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Filter accounts by automatic password management status. Args: @@ -689,41 +1054,48 @@ async def filter_accounts_by_management_status(auto_managed: bool = True) -> Any Returns: List of accounts with the specified management status and exact API fields """ - return await execute_tool("filter_accounts_by_management_status", auto_managed=auto_managed) + return await execute_tool("filter_accounts_by_management_status", ctx=ctx, auto_managed=auto_managed) @mcp.tool() -async def group_accounts_by_safe() -> Any: +async def group_accounts_by_safe( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Group all accounts by their safe name. Returns: Dictionary with safe names as keys and lists of accounts as values """ - return await execute_tool("group_accounts_by_safe") + return await execute_tool("group_accounts_by_safe", ctx=ctx) @mcp.tool() -async def group_accounts_by_platform() -> Any: +async def group_accounts_by_platform( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Group all accounts by their platform type. Returns: Dictionary with platform IDs as keys and lists of accounts as values """ - return await execute_tool("group_accounts_by_platform") + return await execute_tool("group_accounts_by_platform", ctx=ctx) @mcp.tool() -async def analyze_account_distribution() -> Any: +async def analyze_account_distribution( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Analyze distribution of accounts across safes, platforms, and environments. Returns: Analysis report with counts and percentages for various account categories """ - return await execute_tool("analyze_account_distribution") + return await execute_tool("analyze_account_distribution", ctx=ctx) @mcp.tool() async def search_accounts_by_pattern( username_pattern: Optional[str] = None, address_pattern: Optional[str] = None, environment: Optional[str] = None, - platform_group: Optional[str] = None + platform_group: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """Search accounts using multiple pattern criteria. @@ -736,20 +1108,22 @@ async def search_accounts_by_pattern( Returns: List of accounts matching all specified criteria with exact API fields """ - return await execute_tool("search_accounts_by_pattern", + return await execute_tool("search_accounts_by_pattern", ctx=ctx, username_pattern=username_pattern, address_pattern=address_pattern, environment=environment, platform_group=platform_group) @mcp.tool() -async def count_accounts_by_criteria() -> Any: +async def count_accounts_by_criteria( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Count accounts by various criteria (platform, safe, management status). Returns: Count summary with totals for different account categories """ - return await execute_tool("count_accounts_by_criteria") + return await execute_tool("count_accounts_by_criteria", ctx=ctx) @mcp.tool() async def get_safe_details( @@ -767,18 +1141,21 @@ async def get_safe_details( return await execute_tool("get_safe_details", ctx=ctx, safe_name=safe_name) @mcp.tool() -async def list_safes() -> Any: +async def list_safes( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """List all accessible safes in CyberArk Privilege Cloud. Returns: List of safe objects with their exact API fields """ - return await execute_tool("list_safes") + return await execute_tool("list_safes", ctx=ctx) @mcp.tool() async def add_safe( safe_name: str, - description: Optional[str] = None + description: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """Add a new safe to CyberArk Privilege Cloud. @@ -789,7 +1166,7 @@ async def add_safe( Returns: Safe object with its exact API fields after creation """ - return await execute_tool("add_safe", safe_name=safe_name, description=description) + return await execute_tool("add_safe", ctx=ctx, safe_name=safe_name, description=description) @mcp.tool() async def update_safe( @@ -801,7 +1178,8 @@ async def update_safe( number_of_versions_retention: Optional[int] = None, auto_purge_enabled: Optional[bool] = None, olac_enabled: Optional[bool] = None, - managing_cpm: Optional[str] = None + managing_cpm: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """Update properties of an existing safe in CyberArk Privilege Cloud. @@ -820,7 +1198,7 @@ async def update_safe( Updated safe object with its exact API fields """ return await execute_tool( - "update_safe", + "update_safe", ctx=ctx, safe_id=safe_id, safe_name=safe_name, description=description, @@ -833,7 +1211,9 @@ async def update_safe( ) @mcp.tool() -async def delete_safe(safe_id: str) -> Any: +async def delete_safe(safe_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Delete a safe from CyberArk Privilege Cloud. WARNING: This action permanently removes the safe and all its contents. @@ -844,7 +1224,7 @@ async def delete_safe(safe_id: str) -> Any: Returns: Confirmation message with the deleted safe ID """ - return await execute_tool("delete_safe", safe_id=safe_id) + return await execute_tool("delete_safe", ctx=ctx, safe_id=safe_id) # Safe Member Management Tools @@ -856,7 +1236,8 @@ async def list_safe_members( sort: Optional[str] = None, offset: Optional[int] = None, limit: Optional[int] = None, - member_type: Optional[str] = None + member_type: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """List all members of a specific safe with their permissions. @@ -876,7 +1257,7 @@ async def list_safe_members( list_safe_members("HR-Safe", member_type="User") """ return await execute_tool( - "list_safe_members", + "list_safe_members", ctx=ctx, safe_name=safe_name, search=search, sort=sort, @@ -886,7 +1267,9 @@ async def list_safe_members( ) @mcp.tool() -async def get_safe_member_details(safe_name: str, member_name: str) -> Any: +async def get_safe_member_details(safe_name: str, member_name: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Get detailed information about a specific safe member. Args: @@ -899,7 +1282,7 @@ async def get_safe_member_details(safe_name: str, member_name: str) -> Any: Example: get_safe_member_details("IT-Infrastructure", "admin@domain.com") """ - return await execute_tool("get_safe_member_details", safe_name=safe_name, member_name=member_name) + return await execute_tool("get_safe_member_details", ctx=ctx, safe_name=safe_name, member_name=member_name) @mcp.tool() async def add_safe_member( @@ -909,7 +1292,8 @@ async def add_safe_member( search_in: Optional[str] = None, membership_expiration_date: Optional[str] = None, permissions: Optional[Dict[str, Any]] = None, - permission_set: Optional[str] = None + permission_set: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """Add a new member to a safe with specified permissions. @@ -932,7 +1316,7 @@ async def add_safe_member( Note: Defaults to "ReadOnly" permission set if no permissions specified """ return await execute_tool( - "add_safe_member", + "add_safe_member", ctx=ctx, safe_name=safe_name, member_name=member_name, member_type=member_type, @@ -949,7 +1333,8 @@ async def update_safe_member( search_in: Optional[str] = None, membership_expiration_date: Optional[str] = None, permissions: Optional[Dict[str, Any]] = None, - permission_set: Optional[str] = None + permission_set: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """Update permissions for an existing safe member. @@ -969,7 +1354,7 @@ async def update_safe_member( update_safe_member("HR-Safe", "admin", membership_expiration_date="2024-12-31T23:59:59Z") """ return await execute_tool( - "update_safe_member", + "update_safe_member", ctx=ctx, safe_name=safe_name, member_name=member_name, search_in=search_in, @@ -979,7 +1364,9 @@ async def update_safe_member( ) @mcp.tool() -async def remove_safe_member(safe_name: str, member_name: str) -> Any: +async def remove_safe_member(safe_name: str, member_name: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Remove a member from a safe. Args: @@ -994,11 +1381,13 @@ async def remove_safe_member(safe_name: str, member_name: str) -> Any: Note: This action permanently removes the member's access to the safe """ - return await execute_tool("remove_safe_member", safe_name=safe_name, member_name=member_name) + return await execute_tool("remove_safe_member", ctx=ctx, safe_name=safe_name, member_name=member_name) @mcp.tool() -async def get_platform_details(platform_id: str) -> Any: +async def get_platform_details(platform_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Get detailed information about a specific platform in CyberArk Privilege Cloud. Args: @@ -1007,7 +1396,7 @@ async def get_platform_details(platform_id: str) -> Any: Returns: Platform object with complete configuration details and exact API fields """ - return await execute_tool("get_platform_details", platform_id=platform_id) + return await execute_tool("get_platform_details", ctx=ctx, platform_id=platform_id) @mcp.tool() async def list_platforms( @@ -1028,7 +1417,8 @@ async def list_applications( location: Optional[str] = None, only_enabled: Optional[bool] = None, business_owner_name: Optional[str] = None, - business_owner_email: Optional[str] = None + business_owner_email: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """List applications from CyberArk Privilege Cloud. @@ -1051,11 +1441,13 @@ async def list_applications( if business_owner_email is not None: kwargs['business_owner_email'] = business_owner_email - return await execute_tool("list_applications", **kwargs) + return await execute_tool("list_applications", ctx=ctx, **kwargs) @mcp.tool() -async def get_application_details(app_id: str) -> Any: +async def get_application_details(app_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Get detailed information about a specific application. Args: @@ -1064,7 +1456,7 @@ async def get_application_details(app_id: str) -> Any: Returns: Application object with all configuration details """ - return await execute_tool("get_application_details", app_id=app_id) + return await execute_tool("get_application_details", ctx=ctx, app_id=app_id) @mcp.tool() @@ -1079,7 +1471,8 @@ async def add_application( business_owner_first_name: str = "", business_owner_last_name: str = "", business_owner_email: str = "", - business_owner_phone: str = "" + business_owner_phone: str = "", + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """Add a new application to CyberArk Privilege Cloud. @@ -1100,7 +1493,7 @@ async def add_application( Created application object with its details """ return await execute_tool( - "add_application", + "add_application", ctx=ctx, app_id=app_id, description=description, location=location, @@ -1116,7 +1509,9 @@ async def add_application( @mcp.tool() -async def delete_application(app_id: str) -> Any: +async def delete_application(app_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Delete an application from CyberArk Privilege Cloud. Args: @@ -1125,13 +1520,14 @@ async def delete_application(app_id: str) -> Any: Returns: Confirmation of deletion """ - return await execute_tool("delete_application", app_id=app_id) + return await execute_tool("delete_application", ctx=ctx, app_id=app_id) @mcp.tool() async def list_application_auth_methods( app_id: str, - auth_types: Optional[List[str]] = None + auth_types: Optional[List[str]] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """List authentication methods for a specific application. @@ -1146,11 +1542,13 @@ async def list_application_auth_methods( if auth_types is not None: kwargs['auth_types'] = auth_types - return await execute_tool("list_application_auth_methods", **kwargs) + return await execute_tool("list_application_auth_methods", ctx=ctx, **kwargs) @mcp.tool() -async def get_application_auth_method_details(app_id: str, auth_id: str) -> Any: +async def get_application_auth_method_details(app_id: str, auth_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Get detailed information about a specific application authentication method. Args: @@ -1160,7 +1558,7 @@ async def get_application_auth_method_details(app_id: str, auth_id: str) -> Any: Returns: Authentication method object with all configuration details """ - return await execute_tool("get_application_auth_method_details", app_id=app_id, auth_id=auth_id) + return await execute_tool("get_application_auth_method_details", ctx=ctx, app_id=app_id, auth_id=auth_id) @mcp.tool() @@ -1177,7 +1575,8 @@ async def add_application_auth_method( env_var_value: str = "", subject: Optional[List[Dict[str, str]]] = None, issuer: Optional[List[Dict[str, str]]] = None, - subject_alternative_name: Optional[List[Dict[str, str]]] = None + subject_alternative_name: Optional[List[Dict[str, str]]] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None ) -> Any: """Add an authentication method to an application. @@ -1280,7 +1679,7 @@ async def add_application_auth_method( raise ValueError(f"Authentication type '{auth_type}' requires 'auth_value' parameter") return await execute_tool( - "add_application_auth_method", + "add_application_auth_method", ctx=ctx, app_id=app_id, auth_type=auth_type, auth_value=auth_value, @@ -1298,7 +1697,9 @@ async def add_application_auth_method( @mcp.tool() -async def delete_application_auth_method(app_id: str, auth_id: str) -> Any: +async def delete_application_auth_method(app_id: str, auth_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Delete an authentication method from an application. Args: @@ -1308,11 +1709,13 @@ async def delete_application_auth_method(app_id: str, auth_id: str) -> Any: Returns: Confirmation of deletion """ - return await execute_tool("delete_application_auth_method", app_id=app_id, auth_id=auth_id) + return await execute_tool("delete_application_auth_method", ctx=ctx, app_id=app_id, auth_id=auth_id) @mcp.tool() -async def get_applications_stats() -> Any: +async def get_applications_stats( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Get comprehensive statistics about applications in CyberArk Privilege Cloud. Returns: @@ -1320,13 +1723,15 @@ async def get_applications_stats() -> Any: disabled applications, authentication types distribution, and authentication method statistics """ - return await execute_tool("get_applications_stats") + return await execute_tool("get_applications_stats", ctx=ctx) # Session Monitoring Tools using ArkSMService @mcp.tool() -async def list_sessions() -> Any: +async def list_sessions( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """List recent privileged sessions from CyberArk Session Monitoring. Returns all sessions from the last 24 hours with session details including @@ -1335,11 +1740,13 @@ async def list_sessions() -> Any: Returns: List of session objects with their exact API fields """ - return await execute_tool("list_sessions") + return await execute_tool("list_sessions", ctx=ctx) @mcp.tool() -async def list_sessions_by_filter(search: Optional[str] = None) -> Any: +async def list_sessions_by_filter(search: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """List privileged sessions with advanced filtering from CyberArk Session Monitoring. Supports advanced filtering using CyberArk session query syntax: @@ -1354,11 +1761,13 @@ async def list_sessions_by_filter(search: Optional[str] = None) -> Any: Returns: List of filtered session objects with their exact API fields """ - return await execute_tool("list_sessions_by_filter", search=search) + return await execute_tool("list_sessions_by_filter", ctx=ctx, search=search) @mcp.tool() -async def get_session_details(session_id: str) -> Any: +async def get_session_details(session_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Get detailed information about a specific privileged session. Retrieves comprehensive session information including protocol details, @@ -1370,11 +1779,13 @@ async def get_session_details(session_id: str) -> Any: Returns: Detailed session object with all available API fields """ - return await execute_tool("get_session_details", session_id=session_id) + return await execute_tool("get_session_details", ctx=ctx, session_id=session_id) @mcp.tool() -async def list_session_activities(session_id: str) -> Any: +async def list_session_activities(session_id: str, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """List all activities performed within a specific privileged session. Retrieves chronological log of commands, actions, and operations @@ -1386,11 +1797,13 @@ async def list_session_activities(session_id: str) -> Any: Returns: List of activity objects with timestamps, commands, and results """ - return await execute_tool("list_session_activities", session_id=session_id) + return await execute_tool("list_session_activities", ctx=ctx, session_id=session_id) @mcp.tool() -async def count_sessions(search: Optional[str] = None) -> Any: +async def count_sessions(search: Optional[str] = None, + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Count privileged sessions with optional filtering. Provides session counts for analysis and reporting. Supports the same @@ -1402,11 +1815,13 @@ async def count_sessions(search: Optional[str] = None) -> Any: Returns: Dictionary with session count and applied filter information """ - return await execute_tool("count_sessions", search=search) + return await execute_tool("count_sessions", ctx=ctx, search=search) @mcp.tool() -async def get_session_statistics() -> Any: +async def get_session_statistics( + ctx: Optional[Context[ServerSession, AppContext]] = None +) -> Any: """Get general session statistics and analytics. Provides high-level session metrics including total sessions, @@ -1416,14 +1831,59 @@ async def get_session_statistics() -> Any: Returns: Statistics object with session analytics and metrics """ - return await execute_tool("get_session_statistics") + return await execute_tool("get_session_statistics", ctx=ctx) + + +class TrailingSlashMiddleware: + """Strip trailing slashes to prevent Starlette 307 redirects. + + MCP clients (e.g. Copilot Studio) POST to /mcp/ (trailing slash). + Starlette's default redirect_slashes returns a 307 redirect to /mcp, + which causes HTTP clients to strip the Authorization header per RFC 9110, + breaking OAuth Bearer token authentication. + + This middleware normalizes the path at the ASGI level so the router + sees /mcp directly — no redirect, no header loss. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http": + path = scope.get("path", "") + if path != "/" and path.endswith("/"): + scope = dict(scope) + scope["path"] = path.rstrip("/") + await self.app(scope, receive, send) + + +VALID_TRANSPORTS = {"stdio", "sse", "streamable-http"} def main() -> None: - """Main entry point for the MCP server""" - logger.info("Starting CyberArk Privilege Cloud MCP Server") - # Environment validation is handled by server initialization above - mcp.run() + """Main entry point for the MCP server.""" + transport = os.getenv("MCP_TRANSPORT", "stdio") + if transport not in VALID_TRANSPORTS: + logger.error( + "Invalid MCP_TRANSPORT=%r (valid: %s)", + transport, + ", ".join(sorted(VALID_TRANSPORTS)), + ) + sys.exit(1) + logger.info("Starting CyberArk Privilege Cloud MCP Server (transport=%s)", transport) + + if transport == "streamable-http": + import asyncio + import uvicorn + + app = TrailingSlashMiddleware(mcp.streamable_http_app()) + config = uvicorn.Config( + app, host=MCP_HOST, port=MCP_PORT, log_level="info", + ) + asyncio.run(uvicorn.Server(config).serve()) + else: + mcp.run(transport=transport) if __name__ == "__main__": diff --git a/src/mcp_privilege_cloud/models.py b/src/mcp_privilege_cloud/models.py deleted file mode 100644 index 38ceda9..0000000 --- a/src/mcp_privilege_cloud/models.py +++ /dev/null @@ -1,288 +0,0 @@ -""" -Response Models for CyberArk MCP Server - -Pydantic models for typed MCP tool returns with JSON schema generation. -Models use aliases to match CyberArk API field names (CamelCase) while -providing Pythonic snake_case attribute access. -""" - -from typing import Optional, List, Dict, Any -from pydantic import BaseModel, Field, ConfigDict - - -class AccountDetails(BaseModel): - """Detailed account information from CyberArk Privilege Cloud.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" # Allow extra fields for API forward compatibility - ) - - id: str = Field(description="Unique account identifier") - platform_id: str = Field(alias="platformId", description="Platform identifier") - safe_name: str = Field(alias="safeName", description="Safe name containing the account") - name: Optional[str] = Field(default=None, description="Account display name") - user_name: Optional[str] = Field(default=None, alias="userName", description="Account username") - address: Optional[str] = Field(default=None, description="Target system address") - secret_type: Optional[str] = Field(default=None, alias="secretType", description="Secret type (password, key)") - created_time: Optional[str] = Field(default=None, alias="createdTime", description="Account creation timestamp") - secret_management: Optional[Dict[str, Any]] = Field( - default=None, alias="secretManagement", description="Secret management settings" - ) - platform_account_properties: Optional[Dict[str, Any]] = Field( - default=None, alias="platformAccountProperties", description="Platform-specific properties" - ) - - -class AccountSummary(BaseModel): - """Summary account information for list operations.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - id: str = Field(description="Unique account identifier") - name: Optional[str] = Field(default=None, description="Account display name") - platform_id: str = Field(alias="platformId", description="Platform identifier") - safe_name: str = Field(alias="safeName", description="Safe name") - user_name: Optional[str] = Field(default=None, alias="userName", description="Account username") - address: Optional[str] = Field(default=None, description="Target system address") - - -class AccountsList(BaseModel): - """Paginated list of accounts.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - value: List[AccountSummary] = Field(description="List of accounts") - count: int = Field(description="Total number of accounts") - next_link: Optional[str] = Field(default=None, alias="nextLink", description="Next page URL") - - -class SafeCreator(BaseModel): - """Safe creator information.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - id: Optional[str] = Field(default=None, description="Creator user ID") - name: Optional[str] = Field(default=None, description="Creator name") - - -class SafeDetails(BaseModel): - """Detailed safe information from CyberArk Privilege Cloud.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - safe_url_id: str = Field(alias="safeUrlId", description="URL-safe identifier") - safe_name: str = Field(alias="safeName", description="Safe name") - safe_number: Optional[int] = Field(default=None, alias="safeNumber", description="Safe number") - description: Optional[str] = Field(default=None, description="Safe description") - location: Optional[str] = Field(default=None, description="Safe location in vault") - creator: Optional[SafeCreator] = Field(default=None, description="Safe creator information") - olac_enabled: Optional[bool] = Field(default=None, alias="olacEnabled", description="Object level access control enabled") - number_of_days_retention: Optional[int] = Field( - default=None, alias="numberOfDaysRetention", description="Retention days" - ) - number_of_versions_retention: Optional[int] = Field( - default=None, alias="numberOfVersionsRetention", description="Retention versions" - ) - auto_purge_enabled: Optional[bool] = Field(default=None, alias="autoPurgeEnabled", description="Auto purge enabled") - managing_cpm: Optional[str] = Field(default=None, alias="managingCPM", description="Managing CPM name") - - -class SafeMemberPermissions(BaseModel): - """Safe member permissions.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - use_accounts: Optional[bool] = Field(default=None, alias="useAccounts") - retrieve_accounts: Optional[bool] = Field(default=None, alias="retrieveAccounts") - list_accounts: Optional[bool] = Field(default=None, alias="listAccounts") - add_accounts: Optional[bool] = Field(default=None, alias="addAccounts") - update_account_content: Optional[bool] = Field(default=None, alias="updateAccountContent") - update_account_properties: Optional[bool] = Field(default=None, alias="updateAccountProperties") - rename_accounts: Optional[bool] = Field(default=None, alias="renameAccounts") - delete_accounts: Optional[bool] = Field(default=None, alias="deleteAccounts") - unlock_accounts: Optional[bool] = Field(default=None, alias="unlockAccounts") - manage_safe: Optional[bool] = Field(default=None, alias="manageSafe") - manage_safe_members: Optional[bool] = Field(default=None, alias="manageSafeMembers") - view_audit_log: Optional[bool] = Field(default=None, alias="viewAuditLog") - view_safe_members: Optional[bool] = Field(default=None, alias="viewSafeMembers") - - -class SafeMember(BaseModel): - """Safe member with permissions.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - member_name: str = Field(alias="memberName", description="Member name/username") - member_type: str = Field(alias="memberType", description="Member type (User, Group, Role)") - membership_expiration_date: Optional[str] = Field( - default=None, alias="membershipExpirationDate", description="Membership expiration date" - ) - permissions: Optional[Dict[str, Any]] = Field(default=None, description="Member permissions") - search_in: Optional[str] = Field(default=None, alias="searchIn", description="Domain to search in") - - -class PlatformDetails(BaseModel): - """Platform configuration details from CyberArk Privilege Cloud.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" # Allow Policy INI fields - ) - - id: str = Field(description="Platform identifier") - name: str = Field(description="Platform display name") - system_type: Optional[str] = Field(default=None, alias="systemType", description="System type (Windows, Unix, etc.)") - active: Optional[bool] = Field(default=None, description="Whether platform is active") - platform_type: Optional[str] = Field(default=None, alias="platformType", description="Platform type (Regular, Group, etc.)") - description: Optional[str] = Field(default=None, description="Platform description") - platform_base_id: Optional[str] = Field(default=None, alias="platformBaseID", description="Base platform ID") - privileged_access_workflows: Optional[Dict[str, Any]] = Field( - default=None, alias="privilegedAccessWorkflows", description="Privileged access workflow settings" - ) - - -class ApplicationDetails(BaseModel): - """Application details from CyberArk Privilege Cloud.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - app_id: str = Field(alias="AppID", description="Application identifier") - description: Optional[str] = Field(default=None, alias="Description", description="Application description") - location: Optional[str] = Field(default=None, alias="Location", description="Application location") - disabled: Optional[bool] = Field(default=None, alias="Disabled", description="Whether application is disabled") - business_owner_first_name: Optional[str] = Field( - default=None, alias="BusinessOwnerFName", description="Business owner first name" - ) - business_owner_last_name: Optional[str] = Field( - default=None, alias="BusinessOwnerLName", description="Business owner last name" - ) - business_owner_email: Optional[str] = Field( - default=None, alias="BusinessOwnerEmail", description="Business owner email" - ) - business_owner_phone: Optional[str] = Field( - default=None, alias="BusinessOwnerPhone", description="Business owner phone" - ) - access_permitted_from: Optional[str] = Field( - default=None, alias="AccessPermittedFrom", description="Access start time" - ) - access_permitted_to: Optional[str] = Field( - default=None, alias="AccessPermittedTo", description="Access end time" - ) - expiration_date: Optional[str] = Field( - default=None, alias="ExpirationDate", description="Application expiration date" - ) - - -class ApplicationAuthMethod(BaseModel): - """Application authentication method.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - auth_type: str = Field(alias="authType", description="Authentication type") - auth_value: Optional[str] = Field(default=None, alias="authValue", description="Authentication value") - auth_id: Optional[str] = Field(default=None, alias="authID", description="Authentication method ID") - is_folder: Optional[bool] = Field(default=None, alias="isFolder", description="Whether this is a folder auth") - allow_internal_scripts: Optional[bool] = Field( - default=None, alias="allowInternalScripts", description="Allow internal scripts" - ) - comment: Optional[str] = Field(default=None, description="Authentication method comment") - - -class SessionDetails(BaseModel): - """Privileged session details from CyberArk Session Monitoring.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - session_id: str = Field(alias="SessionId", description="Unique session identifier") - protocol: Optional[str] = Field(default=None, alias="Protocol", description="Session protocol (SSH, RDP, etc.)") - user: Optional[str] = Field(default=None, alias="User", description="User who initiated the session") - from_ip: Optional[str] = Field(default=None, alias="FromIP", description="Source IP address") - to_address: Optional[str] = Field(default=None, alias="ToAddress", description="Target address") - to_user: Optional[str] = Field(default=None, alias="ToUser", description="Target user") - session_duration: Optional[str] = Field(default=None, alias="SessionDuration", description="Session duration") - start_time: Optional[str] = Field(default=None, alias="StartTime", description="Session start time") - end_time: Optional[str] = Field(default=None, alias="EndTime", description="Session end time") - risk_score: Optional[int] = Field(default=None, alias="RiskScore", description="Session risk score") - - -class SessionActivity(BaseModel): - """Session activity log entry.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - timestamp: str = Field(alias="Timestamp", description="Activity timestamp") - command: Optional[str] = Field(default=None, alias="Command", description="Command executed") - result: Optional[str] = Field(default=None, alias="Result", description="Command result") - activity_type: Optional[str] = Field(default=None, alias="ActivityType", description="Type of activity") - - -class SessionStatistics(BaseModel): - """Session statistics and analytics.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - total_sessions: Optional[int] = Field(default=None, alias="totalSessions", description="Total session count") - active_sessions: Optional[int] = Field(default=None, alias="activeSessions", description="Active session count") - sessions_by_protocol: Optional[Dict[str, int]] = Field( - default=None, alias="sessionsByProtocol", description="Sessions grouped by protocol" - ) - - -class OperationResponse(BaseModel): - """Generic operation response for create/update/delete operations.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - success: bool = Field(description="Whether the operation succeeded") - message: Optional[str] = Field(default=None, description="Operation result message") - id: Optional[str] = Field(default=None, description="ID of affected resource") - - -class ErrorResponse(BaseModel): - """Error response from CyberArk API.""" - - model_config = ConfigDict( - populate_by_name=True, - extra="allow" - ) - - error_code: Optional[str] = Field(default=None, alias="ErrorCode", description="Error code") - error_message: Optional[str] = Field(default=None, alias="ErrorMessage", description="Error message") - details: Optional[str] = Field(default=None, alias="Details", description="Error details") diff --git a/src/mcp_privilege_cloud/sdk_auth.py b/src/mcp_privilege_cloud/sdk_auth.py index 8466748..a2768c1 100644 --- a/src/mcp_privilege_cloud/sdk_auth.py +++ b/src/mcp_privilege_cloud/sdk_auth.py @@ -15,11 +15,6 @@ logger = logging.getLogger(__name__) -class SDKAuthenticationError(Exception): - """Raised when SDK authentication with CyberArk fails""" - pass - - class CyberArkSDKAuthenticator: """Handles authentication with CyberArk using the official ark-sdk-python""" @@ -38,13 +33,6 @@ def __init__( @classmethod def from_environment(cls) -> "CyberArkSDKAuthenticator": """Create authenticator from environment variables""" - # Debug: log all environment variables for debugging - logger.debug(f"Environment variables check:") - logger.debug(f"CYBERARK_CLIENT_ID: {bool(os.getenv('CYBERARK_CLIENT_ID'))}") - logger.debug(f"CYBERARK_CLIENT_SECRET: {bool(os.getenv('CYBERARK_CLIENT_SECRET'))}") - logger.debug(f"CYBERARK_IDENTITY_TENANT_ID: {bool(os.getenv('CYBERARK_IDENTITY_TENANT_ID'))}") - logger.debug(f"CYBERARK_SUBDOMAIN: {bool(os.getenv('CYBERARK_SUBDOMAIN'))}") - client_id = os.getenv("CYBERARK_CLIENT_ID") client_secret = os.getenv("CYBERARK_CLIENT_SECRET") @@ -100,24 +88,13 @@ def authenticate(self) -> ArkISPAuth: except Exception as e: logger.error(f"SDK authentication failed: {e}") - raise SDKAuthenticationError(f"Failed to authenticate with CyberArk SDK: {e}") + from .exceptions import CyberArkAPIError + raise CyberArkAPIError(f"Failed to authenticate with CyberArk SDK: {e}") def get_authenticated_client(self) -> ArkISPAuth: """Get an authenticated SDK client, authenticating if necessary""" if not self._is_authenticated or self._sdk_auth is None: return self.authenticate() - # TODO: Add token validity check and re-authentication logic - # The SDK should handle this internally, but we may want to add explicit checks - return self._sdk_auth - def is_authenticated(self) -> bool: - """Check if the client is currently authenticated""" - return self._is_authenticated and self._sdk_auth is not None - - -# Backward compatibility function for existing code -def create_sdk_authenticator() -> CyberArkSDKAuthenticator: - """Create SDK authenticator from environment variables""" - return CyberArkSDKAuthenticator.from_environment() \ No newline at end of file diff --git a/src/mcp_privilege_cloud/server.py b/src/mcp_privilege_cloud/server.py index 88665e6..7f8df95 100644 --- a/src/mcp_privilege_cloud/server.py +++ b/src/mcp_privilege_cloud/server.py @@ -1,5 +1,6 @@ import logging import asyncio +import os from concurrent.futures import ThreadPoolExecutor from functools import wraps from typing import Optional, Dict, Any, List, Union @@ -26,34 +27,26 @@ # Try to import response model types - these may be generic Pydantic models try: - # These are the likely response model names based on SDK patterns from ark_sdk_python.models.services.pcloud.accounts import ( - ArkPCloudAccount, # Individual account response - ArkPCloudAccountsList, # List accounts response + ArkPCloudAccount, ) except ImportError: - # Fallback to BaseModel if specific response types aren't available ArkPCloudAccount = BaseModel - ArkPCloudAccountsList = BaseModel # Import common response types for proper type annotations try: from ark_sdk_python.models.services.pcloud.safes import ( - ArkPCloudSafe, ArkPCloudSafeMember, ) except ImportError: - ArkPCloudSafe = BaseModel ArkPCloudSafeMember = BaseModel try: from ark_sdk_python.models.services.pcloud.platforms import ( - ArkPCloudPlatform, ArkPCloudPlatformStatistics, ArkPCloudTargetPlatformStatistics, ) except ImportError: - ArkPCloudPlatform = BaseModel ArkPCloudPlatformStatistics = BaseModel ArkPCloudTargetPlatformStatistics = BaseModel @@ -61,12 +54,10 @@ from ark_sdk_python.models.services.pcloud.applications import ( ArkPCloudApplication, ArkPCloudApplicationAuthMethod, - ArkPCloudApplicationStatistics, ) except ImportError: ArkPCloudApplication = BaseModel ArkPCloudApplicationAuthMethod = BaseModel - ArkPCloudApplicationStatistics = BaseModel try: from ark_sdk_python.models.services.sm import ( @@ -164,52 +155,6 @@ async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: result = await func(self, *args, **kwargs) return result except Exception as e: - # Special handling for known SDK validation issues - error_str = str(e).lower() - if "rotationalgroup" in error_str and operation_name == "listing platforms": - self.logger.warning(f"SDK validation failed due to API/SDK enum mismatch for {operation_name}, attempting direct API workaround: {e}") - - # Attempt direct API call workaround for the platforms enum issue - try: - # Import necessary modules - import json - import httpx - - # Get authentication token - auth_token = await self._run_in_executor( - lambda: self.platforms_service._isp_auth.token.token.get_secret_value() - ) - - # Make direct API call - async with httpx.AsyncClient() as client: - headers = { - 'Authorization': f'Bearer {auth_token}', - 'Content-Type': 'application/json' - } - - # Build API URL using helper method - api_url = self._build_api_url('platforms_service', 'Platforms') - - response = await client.get(api_url, headers=headers) - if response.status_code == 200: - raw_data = response.json() - - # Fix the enum value in the response - for platform in raw_data.get('Platforms', []): - general = platform.get('general', {}) - if general.get('platformType') == 'rotationalgroup': - general['platformType'] = 'rotationalGroups' - - self.logger.info(f"Retrieved {len(raw_data.get('Platforms', []))} platforms via direct API call with enum fix") - return raw_data.get('Platforms', []) - else: - raise Exception(f"API call failed with status {response.status_code}") - - except Exception as api_error: - self.logger.error(f"Direct API call workaround failed for {operation_name}: {api_error}") - # Fall through to normal error handling - pass - # Enhanced error handling with SDK-specific exceptions and user guidance if is_sdk_exception(e): # Extract status code from original SDK exception first @@ -305,31 +250,12 @@ def __init__(self): def from_environment(cls) -> "CyberArkMCPServer": """Create server from environment variables""" return cls() - + async def _run_in_executor(self, func: Any, *args: Any, **kwargs: Any) -> Any: """Run synchronous SDK calls in ThreadPoolExecutor to avoid blocking the event loop.""" loop = asyncio.get_running_loop() return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs)) - def _build_api_url(self, service_name: str, endpoint: str) -> str: - """Build CyberArk API URL from SDK client base URL. - - Args: - service_name: Name of the service ('platforms_service' or 'applications_service') - endpoint: API endpoint (e.g., 'Platforms', 'Applications', 'Applications/Stats') - - Returns: - Complete API URL with proper case conversion - """ - if service_name == 'platforms_service': - base_url = self.platforms_service._client.base_url - elif service_name == 'applications_service': - base_url = self.applications_service._client.base_url - else: - raise ValueError(f"Unknown service: {service_name}") - - return base_url.replace('passwordvault', 'PasswordVault') + endpoint - def _ensure_service_initialized(self, service_name: str) -> None: """Ensure a specific service is initialized, initializing if needed.""" if getattr(self, service_name) is None: @@ -345,83 +271,6 @@ def _ensure_service_initialized(self, service_name: str) -> None: elif service_name == 'sm_service': self.sm_service = ArkSMService(sdk_auth) - def reinitialize_services(self) -> None: - """Reinitialize services - useful for testing or after auth changes.""" - sdk_auth = self.sdk_authenticator.get_authenticated_client() - self.accounts_service = ArkPCloudAccountsService(sdk_auth) - self.safes_service = ArkPCloudSafesService(sdk_auth) - self.platforms_service = ArkPCloudPlatformsService(sdk_auth) - self.applications_service = ArkPCloudApplicationsService(sdk_auth) - self.sm_service = ArkSMService(sdk_auth) - - # Legacy API methods removed - all operations now use ark-sdk-python directly - - def get_available_tools(self) -> List[str]: - """Get list of available MCP tools""" - return [ - "list_accounts", - "get_account_details", - "search_accounts", - "create_account", - "update_account", - "delete_account", - "change_account_password", - "set_next_password", - "verify_account_password", - "reconcile_account_password", - "filter_accounts_by_platform_group", - "filter_accounts_by_environment", - "filter_accounts_by_management_status", - "group_accounts_by_safe", - "group_accounts_by_platform", - "analyze_account_distribution", - "search_accounts_by_pattern", - "count_accounts_by_criteria", - "list_safes", - "get_safe_details", - "add_safe", - "update_safe", - "delete_safe", - "list_safe_members", - "get_safe_member_details", - "add_safe_member", - "update_safe_member", - "remove_safe_member", - "list_platforms", - "get_platform_details", - "import_platform_package", - "export_platform", - "duplicate_target_platform", - "activate_target_platform", - "deactivate_target_platform", - "delete_target_platform", - "list_applications", - "get_application_details", - "add_application", - "delete_application", - "list_application_auth_methods", - "get_application_auth_method_details", - "add_application_auth_method", - "delete_application_auth_method", - "get_applications_stats" - ] - - def clear_cache(self) -> None: - """Clear all cached services and authentication state. Used for testing.""" - # Reset authentication state - if hasattr(self.sdk_authenticator, '_sdk_auth'): - self.sdk_authenticator._sdk_auth = None - self.sdk_authenticator._is_authenticated = False - - # Reinitialize services with fresh authentication - self.reinitialize_services() - - def shutdown(self) -> None: - """Shutdown the ThreadPoolExecutor and clean up resources.""" - if hasattr(self, '_executor') and self._executor: - self._executor.shutdown(wait=True) - self.logger.info("ThreadPoolExecutor shutdown completed") - # Account Management - Using ark-sdk-python @handle_sdk_errors("listing accounts") async def list_accounts(self, safe_name: Optional[str] = None, **kwargs) -> List[BaseModel]: @@ -1409,7 +1258,6 @@ async def import_platform_package( ) -> Any: """Import a platform package using ark-sdk-python""" import base64 - import os # Handle file input - convert to base64 encoded bytes if isinstance(platform_package_file, str): @@ -1500,8 +1348,6 @@ async def list_platforms_with_details(self, **kwargs) -> Any: Returns: List of complete platform configurations with graceful degradation for failures """ - import asyncio - # Get the basic platform list platforms_list = await self.list_platforms(**kwargs) if not platforms_list: @@ -1871,61 +1717,18 @@ async def list_applications(self, **kwargs) -> List[Dict[str, Any]]: if 'business_owner_email' in kwargs: filter_params['business_owner_email'] = kwargs['business_owner_email'] - try: - if filter_params: - app_filter = ArkPCloudApplicationsFilter(**filter_params) - applications = await self._run_in_executor( - self.applications_service.list_applications_by, app_filter - ) - else: - applications = await self._run_in_executor( - self.applications_service.list_applications - ) - - self.logger.info(f"Applications listed successfully: {len(applications)} found") - - # Convert to dict format to avoid Pydantic validation issues with null ExpirationDate fields - return [app.model_dump() if hasattr(app, 'model_dump') else app for app in applications] - - except Exception as e: - # Handle SDK validation errors by bypassing strict validation - error_str = str(e).lower() - error_type = type(e).__name__.lower() - if (("validationerror" in error_str or "validation error" in error_str or "validationerror" in error_type) - and "expirationdate" in error_str): - self.logger.warning(f"SDK validation failed due to null ExpirationDate fields, attempting raw API call workaround: {e}") - - # Import necessary modules for direct API call - import json - import httpx - - # Get authentication token - auth_token = await self._run_in_executor( - lambda: self.applications_service._isp_auth.token.token.get_secret_value() - ) - - # Make direct API call - async with httpx.AsyncClient() as client: - headers = { - 'Authorization': f'Bearer {auth_token}', - 'Content-Type': 'application/json' - } - - # Build API URL using helper method - api_url = self._build_api_url('applications_service', 'Applications') - - response = await client.get(api_url, headers=headers) - if response.status_code == 200: - raw_data = response.json() - applications_list = raw_data.get('Applications', []) - - self.logger.info(f"Retrieved {len(applications_list)} applications via direct API call") - return applications_list - else: - raise Exception(f"API call failed with status {response.status_code}") - else: - # Re-raise non-validation errors - raise + if filter_params: + app_filter = ArkPCloudApplicationsFilter(**filter_params) + applications = await self._run_in_executor( + self.applications_service.list_applications_by, app_filter + ) + else: + applications = await self._run_in_executor( + self.applications_service.list_applications + ) + + self.logger.info(f"Applications listed successfully: {len(applications)} found") + return [app.model_dump() if hasattr(app, 'model_dump') else app for app in applications] @handle_sdk_errors("getting application details") async def get_application_details(self, app_id: str) -> ArkPCloudApplication: @@ -2115,52 +1918,10 @@ async def get_applications_stats(self, **kwargs) -> Dict[str, Any]: """Get applications statistics using ark-sdk-python""" self._ensure_service_initialized('applications_service') - try: - stats = await self._run_in_executor( - self.applications_service.applications_stats - ) - - self.logger.info("Applications statistics retrieved successfully") - - # Convert to dict format to avoid Pydantic validation issues with null ExpirationDate fields - return stats.model_dump() if hasattr(stats, 'model_dump') else stats - - except Exception as e: - # Handle SDK validation errors by bypassing strict validation - error_str = str(e).lower() - error_type = type(e).__name__.lower() - if (("validationerror" in error_str or "validation error" in error_str or "validationerror" in error_type) - and "expirationdate" in error_str): - self.logger.warning(f"SDK validation failed due to null ExpirationDate fields, attempting raw API call workaround: {e}") - - # Import necessary modules for direct API call - import json - import httpx - - # Get authentication token - auth_token = await self._run_in_executor( - lambda: self.applications_service._isp_auth.token.token.get_secret_value() - ) - - # Make direct API call - async with httpx.AsyncClient() as client: - headers = { - 'Authorization': f'Bearer {auth_token}', - 'Content-Type': 'application/json' - } - - # Build API URL using helper method - api_url = self._build_api_url('applications_service', 'Applications/Stats') - - response = await client.get(api_url, headers=headers) - if response.status_code == 200: - raw_data = response.json() - - self.logger.info("Retrieved applications statistics via direct API call") - return raw_data - else: - raise Exception(f"API call failed with status {response.status_code}") - else: - # Re-raise non-validation errors - raise + stats = await self._run_in_executor( + self.applications_service.applications_stats + ) + + self.logger.info("Applications statistics retrieved successfully") + return stats.model_dump() if hasattr(stats, 'model_dump') else stats diff --git a/src/mcp_privilege_cloud/services/__init__.py b/src/mcp_privilege_cloud/services/__init__.py deleted file mode 100644 index 8195c47..0000000 --- a/src/mcp_privilege_cloud/services/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""CyberArk Privilege Cloud services package.""" -from .base import BaseService - -__all__ = ["BaseService"] \ No newline at end of file diff --git a/src/mcp_privilege_cloud/services/base.py b/src/mcp_privilege_cloud/services/base.py deleted file mode 100644 index 53aa5df..0000000 --- a/src/mcp_privilege_cloud/services/base.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Base service class for CyberArk PCloud services.""" - -import logging -from typing import Any - -from ..exceptions import CyberArkAPIError - - -class BaseService: - """Base class for all CyberArk PCloud service implementations.""" - - def __init__(self, sdk_authenticator: Any, logger: logging.Logger) -> None: - """Initialize base service with SDK authenticator and logger. - - Args: - sdk_authenticator: CyberArk SDK authenticator instance - logger: Logger instance for this service - """ - self.sdk_authenticator = sdk_authenticator - self.logger = logger - - def _ensure_service_initialized(self, service_name: str, service_instance: Any) -> None: - """Ensure SDK service is properly initialized. - - Args: - service_name: Name of the service for error reporting - service_instance: SDK service instance to check - - Raises: - CyberArkAPIError: If service is not initialized - """ - if service_instance is None: - raise CyberArkAPIError(f"{service_name} not initialized") diff --git a/src/mcp_privilege_cloud/token_verifier.py b/src/mcp_privilege_cloud/token_verifier.py new file mode 100644 index 0000000..aeca763 --- /dev/null +++ b/src/mcp_privilege_cloud/token_verifier.py @@ -0,0 +1,179 @@ +"""JWT token verification against CyberArk Identity JWKS. + +Implements the MCP SDK's TokenVerifier protocol to validate OAuth JWTs +issued by CyberArk Identity, enabling per-user authentication in +Resource Server mode. + +JWKS URI is resolved lazily from OIDC discovery on first token +verification, ensuring compatibility with all CyberArk Identity app +types (OAuth 2.0 Client, OIDC, etc.). +""" + +import logging +import os +from typing import Optional + +import httpx +import jwt as pyjwt +from jwt import PyJWKClient + +from mcp.server.auth.provider import AccessToken, TokenVerifier + +logger = logging.getLogger(__name__) + +# OIDC application ID for CyberArk Identity. +# Configurable via CYBERARK_OIDC_APP_ID env var; defaults to custom app. +CYBERARK_OIDC_APP_ID = os.getenv("CYBERARK_OIDC_APP_ID", "mcpprivilegecloud") + + +class CyberArkTokenVerifier(TokenVerifier): + """Verify JWTs against CyberArk Identity JWKS endpoint. + + Implements the MCP SDK TokenVerifier protocol: + async def verify_token(self, token: str) -> AccessToken | None + + The JWKS URI is resolved lazily from the OIDC discovery document + on first use, so it works regardless of CyberArk Identity app type. + Returns AccessToken on success, None on any verification failure. + """ + + def __init__(self, identity_tenant_url: str) -> None: + """Initialize the token verifier. + + Args: + identity_tenant_url: CyberArk Identity tenant URL + (e.g., "https://abc1234.id.cyberark.cloud"). + """ + self._identity_tenant_url = identity_tenant_url.rstrip("/") + self._jwks_uri: Optional[str] = None + self._jwks_client: Optional[PyJWKClient] = None + + # The app name used for OIDC discovery and issuer validation + self._expected_app_id = CYBERARK_OIDC_APP_ID + + # Accepted audience for JWT validation. CyberArk Identity sets the + # `aud` claim to the client_id from the Trust tab (CYBERARK_OAUTH_CLIENT_ID). + # Falls back to the OIDC app name if not configured. + oauth_client_id = os.getenv("CYBERARK_OAUTH_CLIENT_ID") + self._expected_audience = {oauth_client_id} if oauth_client_id else {CYBERARK_OIDC_APP_ID} + + logger.info( + "Token verifier initialized (tenant: %s, accepted audiences: %s)", + self._identity_tenant_url, + self._expected_audience, + ) + + async def _ensure_jwks_client(self) -> None: + """Lazily resolve JWKS URI from OIDC discovery and create client. + + Fetches the OIDC discovery document to obtain the authoritative + jwks_uri. Falls back to a constructed URI if discovery fails. + """ + if self._jwks_client is not None: + return + + discovery_url = ( + f"{self._identity_tenant_url}/{CYBERARK_OIDC_APP_ID}" + f"/.well-known/openid-configuration" + ) + fallback_uri = f"{self._identity_tenant_url}/OAuth2/Keys/{CYBERARK_OIDC_APP_ID}" + + try: + async with httpx.AsyncClient() as client: + resp = await client.get(discovery_url) + resp.raise_for_status() + oidc_config = resp.json() + self._jwks_uri = oidc_config.get("jwks_uri", fallback_uri) + logger.info("JWKS URI resolved from OIDC discovery: %s", self._jwks_uri) + except Exception as e: + logger.warning( + "OIDC discovery failed (%s), using fallback JWKS URI: %s", + e, fallback_uri, + ) + self._jwks_uri = fallback_uri + + self._jwks_client = PyJWKClient(self._jwks_uri, cache_keys=True) + + async def verify_token(self, token: str) -> Optional[AccessToken]: + """Verify a JWT token and return an AccessToken if valid. + + Args: + token: Raw JWT Bearer token string. + + Returns: + AccessToken with client_id, scopes, and expiry on success. + None if verification fails for any reason. + """ + try: + claims = await self._decode_and_verify(token) + except ( + pyjwt.ExpiredSignatureError, + pyjwt.InvalidSignatureError, + pyjwt.InvalidAudienceError, + pyjwt.InvalidIssuerError, + pyjwt.DecodeError, + pyjwt.InvalidTokenError, + pyjwt.PyJWKClientConnectionError, + ) as e: + logger.warning("Token verification failed: %s", e) + self._log_unverified_claims(token) + return None + except Exception: + logger.exception("Unexpected error during token verification") + return None + + # Validate required claims + sub = claims.get("sub") + if not sub: + logger.warning("Token missing required 'sub' claim") + return None + + # Extract scopes from space-delimited scope claim + scope_str = claims.get("scope", "") + scopes = scope_str.split() if scope_str else [] + + return AccessToken( + token=token, + client_id=sub, + scopes=scopes, + expires_at=claims.get("exp"), + ) + + def _log_unverified_claims(self, token: str) -> None: + """Log token claims (without signature) for debugging.""" + try: + unverified = pyjwt.decode(token, options={"verify_signature": False}) + logger.warning( + "Token claims — iss: %s, aud: %s, sub: %s (expected aud: %s)", + unverified.get("iss"), unverified.get("aud"), unverified.get("sub"), + self._expected_audience, + ) + except Exception: + pass + + async def _decode_and_verify(self, token: str) -> dict: + """Decode and verify a JWT using JWKS. + + Fetches the signing key from CyberArk Identity's JWKS endpoint + and verifies the token signature, expiry, audience, and issuer. + + Args: + token: Raw JWT string. + + Returns: + Decoded JWT claims dictionary. + + Raises: + jwt.PyJWTError: On any verification failure. + """ + await self._ensure_jwks_client() + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + + return pyjwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=self._expected_audience, + issuer=f"{self._identity_tenant_url}/{self._expected_app_id}/", + options={"require": ["exp", "iss", "sub", "aud"]}, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 2b55e8c..25544dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,127 +1,3 @@ """ Shared pytest configuration and fixtures for test isolation. - -This file provides fixtures to ensure proper test isolation by resetting -global state between tests, and provides modern context/lifespan fixtures. """ - -import pytest -import os -from unittest.mock import patch, Mock, AsyncMock - - -@pytest.fixture(autouse=True) -def reset_global_state(): - """ - Automatically reset global state before each test to ensure isolation. - - This fixture: - 1. Resets the global server instance - 2. Clears any cached authentication - 3. Ensures clean state between tests - """ - # Reset global server state before each test - try: - from mcp_privilege_cloud.mcp_server import reset_server - reset_server() - except ImportError: - # If mcp_server is not available, skip - pass - - yield # Run the test - - # Clean up after each test - try: - from mcp_privilege_cloud.mcp_server import reset_server - reset_server() - except ImportError: - pass - - -@pytest.fixture -def mock_env_vars(): - """Provide mock environment variables for testing.""" - return { - 'CYBERARK_CLIENT_ID': 'test-client-id', - 'CYBERARK_CLIENT_SECRET': 'test-client-secret' - } - - -@pytest.fixture -def mock_server(): - """Provide a mock CyberArkMCPServer for testing tools.""" - server = AsyncMock() - # Configure common method returns - server.list_accounts.return_value = [] - server.list_safes.return_value = [] - server.list_platforms.return_value = [] - return server - - -@pytest.fixture -def mock_context(mock_server): - """Provide a mock MCP context with lifespan_context for testing tools. - - This fixture creates a mock Context object that simulates the structure - used by FastMCP's context injection. - - Usage in tests: - async def test_tool(mock_context): - result = await some_tool(param, ctx=mock_context) - """ - from mcp_privilege_cloud.mcp_server import AppContext - - ctx = Mock() - ctx.request_context.lifespan_context = AppContext(server=mock_server) - return ctx - - -@pytest.fixture -def mock_context_with_server(mock_server): - """Provide mock context with configurable server. - - Returns a tuple of (context, server) so tests can configure the server. - - Usage in tests: - async def test_tool(mock_context_with_server): - ctx, server = mock_context_with_server - server.get_account_details.return_value = {...} - result = await get_account_details("123", ctx=ctx) - """ - from mcp_privilege_cloud.mcp_server import AppContext - - ctx = Mock() - ctx.request_context.lifespan_context = AppContext(server=mock_server) - return ctx, mock_server - - -@pytest.fixture -def isolated_server(): - """ - Provide a completely isolated server instance for testing. - - This fixture mocks the SDK authenticator to prevent real authentication - and provides a clean server instance. - """ - with patch.dict(os.environ, { - 'CYBERARK_CLIENT_ID': 'test-client-id', - 'CYBERARK_CLIENT_SECRET': 'test-client-secret' - }): - # Mock the SDK authenticator to prevent real authentication - with patch('src.mcp_privilege_cloud.server.CyberArkSDKAuthenticator') as mock_auth_class: - from src.mcp_privilege_cloud.server import CyberArkMCPServer - - # Create mock authenticator - mock_auth = mock_auth_class.from_environment.return_value - mock_auth.get_authenticated_client.return_value = 'mock_sdk_client' - - # Create server instance - server = CyberArkMCPServer() - - # Clear any cache to ensure isolation - server.clear_cache() - - yield server - - # Clean up after test - server.clear_cache() \ No newline at end of file diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..4c27704 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,32 @@ +"""Shared test helpers.""" + +import base64 +import json +import time + + +def _default_claims(**overrides: object) -> dict: + """Return default valid JWT claims with optional overrides.""" + claims = { + "sub": "testuser@cyberark.cloud.12345", + "iss": "https://abc1234.id.cyberark.cloud/mcpprivilegecloud/", + "aud": "mcpprivilegecloud", + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + "unique_name": "testuser@abc1234.cyberark.cloud", + "subdomain": "abc1234", + "platform_domain": "cyberark.cloud", + "scope": "openid profile pvwa", + } + claims.update(overrides) + return claims + + +def _make_jwt(claims: dict, header: dict | None = None) -> str: + """Create a minimal JWT string (unsigned) for testing.""" + if header is None: + header = {"alg": "RS256", "typ": "JWT", "kid": "test-key-id"} + h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b"=").decode() + p = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() + s = base64.urlsafe_b64encode(b"fakesig").rstrip(b"=").decode() + return f"{h}.{p}.{s}" diff --git a/tests/test_applications_service.py b/tests/test_applications_service.py index 9d96572..1795087 100644 --- a/tests/test_applications_service.py +++ b/tests/test_applications_service.py @@ -34,28 +34,6 @@ def mock_server(): class TestApplicationsServiceIntegration: """Test applications service integration with server""" - def test_applications_service_in_available_tools(self, mock_server): - """Test that applications tools are included in available tools""" - available_tools = mock_server.get_available_tools() - - applications_tools = [ - "list_applications", - "get_application_details", - "add_application", - "delete_application", - "list_application_auth_methods", - "get_application_auth_method_details", - "add_application_auth_method", - "delete_application_auth_method", - "get_applications_stats" - ] - - for tool in applications_tools: - assert tool in available_tools, f"Application tool '{tool}' not in available tools" - - # Verify total count increased - assert len(available_tools) >= 40, f"Expected at least 40 tools, got {len(available_tools)}" - @patch('src.mcp_privilege_cloud.server.ArkPCloudApplicationsService') def test_ensure_applications_service_initialized(self, mock_apps_service_class, mock_server): """Test applications service initialization""" @@ -69,28 +47,6 @@ def test_ensure_applications_service_initialized(self, mock_apps_service_class, assert mock_server.applications_service is not None mock_apps_service_class.assert_called_once() - @patch('src.mcp_privilege_cloud.server.ArkPCloudApplicationsService') - @patch('src.mcp_privilege_cloud.server.ArkPCloudAccountsService') - @patch('src.mcp_privilege_cloud.server.ArkPCloudSafesService') - @patch('src.mcp_privilege_cloud.server.ArkPCloudPlatformsService') - @patch('src.mcp_privilege_cloud.server.ArkSMService') - def test_reinitialize_services_includes_applications(self, mock_sm_service, mock_platforms_service, - mock_safes_service, mock_accounts_service, - mock_apps_service_class, mock_server): - """Test that reinitialize_services includes applications service""" - # Mock SDK auth object - mock_sdk_auth = Mock() - mock_sdk_auth.token.metadata.keys.return_value = ['env'] - mock_server.sdk_authenticator.get_authenticated_client.return_value = mock_sdk_auth - - # Test reinitialize - mock_server.reinitialize_services() - - # Verify applications service was initialized - assert mock_server.applications_service is not None - mock_apps_service_class.assert_called_once_with(mock_sdk_auth) - - class TestApplicationsOperations: """Test applications management operations""" diff --git a/tests/test_context_injection.py b/tests/test_context_injection.py index 0677b51..3364fa6 100644 --- a/tests/test_context_injection.py +++ b/tests/test_context_injection.py @@ -36,23 +36,12 @@ async def test_tool_accesses_server_via_context(self): mock_server.get_account_details.assert_called_once_with(account_id="123") @pytest.mark.asyncio - async def test_tool_works_without_context_backwards_compatible(self): - """Tool should fall back to global server when context not provided""" + async def test_tool_requires_context(self): + """Tool should raise RuntimeError when context not provided""" from mcp_privilege_cloud.mcp_server import get_account_details - mock_server = AsyncMock() - mock_server.get_account_details.return_value = { - "id": "123", - "platformId": "WinServerLocal", - "safeName": "TestSafe" - } - - # Use legacy pattern without context - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await get_account_details("123") - - assert result["id"] == "123" - mock_server.get_account_details.assert_called_once() + with pytest.raises(RuntimeError, match="No server context available"): + await get_account_details("123") class TestContextInjectionForHighUsageTools: @@ -185,16 +174,9 @@ async def test_tool_propagates_errors_with_context(self): await get_account_details("invalid_id", ctx=mock_ctx) @pytest.mark.asyncio - async def test_tool_handles_none_context_gracefully(self): - """Tools should handle None context by falling back to get_server()""" + async def test_tool_raises_on_none_context(self): + """Tools should raise RuntimeError when ctx=None is passed""" from mcp_privilege_cloud.mcp_server import list_accounts - mock_server = AsyncMock() - mock_server.list_accounts.return_value = [] - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - # Explicitly pass None for ctx - result = await list_accounts(ctx=None) - - assert result == [] - mock_server.list_accounts.assert_called_once() + with pytest.raises(RuntimeError, match="No server context available"): + await list_accounts(ctx=None) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 85013b5..fc347a1 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1,7 +1,6 @@ import pytest import os from unittest.mock import Mock, patch -from mcp_privilege_cloud.exceptions import AuthenticationError from mcp_privilege_cloud.server import CyberArkMCPServer, CyberArkAPIError @@ -145,24 +144,23 @@ def test_server_from_environment_missing_required(self): with pytest.raises(ValueError, match="CYBERARK_CLIENT_ID"): CyberArkMCPServer.from_environment() - def test_tool_registration(self, server_instance): - """Test that required tools are registered""" - tools = server_instance.get_available_tools() - expected_tools = [ + def test_server_has_expected_methods(self, server_instance): + """Test that required server methods exist""" + expected_methods = [ "list_accounts", - "search_accounts", + "search_accounts", "list_safes", "list_platforms", "create_account", - "change_account_password", + "change_account_password", "set_next_password", "verify_account_password", "reconcile_account_password", "import_platform_package" ] - - for tool_name in expected_tools: - assert tool_name in tools + + for method_name in expected_methods: + assert hasattr(server_instance, method_name), f"Missing method: {method_name}" @pytest.mark.asyncio async def test_server_sdk_integration(self, server_instance): diff --git a/tests/test_env_var_resolution.py b/tests/test_env_var_resolution.py new file mode 100644 index 0000000..443dc29 --- /dev/null +++ b/tests/test_env_var_resolution.py @@ -0,0 +1,152 @@ +"""Tests for environment variable resolution priority chains. + +Tests the DCR client_id/secret priority chain to ensure +CYBERARK_OAUTH_CLIENT_ID takes priority over legacy vars. +""" + +import os + +from unittest.mock import patch + + +class TestDCRClientIdResolution: + """Test _build_dcr_response() client_id priority chain: + CYBERARK_OAUTH_CLIENT_ID > CYBERARK_CLIENT_ID > CYBERARK_OIDC_APP_ID + """ + + def test_oauth_client_id_takes_priority(self): + """CYBERARK_OAUTH_CLIENT_ID should win over CYBERARK_CLIENT_ID.""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + + env = { + "CYBERARK_OAUTH_CLIENT_ID": "1fc81892-a1ba-49ca-9bf9-7d1f1de19ea6", + "CYBERARK_CLIENT_ID": "timtest@cyberark.cloud.3240", + } + with patch.dict(os.environ, env, clear=True): + response = _build_dcr_response({}) + + assert response["client_id"] == "1fc81892-a1ba-49ca-9bf9-7d1f1de19ea6" + + def test_legacy_client_id_not_leaked_via_dcr(self): + """CYBERARK_CLIENT_ID (service account) should NOT be returned via DCR. + DCR client_id chain: CYBERARK_OAUTH_CLIENT_ID > CYBERARK_OIDC_APP_ID only.""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + from mcp_privilege_cloud.token_verifier import CYBERARK_OIDC_APP_ID + + env = {"CYBERARK_CLIENT_ID": "timtest@cyberark.cloud.3240"} + with patch.dict(os.environ, env, clear=True): + response = _build_dcr_response({}) + + # Should fall back to OIDC app ID, NOT leak service account username + assert response["client_id"] == CYBERARK_OIDC_APP_ID + + def test_falls_back_to_oidc_app_id(self): + """Without either client ID var, should fall back to CYBERARK_OIDC_APP_ID.""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + from mcp_privilege_cloud.token_verifier import CYBERARK_OIDC_APP_ID + + with patch.dict(os.environ, {}, clear=True): + response = _build_dcr_response({}) + + assert response["client_id"] == CYBERARK_OIDC_APP_ID + + def test_secret_never_in_dcr_response(self): + """DCR should NEVER return client_secret, even when secrets are configured.""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + + env = { + "CYBERARK_OAUTH_CLIENT_SECRET": "oauth-app-secret", + "CYBERARK_CLIENT_SECRET": "service-account-password", + } + with patch.dict(os.environ, env, clear=True): + response = _build_dcr_response({}) + + assert "client_secret" not in response + assert response["token_endpoint_auth_method"] == "none" + + def test_legacy_secret_never_in_dcr_response(self): + """DCR should NEVER return CYBERARK_CLIENT_SECRET (service account password).""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + + env = {"CYBERARK_CLIENT_SECRET": "service-account-password"} + with patch.dict(os.environ, env, clear=True): + response = _build_dcr_response({}) + + assert "client_secret" not in response + assert response["token_endpoint_auth_method"] == "none" + + def test_no_secret_is_public_client(self): + """Without any secret, DCR should return public client (token_endpoint_auth_method=none).""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + + with patch.dict(os.environ, {}, clear=True): + response = _build_dcr_response({}) + + assert response["token_endpoint_auth_method"] == "none" + assert "client_secret" not in response + + +class TestGetOAuthCredentials: + """Test _get_oauth_credentials() helper for server-side credential injection.""" + + def test_oauth_secret_takes_priority(self): + """CYBERARK_OAUTH_CLIENT_SECRET should win over CYBERARK_CLIENT_SECRET.""" + from mcp_privilege_cloud.mcp_server import _get_oauth_credentials + + env = { + "CYBERARK_OAUTH_CLIENT_SECRET": "oauth-app-secret", + "CYBERARK_CLIENT_SECRET": "service-account-password", + } + with patch.dict(os.environ, env, clear=True): + _, secret = _get_oauth_credentials() + + assert secret == "oauth-app-secret" + + def test_no_legacy_secret_fallback(self): + """Should NOT fall back to CYBERARK_CLIENT_SECRET for OAuth credentials.""" + from mcp_privilege_cloud.mcp_server import _get_oauth_credentials + + env = {"CYBERARK_CLIENT_SECRET": "service-account-password"} + with patch.dict(os.environ, env, clear=True): + _, secret = _get_oauth_credentials() + + assert secret == "" + + def test_warns_when_client_id_set_without_secret(self): + """Should warn when CYBERARK_OAUTH_CLIENT_ID is set but secret is not.""" + from mcp_privilege_cloud.mcp_server import _get_oauth_credentials + + env = {"CYBERARK_OAUTH_CLIENT_ID": "some-uuid"} + with patch.dict(os.environ, env, clear=True): + with patch("mcp_privilege_cloud.mcp_server.logger") as mock_logger: + _, secret = _get_oauth_credentials() + + assert secret == "" + mock_logger.warning.assert_called_once() + assert "CYBERARK_OAUTH_CLIENT_SECRET" in mock_logger.warning.call_args[0][0] + + def test_client_id_chain(self): + """Client ID: CYBERARK_OAUTH_CLIENT_ID > CYBERARK_OIDC_APP_ID.""" + from mcp_privilege_cloud.mcp_server import _get_oauth_credentials + from mcp_privilege_cloud.token_verifier import CYBERARK_OIDC_APP_ID + + env = {"CYBERARK_OAUTH_CLIENT_ID": "oauth-uuid-id"} + with patch.dict(os.environ, env, clear=True): + client_id, _ = _get_oauth_credentials() + assert client_id == "oauth-uuid-id" + + # Without CYBERARK_OAUTH_CLIENT_ID, falls back to OIDC app ID + with patch.dict(os.environ, {}, clear=True): + client_id, _ = _get_oauth_credentials() + assert client_id == CYBERARK_OIDC_APP_ID + + def test_empty_secret(self): + """Should return empty string when no secret configured.""" + from mcp_privilege_cloud.mcp_server import _get_oauth_credentials + + with patch.dict(os.environ, {}, clear=True): + _, secret = _get_oauth_credentials() + + assert secret == "" + + diff --git a/tests/test_integration_tools.py b/tests/test_integration_tools.py index c59a52c..91cb577 100644 --- a/tests/test_integration_tools.py +++ b/tests/test_integration_tools.py @@ -1,17 +1,22 @@ """ Integration Tests for CyberArk MCP Tools -This module provides integration tests for the new tool-based approach, -replacing the previous resource-based tests. +Tests tool-based approach with context injection. """ import pytest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock -from mcp_privilege_cloud.mcp_server import list_platforms +from mcp_privilege_cloud.mcp_server import list_platforms, AppContext from mcp_privilege_cloud.server import CyberArkMCPServer +def _make_ctx(mock_server): + ctx = Mock() + ctx.request_context.lifespan_context = AppContext(server=mock_server) + return ctx + + @pytest.mark.integration class TestToolIntegration: """Test integration of tools with the CyberArk server.""" @@ -29,30 +34,24 @@ async def test_list_platforms_integration(self): "description": "Windows Server Local Accounts" } ] - - with patch('mcp_privilege_cloud.mcp_server.CyberArkMCPServer.from_environment') as mock_server_factory: - mock_server = AsyncMock() - mock_server.list_platforms.return_value = mock_platforms - mock_server_factory.return_value = mock_server - - # Test the tool function - result = await list_platforms() - - # Verify exact API data is returned (no manipulation) - assert result == mock_platforms - assert result[0]["systemType"] == "Windows" # Original camelCase preserved - assert result[0]["platformType"] == "Regular" # Original camelCase preserved - - # Verify server method was called - mock_server.list_platforms.assert_called_once() - - @pytest.mark.asyncio + + mock_server = AsyncMock(spec=CyberArkMCPServer) + mock_server.list_platforms.return_value = mock_platforms + ctx = _make_ctx(mock_server) + + result = await list_platforms(ctx=ctx) + + assert result == mock_platforms + assert result[0]["systemType"] == "Windows" + assert result[0]["platformType"] == "Regular" + mock_server.list_platforms.assert_called_once() + + @pytest.mark.asyncio async def test_tool_error_propagation(self): """Test that tool errors are properly propagated.""" - with patch('mcp_privilege_cloud.mcp_server.CyberArkMCPServer.from_environment') as mock_server_factory: - mock_server = AsyncMock() - mock_server.list_platforms.side_effect = Exception("Server Error") - mock_server_factory.return_value = mock_server - - with pytest.raises(Exception, match="Server Error"): - await list_platforms() \ No newline at end of file + mock_server = AsyncMock(spec=CyberArkMCPServer) + mock_server.list_platforms.side_effect = Exception("Server Error") + ctx = _make_ctx(mock_server) + + with pytest.raises(Exception, match="Server Error"): + await list_platforms(ctx=ctx) diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index c43b746..22e4374 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -38,10 +38,7 @@ async def test_lifespan_initializes_server(self): mock_server = Mock() - with patch.dict(os.environ, { - 'CYBERARK_CLIENT_ID': 'test-client', - 'CYBERARK_CLIENT_SECRET': 'test-secret' - }): + with patch('mcp_privilege_cloud.mcp_server.is_oauth_mode', return_value=False): with patch('mcp_privilege_cloud.mcp_server.CyberArkMCPServer') as mock_class: mock_class.from_environment.return_value = mock_server @@ -74,10 +71,7 @@ async def test_lifespan_cleanup_on_shutdown(self): mock_server = Mock() mock_server._executor = mock_executor - with patch.dict(os.environ, { - 'CYBERARK_CLIENT_ID': 'test-client', - 'CYBERARK_CLIENT_SECRET': 'test-secret' - }): + with patch('mcp_privilege_cloud.mcp_server.is_oauth_mode', return_value=False): with patch('mcp_privilege_cloud.mcp_server.CyberArkMCPServer') as mock_class: mock_class.from_environment.return_value = mock_server @@ -94,10 +88,7 @@ async def test_lifespan_handles_server_without_executor(self): mock_server = Mock(spec=['list_accounts']) # No _executor attribute - with patch.dict(os.environ, { - 'CYBERARK_CLIENT_ID': 'test-client', - 'CYBERARK_CLIENT_SECRET': 'test-secret' - }): + with patch('mcp_privilege_cloud.mcp_server.is_oauth_mode', return_value=False): with patch('mcp_privilege_cloud.mcp_server.CyberArkMCPServer') as mock_class: mock_class.from_environment.return_value = mock_server @@ -129,10 +120,7 @@ async def test_lifespan_propagates_init_errors(self): """Lifespan should propagate server initialization errors""" from mcp_privilege_cloud.mcp_server import app_lifespan, mcp - with patch.dict(os.environ, { - 'CYBERARK_CLIENT_ID': 'test-client', - 'CYBERARK_CLIENT_SECRET': 'test-secret' - }): + with patch('mcp_privilege_cloud.mcp_server.is_oauth_mode', return_value=False): with patch('mcp_privilege_cloud.mcp_server.CyberArkMCPServer') as mock_class: mock_class.from_environment.side_effect = ValueError("Missing credentials") @@ -149,10 +137,7 @@ async def test_lifespan_cleanup_runs_on_error(self): mock_server = Mock() mock_server._executor = mock_executor - with patch.dict(os.environ, { - 'CYBERARK_CLIENT_ID': 'test-client', - 'CYBERARK_CLIENT_SECRET': 'test-secret' - }): + with patch('mcp_privilege_cloud.mcp_server.is_oauth_mode', return_value=False): with patch('mcp_privilege_cloud.mcp_server.CyberArkMCPServer') as mock_class: mock_class.from_environment.return_value = mock_server diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index 1219ce8..4830962 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -1,4 +1,4 @@ -#\!/usr/bin/env python3 +#!/usr/bin/env python3 """ MCP Integration Tests @@ -9,16 +9,11 @@ import pytest from unittest.mock import Mock, AsyncMock, patch, MagicMock -import os -import sys - -# Add src to Python path for testing -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) # Account management MCP tools from mcp_privilege_cloud.mcp_server import create_account, set_next_password, update_account, delete_account -# Platform management MCP tools +# Platform management MCP tools from mcp_privilege_cloud.mcp_server import ( import_platform_package, export_platform, @@ -40,9 +35,17 @@ remove_safe_member ) +from mcp_privilege_cloud.mcp_server import AppContext from mcp_privilege_cloud.server import CyberArkMCPServer +def _make_ctx(mock_server): + """Create a mock MCP context with the given server in its lifespan context.""" + ctx = Mock() + ctx.request_context.lifespan_context = AppContext(server=mock_server) + return ctx + + @pytest.mark.integration class TestMCPAccountTools: """Test MCP account management tools integration""" @@ -56,32 +59,30 @@ async def test_create_account_mcp_tool_minimal(self): "safeName": "IT-Infrastructure", "createdDateTime": "2025-06-09T10:30:00Z" } - - # Use AsyncMock for the server instance + mock_server = AsyncMock(spec=CyberArkMCPServer) mock_server.create_account.return_value = expected_response - - # Correctly patch the get_server function - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server) as mock_get_server: - result = await create_account( - platform_id="WinServerLocal", - safe_name="IT-Infrastructure" - ) - - assert result == expected_response - mock_get_server.assert_called_once() - mock_server.create_account.assert_called_once_with( - platform_id="WinServerLocal", - safe_name="IT-Infrastructure", - name=None, - address=None, - user_name=None, - secret=None, - secret_type=None, - platform_account_properties=None, - secret_management=None, - remote_machines_access=None - ) + ctx = _make_ctx(mock_server) + + result = await create_account( + platform_id="WinServerLocal", + safe_name="IT-Infrastructure", + ctx=ctx + ) + + assert result == expected_response + mock_server.create_account.assert_called_once_with( + platform_id="WinServerLocal", + safe_name="IT-Infrastructure", + name=None, + address=None, + user_name=None, + secret=None, + secret_type=None, + platform_account_properties=None, + secret_management=None, + remote_machines_access=None + ) @pytest.mark.asyncio async def test_create_account_mcp_tool_complete(self): @@ -96,257 +97,225 @@ async def test_create_account_mcp_tool_complete(self): "secretType": "password", "createdDateTime": "2025-06-09T10:30:00Z" } - + account_platform_properties = {"LogonDomain": "EXAMPLE", "Port": "3389"} account_secret_mgmt = {"automaticManagementEnabled": True} - - # Use AsyncMock for the server instance + mock_server = AsyncMock(spec=CyberArkMCPServer) mock_server.create_account.return_value = expected_response - - # Correctly patch the get_server function - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server) as mock_get_server: - result = await create_account( - platform_id="WinServerLocal", - safe_name="IT-Infrastructure", - name="admin-server01", - address="server01.example.com", - user_name="admin", - secret="SecurePassword123!", - secret_type="password", - platform_account_properties=account_platform_properties, - secret_management=account_secret_mgmt - ) - - assert result == expected_response - mock_get_server.assert_called_once() - mock_server.create_account.assert_called_once_with( - platform_id="WinServerLocal", - safe_name="IT-Infrastructure", - name="admin-server01", - address="server01.example.com", - user_name="admin", - secret="SecurePassword123!", - secret_type="password", - platform_account_properties=account_platform_properties, - secret_management=account_secret_mgmt, - remote_machines_access=None - ) + ctx = _make_ctx(mock_server) + + result = await create_account( + platform_id="WinServerLocal", + safe_name="IT-Infrastructure", + name="admin-server01", + address="server01.example.com", + user_name="admin", + secret="SecurePassword123!", + secret_type="password", + platform_account_properties=account_platform_properties, + secret_management=account_secret_mgmt, + ctx=ctx + ) + + assert result == expected_response + mock_server.create_account.assert_called_once_with( + platform_id="WinServerLocal", + safe_name="IT-Infrastructure", + name="admin-server01", + address="server01.example.com", + user_name="admin", + secret="SecurePassword123!", + secret_type="password", + platform_account_properties=account_platform_properties, + secret_management=account_secret_mgmt, + remote_machines_access=None + ) @pytest.mark.asyncio async def test_change_account_password_mcp_tool_cpm_managed(self): """Test change_account_password MCP tool for CPM-managed accounts""" from mcp_privilege_cloud.mcp_server import change_account_password - + account_id = "123_456_789" mock_response = { "id": account_id, "lastModifiedTime": "2025-06-30T10:30:00Z", "status": "Password change initiated successfully" } - - # Mock get_server to avoid SDK authentication + mock_server = AsyncMock() mock_server.change_account_password.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - # Call the MCP tool - result = await change_account_password(account_id=account_id) - - # Verify result - assert result == mock_response - assert result["id"] == account_id - assert "lastModifiedTime" in result - - # Verify server method was called correctly - mock_server.change_account_password.assert_called_once_with( - account_id=account_id, - new_password=None - ) + ctx = _make_ctx(mock_server) + + result = await change_account_password(account_id=account_id, ctx=ctx) + + assert result == mock_response + assert result["id"] == account_id + assert "lastModifiedTime" in result + + mock_server.change_account_password.assert_called_once_with( + account_id=account_id + ) @pytest.mark.asyncio async def test_change_account_password_mcp_tool_error_handling(self): """Test change_account_password MCP tool error handling""" from mcp_privilege_cloud.mcp_server import change_account_password from mcp_privilege_cloud.server import CyberArkAPIError - + account_id = "invalid_account" - - # Use AsyncMock for the server instance + mock_server = AsyncMock() mock_server.change_account_password.side_effect = CyberArkAPIError("Account not found", 404) + ctx = _make_ctx(mock_server) - # Correctly patch the get_server function - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - # Verify that the error is propagated - with pytest.raises(CyberArkAPIError, match="Account not found"): - await change_account_password(account_id=account_id) - - # Verify server method was called correctly - mock_server.change_account_password.assert_called_once_with( - account_id=account_id, - new_password=None - ) + with pytest.raises(CyberArkAPIError, match="Account not found"): + await change_account_password(account_id=account_id, ctx=ctx) + + mock_server.change_account_password.assert_called_once_with( + account_id=account_id + ) @pytest.mark.asyncio async def test_set_next_password_mcp_tool_success(self): """Test set_next_password MCP tool with successful operation""" account_id = "123_456_789" new_password = "NewSecureP@ssw0rd123" - + mock_response = { "id": account_id, "lastModifiedTime": "2025-06-30T10:30:00Z", "status": "Next password set successfully" } - - # Use AsyncMock for the server instance + mock_server = AsyncMock(spec=CyberArkMCPServer) mock_server.set_next_password.return_value = mock_response + ctx = _make_ctx(mock_server) - # Correctly patch the get_server function - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server) as mock_get_server: - # Call the MCP tool - result = await set_next_password( - account_id=account_id, - new_password=new_password - ) - - # Verify result - assert result == mock_response - assert result["id"] == account_id - assert "lastModifiedTime" in result - - # Verify mock_get_server was called and server method was called correctly - mock_get_server.assert_called_once() - mock_server.set_next_password.assert_called_once_with( - account_id=account_id, - new_password=new_password, - change_immediately=True - ) + result = await set_next_password( + account_id=account_id, + new_password=new_password, + ctx=ctx + ) + + assert result == mock_response + assert result["id"] == account_id + assert "lastModifiedTime" in result + + mock_server.set_next_password.assert_called_once_with( + account_id=account_id, + new_password=new_password, + change_immediately=True + ) @pytest.mark.asyncio async def test_set_next_password_mcp_tool_with_change_immediately_false(self): """Test set_next_password MCP tool with change_immediately=False""" account_id = "123_456_789" new_password = "NewSecureP@ssw0rd123" - + mock_response = { "id": account_id, "lastModifiedTime": "2025-06-30T10:30:00Z", "status": "Next password scheduled successfully" } - - # Use AsyncMock for the server instance + mock_server = AsyncMock(spec=CyberArkMCPServer) mock_server.set_next_password.return_value = mock_response + ctx = _make_ctx(mock_server) - # Correctly patch the get_server function - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server) as mock_get_server: - # Call the MCP tool - result = await set_next_password( - account_id=account_id, - new_password=new_password, - change_immediately=False - ) - - # Verify result - assert result == mock_response - assert result["id"] == account_id - - # Verify mock_get_server was called and server method was called correctly - mock_get_server.assert_called_once() - mock_server.set_next_password.assert_called_once_with( - account_id=account_id, - new_password=new_password, - change_immediately=False - ) + result = await set_next_password( + account_id=account_id, + new_password=new_password, + change_immediately=False, + ctx=ctx + ) + + assert result == mock_response + assert result["id"] == account_id + + mock_server.set_next_password.assert_called_once_with( + account_id=account_id, + new_password=new_password, + change_immediately=False + ) @pytest.mark.asyncio async def test_set_next_password_mcp_tool_error_handling(self): """Test set_next_password MCP tool error handling""" from mcp_privilege_cloud.server import CyberArkAPIError - + account_id = "invalid_account" new_password = "TestPassword123" - - # Use AsyncMock for the server instance + mock_server = AsyncMock(spec=CyberArkMCPServer) mock_server.set_next_password.side_effect = CyberArkAPIError("Account not found", 404) + ctx = _make_ctx(mock_server) - # Correctly patch the get_server function - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server) as mock_get_server: - # Verify that the error is propagated - with pytest.raises(CyberArkAPIError, match="Account not found"): - await set_next_password( - account_id=account_id, - new_password=new_password - ) - - # Verify mock_get_server was called and server method was called correctly - mock_get_server.assert_called_once() - mock_server.set_next_password.assert_called_once_with( + with pytest.raises(CyberArkAPIError, match="Account not found"): + await set_next_password( account_id=account_id, new_password=new_password, - change_immediately=True + ctx=ctx ) + mock_server.set_next_password.assert_called_once_with( + account_id=account_id, + new_password=new_password, + change_immediately=True + ) + @pytest.mark.asyncio async def test_verify_account_password_mcp_tool_success(self): """Test verify account password MCP tool with successful verification""" account_id = "123_456_789" - + mock_response = { "id": account_id, "lastVerifiedDateTime": "2025-06-30T10:30:00Z", "verified": True, "status": "Password verified successfully" } - - # Import the MCP tool function + from mcp_privilege_cloud.mcp_server import verify_account_password - + mock_server = AsyncMock() mock_server.verify_account_password.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await verify_account_password(account_id=account_id) - - # Verify the result - assert result["id"] == account_id - assert result["verified"] is True - assert "lastVerifiedDateTime" in result - assert "status" in result - - # Verify server method was called correctly - mock_server.verify_account_password.assert_called_once_with(account_id=account_id) - + ctx = _make_ctx(mock_server) + + result = await verify_account_password(account_id=account_id, ctx=ctx) + + assert result["id"] == account_id + assert result["verified"] is True + assert "lastVerifiedDateTime" in result + assert "status" in result + + mock_server.verify_account_password.assert_called_once_with(account_id=account_id) + @pytest.mark.asyncio async def test_verify_account_password_mcp_tool_error_handling(self): """Test verify account password MCP tool error handling""" account_id = "invalid_account" - - # Import the MCP tool function + from mcp_privilege_cloud.mcp_server import verify_account_password - + mock_server = AsyncMock() mock_server.verify_account_password.side_effect = Exception("Account not found") - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - with pytest.raises(Exception, match="Account not found"): - await verify_account_password(account_id=account_id) - - # Verify server method was called - mock_server.verify_account_password.assert_called_once_with(account_id=account_id) + ctx = _make_ctx(mock_server) + + with pytest.raises(Exception, match="Account not found"): + await verify_account_password(account_id=account_id, ctx=ctx) + + mock_server.verify_account_password.assert_called_once_with(account_id=account_id) @pytest.mark.asyncio async def test_reconcile_account_password_mcp_tool_success(self): """Test the reconcile_account_password MCP tool with successful reconciliation""" from mcp_privilege_cloud.mcp_server import reconcile_account_password - + account_id = "test_account_123" - - # Mock response from server + mock_response = { "id": account_id, "reconciled": True, @@ -355,52 +324,40 @@ async def test_reconcile_account_password_mcp_tool_success(self): "platformId": "WinDomain", "safeName": "TestSafe" } - - # Use AsyncMock for the server instance + mock_server = AsyncMock(spec=CyberArkMCPServer) mock_server.reconcile_account_password.return_value = mock_response + ctx = _make_ctx(mock_server) + + result = await reconcile_account_password(account_id=account_id, ctx=ctx) - # Correctly patch the get_server function - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server) as mock_get_server: - # Call the MCP tool function - result = await reconcile_account_password(account_id=account_id) - - # Verify the result - assert result == mock_response - assert result["id"] == account_id - assert result["reconciled"] is True - assert result["status"] == "Password reconciled successfully" - assert result["lastReconciledDateTime"] == "2025-06-30T10:30:00Z" - - # Verify mock_get_server was called and server method was called correctly - mock_get_server.assert_called_once() - mock_server.reconcile_account_password.assert_called_once_with(account_id=account_id) + assert result == mock_response + assert result["id"] == account_id + assert result["reconciled"] is True + assert result["status"] == "Password reconciled successfully" + assert result["lastReconciledDateTime"] == "2025-06-30T10:30:00Z" + + mock_server.reconcile_account_password.assert_called_once_with(account_id=account_id) @pytest.mark.asyncio async def test_reconcile_account_password_mcp_tool_error_handling(self): """Test the reconcile_account_password MCP tool error handling""" from mcp_privilege_cloud.mcp_server import reconcile_account_password from mcp_privilege_cloud.server import CyberArkAPIError - + account_id = "invalid_account" - - # Use AsyncMock for the server instance + mock_server = AsyncMock(spec=CyberArkMCPServer) mock_server.reconcile_account_password.side_effect = CyberArkAPIError("Account not found", 404) + ctx = _make_ctx(mock_server) + + with pytest.raises(CyberArkAPIError) as exc_info: + await reconcile_account_password(account_id=account_id, ctx=ctx) + + assert exc_info.value.status_code == 404 + assert "Account not found" in str(exc_info.value) - # Correctly patch the get_server function - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server) as mock_get_server: - # Call the MCP tool function and expect error - with pytest.raises(CyberArkAPIError) as exc_info: - await reconcile_account_password(account_id=account_id) - - # Verify the error details - assert exc_info.value.status_code == 404 - assert "Account not found" in str(exc_info.value) - - # Verify mock_get_server was called and server method was called correctly - mock_get_server.assert_called_once() - mock_server.reconcile_account_password.assert_called_once_with(account_id=account_id) + mock_server.reconcile_account_password.assert_called_once_with(account_id=account_id) @pytest.mark.integration @@ -420,25 +377,25 @@ async def test_mcp_import_platform_package_tool(self, platform_mock_env_vars): """Test the MCP import_platform_package tool""" platform_file = "/tmp/test_platform.zip" mock_response = {"PlatformID": "ImportedPlatform123"} - + mock_server = AsyncMock() mock_server.import_platform_package.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await import_platform_package(platform_file) - - assert result == mock_response - mock_server.import_platform_package.assert_called_once_with(platform_package_file=platform_file) + ctx = _make_ctx(mock_server) + + result = await import_platform_package(platform_file, ctx=ctx) + + assert result == mock_response + mock_server.import_platform_package.assert_called_once_with(platform_package_file=platform_file) @pytest.mark.asyncio async def test_mcp_import_platform_package_error_handling(self, platform_mock_env_vars): """Test MCP import_platform_package tool handles errors properly""" mock_server = AsyncMock() mock_server.import_platform_package.side_effect = ValueError("Platform package file not found") - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - with pytest.raises(ValueError, match="Platform package file not found"): - await import_platform_package("/tmp/nonexistent.zip") + ctx = _make_ctx(mock_server) + + with pytest.raises(ValueError, match="Platform package file not found"): + await import_platform_package("/tmp/nonexistent.zip", ctx=ctx) @pytest.mark.asyncio async def test_mcp_export_platform_tool(self, platform_mock_env_vars): @@ -450,28 +407,28 @@ async def test_mcp_export_platform_tool(self, platform_mock_env_vars): "output_folder": output_folder, "status": "exported" } - + mock_server = AsyncMock() mock_server.export_platform.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await export_platform(platform_id, output_folder) - - assert result == mock_response - mock_server.export_platform.assert_called_once_with( - platform_id=platform_id, - output_folder=output_folder - ) + ctx = _make_ctx(mock_server) + + result = await export_platform(platform_id, output_folder, ctx=ctx) + + assert result == mock_response + mock_server.export_platform.assert_called_once_with( + platform_id=platform_id, + output_folder=output_folder + ) @pytest.mark.asyncio async def test_mcp_export_platform_error_handling(self, platform_mock_env_vars): """Test MCP export_platform tool handles errors properly""" mock_server = AsyncMock() mock_server.export_platform.side_effect = ValueError("Platform not found") - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - with pytest.raises(ValueError, match="Platform not found"): - await export_platform("NonexistentPlatform", "/tmp/exports") + ctx = _make_ctx(mock_server) + + with pytest.raises(ValueError, match="Platform not found"): + await export_platform("NonexistentPlatform", "/tmp/exports", ctx=ctx) @pytest.mark.asyncio async def test_mcp_duplicate_target_platform_tool(self, platform_mock_env_vars): @@ -485,19 +442,19 @@ async def test_mcp_duplicate_target_platform_tool(self, platform_mock_env_vars): "description": description, "status": "duplicated" } - + mock_server = AsyncMock() mock_server.duplicate_target_platform.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await duplicate_target_platform(target_platform_id, name, description) - - assert result == mock_response - mock_server.duplicate_target_platform.assert_called_once_with( - target_platform_id=target_platform_id, - name=name, - description=description - ) + ctx = _make_ctx(mock_server) + + result = await duplicate_target_platform(target_platform_id, name, description, ctx=ctx) + + assert result == mock_response + mock_server.duplicate_target_platform.assert_called_once_with( + target_platform_id=target_platform_id, + name=name, + description=description + ) @pytest.mark.asyncio async def test_mcp_duplicate_target_platform_without_description(self, platform_mock_env_vars): @@ -510,29 +467,29 @@ async def test_mcp_duplicate_target_platform_without_description(self, platform_ "description": None, "status": "duplicated" } - + mock_server = AsyncMock() mock_server.duplicate_target_platform.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await duplicate_target_platform(target_platform_id, name) - - assert result == mock_response - mock_server.duplicate_target_platform.assert_called_once_with( - target_platform_id=target_platform_id, - name=name, - description=None - ) + ctx = _make_ctx(mock_server) + + result = await duplicate_target_platform(target_platform_id, name, ctx=ctx) + + assert result == mock_response + mock_server.duplicate_target_platform.assert_called_once_with( + target_platform_id=target_platform_id, + name=name, + description=None + ) @pytest.mark.asyncio async def test_mcp_duplicate_target_platform_error_handling(self, platform_mock_env_vars): """Test MCP duplicate_target_platform tool handles errors properly""" mock_server = AsyncMock() mock_server.duplicate_target_platform.side_effect = ValueError("Target platform not found") - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - with pytest.raises(ValueError, match="Target platform not found"): - await duplicate_target_platform(999, "Test Platform") + ctx = _make_ctx(mock_server) + + with pytest.raises(ValueError, match="Target platform not found"): + await duplicate_target_platform(999, "Test Platform", ctx=ctx) @pytest.mark.asyncio async def test_mcp_activate_target_platform_tool(self, platform_mock_env_vars): @@ -542,27 +499,27 @@ async def test_mcp_activate_target_platform_tool(self, platform_mock_env_vars): "target_platform_id": target_platform_id, "status": "activated" } - + mock_server = AsyncMock() mock_server.activate_target_platform.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await activate_target_platform(target_platform_id) - - assert result == mock_response - mock_server.activate_target_platform.assert_called_once_with( - target_platform_id=target_platform_id - ) + ctx = _make_ctx(mock_server) + + result = await activate_target_platform(target_platform_id, ctx=ctx) + + assert result == mock_response + mock_server.activate_target_platform.assert_called_once_with( + target_platform_id=target_platform_id + ) @pytest.mark.asyncio async def test_mcp_activate_target_platform_error_handling(self, platform_mock_env_vars): """Test MCP activate_target_platform tool handles errors properly""" mock_server = AsyncMock() mock_server.activate_target_platform.side_effect = ValueError("Target platform not found") - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - with pytest.raises(ValueError, match="Target platform not found"): - await activate_target_platform(999) + ctx = _make_ctx(mock_server) + + with pytest.raises(ValueError, match="Target platform not found"): + await activate_target_platform(999, ctx=ctx) @pytest.mark.asyncio async def test_mcp_deactivate_target_platform_tool(self, platform_mock_env_vars): @@ -572,27 +529,27 @@ async def test_mcp_deactivate_target_platform_tool(self, platform_mock_env_vars) "target_platform_id": target_platform_id, "status": "deactivated" } - + mock_server = AsyncMock() mock_server.deactivate_target_platform.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await deactivate_target_platform(target_platform_id) - - assert result == mock_response - mock_server.deactivate_target_platform.assert_called_once_with( - target_platform_id=target_platform_id - ) + ctx = _make_ctx(mock_server) + + result = await deactivate_target_platform(target_platform_id, ctx=ctx) + + assert result == mock_response + mock_server.deactivate_target_platform.assert_called_once_with( + target_platform_id=target_platform_id + ) @pytest.mark.asyncio async def test_mcp_deactivate_target_platform_error_handling(self, platform_mock_env_vars): """Test MCP deactivate_target_platform tool handles errors properly""" mock_server = AsyncMock() mock_server.deactivate_target_platform.side_effect = ValueError("Target platform not found") - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - with pytest.raises(ValueError, match="Target platform not found"): - await deactivate_target_platform(999) + ctx = _make_ctx(mock_server) + + with pytest.raises(ValueError, match="Target platform not found"): + await deactivate_target_platform(999, ctx=ctx) @pytest.mark.asyncio async def test_mcp_delete_target_platform_tool(self, platform_mock_env_vars): @@ -602,27 +559,27 @@ async def test_mcp_delete_target_platform_tool(self, platform_mock_env_vars): "target_platform_id": target_platform_id, "status": "deleted" } - + mock_server = AsyncMock() mock_server.delete_target_platform.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await delete_target_platform(target_platform_id) - - assert result == mock_response - mock_server.delete_target_platform.assert_called_once_with( - target_platform_id=target_platform_id - ) + ctx = _make_ctx(mock_server) + + result = await delete_target_platform(target_platform_id, ctx=ctx) + + assert result == mock_response + mock_server.delete_target_platform.assert_called_once_with( + target_platform_id=target_platform_id + ) @pytest.mark.asyncio async def test_mcp_delete_target_platform_error_handling(self, platform_mock_env_vars): """Test MCP delete_target_platform tool handles errors properly""" mock_server = AsyncMock() mock_server.delete_target_platform.side_effect = ValueError("Target platform not found or has dependencies") - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - with pytest.raises(ValueError, match="Target platform not found or has dependencies"): - await delete_target_platform(999) + ctx = _make_ctx(mock_server) + + with pytest.raises(ValueError, match="Target platform not found or has dependencies"): + await delete_target_platform(999, ctx=ctx) @pytest.mark.integration @@ -650,15 +607,15 @@ async def test_list_accounts_tool(self, mock_env_vars): "safeName": "IT-Infrastructure" } ] - + mock_server = AsyncMock() mock_server.list_accounts.return_value = mock_accounts - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await list_accounts() - - assert result == mock_accounts - mock_server.list_accounts.assert_called_once() + ctx = _make_ctx(mock_server) + + result = await list_accounts(ctx=ctx) + + assert result == mock_accounts + mock_server.list_accounts.assert_called_once() @pytest.mark.asyncio async def test_search_accounts_tool(self, mock_env_vars): @@ -672,21 +629,21 @@ async def test_search_accounts_tool(self, mock_env_vars): "_score": 0.85 } ] - + mock_server = AsyncMock() mock_server.search_accounts.return_value = mock_accounts - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await search_accounts(query="admin", safe_name="IT-Infrastructure") - - assert result == mock_accounts - mock_server.search_accounts.assert_called_once_with( - query="admin", - safe_name="IT-Infrastructure", - username=None, - address=None, - platform_id=None - ) + ctx = _make_ctx(mock_server) + + result = await search_accounts(query="admin", safe_name="IT-Infrastructure", ctx=ctx) + + assert result == mock_accounts + mock_server.search_accounts.assert_called_once_with( + query="admin", + safe_name="IT-Infrastructure", + username=None, + address=None, + platform_id=None + ) @pytest.mark.asyncio async def test_list_safes_tool(self, mock_env_vars): @@ -699,22 +656,22 @@ async def test_list_safes_tool(self, mock_env_vars): "createdBy": "Administrator" } ] - + mock_server = AsyncMock() mock_server.list_safes.return_value = mock_safes - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await list_safes() - - assert result == mock_safes - mock_server.list_safes.assert_called_once() + ctx = _make_ctx(mock_server) + + result = await list_safes(ctx=ctx) + + assert result == mock_safes + mock_server.list_safes.assert_called_once() @pytest.mark.asyncio async def test_add_safe_tool(self, mock_env_vars): """Test the add_safe MCP tool""" safe_name = "TestSafe" description = "Test safe description" - + mock_response = { "safeName": safe_name, "safeId": "123", @@ -722,21 +679,21 @@ async def test_add_safe_tool(self, mock_env_vars): "location": "\\", "creator": "admin" } - + mock_server = AsyncMock() mock_server.add_safe.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await add_safe(safe_name, description) - - assert result == mock_response - mock_server.add_safe.assert_called_once_with(safe_name=safe_name, description=description) + ctx = _make_ctx(mock_server) + + result = await add_safe(safe_name, description, ctx=ctx) + + assert result == mock_response + mock_server.add_safe.assert_called_once_with(safe_name=safe_name, description=description) @pytest.mark.asyncio async def test_add_safe_tool_minimal(self, mock_env_vars): """Test the add_safe MCP tool with minimal parameters""" safe_name = "MinimalTestSafe" - + mock_response = { "safeName": safe_name, "safeId": "124", @@ -744,15 +701,15 @@ async def test_add_safe_tool_minimal(self, mock_env_vars): "location": "\\", "creator": "admin" } - + mock_server = AsyncMock() mock_server.add_safe.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await add_safe(safe_name) - - assert result == mock_response - mock_server.add_safe.assert_called_once_with(safe_name=safe_name, description=None) + ctx = _make_ctx(mock_server) + + result = await add_safe(safe_name, ctx=ctx) + + assert result == mock_response + mock_server.add_safe.assert_called_once_with(safe_name=safe_name, description=None) @pytest.mark.asyncio async def test_update_safe_tool(self, mock_env_vars): @@ -760,7 +717,7 @@ async def test_update_safe_tool(self, mock_env_vars): safe_id = "123" safe_name = "UpdatedSafeName" description = "Updated description" - + mock_response = { "safeId": safe_id, "safeName": safe_name, @@ -768,78 +725,78 @@ async def test_update_safe_tool(self, mock_env_vars): "location": "\\", "numberOfDaysRetention": 30 } - + mock_server = AsyncMock() mock_server.update_safe.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - from mcp_privilege_cloud.mcp_server import update_safe - result = await update_safe(safe_id, safe_name=safe_name, description=description) - - assert result == mock_response - mock_server.update_safe.assert_called_once_with( - safe_id=safe_id, - safe_name=safe_name, - description=description, - location=None, - number_of_days_retention=None, - number_of_versions_retention=None, - auto_purge_enabled=None, - olac_enabled=None, - managing_cpm=None - ) + ctx = _make_ctx(mock_server) + + from mcp_privilege_cloud.mcp_server import update_safe + result = await update_safe(safe_id, safe_name=safe_name, description=description, ctx=ctx) + + assert result == mock_response + mock_server.update_safe.assert_called_once_with( + safe_id=safe_id, + safe_name=safe_name, + description=description, + location=None, + number_of_days_retention=None, + number_of_versions_retention=None, + auto_purge_enabled=None, + olac_enabled=None, + managing_cpm=None + ) @pytest.mark.asyncio async def test_update_safe_tool_minimal(self, mock_env_vars): """Test the update_safe MCP tool with minimal parameters""" safe_id = "123" - + mock_response = { "safeId": safe_id, "safeName": "ExistingSafeName", "description": "Existing description", "location": "\\" } - + mock_server = AsyncMock() mock_server.update_safe.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - from mcp_privilege_cloud.mcp_server import update_safe - result = await update_safe(safe_id) - - assert result == mock_response - mock_server.update_safe.assert_called_once_with( - safe_id=safe_id, - safe_name=None, - description=None, - location=None, - number_of_days_retention=None, - number_of_versions_retention=None, - auto_purge_enabled=None, - olac_enabled=None, - managing_cpm=None - ) + ctx = _make_ctx(mock_server) + + from mcp_privilege_cloud.mcp_server import update_safe + result = await update_safe(safe_id, ctx=ctx) + + assert result == mock_response + mock_server.update_safe.assert_called_once_with( + safe_id=safe_id, + safe_name=None, + description=None, + location=None, + number_of_days_retention=None, + number_of_versions_retention=None, + auto_purge_enabled=None, + olac_enabled=None, + managing_cpm=None + ) @pytest.mark.asyncio async def test_delete_safe_tool(self, mock_env_vars): """Test the delete_safe MCP tool""" safe_id = "123" - + mock_response = { "message": f"Safe {safe_id} deleted successfully", "safe_id": safe_id } - + mock_server = AsyncMock() mock_server.delete_safe.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - from mcp_privilege_cloud.mcp_server import delete_safe - result = await delete_safe(safe_id) - - assert result == mock_response - mock_server.delete_safe.assert_called_once_with(safe_id=safe_id) + ctx = _make_ctx(mock_server) + + from mcp_privilege_cloud.mcp_server import delete_safe + result = await delete_safe(safe_id, ctx=ctx) + + assert result == mock_response + mock_server.delete_safe.assert_called_once_with(safe_id=safe_id) @pytest.mark.asyncio async def test_list_platforms_tool(self, mock_env_vars): @@ -853,15 +810,15 @@ async def test_list_platforms_tool(self, mock_env_vars): "platformType": "Regular" } ] - + mock_server = AsyncMock() mock_server.list_platforms.return_value = mock_platforms - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await list_platforms() - - assert result == mock_platforms - mock_server.list_platforms.assert_called_once() + ctx = _make_ctx(mock_server) + + result = await list_platforms(ctx=ctx) + + assert result == mock_platforms + mock_server.list_platforms.assert_called_once() @pytest.mark.asyncio async def test_update_account_tool(self, mock_env_vars): @@ -873,86 +830,86 @@ async def test_update_account_tool(self, mock_env_vars): "user_name": "updated_user", "platform_account_properties": {"Port": "2222"} } - + mock_response = {"id": account_id, **update_data, "updated": True} - + mock_server = AsyncMock() mock_server.update_account.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await update_account(account_id=account_id, **update_data) - - assert result == mock_response - mock_server.update_account.assert_called_once_with( - account_id=account_id, - name=update_data["name"], - address=update_data["address"], - user_name=update_data["user_name"], - platform_account_properties=update_data["platform_account_properties"], - secret_management=None, - remote_machines_access=None - ) + ctx = _make_ctx(mock_server) + + result = await update_account(account_id=account_id, **update_data, ctx=ctx) + + assert result == mock_response + mock_server.update_account.assert_called_once_with( + account_id=account_id, + name=update_data["name"], + address=update_data["address"], + user_name=update_data["user_name"], + platform_account_properties=update_data["platform_account_properties"], + secret_management=None, + remote_machines_access=None + ) @pytest.mark.asyncio async def test_update_account_tool_minimal(self, mock_env_vars): """Test the update_account MCP tool with minimal parameters""" account_id = "123_456" name = "minimal-update" - + mock_response = {"id": account_id, "name": name, "updated": True} - + mock_server = AsyncMock() mock_server.update_account.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await update_account(account_id=account_id, name=name) - - assert result == mock_response - mock_server.update_account.assert_called_once_with( - account_id=account_id, - name=name, - address=None, - user_name=None, - platform_account_properties=None, - secret_management=None, - remote_machines_access=None - ) + ctx = _make_ctx(mock_server) + + result = await update_account(account_id=account_id, name=name, ctx=ctx) + + assert result == mock_response + mock_server.update_account.assert_called_once_with( + account_id=account_id, + name=name, + address=None, + user_name=None, + platform_account_properties=None, + secret_management=None, + remote_machines_access=None + ) @pytest.mark.asyncio async def test_delete_account_tool(self, mock_env_vars): """Test the delete_account MCP tool""" account_id = "123_456" - + mock_response = {"id": account_id, "deleted": True, "status": "success"} - + mock_server = AsyncMock() mock_server.delete_account.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await delete_account(account_id=account_id) - - assert result == mock_response - mock_server.delete_account.assert_called_once_with(account_id=account_id) + ctx = _make_ctx(mock_server) + + result = await delete_account(account_id=account_id, ctx=ctx) + + assert result == mock_response + mock_server.delete_account.assert_called_once_with(account_id=account_id) @pytest.mark.asyncio async def test_update_account_tool_error_handling(self, mock_env_vars): """Test update_account MCP tool handles errors properly""" mock_server = AsyncMock() mock_server.update_account.side_effect = ValueError("account_id is required") - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - with pytest.raises(ValueError, match="account_id is required"): - await update_account(account_id="", name="test") + ctx = _make_ctx(mock_server) + + with pytest.raises(ValueError, match="account_id is required"): + await update_account(account_id="", name="test", ctx=ctx) @pytest.mark.asyncio async def test_delete_account_tool_error_handling(self, mock_env_vars): """Test delete_account MCP tool handles errors properly""" mock_server = AsyncMock() mock_server.delete_account.side_effect = ValueError("account_id is required") - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - with pytest.raises(ValueError, match="account_id is required"): - await delete_account(account_id="") + ctx = _make_ctx(mock_server) + + with pytest.raises(ValueError, match="account_id is required"): + await delete_account(account_id="", ctx=ctx) @pytest.mark.integration @@ -982,26 +939,26 @@ async def test_list_safe_members_tool(self, mock_env_vars): { "safeName": safe_name, "memberName": "ReadOnlyUser", - "memberType": "User", + "memberType": "User", "permissionSet": "ReadOnly" } ] - + mock_server = AsyncMock() mock_server.list_safe_members.return_value = mock_members - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await list_safe_members(safe_name=safe_name) - - assert result == mock_members - mock_server.list_safe_members.assert_called_once_with( - safe_name=safe_name, - search=None, - sort=None, - offset=None, - limit=None, - member_type=None - ) + ctx = _make_ctx(mock_server) + + result = await list_safe_members(safe_name=safe_name, ctx=ctx) + + assert result == mock_members + mock_server.list_safe_members.assert_called_once_with( + safe_name=safe_name, + search=None, + sort=None, + offset=None, + limit=None, + member_type=None + ) @pytest.mark.asyncio async def test_add_safe_member_tool(self, mock_env_vars): @@ -1010,35 +967,36 @@ async def test_add_safe_member_tool(self, mock_env_vars): member_name = "newuser@domain.com" member_type = "User" permission_set = "ReadOnly" - + mock_response = { "safeName": safe_name, "memberName": member_name, "memberType": member_type, "permissionSet": permission_set } - + mock_server = AsyncMock() mock_server.add_safe_member.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await add_safe_member( - safe_name=safe_name, - member_name=member_name, - member_type=member_type, - permission_set=permission_set - ) - - assert result == mock_response - mock_server.add_safe_member.assert_called_once_with( - safe_name=safe_name, - member_name=member_name, - member_type=member_type, - search_in=None, - membership_expiration_date=None, - permissions=None, - permission_set=permission_set - ) + ctx = _make_ctx(mock_server) + + result = await add_safe_member( + safe_name=safe_name, + member_name=member_name, + member_type=member_type, + permission_set=permission_set, + ctx=ctx + ) + + assert result == mock_response + mock_server.add_safe_member.assert_called_once_with( + safe_name=safe_name, + member_name=member_name, + member_type=member_type, + search_in=None, + membership_expiration_date=None, + permissions=None, + permission_set=permission_set + ) @pytest.mark.asyncio async def test_update_safe_member_tool(self, mock_env_vars): @@ -1046,63 +1004,63 @@ async def test_update_safe_member_tool(self, mock_env_vars): safe_name = "IT-Infrastructure" member_name = "user@domain.com" permission_set = "AccountsManager" - + mock_response = { "safeName": safe_name, "memberName": member_name, "permissionSet": permission_set, "updated": True } - + mock_server = AsyncMock() mock_server.update_safe_member.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await update_safe_member( - safe_name=safe_name, - member_name=member_name, - permission_set=permission_set - ) - - assert result == mock_response - mock_server.update_safe_member.assert_called_once_with( - safe_name=safe_name, - member_name=member_name, - search_in=None, - membership_expiration_date=None, - permissions=None, - permission_set=permission_set - ) + ctx = _make_ctx(mock_server) + + result = await update_safe_member( + safe_name=safe_name, + member_name=member_name, + permission_set=permission_set, + ctx=ctx + ) + + assert result == mock_response + mock_server.update_safe_member.assert_called_once_with( + safe_name=safe_name, + member_name=member_name, + search_in=None, + membership_expiration_date=None, + permissions=None, + permission_set=permission_set + ) @pytest.mark.asyncio async def test_remove_safe_member_tool(self, mock_env_vars): """Test the remove_safe_member MCP tool""" safe_name = "IT-Infrastructure" member_name = "olduser@domain.com" - + mock_response = { "message": f"Member {member_name} removed from safe {safe_name} successfully", "safe_name": safe_name, "member_name": member_name } - + mock_server = AsyncMock() mock_server.remove_safe_member.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await remove_safe_member(safe_name=safe_name, member_name=member_name) - - assert result == mock_response - mock_server.remove_safe_member.assert_called_once_with( - safe_name=safe_name, - member_name=member_name - ) + ctx = _make_ctx(mock_server) + + result = await remove_safe_member(safe_name=safe_name, member_name=member_name, ctx=ctx) + + assert result == mock_response + mock_server.remove_safe_member.assert_called_once_with( + safe_name=safe_name, + member_name=member_name + ) async def test_get_platform_statistics_tool(self, mock_env_vars): """Test get_platform_statistics MCP tool integration""" - # Import the tool function from mcp_privilege_cloud.mcp_server import get_platform_statistics - + mock_response = { "platforms_count": 15, "platforms_count_by_type": { @@ -1111,21 +1069,20 @@ async def test_get_platform_statistics_tool(self, mock_env_vars): "group": 1 } } - + mock_server = AsyncMock() mock_server.get_platform_statistics.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await get_platform_statistics() - - assert result == mock_response - mock_server.get_platform_statistics.assert_called_once() + ctx = _make_ctx(mock_server) + + result = await get_platform_statistics(ctx=ctx) + + assert result == mock_response + mock_server.get_platform_statistics.assert_called_once() async def test_get_target_platform_statistics_tool(self, mock_env_vars): """Test get_target_platform_statistics MCP tool integration""" - # Import the tool function from mcp_privilege_cloud.mcp_server import get_target_platform_statistics - + mock_response = { "target_platforms_count": 8, "target_platforms_count_by_system_type": { @@ -1135,12 +1092,12 @@ async def test_get_target_platform_statistics_tool(self, mock_env_vars): "Database": 1 } } - + mock_server = AsyncMock() mock_server.get_target_platform_statistics.return_value = mock_response - - with patch('mcp_privilege_cloud.mcp_server.get_server', return_value=mock_server): - result = await get_target_platform_statistics() - - assert result == mock_response - mock_server.get_target_platform_statistics.assert_called_once() + ctx = _make_ctx(mock_server) + + result = await get_target_platform_statistics(ctx=ctx) + + assert result == mock_response + mock_server.get_target_platform_statistics.assert_called_once() diff --git a/tests/test_oauth_integration.py b/tests/test_oauth_integration.py new file mode 100644 index 0000000..04c9ab9 --- /dev/null +++ b/tests/test_oauth_integration.py @@ -0,0 +1,204 @@ +"""Integration tests for OAuth + Streamable HTTP wiring. + +Tests the full integration of: +- FastMCP configured with CyberArkTokenVerifier and AuthSettings +- AppContext with is_oauth flag +- execute_tool verifying user identity, then using shared service account server +- OAuth env var configuration +- Backward compatibility with legacy service account mode +""" + +import os + +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from helpers import _default_claims, _make_jwt + + +class TestOAuthEnvVarConfiguration: + """Test OAuth environment variable detection and configuration.""" + + def test_oauth_mode_detected_when_env_vars_set(self): + """OAuth mode should be detected when CYBERARK_IDENTITY_TENANT_URL is set.""" + from mcp_privilege_cloud.mcp_server import is_oauth_mode + + with patch.dict(os.environ, { + "CYBERARK_IDENTITY_TENANT_URL": "https://abc1234.id.cyberark.cloud", + }): + assert is_oauth_mode() is True + + def test_legacy_mode_when_no_oauth_vars(self): + """Legacy mode should be detected when OAuth env vars are absent.""" + from mcp_privilege_cloud.mcp_server import is_oauth_mode + + with patch.dict(os.environ, {}, clear=True): + assert is_oauth_mode() is False + + def test_legacy_mode_with_only_client_id(self): + """Legacy mode when only CYBERARK_CLIENT_ID is set.""" + from mcp_privilege_cloud.mcp_server import is_oauth_mode + + with patch.dict(os.environ, { + "CYBERARK_CLIENT_ID": "svc-account", + "CYBERARK_CLIENT_SECRET": "secret", + }, clear=True): + assert is_oauth_mode() is False + + +class TestAppContextIsOAuth: + """Test AppContext supports is_oauth field.""" + + def test_app_context_has_is_oauth(self): + """AppContext should accept is_oauth parameter.""" + from mcp_privilege_cloud.mcp_server import AppContext + + ctx = AppContext(server=MagicMock(), is_oauth=True) + assert ctx.is_oauth is True + + def test_app_context_is_oauth_defaults_false(self): + """AppContext.is_oauth should default to False for legacy compat.""" + from mcp_privilege_cloud.mcp_server import AppContext + + mock_server = MagicMock() + ctx = AppContext(server=mock_server) + assert ctx.is_oauth is False + + def test_app_context_backward_compat_server_field(self): + """AppContext.server should still work for legacy mode.""" + from mcp_privilege_cloud.mcp_server import AppContext + + mock_server = MagicMock() + ctx = AppContext(server=mock_server) + assert ctx.server is mock_server + + +class TestExecuteToolOAuthResolution: + """Test execute_tool verifying identity and using service account server.""" + + @pytest.mark.asyncio + async def test_execute_tool_uses_service_account_server_in_oauth_mode(self): + """execute_tool should verify identity via token, then use app_ctx.server.""" + from mcp_privilege_cloud.mcp_server import AppContext, execute_tool + + mock_server = AsyncMock() + mock_server.list_accounts = AsyncMock(return_value=[]) + + claims = _default_claims() + jwt_token = _make_jwt(claims) + + ctx = Mock() + ctx.request_context.lifespan_context = AppContext( + server=mock_server, is_oauth=True + ) + + mock_access_token = Mock() + mock_access_token.token = jwt_token + mock_access_token.client_id = claims["sub"] + + with patch( + "mcp_privilege_cloud.mcp_server.get_access_token", + return_value=mock_access_token, + ): + result = await execute_tool("list_accounts", ctx=ctx) + + mock_server.list_accounts.assert_called_once() + + @pytest.mark.asyncio + async def test_execute_tool_falls_back_to_legacy_server(self): + """execute_tool should use ctx.server when is_oauth=False.""" + from mcp_privilege_cloud.mcp_server import AppContext, execute_tool + + mock_server = AsyncMock() + mock_server.list_accounts = AsyncMock(return_value=[]) + + ctx = Mock() + ctx.request_context.lifespan_context = AppContext(server=mock_server) + + result = await execute_tool("list_accounts", ctx=ctx) + mock_server.list_accounts.assert_called_once() + + @pytest.mark.asyncio + async def test_execute_tool_rejects_unauthenticated_oauth(self): + """execute_tool should reject unauthenticated requests in OAuth mode.""" + from mcp_privilege_cloud.mcp_server import AppContext, execute_tool + + mock_server = AsyncMock() + + ctx = Mock() + ctx.request_context.lifespan_context = AppContext( + server=mock_server, is_oauth=True + ) + + with patch( + "mcp_privilege_cloud.mcp_server.get_access_token", + return_value=None, + ): + with pytest.raises(PermissionError, match="OAuth mode requires authentication"): + await execute_tool("list_accounts", ctx=ctx) + + @pytest.mark.asyncio + async def test_execute_tool_logs_authenticated_user(self): + """execute_tool should log the authenticated user's identity.""" + from mcp_privilege_cloud.mcp_server import AppContext, execute_tool + + mock_server = AsyncMock() + mock_server.list_accounts = AsyncMock(return_value=[]) + + ctx = Mock() + ctx.request_context.lifespan_context = AppContext( + server=mock_server, is_oauth=True + ) + + claims = _default_claims() + jwt_token = _make_jwt(claims) + + mock_access_token = Mock() + mock_access_token.token = jwt_token + mock_access_token.client_id = claims["sub"] + + with patch( + "mcp_privilege_cloud.mcp_server.get_access_token", + return_value=mock_access_token, + ): + with patch("mcp_privilege_cloud.mcp_server.logger") as mock_logger: + await execute_tool("list_accounts", ctx=ctx) + + mock_logger.info.assert_any_call( + "Authenticated user: %s", claims["sub"] + ) + + +class TestMCPServerOAuthInit: + """Test FastMCP initialization with OAuth settings.""" + + def test_create_mcp_server_oauth_creates_verifier(self): + """create_mcp_server should create a CyberArkTokenVerifier in OAuth mode.""" + from mcp_privilege_cloud.mcp_server import create_mcp_server + + with patch.dict(os.environ, { + "CYBERARK_IDENTITY_TENANT_URL": "https://abc1234.id.cyberark.cloud", + "MCP_HOST": "127.0.0.1", + "MCP_PORT": "8000", + }): + with patch("mcp_privilege_cloud.mcp_server.is_oauth_mode", return_value=True): + with patch("mcp_privilege_cloud.mcp_server.FastMCP") as MockFastMCP: + create_mcp_server() + + call_kwargs = MockFastMCP.call_args.kwargs + assert "token_verifier" in call_kwargs + assert call_kwargs["token_verifier"] is not None + assert "auth" in call_kwargs + assert call_kwargs["auth"] is not None + + def test_create_mcp_server_legacy_no_verifier(self): + """create_mcp_server in legacy mode should not set token_verifier.""" + from mcp_privilege_cloud.mcp_server import create_mcp_server + + with patch("mcp_privilege_cloud.mcp_server.is_oauth_mode", return_value=False): + with patch("mcp_privilege_cloud.mcp_server.FastMCP") as MockFastMCP: + create_mcp_server() + + call_kwargs = MockFastMCP.call_args.kwargs + assert call_kwargs.get("token_verifier") is None + assert call_kwargs.get("auth") is None diff --git a/tests/test_oauth_metadata.py b/tests/test_oauth_metadata.py new file mode 100644 index 0000000..21b4dab --- /dev/null +++ b/tests/test_oauth_metadata.py @@ -0,0 +1,474 @@ +"""Tests for OAuth discovery and Dynamic Client Registration endpoints. + +Tests the RFC 8414 metadata endpoint and RFC 7591 DCR proxy that enable +claude.ai OAuth flow compatibility with CyberArk Identity. +""" + +import os + +import httpx +import pytest +from unittest.mock import AsyncMock, patch + + +# Sample OIDC discovery response from CyberArk Identity +SAMPLE_OIDC_DISCOVERY = { + "issuer": "https://abc1234.id.cyberark.cloud/mcpprivilegecloud/", + "authorization_endpoint": "https://abc1234.id.cyberark.cloud/OAuth2/Authorize/mcpprivilegecloud", + "token_endpoint": "https://abc1234.id.cyberark.cloud/OAuth2/Token/mcpprivilegecloud", + "userinfo_endpoint": "https://abc1234.id.cyberark.cloud/OAuth2/UserInfo/mcpprivilegecloud", + "jwks_uri": "https://abc1234.id.cyberark.cloud/OAuth2/Keys/mcpprivilegecloud", + "response_types_supported": ["code", "id_token", "code id_token"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["openid", "profile", "email"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], +} + +# _build_oauth_metadata now receives the MCP endpoint URL (with /mcp path) +# to match RFC 8414 issuer derivation from /.well-known/oauth-authorization-server/mcp +SERVER_URL = "https://mcp.example.com/mcp" + + +class TestFetchOidcDiscovery: + """Test the _fetch_oidc_discovery helper function.""" + + @pytest.mark.asyncio + async def test_fetches_and_returns_oidc_config(self): + """Should fetch OIDC discovery from tenant URL and return parsed JSON.""" + from mcp_privilege_cloud.mcp_server import _fetch_oidc_discovery + + # httpx.Response.json() is sync, raise_for_status() is sync + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = lambda: SAMPLE_OIDC_DISCOVERY + mock_response.raise_for_status = lambda: None + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_response) + + with patch("mcp_privilege_cloud.mcp_server.httpx.AsyncClient", return_value=mock_client): + # Clear any cached value from previous tests + import mcp_privilege_cloud.mcp_server as mod + mod._oidc_discovery_cache = None + + result = await _fetch_oidc_discovery("https://abc1234.id.cyberark.cloud") + + assert result["issuer"] == "https://abc1234.id.cyberark.cloud/mcpprivilegecloud/" + assert "authorization_endpoint" in result + mock_client.get.assert_called_once_with( + "https://abc1234.id.cyberark.cloud/mcpprivilegecloud/.well-known/openid-configuration" + ) + + @pytest.mark.asyncio + async def test_caches_oidc_discovery(self): + """Should fetch OIDC discovery once and return cached result on subsequent calls.""" + from mcp_privilege_cloud.mcp_server import _fetch_oidc_discovery + + import mcp_privilege_cloud.mcp_server as mod + mod._oidc_discovery_cache = None + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = lambda: SAMPLE_OIDC_DISCOVERY + mock_response.raise_for_status = lambda: None + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_response) + + with patch("mcp_privilege_cloud.mcp_server.httpx.AsyncClient", return_value=mock_client): + result1 = await _fetch_oidc_discovery("https://abc1234.id.cyberark.cloud") + result2 = await _fetch_oidc_discovery("https://abc1234.id.cyberark.cloud") + + assert result1 is result2 + # Only one HTTP call despite two invocations + mock_client.get.assert_called_once() + + @pytest.mark.asyncio + async def test_strips_trailing_slash_from_tenant_url(self): + """Should handle tenant URLs with trailing slashes.""" + from mcp_privilege_cloud.mcp_server import _fetch_oidc_discovery + + import mcp_privilege_cloud.mcp_server as mod + mod._oidc_discovery_cache = None + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = lambda: SAMPLE_OIDC_DISCOVERY + mock_response.raise_for_status = lambda: None + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_response) + + with patch("mcp_privilege_cloud.mcp_server.httpx.AsyncClient", return_value=mock_client): + await _fetch_oidc_discovery("https://abc1234.id.cyberark.cloud/") + + mock_client.get.assert_called_once_with( + "https://abc1234.id.cyberark.cloud/mcpprivilegecloud/.well-known/openid-configuration" + ) + + @pytest.mark.asyncio + async def test_raises_on_http_error(self): + """Should propagate HTTP errors from OIDC discovery endpoint.""" + from mcp_privilege_cloud.mcp_server import _fetch_oidc_discovery + + import mcp_privilege_cloud.mcp_server as mod + mod._oidc_discovery_cache = None + + mock_request = httpx.Request("GET", "https://example.com/.well-known/openid-configuration") + mock_response = httpx.Response(500, request=mock_request) + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_response) + + with patch("mcp_privilege_cloud.mcp_server.httpx.AsyncClient", return_value=mock_client): + with pytest.raises(httpx.HTTPStatusError): + await _fetch_oidc_discovery("https://abc1234.id.cyberark.cloud") + + +class TestBuildOAuthMetadata: + """Test the _build_oauth_metadata helper.""" + + def test_returns_correct_rfc8414_fields(self): + """Should return RFC 8414 metadata with endpoints from OIDC discovery.""" + from mcp_privilege_cloud.mcp_server import _build_oauth_metadata + + metadata = _build_oauth_metadata(SAMPLE_OIDC_DISCOVERY, SERVER_URL) + + # issuer uses AnyHttpUrl normalization; URLs with path don't get trailing slash + assert metadata["issuer"] == SERVER_URL + # authorization/token endpoints are same-origin (proxied through our server) + assert metadata["authorization_endpoint"] == "https://mcp.example.com/authorize" + assert metadata["token_endpoint"] == "https://mcp.example.com/token" + assert metadata["response_types_supported"] == ["code", "id_token", "code id_token"] + assert metadata["code_challenge_methods_supported"] == ["S256"] + assert metadata["scopes_supported"] == ["openid", "profile", "email"] + assert metadata["jwks_uri"] == SAMPLE_OIDC_DISCOVERY["jwks_uri"] + + def test_includes_registration_endpoint(self): + """Should include registration_endpoint at server root (not under /mcp).""" + from mcp_privilege_cloud.mcp_server import _build_oauth_metadata + + metadata = _build_oauth_metadata(SAMPLE_OIDC_DISCOVERY, SERVER_URL) + + # registration_endpoint uses server base URL, not the MCP endpoint path + assert metadata["registration_endpoint"] == "https://mcp.example.com/register" + + def test_includes_grant_types_and_auth_methods(self): + """Should include grant_types_supported and token_endpoint_auth_methods.""" + from mcp_privilege_cloud.mcp_server import _build_oauth_metadata + + metadata = _build_oauth_metadata(SAMPLE_OIDC_DISCOVERY, SERVER_URL) + + assert metadata["grant_types_supported"] == ["authorization_code", "refresh_token"] + assert metadata["token_endpoint_auth_methods_supported"] == ["none"] + + def test_defaults_when_oidc_fields_missing(self): + """Should use sensible defaults when OIDC discovery omits optional fields.""" + from mcp_privilege_cloud.mcp_server import _build_oauth_metadata + + minimal_oidc = { + "issuer": "https://abc1234.id.cyberark.cloud/", + "authorization_endpoint": "https://abc1234.id.cyberark.cloud/OAuth2/Authorize/x", + "token_endpoint": "https://abc1234.id.cyberark.cloud/OAuth2/Token/x", + } + + metadata = _build_oauth_metadata(minimal_oidc, SERVER_URL) + + assert metadata["response_types_supported"] == ["code"] + assert metadata["code_challenge_methods_supported"] == ["S256"] + assert "scopes_supported" not in metadata + + def test_issuer_matches_sdk_anyhttp_normalization(self): + """Issuer MUST match the authorization_servers URL from SDK's protected resource metadata. + + The MCP SDK normalizes URLs through Pydantic AnyHttpUrl. URLs with a path + (like /mcp) keep that path without a trailing slash. The issuer must match + what the client derives from /.well-known/oauth-authorization-server/mcp + per RFC 8414 section 3.3. + """ + from mcp_privilege_cloud.mcp_server import _build_oauth_metadata + from pydantic import AnyHttpUrl + + # URL with /mcp path — no trailing slash added + metadata = _build_oauth_metadata(SAMPLE_OIDC_DISCOVERY, "https://mcp.example.com/mcp") + assert metadata["issuer"] == "https://mcp.example.com/mcp" + assert metadata["issuer"] == str(AnyHttpUrl("https://mcp.example.com/mcp")) + + # registration_endpoint at server root, not under /mcp + assert metadata["registration_endpoint"] == "https://mcp.example.com/register" + + # Bare domain gets trailing slash from AnyHttpUrl + metadata = _build_oauth_metadata(SAMPLE_OIDC_DISCOVERY, "https://mcp.example.com") + assert metadata["issuer"] == "https://mcp.example.com/" + assert metadata["registration_endpoint"] == "https://mcp.example.com/register" + + def test_excludes_none_scopes(self): + """Should not include scopes_supported when not in OIDC config.""" + from mcp_privilege_cloud.mcp_server import _build_oauth_metadata + + minimal_oidc = { + "issuer": "https://abc1234.id.cyberark.cloud/", + "authorization_endpoint": "https://abc1234.id.cyberark.cloud/OAuth2/Authorize/x", + "token_endpoint": "https://abc1234.id.cyberark.cloud/OAuth2/Token/x", + } + + metadata = _build_oauth_metadata(minimal_oidc, SERVER_URL) + + assert "scopes_supported" not in metadata + + +class TestDynamicClientRegistration: + """Test the /register DCR proxy endpoint.""" + + def test_dcr_echoes_client_name(self): + """DCR should echo back the client_name from the request.""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + + response = _build_dcr_response({"client_name": "My MCP Client"}) + assert response["client_name"] == "My MCP Client" + + def test_dcr_echoes_redirect_uris(self): + """DCR should echo back redirect_uris from the request.""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + + uris = ["https://claude.ai/api/mcp/auth_callback"] + response = _build_dcr_response({"redirect_uris": uris}) + assert response["redirect_uris"] == uris + + def test_dcr_defaults_for_empty_body(self): + """DCR should use OIDC app ID as fallback client_id.""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + from mcp_privilege_cloud.token_verifier import CYBERARK_OIDC_APP_ID + + with patch.dict(os.environ, {}, clear=True): + response = _build_dcr_response({}) + + assert response["client_id"] == CYBERARK_OIDC_APP_ID + assert response["client_name"] == "MCP Client" + assert response["redirect_uris"] == [] + + def test_dcr_logs_warning_when_no_client_id_set(self): + """DCR should log a warning when neither OAuth nor legacy client ID is set.""" + from mcp_privilege_cloud.mcp_server import _build_dcr_response + from mcp_privilege_cloud.token_verifier import CYBERARK_OIDC_APP_ID + import logging + + with patch.dict(os.environ, {}, clear=True): + with patch("mcp_privilege_cloud.mcp_server.logger") as mock_logger: + response = _build_dcr_response({}) + + mock_logger.warning.assert_called_once() + warning_msg = mock_logger.warning.call_args[0][0] + assert "CYBERARK_OAUTH_CLIENT_ID" in warning_msg + assert response["client_id"] == CYBERARK_OIDC_APP_ID + + +class TestTokenProxyCredentialInjection: + """Test that /token proxy injects server-side credentials.""" + + @pytest.mark.asyncio + async def test_injects_credentials(self): + """Token proxy should inject server-side client_id and client_secret.""" + from mcp_privilege_cloud.mcp_server import _get_oauth_credentials + from urllib.parse import parse_qs, urlencode + + env = { + "CYBERARK_OAUTH_CLIENT_ID": "injected-client-id", + "CYBERARK_OAUTH_CLIENT_SECRET": "injected-secret", + } + with patch.dict(os.environ, env, clear=True): + client_id, client_secret = _get_oauth_credentials() + + # Simulate what token_proxy does: parse client body, inject creds + client_body = urlencode({"grant_type": "authorization_code", "code": "abc123"}) + params = parse_qs(client_body, keep_blank_values=True) + flat_params = {k: v[0] for k, v in params.items()} + flat_params["client_id"] = client_id + flat_params["client_secret"] = client_secret + forwarded = urlencode(flat_params) + + parsed = parse_qs(forwarded) + assert parsed["client_id"] == ["injected-client-id"] + assert parsed["client_secret"] == ["injected-secret"] + assert parsed["grant_type"] == ["authorization_code"] + assert parsed["code"] == ["abc123"] + + @pytest.mark.asyncio + async def test_overwrites_client_credentials(self): + """Token proxy should overwrite any client-supplied credentials.""" + from mcp_privilege_cloud.mcp_server import _get_oauth_credentials + from urllib.parse import parse_qs, urlencode + + env = { + "CYBERARK_OAUTH_CLIENT_ID": "server-id", + "CYBERARK_OAUTH_CLIENT_SECRET": "server-secret", + } + with patch.dict(os.environ, env, clear=True): + client_id, client_secret = _get_oauth_credentials() + + # Client tries to send its own credentials + client_body = urlencode({ + "grant_type": "authorization_code", + "client_id": "attacker-id", + "client_secret": "attacker-secret", + }) + params = parse_qs(client_body, keep_blank_values=True) + flat_params = {k: v[0] for k, v in params.items()} + flat_params["client_id"] = client_id + flat_params["client_secret"] = client_secret + forwarded = urlencode(flat_params) + + parsed = parse_qs(forwarded) + assert parsed["client_id"] == ["server-id"] + assert parsed["client_secret"] == ["server-secret"] + + @pytest.mark.asyncio + async def test_preserves_other_params(self): + """Token proxy should preserve grant_type, code, redirect_uri, code_verifier.""" + from mcp_privilege_cloud.mcp_server import _get_oauth_credentials + from urllib.parse import parse_qs, urlencode + + env = {"CYBERARK_OAUTH_CLIENT_ID": "id", "CYBERARK_OAUTH_CLIENT_SECRET": "secret"} + with patch.dict(os.environ, env, clear=True): + client_id, client_secret = _get_oauth_credentials() + + client_body = urlencode({ + "grant_type": "authorization_code", + "code": "authcode", + "redirect_uri": "https://example.com/callback", + "code_verifier": "pkce_verifier_value", + }) + params = parse_qs(client_body, keep_blank_values=True) + flat_params = {k: v[0] for k, v in params.items()} + flat_params["client_id"] = client_id + flat_params["client_secret"] = client_secret + forwarded = urlencode(flat_params) + + parsed = parse_qs(forwarded) + assert parsed["grant_type"] == ["authorization_code"] + assert parsed["code"] == ["authcode"] + assert parsed["redirect_uri"] == ["https://example.com/callback"] + assert parsed["code_verifier"] == ["pkce_verifier_value"] + + +class TestTokenProxyEndToEnd: + """End-to-end test for /token proxy: body parsing, credential injection, upstream forwarding.""" + + @pytest.mark.asyncio + async def test_token_proxy_full_flow(self): + """Token proxy should parse body, inject creds, forward to upstream, and return response.""" + from mcp_privilege_cloud.mcp_server import _register_oauth_routes, _fetch_oidc_discovery + from starlette.testclient import TestClient + from starlette.applications import Starlette + from starlette.routing import Route + from urllib.parse import parse_qs + + import mcp_privilege_cloud.mcp_server as mod + + # Clear OIDC discovery cache + mod._oidc_discovery_cache = None + + env = { + "CYBERARK_IDENTITY_TENANT_URL": "https://abc1234.id.cyberark.cloud", + "CYBERARK_OAUTH_CLIENT_ID": "server-client-id", + "CYBERARK_OAUTH_CLIENT_SECRET": "server-secret", + } + + # Mock OIDC discovery + mock_oidc_response = AsyncMock() + mock_oidc_response.status_code = 200 + mock_oidc_response.json = lambda: SAMPLE_OIDC_DISCOVERY + mock_oidc_response.raise_for_status = lambda: None + + # Mock upstream token response + upstream_response = httpx.Response( + 200, + json={"access_token": "tok_123", "token_type": "Bearer"}, + request=httpx.Request("POST", SAMPLE_OIDC_DISCOVERY["token_endpoint"]), + ) + + # Capture the body forwarded to upstream + captured_body = {} + + async def mock_post(url, content=None, headers=None): + captured_body["url"] = url + captured_body["params"] = parse_qs(content) + return upstream_response + + mock_http_client = AsyncMock() + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + mock_http_client.get = AsyncMock(return_value=mock_oidc_response) + mock_http_client.post = mock_post + + with patch.dict(os.environ, env, clear=False): + with patch("mcp_privilege_cloud.mcp_server.httpx.AsyncClient", return_value=mock_http_client): + # Register routes on a mock FastMCP that stores them + routes = {} + + class MockMCP: + def custom_route(self, path, methods=None): + def decorator(fn): + routes[path] = fn + return fn + return decorator + + mock_mcp = MockMCP() + _register_oauth_routes(mock_mcp) + + # Build a Starlette Request with form body + from starlette.requests import Request as StarletteRequest + + scope = { + "type": "http", + "method": "POST", + "path": "/token", + "query_string": b"", + "headers": [(b"content-type", b"application/x-www-form-urlencoded")], + } + + body_bytes = b"grant_type=authorization_code&code=auth_code_123&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback" + + async def receive(): + return {"type": "http.request", "body": body_bytes} + + request = StarletteRequest(scope, receive) + response = await routes["/token"](request) + + # Verify upstream received correct params + assert captured_body["url"] == SAMPLE_OIDC_DISCOVERY["token_endpoint"] + assert captured_body["params"]["client_id"] == ["server-client-id"] + assert captured_body["params"]["client_secret"] == ["server-secret"] + assert captured_body["params"]["grant_type"] == ["authorization_code"] + assert captured_body["params"]["code"] == ["auth_code_123"] + assert response.status_code == 200 + + +class TestOAuthRouteRegistration: + """Test that OAuth routes are registered correctly.""" + + def test_routes_registered_in_oauth_mode(self): + """OAuth routes should be registered in OAuth mode.""" + with patch.dict(os.environ, { + "CYBERARK_IDENTITY_TENANT_URL": "https://abc1234.id.cyberark.cloud", + }): + with patch("mcp_privilege_cloud.mcp_server.is_oauth_mode", return_value=True): + import mcp_privilege_cloud.mcp_server as mod + + assert hasattr(mod, '_register_oauth_routes') + + def test_routes_not_registered_in_legacy_mode(self): + """OAuth routes should NOT be registered when OAuth is disabled.""" + with patch.dict(os.environ, {}, clear=True): + with patch("mcp_privilege_cloud.mcp_server.is_oauth_mode", return_value=False): + from mcp_privilege_cloud.mcp_server import is_oauth_mode + assert is_oauth_mode() is False diff --git a/tests/test_response_models.py b/tests/test_response_models.py deleted file mode 100644 index 3a7885c..0000000 --- a/tests/test_response_models.py +++ /dev/null @@ -1,315 +0,0 @@ -""" -Response Models Tests (RED Phase) - -Tests for Pydantic response models used in MCP tool return types. -These tests verify model validation and JSON schema generation for MCP compatibility. -""" - -import pytest -from typing import Optional, List, Dict, Any - - -class TestAccountModels: - """Test account-related response models""" - - def test_account_details_model_validates_required_fields(self): - """Model should validate required SDK response fields""" - from mcp_privilege_cloud.models import AccountDetails - - data = { - "id": "123_456", - "platformId": "WinServerLocal", - "safeName": "IT-Infrastructure" - } - account = AccountDetails(**data) - assert account.id == "123_456" - assert account.platform_id == "WinServerLocal" - assert account.safe_name == "IT-Infrastructure" - - def test_account_details_model_validates_optional_fields(self): - """Model should handle optional fields correctly""" - from mcp_privilege_cloud.models import AccountDetails - - data = { - "id": "123_456", - "platformId": "WinServerLocal", - "safeName": "IT-Infrastructure", - "name": "admin-server01", - "userName": "admin", - "address": "server01.corp.com", - "secretType": "password" - } - account = AccountDetails(**data) - assert account.name == "admin-server01" - assert account.user_name == "admin" - assert account.address == "server01.corp.com" - assert account.secret_type == "password" - - def test_account_details_model_generates_schema(self): - """Model should generate JSON schema for MCP structured output""" - from mcp_privilege_cloud.models import AccountDetails - - schema = AccountDetails.model_json_schema() - assert "properties" in schema - assert "id" in schema["properties"] - assert "platformId" in schema["properties"] # Alias should appear in schema - assert "safeName" in schema["properties"] - - def test_account_details_model_serializes_with_aliases(self): - """Model should serialize using API field names (aliases)""" - from mcp_privilege_cloud.models import AccountDetails - - account = AccountDetails( - id="123_456", - platform_id="WinServerLocal", - safe_name="IT-Infrastructure", - user_name="admin" - ) - serialized = account.model_dump(by_alias=True) - assert "platformId" in serialized - assert "safeName" in serialized - assert "userName" in serialized - assert serialized["platformId"] == "WinServerLocal" - - def test_account_summary_model_validates(self): - """AccountSummary should contain minimal account info for lists""" - from mcp_privilege_cloud.models import AccountSummary - - data = { - "id": "123_456", - "name": "admin-server01", - "platformId": "WinServerLocal", - "safeName": "IT-Infrastructure" - } - summary = AccountSummary(**data) - assert summary.id == "123_456" - assert summary.name == "admin-server01" - - -class TestSafeModels: - """Test safe-related response models""" - - def test_safe_details_model_validates(self): - """SafeDetails should validate safe properties""" - from mcp_privilege_cloud.models import SafeDetails - - data = { - "safeUrlId": "IT-Infrastructure", - "safeName": "IT-Infrastructure", - "safeNumber": 123, - "description": "IT Infrastructure accounts", - "location": "\\", - "creator": {"id": "1", "name": "Administrator"} - } - safe = SafeDetails(**data) - assert safe.safe_url_id == "IT-Infrastructure" - assert safe.safe_name == "IT-Infrastructure" - assert safe.safe_number == 123 - - def test_safe_details_model_generates_schema(self): - """SafeDetails should generate JSON schema""" - from mcp_privilege_cloud.models import SafeDetails - - schema = SafeDetails.model_json_schema() - assert "properties" in schema - assert "safeName" in schema["properties"] - assert "safeNumber" in schema["properties"] - - def test_safe_member_model_validates(self): - """SafeMember should validate member properties and permissions""" - from mcp_privilege_cloud.models import SafeMember - - data = { - "memberName": "admin@domain.com", - "memberType": "User", - "permissions": { - "useAccounts": True, - "retrieveAccounts": True, - "listAccounts": True - } - } - member = SafeMember(**data) - assert member.member_name == "admin@domain.com" - assert member.member_type == "User" - assert member.permissions is not None - - -class TestPlatformModels: - """Test platform-related response models""" - - def test_platform_details_model_validates(self): - """PlatformDetails should validate platform properties""" - from mcp_privilege_cloud.models import PlatformDetails - - data = { - "id": "WinServerLocal", - "name": "Windows Server Local", - "systemType": "Windows", - "active": True, - "platformType": "Regular" - } - platform = PlatformDetails(**data) - assert platform.id == "WinServerLocal" - assert platform.name == "Windows Server Local" - assert platform.system_type == "Windows" - assert platform.active is True - - def test_platform_details_model_handles_policy_details(self): - """PlatformDetails should handle detailed Policy INI configuration""" - from mcp_privilege_cloud.models import PlatformDetails - - data = { - "id": "WinServerLocal", - "name": "Windows Server Local", - "systemType": "Windows", - "active": True, - "platformType": "Regular", - # Additional policy details from details API - "PasswordLength": "12", - "MinUpperCase": "2", - "MinLowerCase": "2" - } - platform = PlatformDetails(**data) - assert platform.id == "WinServerLocal" - # Policy details should be preserved in extra fields - assert platform.model_extra.get("PasswordLength") == "12" - - def test_platform_details_generates_schema(self): - """PlatformDetails should generate JSON schema""" - from mcp_privilege_cloud.models import PlatformDetails - - schema = PlatformDetails.model_json_schema() - assert "properties" in schema - assert "id" in schema["properties"] - assert "name" in schema["properties"] - - -class TestApplicationModels: - """Test application-related response models""" - - def test_application_details_model_validates(self): - """ApplicationDetails should validate application properties""" - from mcp_privilege_cloud.models import ApplicationDetails - - data = { - "AppID": "TestApp", - "Description": "Test application", - "Location": "\\Applications", - "Disabled": False - } - app = ApplicationDetails(**data) - assert app.app_id == "TestApp" - assert app.description == "Test application" - assert app.disabled is False - - def test_application_auth_method_model_validates(self): - """ApplicationAuthMethod should validate auth method properties""" - from mcp_privilege_cloud.models import ApplicationAuthMethod - - data = { - "authType": "certificate", - "authValue": "CN=TestCert" - } - auth = ApplicationAuthMethod(**data) - assert auth.auth_type == "certificate" - assert auth.auth_value == "CN=TestCert" - - -class TestSessionModels: - """Test session monitoring response models""" - - def test_session_details_model_validates(self): - """SessionDetails should validate session properties""" - from mcp_privilege_cloud.models import SessionDetails - - data = { - "SessionId": "550e8400-e29b-41d4-a716-446655440000", - "Protocol": "SSH", - "User": "admin", - "FromIP": "192.168.1.100", - "SessionDuration": "00:30:00" - } - session = SessionDetails(**data) - assert session.session_id == "550e8400-e29b-41d4-a716-446655440000" - assert session.protocol == "SSH" - assert session.user == "admin" - - def test_session_activity_model_validates(self): - """SessionActivity should validate activity log entries""" - from mcp_privilege_cloud.models import SessionActivity - - data = { - "Timestamp": "2025-01-15T10:30:00Z", - "Command": "ls -la", - "Result": "success" - } - activity = SessionActivity(**data) - assert activity.timestamp == "2025-01-15T10:30:00Z" - assert activity.command == "ls -la" - - -class TestModelConfig: - """Test model configuration and behavior""" - - def test_models_allow_population_by_field_name(self): - """Models should allow population using Python field names""" - from mcp_privilege_cloud.models import AccountDetails - - # Using Python field names (snake_case) - account = AccountDetails( - id="123", - platform_id="WinServerLocal", - safe_name="TestSafe" - ) - assert account.platform_id == "WinServerLocal" - assert account.safe_name == "TestSafe" - - def test_models_allow_population_by_alias(self): - """Models should allow population using API aliases (CamelCase)""" - from mcp_privilege_cloud.models import AccountDetails - - # Using API aliases (CamelCase) - account = AccountDetails( - id="123", - platformId="WinServerLocal", - safeName="TestSafe" - ) - assert account.platform_id == "WinServerLocal" - assert account.safe_name == "TestSafe" - - def test_models_use_extra_allow_for_unknown_fields(self): - """Models should allow extra fields for API forward compatibility""" - from mcp_privilege_cloud.models import AccountDetails - - # API may return new fields not yet in model - data = { - "id": "123", - "platformId": "WinServerLocal", - "safeName": "TestSafe", - "newApiField": "someValue", # Unknown field - "anotherNewField": 42 - } - account = AccountDetails(**data) - assert account.id == "123" - # Extra fields should be accessible via model_extra - assert account.model_extra.get("newApiField") == "someValue" - - -class TestListModels: - """Test list/collection response models""" - - def test_accounts_list_model_validates(self): - """AccountsList should wrap list of account summaries""" - from mcp_privilege_cloud.models import AccountsList, AccountSummary - - data = { - "value": [ - {"id": "1", "name": "acc1", "platformId": "Win", "safeName": "Safe1"}, - {"id": "2", "name": "acc2", "platformId": "Unix", "safeName": "Safe2"} - ], - "count": 2 - } - accounts_list = AccountsList(**data) - assert accounts_list.count == 2 - assert len(accounts_list.value) == 2 - assert accounts_list.value[0].id == "1" diff --git a/tests/test_token_verifier.py b/tests/test_token_verifier.py new file mode 100644 index 0000000..8ab9c3e --- /dev/null +++ b/tests/test_token_verifier.py @@ -0,0 +1,455 @@ +"""Tests for CyberArkTokenVerifier. + +Tests JWT verification against CyberArk Identity JWKS endpoint, +implementing the MCP SDK's TokenVerifier protocol. +""" + +import os +import time + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from helpers import _default_claims, _make_jwt + + +class TestCyberArkTokenVerifierInit: + """Test CyberArkTokenVerifier initialization.""" + + def test_oidc_app_id_constant(self): + """CYBERARK_OIDC_APP_ID should default to the custom app.""" + from mcp_privilege_cloud.token_verifier import CYBERARK_OIDC_APP_ID + + assert CYBERARK_OIDC_APP_ID == "mcpprivilegecloud" + + def test_init_with_tenant_url(self): + """Verifier should initialize with a CyberArk Identity tenant URL.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + assert verifier._identity_tenant_url == "https://abc1234.id.cyberark.cloud" + + def test_init_strips_trailing_slash(self): + """Tenant URL should have trailing slash stripped.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud/", + ) + + assert verifier._identity_tenant_url == "https://abc1234.id.cyberark.cloud" + + def test_jwks_uri_none_before_resolution(self): + """JWKS URI should be None before lazy resolution.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + assert verifier._jwks_uri is None + + +class TestCyberArkTokenVerifierVerify: + """Test CyberArkTokenVerifier.verify_token() method.""" + + @pytest.mark.asyncio + async def test_valid_token_returns_access_token(self): + """A valid JWT should return an AccessToken with correct fields.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + claims = _default_claims() + jwt_token = _make_jwt(claims) + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + # Mock the JWT decode to return our claims (skip actual signature verification) + with patch.object(verifier, "_decode_and_verify", return_value=claims): + result = await verifier.verify_token(jwt_token) + + assert result is not None + assert result.token == jwt_token + assert result.client_id == claims["sub"] + assert result.expires_at == claims["exp"] + + @pytest.mark.asyncio + async def test_valid_token_extracts_scopes(self): + """Scopes should be extracted from the JWT scope claim.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + claims = _default_claims(scope="openid profile pvwa") + jwt_token = _make_jwt(claims) + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + with patch.object(verifier, "_decode_and_verify", return_value=claims): + result = await verifier.verify_token(jwt_token) + + assert result is not None + assert result.scopes == ["openid", "profile", "pvwa"] + + @pytest.mark.asyncio + async def test_expired_token_returns_none(self): + """An expired JWT should return None.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + claims = _default_claims(exp=int(time.time()) - 100) + jwt_token = _make_jwt(claims) + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + # Simulate PyJWT raising ExpiredSignatureError + import jwt as pyjwt + + with patch.object( + verifier, + "_decode_and_verify", + side_effect=pyjwt.ExpiredSignatureError("token expired"), + ): + result = await verifier.verify_token(jwt_token) + + assert result is None + + @pytest.mark.asyncio + async def test_invalid_signature_returns_none(self): + """A JWT with invalid signature should return None.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + jwt_token = _make_jwt(_default_claims()) + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + import jwt as pyjwt + + with patch.object( + verifier, + "_decode_and_verify", + side_effect=pyjwt.InvalidSignatureError("bad signature"), + ): + result = await verifier.verify_token(jwt_token) + + assert result is None + + @pytest.mark.asyncio + async def test_invalid_audience_returns_none(self): + """A JWT with wrong audience should return None.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + claims = _default_claims(aud="wrong-app-id") + jwt_token = _make_jwt(claims) + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + import jwt as pyjwt + + with patch.object( + verifier, + "_decode_and_verify", + side_effect=pyjwt.InvalidAudienceError("invalid audience"), + ): + result = await verifier.verify_token(jwt_token) + + assert result is None + + @pytest.mark.asyncio + async def test_malformed_token_returns_none(self): + """A malformed JWT string should return None.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + import jwt as pyjwt + + with patch.object( + verifier, + "_decode_and_verify", + side_effect=pyjwt.DecodeError("invalid token"), + ): + result = await verifier.verify_token("not-a-jwt") + + assert result is None + + @pytest.mark.asyncio + async def test_missing_sub_claim_returns_none(self): + """A JWT missing the sub claim should return None.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + claims = _default_claims() + del claims["sub"] + jwt_token = _make_jwt(claims) + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + with patch.object(verifier, "_decode_and_verify", return_value=claims): + result = await verifier.verify_token(jwt_token) + + assert result is None + + @pytest.mark.asyncio + async def test_empty_scope_returns_empty_list(self): + """A JWT with no scope claim should return empty scopes list.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + claims = _default_claims() + del claims["scope"] + jwt_token = _make_jwt(claims) + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + with patch.object(verifier, "_decode_and_verify", return_value=claims): + result = await verifier.verify_token(jwt_token) + + assert result is not None + assert result.scopes == [] + + +class TestCyberArkTokenVerifierJWKS: + """Test JWKS fetching and caching.""" + + def test_jwks_client_not_created_at_init(self): + """JWKS client should NOT be created at init (lazy resolution).""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + assert verifier._jwks_client is None + + @pytest.mark.asyncio + async def test_jwks_uri_resolved_from_oidc_discovery(self): + """JWKS URI should be resolved from OIDC discovery on first use.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + oidc_response = { + "jwks_uri": "https://abc1234.id.cyberark.cloud/OAuth2/Keys/real-app-key-id", + "issuer": "https://abc1234.id.cyberark.cloud/mcpprivilegecloud/", + } + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = oidc_response + mock_resp.raise_for_status = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + await verifier._ensure_jwks_client() + + assert verifier._jwks_uri == "https://abc1234.id.cyberark.cloud/OAuth2/Keys/real-app-key-id" + assert verifier._jwks_client is not None + + @pytest.mark.asyncio + async def test_jwks_uri_falls_back_on_discovery_failure(self): + """Should fall back to constructed JWKS URI if OIDC discovery fails.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier, CYBERARK_OIDC_APP_ID + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.side_effect = Exception("connection refused") + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + await verifier._ensure_jwks_client() + + expected_fallback = f"https://abc1234.id.cyberark.cloud/OAuth2/Keys/{CYBERARK_OIDC_APP_ID}" + assert verifier._jwks_uri == expected_fallback + assert verifier._jwks_client is not None + + @pytest.mark.asyncio + async def test_jwks_client_cached_after_first_resolution(self): + """JWKS client should be reused after first resolution.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + oidc_response = { + "jwks_uri": "https://abc1234.id.cyberark.cloud/OAuth2/Keys/real-key", + "issuer": "https://abc1234.id.cyberark.cloud/mcpprivilegecloud/", + } + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = oidc_response + mock_resp.raise_for_status = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + await verifier._ensure_jwks_client() + first_client = verifier._jwks_client + + # Second call should not re-fetch + await verifier._ensure_jwks_client() + assert verifier._jwks_client is first_client + + @pytest.mark.asyncio + async def test_jwks_fetch_failure_returns_none(self): + """If JWKS fetch fails, verify_token should return None.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + jwt_token = _make_jwt(_default_claims()) + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + import jwt as pyjwt + + with patch.object( + verifier, + "_decode_and_verify", + side_effect=pyjwt.PyJWKClientConnectionError("connection failed"), + ): + result = await verifier.verify_token(jwt_token) + + assert result is None + + @pytest.mark.asyncio + async def test_decode_and_verify_calls_pyjwt(self): + """_decode_and_verify should use PyJWKClient and jwt.decode.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + claims = _default_claims() + jwt_token = _make_jwt(claims) + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + mock_key = MagicMock() + mock_key.key = "mock-public-key" + + # Pre-set a mock JWKS client so _ensure_jwks_client is a no-op + mock_jwks_client = MagicMock() + mock_jwks_client.get_signing_key_from_jwt.return_value = mock_key + verifier._jwks_client = mock_jwks_client + + with patch("jwt.decode", return_value=claims) as mock_decode: + result = await verifier._decode_and_verify(jwt_token) + + mock_decode.assert_called_once_with( + jwt_token, + mock_key.key, + algorithms=["RS256"], + audience=verifier._expected_audience, + issuer=f"{verifier._identity_tenant_url}/{verifier._expected_app_id}/", + options={"require": ["exp", "iss", "sub", "aud"]}, + ) + assert result == claims + + +class TestCyberArkTokenVerifierAudience: + """Test audience resolution: CYBERARK_OAUTH_CLIENT_ID, falling back to OIDC app ID.""" + + @pytest.mark.asyncio + async def test_audience_uses_oauth_client_id(self): + """CYBERARK_OAUTH_CLIENT_ID should be used as the accepted audience.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + oauth_client_id = "c21840a7-trust-tab-client-id" + + env = { + "CYBERARK_OAUTH_CLIENT_ID": oauth_client_id, + "CYBERARK_CLIENT_ID": "timtest@cyberark.cloud.3240", + } + with patch.dict(os.environ, env): + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + assert verifier._expected_audience == {oauth_client_id} + + @pytest.mark.asyncio + async def test_audience_ignores_service_account_client_id(self): + """CYBERARK_CLIENT_ID (service account) should NOT be in audience set.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier, CYBERARK_OIDC_APP_ID + + env = {"CYBERARK_CLIENT_ID": "svc@cyberark.cloud.3240"} + with patch.dict(os.environ, env): + os.environ.pop("CYBERARK_OAUTH_CLIENT_ID", None) + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + # Should fall back to OIDC app ID, not use service account username + assert verifier._expected_audience == {CYBERARK_OIDC_APP_ID} + + @pytest.mark.asyncio + async def test_audience_falls_back_to_oidc_app_id(self): + """Without any env vars, should use CYBERARK_OIDC_APP_ID as audience.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier, CYBERARK_OIDC_APP_ID + + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("CYBERARK_OAUTH_CLIENT_ID", None) + os.environ.pop("CYBERARK_CLIENT_ID", None) + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + assert verifier._expected_audience == {CYBERARK_OIDC_APP_ID} + + +class TestCyberArkTokenVerifierProtocol: + """Test that CyberArkTokenVerifier satisfies the MCP TokenVerifier protocol.""" + + def test_has_verify_token_method(self): + """Verifier must have an async verify_token method.""" + from mcp_privilege_cloud.token_verifier import CyberArkTokenVerifier + + verifier = CyberArkTokenVerifier( + identity_tenant_url="https://abc1234.id.cyberark.cloud", + ) + + assert hasattr(verifier, "verify_token") + assert callable(verifier.verify_token) + + def test_returns_access_token_type(self): + """verify_token return type should be compatible with MCP AccessToken.""" + from mcp.server.auth.provider import AccessToken + + # Just verify AccessToken can be imported and has expected fields + token = AccessToken( + token="test", + client_id="client", + scopes=["read"], + expires_at=None, + ) + assert token.token == "test" + assert token.client_id == "client" + assert token.scopes == ["read"] diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..c1020b7 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,184 @@ +"""Tests for Streamable HTTP transport configuration. + +Verifies that the MCP server correctly configures Streamable HTTP transport +with the expected routes and settings. +""" + +import pytest +from unittest.mock import patch, MagicMock + +from mcp_privilege_cloud.mcp_server import TrailingSlashMiddleware + + +class TestStreamableHTTPTransport: + """Test Streamable HTTP transport configuration.""" + + def test_streamable_http_app_returns_starlette(self): + """Test that mcp.streamable_http_app() returns a Starlette application.""" + from mcp_privilege_cloud.mcp_server import mcp + + app = mcp.streamable_http_app() + + # Starlette app should have routes attribute + assert hasattr(app, "routes") + + def test_streamable_http_app_has_mcp_route(self): + """Test that the Starlette app includes the /mcp endpoint.""" + from mcp_privilege_cloud.mcp_server import mcp + + app = mcp.streamable_http_app() + + # Check that /mcp route exists + route_paths = [] + for route in app.routes: + if hasattr(route, "path"): + route_paths.append(route.path) + elif hasattr(route, "routes"): + # Mount/sub-app routes + for sub_route in route.routes: + if hasattr(sub_route, "path"): + route_paths.append(sub_route.path) + + assert any("/mcp" in path for path in route_paths), ( + f"Expected /mcp route, found: {route_paths}" + ) + + def test_host_port_defaults(self): + """Test that MCP_HOST and MCP_PORT have sensible defaults.""" + with patch.dict("os.environ", {}, clear=False): + # Remove any existing env vars to test defaults + import os + host = os.getenv("MCP_HOST", "127.0.0.1") + port = int(os.getenv("MCP_PORT", "8000")) + + assert host == "127.0.0.1" + assert port == 8000 + + def test_host_port_from_env(self): + """Test that MCP_HOST and MCP_PORT can be configured via env vars.""" + with patch.dict("os.environ", { + "MCP_HOST": "0.0.0.0", + "MCP_PORT": "9000", + }): + import os + host = os.getenv("MCP_HOST", "127.0.0.1") + port = int(os.getenv("MCP_PORT", "8000")) + + assert host == "0.0.0.0" + assert port == 9000 + + def test_main_defaults_to_stdio(self): + """Test that main() defaults to stdio transport when MCP_TRANSPORT is not set.""" + with patch.dict("os.environ", {}, clear=False): + import os + os.environ.pop("MCP_TRANSPORT", None) + with patch("mcp_privilege_cloud.mcp_server.mcp") as mock_mcp: + from mcp_privilege_cloud.mcp_server import main + main() + + mock_mcp.run.assert_called_once_with(transport="stdio") + + def test_main_uses_streamable_http_when_configured(self): + """Test that main() wraps app with TrailingSlashMiddleware for streamable-http.""" + with patch.dict("os.environ", {"MCP_TRANSPORT": "streamable-http"}): + with patch("mcp_privilege_cloud.mcp_server.mcp") as mock_mcp: + mock_mcp.streamable_http_app.return_value = MagicMock() + with patch("asyncio.run"): + with patch("uvicorn.Config") as mock_config: + with patch("uvicorn.Server"): + from mcp_privilege_cloud.mcp_server import main + main() + + # Should create Starlette app and wrap it + mock_mcp.streamable_http_app.assert_called_once() + # Should NOT call mcp.run() + mock_mcp.run.assert_not_called() + # Should pass wrapped app to uvicorn + mock_config.assert_called_once() + app = mock_config.call_args[0][0] + assert isinstance(app, TrailingSlashMiddleware) + + def test_main_rejects_invalid_transport(self): + """Test that main() exits with error for invalid MCP_TRANSPORT value.""" + with patch.dict("os.environ", {"MCP_TRANSPORT": "invalid"}): + with patch("mcp_privilege_cloud.mcp_server.mcp"): + from mcp_privilege_cloud.mcp_server import main + with pytest.raises(SystemExit): + main() + + +class TestTrailingSlashMiddleware: + """Test that trailing slashes are stripped to prevent 307 redirects. + + MCP clients (e.g. Copilot Studio) POST to /mcp/ (trailing slash). + Starlette's redirect_slashes returns a 307, which causes HTTP clients + to strip the Authorization header, breaking OAuth Bearer auth. + """ + + @pytest.mark.asyncio + async def test_strips_trailing_slash(self): + """Trailing slash should be stripped before reaching the inner app.""" + received_path = None + + async def inner_app(scope, receive, send): + nonlocal received_path + received_path = scope["path"] + + app = TrailingSlashMiddleware(inner_app) + scope = {"type": "http", "path": "/mcp/"} + await app(scope, lambda: None, lambda msg: None) + assert received_path == "/mcp" + + @pytest.mark.asyncio + async def test_preserves_path_without_trailing_slash(self): + """Paths without trailing slash should pass through unchanged.""" + received_path = None + + async def inner_app(scope, receive, send): + nonlocal received_path + received_path = scope["path"] + + app = TrailingSlashMiddleware(inner_app) + scope = {"type": "http", "path": "/mcp"} + await app(scope, lambda: None, lambda msg: None) + assert received_path == "/mcp" + + @pytest.mark.asyncio + async def test_preserves_root_path(self): + """Root path '/' should not be stripped.""" + received_path = None + + async def inner_app(scope, receive, send): + nonlocal received_path + received_path = scope["path"] + + app = TrailingSlashMiddleware(inner_app) + scope = {"type": "http", "path": "/"} + await app(scope, lambda: None, lambda msg: None) + assert received_path == "/" + + @pytest.mark.asyncio + async def test_passes_non_http_scopes_unchanged(self): + """Non-HTTP scopes (websocket, lifespan) should pass through.""" + received_scope = None + + async def inner_app(scope, receive, send): + nonlocal received_scope + received_scope = scope + + app = TrailingSlashMiddleware(inner_app) + scope = {"type": "lifespan", "path": "/mcp/"} + await app(scope, lambda: None, lambda msg: None) + assert received_scope["path"] == "/mcp/" + + @pytest.mark.asyncio + async def test_does_not_mutate_original_scope(self): + """Original scope dict should not be modified.""" + original_scope = {"type": "http", "path": "/mcp/"} + + async def inner_app(scope, receive, send): + pass + + app = TrailingSlashMiddleware(inner_app) + await app(original_scope, lambda: None, lambda msg: None) + assert original_scope["path"] == "/mcp/" diff --git a/tests/test_typed_tools.py b/tests/test_typed_tools.py deleted file mode 100644 index 0f83030..0000000 --- a/tests/test_typed_tools.py +++ /dev/null @@ -1,213 +0,0 @@ -""" -Typed Tool Returns Tests (RED then GREEN Phase) - -Tests verifying that MCP tools return typed Pydantic models for structured output. -""" - -import pytest -from unittest.mock import Mock, AsyncMock, patch -import os - - -class TestAccountToolsTypedReturns: - """Test account tools return typed models""" - - @pytest.mark.asyncio - async def test_get_account_details_returns_account_model(self): - """get_account_details should return AccountDetails model for type safety""" - from mcp_privilege_cloud.mcp_server import get_account_details, AppContext - from mcp_privilege_cloud.models import AccountDetails - - # Mock server returning dict (simulating SDK response) - mock_server = AsyncMock() - mock_server.get_account_details.return_value = { - "id": "123_456", - "platformId": "WinServerLocal", - "safeName": "IT-Infrastructure", - "userName": "admin", - "address": "server01.corp.com" - } - - mock_ctx = Mock() - mock_ctx.request_context.lifespan_context = AppContext(server=mock_server) - - result = await get_account_details("123_456", ctx=mock_ctx) - - # Result should be dict (execute_tool converts to dict for MCP) - # but the data should match AccountDetails schema - assert isinstance(result, dict) - assert result["id"] == "123_456" - assert "platformId" in result or "platform_id" in result - - @pytest.mark.asyncio - async def test_list_accounts_returns_list_of_accounts(self): - """list_accounts should return list compatible with AccountSummary""" - from mcp_privilege_cloud.mcp_server import list_accounts, AppContext - from mcp_privilege_cloud.models import AccountSummary - - mock_server = AsyncMock() - mock_server.list_accounts.return_value = [ - {"id": "1", "platformId": "Win", "safeName": "Safe1", "name": "acc1"}, - {"id": "2", "platformId": "Unix", "safeName": "Safe2", "name": "acc2"} - ] - - mock_ctx = Mock() - mock_ctx.request_context.lifespan_context = AppContext(server=mock_server) - - result = await list_accounts(ctx=mock_ctx) - - assert isinstance(result, list) - assert len(result) == 2 - # Each item should be dict with expected fields - for item in result: - assert "id" in item - assert "platformId" in item or "platform_id" in item - - -class TestSafeToolsTypedReturns: - """Test safe tools return typed models""" - - @pytest.mark.asyncio - async def test_get_safe_details_returns_safe_model(self): - """get_safe_details should return SafeDetails compatible data""" - from mcp_privilege_cloud.mcp_server import get_safe_details, AppContext - from mcp_privilege_cloud.models import SafeDetails - - mock_server = AsyncMock() - mock_server.get_safe_details.return_value = { - "safeUrlId": "TestSafe", - "safeName": "TestSafe", - "safeNumber": 123, - "description": "Test safe" - } - - mock_ctx = Mock() - mock_ctx.request_context.lifespan_context = AppContext(server=mock_server) - - result = await get_safe_details("TestSafe", ctx=mock_ctx) - - assert isinstance(result, dict) - assert result["safeName"] == "TestSafe" - # Verify SafeDetails model can parse the result - safe = SafeDetails(**result) - assert safe.safe_name == "TestSafe" - - -class TestPlatformToolsTypedReturns: - """Test platform tools return typed models""" - - @pytest.mark.asyncio - async def test_list_platforms_returns_platform_list(self): - """list_platforms should return list compatible with PlatformDetails""" - from mcp_privilege_cloud.mcp_server import list_platforms, AppContext - from mcp_privilege_cloud.models import PlatformDetails - - mock_server = AsyncMock() - mock_server.list_platforms.return_value = [ - {"id": "Win", "name": "Windows", "systemType": "Windows", "active": True}, - {"id": "Unix", "name": "Unix SSH", "systemType": "Unix", "active": True} - ] - - mock_ctx = Mock() - mock_ctx.request_context.lifespan_context = AppContext(server=mock_server) - - result = await list_platforms(ctx=mock_ctx) - - assert isinstance(result, list) - assert len(result) == 2 - # Verify PlatformDetails model can parse each result - for item in result: - platform = PlatformDetails(**item) - assert platform.id is not None - assert platform.name is not None - - -class TestModelSchemaGeneration: - """Test that models generate proper JSON schemas for MCP""" - - def test_account_details_schema_for_mcp(self): - """AccountDetails should generate MCP-compatible JSON schema""" - from mcp_privilege_cloud.models import AccountDetails - - schema = AccountDetails.model_json_schema() - - # Schema should have required MCP structure - assert "properties" in schema - assert "type" in schema - assert schema["type"] == "object" - - # Key fields should be present with descriptions - assert "id" in schema["properties"] - assert "description" in schema["properties"]["id"] - - def test_safe_details_schema_for_mcp(self): - """SafeDetails should generate MCP-compatible JSON schema""" - from mcp_privilege_cloud.models import SafeDetails - - schema = SafeDetails.model_json_schema() - - assert "properties" in schema - assert "safeName" in schema["properties"] # Aliased field - - def test_platform_details_schema_for_mcp(self): - """PlatformDetails should generate MCP-compatible JSON schema""" - from mcp_privilege_cloud.models import PlatformDetails - - schema = PlatformDetails.model_json_schema() - - assert "properties" in schema - assert "id" in schema["properties"] - assert "name" in schema["properties"] - - -class TestPydanticModelConversion: - """Test Pydantic model conversion at MCP boundary""" - - @pytest.mark.asyncio - async def test_sdk_pydantic_model_converted_to_dict(self): - """SDK Pydantic models should be converted to dicts for MCP""" - from mcp_privilege_cloud.mcp_server import get_account_details, AppContext, _convert_to_dict - from pydantic import BaseModel - - # Create a mock Pydantic model similar to SDK response - class MockSDKAccount(BaseModel): - id: str - platformId: str - safeName: str - - mock_account = MockSDKAccount(id="123", platformId="Win", safeName="Safe") - - # Conversion should produce dict - result = _convert_to_dict(mock_account) - assert isinstance(result, dict) - assert result["id"] == "123" - - def test_list_of_pydantic_models_converted(self): - """List of Pydantic models should all be converted to dicts""" - from mcp_privilege_cloud.mcp_server import _convert_to_dict - from pydantic import BaseModel - - class MockItem(BaseModel): - id: str - name: str - - items = [MockItem(id="1", name="a"), MockItem(id="2", name="b")] - - result = _convert_to_dict(items) - assert isinstance(result, list) - assert all(isinstance(item, dict) for item in result) - - def test_nested_dict_with_pydantic_converted(self): - """Nested structures with Pydantic models should be fully converted""" - from mcp_privilege_cloud.mcp_server import _convert_to_dict - from pydantic import BaseModel - - class MockNested(BaseModel): - value: str - - nested = {"outer": MockNested(value="inner"), "plain": "text"} - - result = _convert_to_dict(nested) - assert isinstance(result["outer"], dict) - assert result["outer"]["value"] == "inner" - assert result["plain"] == "text" diff --git a/uv.lock b/uv.lock index 89e19bf..0105797 100644 --- a/uv.lock +++ b/uv.lock @@ -761,11 +761,14 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ark-sdk-python" }, + { name = "cryptography" }, { name = "httpx" }, { name = "mcp", extra = ["cli"] }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "python-dotenv" }, + { name = "uvicorn" }, ] [package.optional-dependencies] @@ -792,11 +795,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "ark-sdk-python", git = "https://github.com/cyberark/ark-sdk-python.git?rev=v2.1.0" }, + { name = "cryptography", specifier = ">=41.0.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.20.0,<2.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "pydantic", specifier = ">=2.5.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, + { name = "pyjwt", specifier = ">=2.8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, @@ -807,6 +812,7 @@ requires-dist = [ { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "uvicorn", specifier = ">=0.30.0" }, ] provides-extras = ["dev", "test"]