diff --git a/API_REFERENCE.md b/API_REFERENCE.md index d615a8d..df19f46 100644 --- a/API_REFERENCE.md +++ b/API_REFERENCE.md @@ -1,42 +1,30 @@ # ACP MCP Server - API Reference -Complete reference for all 19 tools available in the ACP MCP Server. +Complete reference for all 8 tools available in the ACP MCP Server. + +See [issue #27](https://github.com/ambient-code/mcp/issues/27) for planned additional tools. --- ## Table of Contents -1. [Priority 0 Tools (Critical)](#priority-0-tools-critical) +1. [Session Management](#session-management) - [acp_list_sessions](#acp_list_sessions) + - [acp_get_session](#acp_get_session) + - [acp_create_session](#acp_create_session) - [acp_delete_session](#acp_delete_session) -2. [Priority 1 Tools (Important)](#priority-1-tools-important) - - [acp_restart_session](#acp_restart_session) +2. [Bulk Operations](#bulk-operations) - [acp_bulk_delete_sessions](#acp_bulk_delete_sessions) - - [acp_bulk_stop_sessions](#acp_bulk_stop_sessions) - - [acp_get_session_logs](#acp_get_session_logs) + +3. [Cluster Management](#cluster-management) - [acp_list_clusters](#acp_list_clusters) - [acp_whoami](#acp_whoami) - -3. [Priority 2 Tools (Power Users)](#priority-2-tools-power-users) - - [acp_clone_session](#acp_clone_session) - - [acp_get_session_transcript](#acp_get_session_transcript) - - [acp_update_session](#acp_update_session) - - [acp_export_session](#acp_export_session) - -4. [Priority 3 Tools (Advanced)](#priority-3-tools-advanced) - - [acp_get_session_metrics](#acp_get_session_metrics) - - [acp_list_workflows](#acp_list_workflows) - - [acp_create_session_from_template](#acp_create_session_from_template) - -5. [Authentication Tools](#authentication-tools) - - [acp_login](#acp_login) - [acp_switch_cluster](#acp_switch_cluster) - - [acp_add_cluster](#acp_add_cluster) --- -## Priority 0 Tools (Critical) +## Session Management ### acp_list_sessions @@ -45,12 +33,11 @@ List sessions with advanced filtering, sorting, and limiting. **Input Schema:** ```json { - "project": "string (required)", + "project": "string (optional, uses default if not provided)", "status": "string (optional) - running|stopped|creating|failed", - "has_display_name": "boolean (optional)", "older_than": "string (optional) - e.g., '7d', '24h', '30m'", "sort_by": "string (optional) - created|stopped|name", - "limit": "integer (optional)" + "limit": "integer (optional, minimum: 1)" } ``` @@ -59,9 +46,9 @@ List sessions with advanced filtering, sorting, and limiting. { "sessions": [ { - "metadata": {"name": "...", "creationTimestamp": "..."}, - "spec": {"displayName": "...", "stopped": false}, - "status": {"phase": "running", "stoppedAt": null} + "id": "session-1", + "status": "running", + "createdAt": "2026-01-29T10:00:00Z" } ], "total": 10, @@ -70,74 +57,56 @@ List sessions with advanced filtering, sorting, and limiting. ``` **Behavior:** -- Build filter predicates based on parameters +- Calls `GET /v1/sessions` on the public-api gateway +- Builds filter predicates based on parameters - Single-pass filtering: `all(f(s) for f in filters)` -- Sort results if `sort_by` specified (reverse for created/stopped, normal for name) -- Apply limit after filtering and sorting -- Return metadata about filters applied - -**Implementation Notes:** -- Parse time deltas: `(\d+)([dhm])` → timedelta -- Compare timestamps after converting to timezone-naive UTC -- Default sort: no sorting (insertion order from API) +- Sorts results if `sort_by` specified (reverse for created/stopped, normal for name) +- Applies limit after filtering and sorting --- -### acp_delete_session +### acp_get_session -Delete a session with optional dry-run. +Get details of a specific session by ID. **Input Schema:** ```json { - "project": "string (required)", - "session": "string (required)", - "dry_run": "boolean (optional, default: false)" + "project": "string (optional, uses default if not provided)", + "session": "string (required) - Session ID" } ``` **Output:** ```json { - "deleted": true, - "message": "Successfully deleted session 'foo' from project 'bar'", - "dry_run": false -} -``` - -**Dry-Run Output:** -```json -{ - "dry_run": true, - "success": true, - "message": "Would delete session 'foo' in project 'bar'", - "session_info": { - "name": "foo", - "status": "running", - "created": "2026-01-29T10:00:00Z", - "stopped_at": null - } + "id": "session-1", + "status": "running", + "displayName": "My Session", + "createdAt": "2026-01-29T10:00:00Z" } ``` **Behavior:** -- If dry_run=true: Check session exists, return preview info -- If dry_run=false: Execute `oc delete agenticsession -n ` -- Return success/failure with descriptive message +- Calls `GET /v1/sessions/{session}` on the public-api gateway +- Validates session name (DNS-1123 format) --- -## Priority 1 Tools (Important) +### acp_create_session -### acp_restart_session - -Restart a stopped session. +Create an ACP AgenticSession with a custom prompt. **Input Schema:** ```json { - "project": "string (required)", - "session": "string (required)", + "project": "string (optional, uses default if not provided)", + "initial_prompt": "string (required) - The prompt/instructions for the session", + "display_name": "string (optional) - Human-readable display name", + "repos": "array[string] (optional) - Repository URLs to clone", + "interactive": "boolean (optional, default: false)", + "model": "string (optional, default: 'claude-sonnet-4')", + "timeout": "integer (optional, default: 900, minimum: 60) - seconds", "dry_run": "boolean (optional, default: false)" } ``` @@ -145,28 +114,45 @@ Restart a stopped session. **Output:** ```json { - "status": "restarting", - "message": "Successfully restarted session 'foo' in project 'bar'" + "created": true, + "session": "compiled-abc12", + "project": "my-workspace", + "message": "Session 'compiled-abc12' created in project 'my-workspace'" +} +``` + +**Dry-Run Output:** +```json +{ + "dry_run": true, + "success": true, + "message": "Would create session with custom prompt", + "manifest": { + "initialPrompt": "...", + "interactive": false, + "llmConfig": {"model": "claude-sonnet-4"}, + "timeout": 900 + }, + "project": "my-workspace" } ``` **Behavior:** -- Get current session status -- If dry_run: Return current status and preview -- If not dry_run: Patch session with `{"spec": {"stopped": false}}` -- Command: `oc patch agenticsession -n --type=merge -p ''` +- Validates project name (DNS-1123 format) +- If dry_run: Returns the manifest without calling the API +- Calls `POST /v1/sessions` on the public-api gateway --- -### acp_bulk_delete_sessions +### acp_delete_session -Delete multiple sessions in one operation. +Delete a session with optional dry-run. **Input Schema:** ```json { - "project": "string (required)", - "sessions": "array[string] (required)", + "project": "string (optional, uses default if not provided)", + "session": "string (required)", "dry_run": "boolean (optional, default: false)" } ``` @@ -174,97 +160,69 @@ Delete multiple sessions in one operation. **Output:** ```json { - "deleted": ["session-1", "session-2"], - "failed": [ - {"session": "session-3", "error": "not found"} - ], - "dry_run": false + "deleted": true, + "message": "Successfully deleted session 'foo' from project 'bar'" } ``` **Dry-Run Output:** ```json { - "deleted": [], - "failed": [], "dry_run": true, - "dry_run_info": { - "would_execute": [ - {"session": "session-1", "info": {...}}, - {"session": "session-2", "info": {...}} - ], - "skipped": [ - {"session": "session-3", "reason": "Session 'session-3' not found"} - ] + "success": true, + "message": "Would delete session 'foo' in project 'bar'", + "session_info": { + "name": "foo", + "status": "running", + "created": "2026-01-29T10:00:00Z" } } ``` **Behavior:** -- Use `_bulk_operation()` helper -- Call `delete_session()` for each session -- Collect successes and failures -- Return aggregated results +- If dry_run: Calls `GET /v1/sessions/{session}` to verify existence +- If not dry_run: Calls `DELETE /v1/sessions/{session}` --- -### acp_bulk_stop_sessions +## Bulk Operations -Stop multiple running sessions. - -**Input Schema:** -```json -{ - "project": "string (required)", - "sessions": "array[string] (required)", - "dry_run": "boolean (optional, default: false)" -} -``` - -**Output:** Same structure as bulk_delete - -**Behavior:** -- Use `_bulk_operation()` helper -- For each session: Patch with `{"spec": {"stopped": true}}` -- In dry-run: Check status, only count as "would_execute" if status is "running" - ---- - -### acp_get_session_logs +### acp_bulk_delete_sessions -Retrieve container logs for a session. +Delete multiple sessions (max 3). Requires `confirm=true` for non-dry-run execution. **Input Schema:** ```json { - "project": "string (required)", - "session": "string (required)", - "container": "string (optional)", - "tail_lines": "integer (optional, max: 10000)" + "project": "string (optional, uses default if not provided)", + "sessions": "array[string] (required) - max 3 items", + "confirm": "boolean (optional, default: false) - required for destructive operations", + "dry_run": "boolean (optional, default: false)" } ``` **Output:** ```json { - "logs": "log line 1\nlog line 2\n...", - "container": "default", - "lines": 150 + "deleted": ["session-1", "session-2"], + "failed": [ + {"session": "session-3", "error": "not found"} + ] } ``` **Behavior:** -- Find pod with label selector: `agenticsession=` -- Get pod name from first matching pod -- Execute: `oc logs -n [-c ] [--tail ]` -- Default tail: 1000 lines (if not specified) -- Max tail: 10,000 lines (security limit) +- Validates bulk limit (max 3 sessions) +- Server enforces `confirm=true` for non-dry-run execution +- Iterates through sessions, calling `delete_session()` for each --- +## Cluster Management + ### acp_list_clusters -List configured cluster aliases. +List configured cluster aliases from clusters.yaml. **Input Schema:** ```json @@ -276,27 +234,27 @@ List configured cluster aliases. { "clusters": [ { - "name": "dev", - "server": "https://api.dev.example.com:6443", - "description": "Development cluster", + "name": "vteam-stage", + "server": "https://public-api-ambient.apps.vteam-stage.example.com", + "description": "Staging cluster", "default_project": "my-workspace", "is_default": true } ], - "default_cluster": "dev" + "default_cluster": "vteam-stage" } ``` **Behavior:** -- Read from config file -- Mark default cluster with is_default: true -- Synchronous operation (no oc command) +- Reads from clusters.yaml configuration +- Marks default cluster with `is_default: true` +- Synchronous operation (no API call) --- ### acp_whoami -Get current user and cluster information. +Get current configuration and authentication status. **Input Schema:** ```json @@ -306,348 +264,18 @@ Get current user and cluster information. **Output:** ```json { - "user": "john.doe", - "cluster": "dev", - "server": "https://api.dev.example.com:6443", + "cluster": "vteam-stage", + "server": "https://public-api-ambient.apps.vteam-stage.example.com", "project": "my-workspace", - "token_expires": null, "token_valid": true, "authenticated": true } ``` **Behavior:** -- Execute: `oc whoami` → get user -- Execute: `oc whoami --show-server` → get server URL -- Execute: `oc project -q` → get current project -- Execute: `oc whoami -t` → verify token validity -- Match server URL against config to find cluster name -- Return aggregated info - ---- - -## Priority 2 Tools (Power Users) - -### acp_clone_session - -Clone an existing session with its configuration. - -**Input Schema:** -```json -{ - "project": "string (required)", - "source_session": "string (required)", - "new_display_name": "string (required)", - "dry_run": "boolean (optional, default: false)" -} -``` - -**Output:** -```json -{ - "cloned": true, - "session": "session-1-clone-abc123", - "message": "Successfully cloned session 'session-1' to 'session-1-clone-abc123'" -} -``` - -**Behavior:** -- Get source session spec: `oc get agenticsession -n -o json` -- Copy spec, update displayName, set stopped: false -- Create manifest with generateName: `-clone-` -- Write manifest to secure temp file (0600 permissions, random prefix) -- Execute: `oc create -f -o json` -- Parse created session name from response -- Clean up temp file in finally block - ---- - -### acp_get_session_transcript - -Retrieve conversation history in JSON or Markdown format. - -**Input Schema:** -```json -{ - "project": "string (required)", - "session": "string (required)", - "format": "string (optional, default: 'json') - json|markdown" -} -``` - -**Output (JSON format):** -```json -{ - "transcript": [ - {"role": "user", "content": "...", "timestamp": "..."}, - {"role": "assistant", "content": "...", "timestamp": "..."} - ], - "format": "json", - "message_count": 2 -} -``` - -**Output (Markdown format):** -```json -{ - "transcript": "# Session Transcript: session-1\n\n## Message 1 - user\n...", - "format": "markdown", - "message_count": 2 -} -``` - -**Behavior:** -- Get session: `oc get agenticsession -n -o json` -- Extract: `status.transcript` (array of message objects) -- If format=markdown: Convert to markdown with headers, timestamps -- If format=json: Return raw array - ---- - -### acp_update_session - -Update session metadata (display name, timeout). - -**Input Schema:** -```json -{ - "project": "string (required)", - "session": "string (required)", - "display_name": "string (optional)", - "timeout": "integer (optional) - seconds", - "dry_run": "boolean (optional, default: false)" -} -``` - -**Output:** -```json -{ - "updated": true, - "session": {...}, - "message": "Successfully updated session 'foo'" -} -``` - -**Behavior:** -- Get current session -- If dry_run: Show current values and what would change -- Build patch: `{"spec": {"displayName": "...", "timeout": 3600}}` -- Execute: `oc patch agenticsession -n --type=merge -p '' -o json` -- Return updated session data - ---- - -### acp_export_session - -Export session configuration and transcript for archival. - -**Input Schema:** -```json -{ - "project": "string (required)", - "session": "string (required)" -} -``` - -**Output:** -```json -{ - "exported": true, - "data": { - "config": { - "name": "session-1", - "displayName": "My Session", - "repos": ["https://github.com/..."], - "workflow": "bugfix", - "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.3} - }, - "transcript": [...], - "metadata": { - "created": "2026-01-29T10:00:00Z", - "status": "stopped", - "stoppedAt": "2026-01-29T11:00:00Z", - "messageCount": 42 - } - }, - "message": "Successfully exported session 'session-1'" -} -``` - -**Behavior:** -- Get session data -- Get transcript via `get_session_transcript()` -- Combine into export structure -- Return complete export - ---- - -## Priority 3 Tools (Advanced) - -### acp_get_session_metrics - -Get usage statistics (tokens, duration, tool calls). - -**Input Schema:** -```json -{ - "project": "string (required)", - "session": "string (required)" -} -``` - -**Output:** -```json -{ - "token_count": 15420, - "duration_seconds": 3600, - "tool_calls": { - "Read": 42, - "Write": 15, - "Bash": 8 - }, - "message_count": 84, - "status": "stopped" -} -``` - -**Behavior:** -- Get session and transcript -- Calculate approximate token count: `sum(len(msg.content.split()) * 1.3)` -- Extract tool calls from transcript -- Calculate duration: `stopped_at - created_at` -- Return aggregated metrics - ---- - -### acp_list_workflows - -Discover available workflows from a Git repository. - -**Input Schema:** -```json -{ - "repo_url": "string (optional, default: 'https://github.com/ambient-code/ootb-ambient-workflows')" -} -``` - -**Output:** -```json -{ - "workflows": [ - { - "name": "bugfix", - "path": "bugfix.yaml", - "description": "Fix bugs and issues" - } - ], - "repo_url": "https://github.com/...", - "count": 1 -} -``` - -**Behavior:** -- Validate URL (must be https:// or http://, no special characters) -- Create secure temp directory with random prefix -- Execute: `git clone --depth 1 -- ` with 60s timeout -- Find all `workflows/**/*.yaml` files (max 100) -- Validate files are within workflows directory (prevent traversal) -- Parse each YAML to extract description -- Clean up temp directory in finally block - ---- - -### acp_create_session_from_template - -Create session from predefined template. - -**Input Schema:** -```json -{ - "project": "string (required)", - "template": "string (required) - triage|bugfix|feature|exploration", - "display_name": "string (required)", - "repos": "array[string] (optional)", - "dry_run": "boolean (optional, default: false)" -} -``` - -**Templates:** -```json -{ - "triage": { - "workflow": "triage", - "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.7}, - "description": "Triage and analyze issues" - }, - "bugfix": { - "workflow": "bugfix", - "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.3}, - "description": "Fix bugs and issues" - }, - "feature": { - "workflow": "feature-development", - "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.5}, - "description": "Develop new features" - }, - "exploration": { - "workflow": "codebase-exploration", - "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.8}, - "description": "Explore codebase" - } -} -``` - -**Output:** -```json -{ - "created": true, - "session": "bugfix-abc123", - "message": "Successfully created session 'bugfix-abc123' from template 'bugfix'" -} -``` - -**Behavior:** -- Validate template exists -- If dry_run: Show template config -- Create manifest with template's workflow and llmConfig -- Write to secure temp file -- Execute: `oc create -f -o json` -- Clean up temp file - ---- - -## Authentication Tools - -### acp_login - -Authenticate to OpenShift cluster. - -**Input Schema:** -```json -{ - "cluster": "string (required) - alias or server URL", - "web": "boolean (optional, default: true)", - "token": "string (optional)" -} -``` - -**Output:** -```json -{ - "authenticated": true, - "user": "john.doe", - "cluster": "dev", - "server": "https://api.dev.example.com:6443", - "message": "Successfully logged in to dev" -} -``` - -**Behavior:** -- Resolve cluster name to server URL from config -- If token provided: `oc login --token --server ` -- If web=true: `oc login --web --server ` (opens browser) -- After login: Call `whoami()` to get user info -- Return login status +- Reads current cluster configuration +- Checks if Bearer token is configured +- Returns cluster, server, project, and authentication status --- @@ -658,7 +286,7 @@ Switch to a different cluster context. **Input Schema:** ```json { - "cluster": "string (required) - cluster alias" + "cluster": "string (required) - cluster alias name" } ``` @@ -666,215 +294,74 @@ Switch to a different cluster context. ```json { "switched": true, - "previous": "dev", - "current": "prod", - "user": "john.doe", - "message": "Switched from dev to prod" + "previous": "vteam-stage", + "current": "vteam-prod", + "message": "Switched from vteam-stage to vteam-prod" } ``` **Behavior:** -- Verify cluster exists in config -- Get current cluster via `whoami()` -- Execute: `oc login --server ` (assumes already authenticated) -- Get new user info via `whoami()` -- Return switch status +- Verifies cluster exists in configuration +- Updates the active cluster context --- -### acp_add_cluster - -Add cluster to configuration file. +## Error Handling -**Input Schema:** -```json -{ - "name": "string (required)", - "server": "string (required) - https://...", - "description": "string (optional)", - "default_project": "string (optional)", - "set_default": "boolean (optional, default: false)" -} +**Validation errors:** ``` - -**Output:** -```json -{ - "added": true, - "cluster": { - "name": "prod", - "server": "https://api.prod.example.com:6443", - "description": "Production cluster", - "default_project": "my-workspace", - "is_default": false - }, - "message": "Successfully added cluster 'prod'" -} +Validation Error: Field 'session' contains invalid characters ``` -**Behavior:** -- Validate inputs (name format, server URL) -- Add to config dict -- Write config to YAML file -- Set file permissions to 0600 -- Return success status - ---- - -## Security Requirements Summary - -### Input Validation - -**Resource Name Validation Pattern:** `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` -- Max Length: 253 characters -- Applies to: project, session, container, cluster names, default_project - -**URL Validation:** -- Must start with `https://` or `http://` -- No special characters: `;`, `|`, `&`, `$`, `` ` ``, `\n`, `\r`, space - -**Label Selector Validation Pattern:** `^[a-zA-Z0-9=,_-]+$` - -### Command Injection Prevention - -- Always use argument arrays (never shell=True) -- Validate all arguments before execution -- Use `--` separator for git commands - -### Resource Exhaustion Protection - -- Default timeout: 300 seconds (5 minutes) -- Git clone timeout: 60 seconds -- Max log lines: 10,000 per request -- Default log lines: 1,000 (if not specified) -- Max workflow files: 100 per repository - -### File System Security - -- Temporary files must use secure creation with 0600 permissions -- Configuration files must be within user's home directory -- Configuration files must have 0600 permissions -- Path traversal prevention using `Path.resolve()` - -### Resource Type Whitelist - -Only allow these Kubernetes resource types: -- `agenticsession` -- `pods` -- `event` - ---- - -## Error Handling - -### Exception Types - -**ValueError:** -- Input validation failures -- Configuration errors -- Invalid parameters -- Log as WARNING - -**asyncio.TimeoutError:** -- Command timeouts -- Process exceeded time limit -- Log as ERROR - -**Exception:** -- Unexpected errors -- System failures -- Log as ERROR with full stack trace - -### Error Response Format - -**From Client Methods:** -```json -{ - "success": false, - "error": "Detailed error message", - "message": "User-friendly error message" -} +**Timeout errors:** +``` +Timeout Error: Request timed out: /v1/sessions ``` -**Generic Error Responses:** +**API errors:** ``` -Validation Error: Field 'session' contains invalid characters -Timeout Error: Command timed out after 300s -Error: Unexpected system error +Error: HTTP 404: session not found ``` --- -## Configuration Management +## Configuration **Config File Location:** `~/.config/acp/clusters.yaml` **Format:** ```yaml clusters: - dev: - server: https://api.dev.example.com:6443 - description: Development cluster + vteam-stage: + server: https://public-api-ambient.apps.vteam-stage.example.com + token: your-bearer-token-here + description: Staging cluster default_project: my-workspace - prod: - server: https://api.prod.example.com:6443 - description: Production cluster - default_project: prod-workspace - -default_cluster: dev +default_cluster: vteam-stage ``` -**Permissions:** 0600 (read/write owner only) - -**Environment Variable Override:** `ACP_CLUSTER_CONFIG` +**Environment Variables:** +- `ACP_CLUSTER_CONFIG`: Override config file path +- `ACP_TOKEN`: Override Bearer token --- ## Tool Inventory Summary -**Total: 19 Tools** - -| Priority | Category | Count | Tools | -|----------|----------|-------|-------| -| P0 | Critical | 2 | list_sessions, delete_session | -| P1 | Important | 6 | restart, bulk_delete, bulk_stop, logs, clusters, whoami | -| P2 | Power Users | 4 | clone, transcript, update, export | -| P3 | Advanced | 3 | metrics, workflows, templates | -| Auth | Authentication | 4 | login, switch_cluster, add_cluster, enhanced whoami | +**Total: 8 Tools** ---- - -## Implementation Notes - -### Subprocess Execution +| Category | Count | Tools | +|----------|-------|-------| +| Session Management | 4 | list_sessions, get_session, create_session, delete_session | +| Bulk Operations | 1 | bulk_delete_sessions | +| Cluster Management | 3 | list_clusters, whoami, switch_cluster | -All subprocess operations must: -1. Use `asyncio.create_subprocess_exec()` (not shell=True) -2. Have timeouts enforced with `asyncio.wait_for()` -3. Kill processes on timeout -4. Capture stdout/stderr +See [issue #27](https://github.com/ambient-code/mcp/issues/27) for 21 planned additional tools. -### Temporary Files - -Create with secure parameters: -```python -fd, filepath = tempfile.mkstemp( - suffix='.yaml', - prefix=f'acp-operation-{secrets.token_hex(8)}-' -) -``` - -Always clean up in finally block with try/except. - -### Filtering and Sorting - -- Build filter predicates as callables -- Use single-pass filtering: `all(f(s) for f in filters)` -- Sort results only if `sort_by` specified -- Apply limit after filtering and sorting +--- -### MCP Protocol +## MCP Protocol - Transport: stdio - Protocol Version: MCP 1.0.0+ diff --git a/CLAUDE.md b/CLAUDE.md index bba10a6..068b676 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,19 +84,19 @@ uv run python -m mcp_acp.server ### Three-Layer Design **1. MCP Server Layer (`server.py`)** -- Exposes 27 MCP tools via stdio protocol -- Schema-driven tool definitions using `SCHEMA_FRAGMENTS` -- Dispatch table maps tool names to (handler, formatter) pairs +- Exposes 8 MCP tools via stdio protocol +- Inline JSON Schema definitions per tool +- if/elif dispatch in `call_tool()` maps tool names to handlers - Server-layer confirmation enforcement for destructive bulk operations **2. Client Layer (`client.py`)** -- `ACPClient` wraps OpenShift CLI (`oc`) operations -- All interactions with Kubernetes happen via subprocess execution -- Input validation, bulk safety limits, label management +- `ACPClient` communicates with the public-api gateway via `httpx` +- All interactions happen via HTTP REST calls with Bearer token auth +- Input validation (DNS-1123), bulk safety limits - Async I/O throughout (all operations are `async def`) **3. Formatting Layer (`formatters.py`)** -- Converts raw responses to user-friendly text +- Converts raw API responses to user-friendly text - Handles dry-run output, error states, bulk results - Format functions: `format_result()`, `format_bulk_result()`, `format_sessions_list()`, etc. @@ -106,10 +106,10 @@ uv run python -m mcp_acp.server MCP Client (Claude Desktop/CLI) ↓ MCP stdio protocol MCP Server (list_tools, call_tool) - ↓ Dispatch table lookup + ↓ if/elif dispatch ACPClient method (e.g., delete_session) - ↓ Input validation + safety checks -OpenShift CLI (oc delete agenticsession ...) + ↓ httpx REST call with Bearer token +Public API Gateway ↓ Kubernetes API ACP AgenticSession Resource ``` @@ -118,56 +118,14 @@ ACP AgenticSession Resource ## Key Architectural Patterns -### Schema Fragment Reuse - -Tools share common parameter schemas via `SCHEMA_FRAGMENTS` in `server.py`: - -```python -SCHEMA_FRAGMENTS = { - "project": {...}, - "session": {...}, - "dry_run": {...}, - "labels_dict": {...}, - # ... etc -} - -# Build tool schema -Tool( - name="acp_label_resource", - inputSchema=create_tool_schema( - properties={"resource_type": "resource_type", "labels": "labels_dict"}, - required=["resource_type", "labels"] - ) -) -``` - -### Dispatch Table Pattern - -`create_dispatch_table()` maps tool names to handler/formatter pairs: - -```python -{ - "acp_delete_session": ( - client.delete_session, # Handler - format_result, # Formatter - ), - "acp_bulk_delete_sessions": ( - lambda **args: _check_confirmation_then_execute(...), # Wrapper for safety - lambda r: format_bulk_result(r, "delete"), - ), -} -``` - ### Confirmation Enforcement (Server Layer) Destructive bulk operations require `confirm=true`: ```python -async def _check_confirmation_then_execute(fn, args, operation): - """Enforce confirmation at server layer.""" - if not args.get('dry_run') and not args.get('confirm'): - raise ValueError(f"Bulk {operation} requires explicit confirmation.") - return await fn(**args) +# In call_tool(): +if not arguments.get("dry_run") and not arguments.get("confirm"): + raise ValueError("Bulk delete requires confirm=true. Use dry_run=true to preview first.") ``` ### Bulk Operation Safety (Client Layer) @@ -175,47 +133,23 @@ async def _check_confirmation_then_execute(fn, args, operation): All bulk operations enforce 3-item max: ```python -def _validate_bulk_operation(self, items: List[str], operation_name: str): +def _validate_bulk_operation(self, items: list[str], operation_name: str): if len(items) > self.MAX_BULK_ITEMS: # MAX_BULK_ITEMS = 3 raise ValueError(f"Bulk {operation_name} limited to 3 items for safety.") ``` ---- - -## Label Management System - -### Label Format - -All user labels are prefixed: `acp.ambient-code.ai/label-{key}={value}` - -### Label Operations - -```python -# Individual operations -await client.label_resource("agenticsession", "session-1", "project", {"env": "dev"}) -await client.unlabel_resource("agenticsession", "session-1", "project", ["env"]) - -# Bulk operations (max 3 items) -await client.bulk_label_resources("agenticsession", ["s1", "s2"], "project", {"team": "api"}) - -# List by label -await client.list_sessions_by_user_labels("project", labels={"env": "dev"}) - -# Bulk operations by label (max 3 matched sessions) -await client.bulk_delete_sessions_by_label("project", labels={"cleanup": "true"}) -``` - -### Early Validation Pattern +### HTTP Request Pattern -Label-based bulk operations validate count BEFORE processing: +All API calls go through `_request()`: ```python -session_names = [s["metadata"]["name"] for s in sessions] -if len(session_names) > self.MAX_BULK_ITEMS: - raise ValueError( - f"Label selector matches {len(session_names)} sessions. " - f"Max {self.MAX_BULK_ITEMS} allowed. Refine your labels." - ) +async def _request(self, method, path, project, cluster_name=None, json_data=None, params=None): + """Make an HTTP request to the public API.""" + cluster_config = self._get_cluster_config(cluster_name) + token = self._get_token(cluster_config) + url = f"{cluster_config['server']}{path}" + headers = {"Authorization": f"Bearer {token}", "X-Ambient-Project": project} + # ... httpx request with error handling ``` --- @@ -231,35 +165,24 @@ def _validate_input(self, value: str, field_name: str): raise ValueError(f"{field_name} contains invalid characters") ``` -**Label selector validation:** -```python -if selector and not re.match(r"^[a-zA-Z0-9=,_.\-/]+$", selector): - raise ValueError(f"Invalid label selector format: {selector}") -``` - -### Command Injection Prevention +### HTTP Client Security -```python -# Always use asyncio.create_subprocess_exec (NOT shell=True) -process = await asyncio.create_subprocess_exec( - "oc", "delete", "agenticsession", name, "-n", project, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, -) +- All API calls use Bearer token authentication +- TLS required (server URLs must start with `https://` or `http://`) +- Direct Kubernetes API URLs (port 6443) are rejected at config validation +- Tokens sourced from `clusters.yaml` or `ACP_TOKEN` environment variable +- Sensitive data (tokens, passwords) filtered from logs -# Validate arguments before execution -for arg in args: - if any(char in arg for char in [';', '|', '&', '$', '`', '\n', '\r']): - raise ValueError(f"Argument contains suspicious characters: {arg}") -``` - -### Resource Type Whitelist +### Gateway URL Enforcement ```python -ALLOWED_RESOURCE_TYPES = {"agenticsession", "pods", "event"} - -if resource_type not in self.ALLOWED_RESOURCE_TYPES: - raise ValueError(f"Resource type '{resource_type}' not allowed") +@field_validator("server") +def validate_server_url(cls, v: str) -> str: + if not v.startswith(("https://", "http://")): + raise ValueError("Server URL must start with https:// or http://") + if ":6443" in v: + raise ValueError("Direct Kubernetes API URLs (port 6443) are not supported.") + return v.rstrip("/") ``` --- @@ -279,26 +202,29 @@ class TestBulkSafety: with pytest.raises(ValueError, match="limited to 3 items"): client._validate_bulk_operation(["s1", "s2", "s3", "s4"], "delete") -class TestLabelOperations: - """Tests for label operations.""" +class TestHTTPRequests: + """Tests for HTTP request handling.""" @pytest.mark.asyncio - async def test_label_resource_success(self, client): - """Should label resource successfully.""" - with patch.object(client, "_run_oc_command", return_value=MagicMock(returncode=0)): - result = await client.label_resource(...) - assert result["labeled"] is True + async def test_list_sessions(self, client): + """Should list sessions via HTTP.""" + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_http_client + result = await client.list_sessions("test-project") + assert result["total"] == 1 ``` ### What to Test -✅ **DO test:** +**DO test:** - Happy path (basic success case) - Critical validation (input validation, safety limits) - Error conditions users will hit - Bulk operation limits -❌ **DON'T test:** +**DON'T test:** - Every possible edge case - Implementation details - Kubernetes API behavior @@ -307,11 +233,14 @@ class TestLabelOperations: ### Mocking Strategy ```python -# ✅ GOOD: Mock external dependencies -with patch.object(client, "_run_oc_command", return_value=mock_response): +# GOOD: Mock the HTTP client +with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_http_client result = await client.some_method() -# ❌ BAD: Don't mock the method you're testing +# BAD: Don't mock the method you're testing with patch.object(client, "some_method"): result = await client.some_method() ``` @@ -327,7 +256,8 @@ with patch.object(client, "some_method"): ```yaml clusters: vteam-stage: - server: https://api.vteam-stage.example.com:443 + server: https://public-api-ambient.apps.vteam-stage.example.com + token: your-bearer-token-here description: "V-Team Staging Environment" default_project: my-workspace @@ -342,10 +272,11 @@ Uses Pydantic Settings for configuration: class Settings(BaseSettings): config_path: Path = Path.home() / ".config" / "acp" / "clusters.yaml" log_level: str = "INFO" - max_sessions: int = 100 - class Config: - env_prefix = "ACP_" # Environment variables: ACP_LOG_LEVEL, etc. + model_config = SettingsConfigDict( + env_prefix="MCP_ACP_", + case_sensitive=False, + ) ``` --- @@ -356,9 +287,9 @@ class Settings(BaseSettings): src/mcp_acp/ ├── __init__.py # Package initialization ├── settings.py # Pydantic settings and config loading -├── client.py # ACPClient - OpenShift CLI wrapper (600+ lines) -├── server.py # MCP server - tool definitions and dispatch (800+ lines) -└── formatters.py # Output formatting functions (400+ lines) +├── client.py # ACPClient - httpx REST client +├── server.py # MCP server - tool definitions and dispatch +└── formatters.py # Output formatting functions tests/ ├── test_client.py # Client unit tests @@ -377,70 +308,52 @@ utils/ 1. **Add client method** in `client.py`: ```python -async def new_operation(self, project: str, param: str) -> Dict[str, Any]: +async def new_operation(self, project: str, param: str) -> dict[str, Any]: """Docstring.""" - # Validation self._validate_input(param, "param") - # Execute - result = await self._run_oc_command([...]) - return {"success": True, ...} + return await self._request("GET", f"/v1/resource/{param}", project) ``` -2. **Add schema fragment** in `server.py` (if needed): -```python -SCHEMA_FRAGMENTS["new_param"] = { - "type": "string", - "description": "New parameter description", -} -``` - -3. **Add tool definition** in `list_tools()`: +2. **Add tool definition** in `list_tools()` in `server.py`: ```python Tool( name="acp_new_operation", description="Description of what it does", - inputSchema=create_tool_schema( - properties={"project": "project", "param": "new_param"}, - required=["param"] - ), + inputSchema={ + "type": "object", + "properties": { + "project": {"type": "string", "description": "Project/namespace"}, + "param": {"type": "string", "description": "Parameter"}, + }, + "required": ["param"], + }, ) ``` -4. **Add dispatch entry** in `create_dispatch_table()`: +3. **Add dispatch branch** in `call_tool()` in `server.py`: ```python -"acp_new_operation": ( - client.new_operation, - format_result, -), +elif name == "acp_new_operation": + result = await client.new_operation( + project=arguments.get("project", ""), + param=arguments["param"], + ) + text = format_result(result) ``` -5. **Write unit tests** in `tests/test_client.py`: +4. **Write unit tests** in `tests/test_client.py`: ```python class TestNewOperation: @pytest.mark.asyncio async def test_success(self, client): - with patch.object(client, "_run_oc_command", ...): - result = await client.new_operation(...) - assert result["success"] is True -``` - -### Adding Bulk Safety to New Operations - -1. Call `_validate_bulk_operation()` early: -```python -async def bulk_new_operation(self, items: List[str], ...): - self._validate_bulk_operation(items, "operation_name") - # ... rest of implementation -``` - -2. Add confirmation wrapper in dispatch table: -```python -"acp_bulk_new_operation": ( - lambda **args: _check_confirmation_then_execute( - client.bulk_new_operation, args, "operation_name" - ), - format_bulk_result, -), + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": "ok"} + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_http_client + result = await client.new_operation("test-project", "param-value") + assert result["result"] == "ok" ``` --- @@ -450,25 +363,13 @@ async def bulk_new_operation(self, items: List[str], ...): ### Enable Verbose Logging ```bash -# Set log level via environment variable -export ACP_LOG_LEVEL=DEBUG +export MCP_ACP_LOG_LEVEL=DEBUG python -m mcp_acp.server ``` -### Test Individual Client Methods - -```python -from mcp_acp.client import ACPClient - -client = ACPClient() -result = await client.list_sessions(project="my-workspace") -print(result) -``` - ### Inspect MCP Tool Schemas ```bash -# List available tools echo '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' | python -m mcp_acp.server ``` @@ -479,11 +380,8 @@ echo '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' | python -m mcp_acp.s From `client.py`: ```python -ALLOWED_RESOURCE_TYPES = {"agenticsession", "pods", "event"} MAX_BULK_ITEMS = 3 -LABEL_PREFIX = "acp.ambient-code.ai/label-" -MAX_COMMAND_TIMEOUT = 120 # seconds -MAX_LOG_LINES = 10000 +DEFAULT_TIMEOUT = 30.0 # seconds (httpx request timeout) ``` --- @@ -493,28 +391,31 @@ MAX_LOG_LINES = 10000 **Core:** - `mcp>=1.0.0` - MCP protocol SDK - `pydantic>=2.0.0` - Settings and validation +- `pydantic-settings>=2.0.0` - Environment-based settings - `structlog>=25.0.0` - Structured logging +- `httpx>=0.27.0` - HTTP client for public-api gateway - `pyyaml>=6.0` - Config file parsing **Development:** - `pytest>=7.0.0` - Testing framework - `pytest-asyncio>=0.21.0` - Async test support - `pytest-cov>=4.0.0` - Coverage reporting -- `ruff>=0.12.0` - Code formatting and linting (replaces black, isort, flake8) +- `ruff>=0.12.0` - Code formatting and linting - `mypy>=1.0.0` - Type checking **Runtime Requirement:** -- OpenShift CLI (`oc`) must be installed and in PATH -- Authenticated session via `oc login` +- Bearer token configured in `clusters.yaml` or `ACP_TOKEN` environment variable +- Network access to the public-api gateway --- ## Documentation -- **[README.md](README.md)** - Project overview and quick start -- **[API_REFERENCE.md](API_REFERENCE.md)** - Complete tool specifications +- **[README.md](README.md)** - Project overview, quick start, and usage guide +- **[API_REFERENCE.md](API_REFERENCE.md)** - Complete tool specifications (8 tools) - **[SECURITY.md](SECURITY.md)** - Security features and threat model -- **[QUICKSTART.md](QUICKSTART.md)** - Usage examples and workflows + +See [issue #27](https://github.com/ambient-code/mcp/issues/27) for 21 planned additional tools. --- @@ -523,25 +424,23 @@ MAX_LOG_LINES = 10000 ### When Modifying Bulk Operations - Always enforce `MAX_BULK_ITEMS = 3` limit -- Add server-layer confirmation via `_check_confirmation_then_execute()` -- Include early validation for label-based operations +- Add server-layer confirmation check in `call_tool()` - Support `dry_run` parameter - Write focused unit tests -### When Adding Label Support - -- Use `LABEL_PREFIX` constant for all user labels -- Validate label keys/values (max 63 chars, alphanumeric + dash/underscore/dot) -- Build K8s label selectors with proper prefixes -- Test with label selector regex: `r"^[a-zA-Z0-9=,_.\-/]+$"` - ### When Working with Async Code - All client methods are async (`async def`) - Use `await` when calling client methods -- Mock with `AsyncMock` for async functions +- Mock httpx with `AsyncMock` for async functions - Use `@pytest.mark.asyncio` for async tests +### When Adding New Tools + +- See [issue #27](https://github.com/ambient-code/mcp/issues/27) for the backlog of planned tools +- Follow the 4-step pattern: client method -> tool definition -> dispatch branch -> tests +- All API calls go through `_request()` method + ### Code Quality Standards - **NO line length enforcement** (ignore E501) diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index 2987dc1..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -1,509 +0,0 @@ -# MCP ACP Server - Quick Start Guide - -Get up and running with MCP ACP Server in 10 minutes. - ---- - -## Prerequisites - -- **Python 3.10 or higher** -- **OpenShift CLI (`oc`)** - Will be installed automatically if missing -- **Access to an OpenShift cluster** with ACP (Ambient Code Platform) -- **Claude Desktop** or another MCP-compatible client - ---- - -## Installation - -### macOS - -```bash -# Run the installation script -chmod +x install-macos.sh -./install-macos.sh - -# Restart your shell -exec $SHELL -``` - -### Linux - -```bash -# Run the installation script -chmod +x install-linux.sh -./install-linux.sh - -# Restart your shell -exec $SHELL -``` - -### Manual Installation - -```bash -# Install from PyPI (when published) -pip install mcp-acp - -# Or install from wheel -pip install dist/mcp_acp-*.whl - -# Or install from source -pip install . -``` - ---- - -## Configuration - -### 1. Create Cluster Configuration - -Edit `~/.config/acp/clusters.yaml` with your actual cluster details: - -```yaml -clusters: - my-cluster: - server: https://api.your-cluster.example.com:6443 - description: "My OpenShift Cluster" - default_project: my-workspace - -default_cluster: my-cluster -``` - -**Important**: Replace with your actual: - -- Cluster API server URL -- Default project/namespace name - -### 2. Authenticate to OpenShift - -```bash -# Option 1: Token authentication (recommended) -oc login --server=https://api.your-cluster.example.com:6443 \ - --token=YOUR_TOKEN_HERE - -# Option 2: Username/password -oc login --server=https://api.your-cluster.example.com:6443 \ - --username=your-username - -# Option 3: Web authentication -oc login --web -``` - -> **Note**: Direct OpenShift CLI authentication is required for testing until the frontend API is available (tracked in PR #558). - -**Get your token**: In OpenShift web console → Click your username → Copy login command → Show token - -### 3. Verify Authentication - -```bash -# Check you're logged in -oc whoami - -# Verify you can access your project -oc project your-project-name - -# Check if ACP sessions exist -oc get agenticsession -n your-project-name -``` - ---- - -## Claude Desktop Integration - -### macOS Configuration - -Edit: `~/Library/Application Support/Claude/claude_desktop_config.json` - -```json -{ - "mcpServers": { - "acp": { - "command": "mcp-acp" - } - } -} -``` - -### Linux Configuration - -Edit: `~/.config/claude/claude_desktop_config.json` - -```json -{ - "mcpServers": { - "acp": { - "command": "mcp-acp" - } - } -} -``` - -### Restart Claude Desktop - -After editing the config, **completely quit and restart Claude Desktop** (not just close the window). - ---- - -## First Commands - -Try these in Claude Desktop to verify everything works: - -### Check Authentication - -``` -Use acp_whoami to check my authentication status -``` - -**Expected Output:** - -``` -Current Authentication Status: - -Authenticated: Yes -User: your-username -Server: https://api.your-cluster.example.com:6443 -Project: your-project-name -Token Valid: Yes -``` - -### List Sessions - -``` -List all sessions in my-workspace project -``` - -### Filter Sessions - -``` -List only running sessions in my-workspace -``` - -``` -Show me sessions older than 7 days in my-workspace -``` - -### Delete with Dry-Run (Safe!) - -``` -Delete old-session from my-workspace in dry-run mode -``` - -This shows what would be deleted without actually deleting it. - -### Get Session Logs - -``` -Get logs from debug-session in my-workspace -``` - ---- - -## Common Usage Patterns - -### Find Old Sessions to Clean Up - -``` -List stopped sessions older than 7 days in my-workspace -``` - -Then bulk delete them (with dry-run first): - -``` -Delete these sessions: session-1, session-2, session-3 from my-workspace (dry-run first) -``` - -### Debug a Failed Session - -``` -List failed sessions in my-workspace -``` - -Then get logs: - -``` -Get logs from failed-session in my-workspace, last 200 lines -``` - -### Restart Stopped Sessions - -``` -List stopped sessions in my-workspace -``` - -Then restart: - -``` -Restart my-session in my-workspace -``` - ---- - -## Troubleshooting - -### "oc: command not found" - -The installation script should have installed it. If not: - -**macOS**: - -```bash -brew install openshift-cli -``` - -**Linux**: - -```bash -curl -sL https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/openshift-client-linux.tar.gz | \ - tar -xz -C /tmp && \ - sudo mv /tmp/oc /usr/local/bin/ -``` - -### "mcp-acp: command not found" - -Add Python user bin to PATH: - -**macOS**: - -```bash -export PATH="$HOME/Library/Python/3.*/bin:$PATH" -``` - -**Linux**: - -```bash -export PATH="$HOME/.local/bin:$PATH" -``` - -Then restart your shell. - -### "error: You must be logged in to the server" - -Your token expired. Re-authenticate: - -```bash -oc login --server=https://your-cluster:6443 -``` - -### "error: the server doesn't have a resource type 'agenticsession'" - -Either: - -1. Your cluster doesn't have ACP installed -2. You're in the wrong project/namespace - -Check available projects: - -```bash -oc projects -``` - -### MCP Tools Not Showing in Claude - -1. Check Claude Desktop logs: Help → View Logs -2. Verify config file syntax is valid JSON -3. Make sure `mcp-acp` is in PATH -4. Restart Claude Desktop completely (quit, not just close) - -### "Permission denied" on clusters.yaml - -Fix permissions: - -```bash -chmod 600 ~/.config/acp/clusters.yaml -chmod 700 ~/.config/acp -``` - ---- - -## Available Tools (19 Total) - -### Core Session Management - -- `acp_list_sessions` - List/filter sessions -- `acp_delete_session` - Delete with dry-run -- `acp_restart_session` - Restart stopped sessions - -### Bulk Operations - -- `acp_bulk_delete_sessions` - Delete multiple -- `acp_bulk_stop_sessions` - Stop multiple - -### Debugging - -- `acp_get_session_logs` - Get container logs -- `acp_get_session_transcript` - Get conversation history -- `acp_get_session_metrics` - Usage statistics - -### Advanced Features - -- `acp_clone_session` - Clone configurations -- `acp_update_session` - Update metadata -- `acp_export_session` - Export session data -- `acp_create_session_from_template` - Create from template - -### Cluster Management - -- `acp_list_clusters` - List configured clusters -- `acp_whoami` - Check authentication -- `acp_login` - Web authentication -- `acp_switch_cluster` - Switch contexts -- `acp_add_cluster` - Add new cluster - -### Workflows - -- `acp_list_workflows` - Discover workflows - ---- - -## Security Notes - -- ✅ All 13 security tests passing -- ✅ Input validation on all parameters -- ✅ Command injection prevention -- ✅ Resource limits enforced -- ✅ Dry-run mode on all destructive operations -- ✅ Secure temporary file handling - -**Always use dry-run first** before destructive operations! - ---- - -## Getting Help - -### Documentation - -- **Full Usage Guide**: See `USAGE_GUIDE.md` for 40+ examples -- **API Reference**: See `API_REFERENCE.md` for all tool specs -- **Security**: See `SECURITY.md` for security features -- **Architecture**: See `ARCHITECTURE.md` for system design -- **Development**: See `DEVELOPMENT.md` for contributing - -### Common Questions - -**Q: Can I use this with multiple clusters?** -A: Yes! Add multiple clusters to `~/.config/acp/clusters.yaml` and use `acp_switch_cluster` to change between them. - -**Q: Is it safe to delete sessions?** -A: Always use dry-run mode first (`dry_run=True`) to preview what will be deleted. - -**Q: Can I use this outside of Claude Desktop?** -A: Yes! Any MCP-compatible client can use it. It communicates via stdio using the MCP protocol. - -**Q: Where are logs stored?** -A: Server logs go to stderr. Configure your MCP client to capture them. - -**Q: Can I run this in CI/CD?** -A: Yes! The Python API can be used programmatically. See `USAGE_GUIDE.md` for examples. - ---- - -## Next Steps - -### Learn More - -1. **Explore Filtering**: Try different filter combinations with `acp_list_sessions` -2. **Bulk Operations**: Clean up old sessions efficiently -3. **Workflows**: Discover available workflows with `acp_list_workflows` -4. **Metrics**: Track usage with `acp_get_session_metrics` - -### Advanced Usage - -- Read the full **USAGE_GUIDE.md** for all 40+ examples -- Check **API_REFERENCE.md** for complete tool specifications -- Review **SECURITY.md** for security best practices - -### Contributing - -See **DEVELOPMENT.md** for: - -- Development setup -- Testing guidelines -- Code quality standards -- Contribution process - ---- - -## Quick Reference Card - -| Task | Command Pattern | -|------|----------------| -| Check auth | `Use acp_whoami` | -| List all | `List sessions in PROJECT` | -| Filter status | `List running sessions in PROJECT` | -| Filter age | `List sessions older than 7d in PROJECT` | -| Delete (dry) | `Delete SESSION in PROJECT (dry-run)` | -| Delete (real) | `Delete SESSION in PROJECT` | -| Bulk delete | `Delete session-1, session-2 in PROJECT` | -| Restart | `Restart SESSION in PROJECT` | -| Get logs | `Get logs from SESSION in PROJECT` | -| List clusters | `Use acp_list_clusters` | - ---- - -## Summary - -You now have: - -- ✅ MCP ACP Server installed -- ✅ OpenShift CLI configured -- ✅ Cluster authentication set up -- ✅ Claude Desktop integrated -- ✅ Ready to manage ACP sessions! - -**Start managing your sessions with natural language through Claude!** 🚀 - -For detailed documentation, see the other guides in this package. - ---- - -## Alternative: Using with uvx (Recommended) - -**uvx** provides the fastest and easiest way to run MCP ACP Server without installation. - -### Install uv - -```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -``` - -### Configure Claude Desktop - -**macOS**: Edit `~/Library/Application Support/Claude/claude_desktop_config.json` - -**Linux**: Edit `~/.config/claude/claude_desktop_config.json` - -```json -{ - "mcpServers": { - "acp": { - "command": "uvx", - "args": ["mcp-acp"] - } - } -} -``` - -For local wheel (before PyPI publish): - -```json -{ - "mcpServers": { - "acp": { - "command": "uvx", - "args": [ - "--from", - "/full/path/to/dist/mcp_acp-*.whl", - "mcp-acp" - ] - } - } -} -``` - -### Benefits of uvx - -- ⚡ **10-100x faster** than pip -- 🔒 **Isolated** - no global Python pollution -- 🎯 **No installation** - runs directly -- 🚀 **Auto-caching** - subsequent runs are instant - -See `UVX_USAGE.md` for complete uvx documentation. diff --git a/README.md b/README.md index e3d91da..939fc2f 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,145 @@ # MCP ACP Server -A Model Context Protocol (MCP) server for managing Ambient Code Platform (ACP) sessions on OpenShift/Kubernetes clusters. +A Model Context Protocol (MCP) server for managing Ambient Code Platform (ACP) sessions via the public-api gateway. -[Based on template-agent](https://github.com/redhat-data-and-ai/template-agent) +--- + +## Table of Contents + +- [Quick Start](#quick-start) +- [Features](#features) +- [Installation](#installation) +- [Configuration](#configuration) +- [Usage](#usage) +- [Tool Reference](#tool-reference) +- [Troubleshooting](#troubleshooting) +- [Architecture](#architecture) +- [Security](#security) +- [Development](#development) +- [Roadmap](#roadmap) +- [Contributing](#contributing) +- [Status](#status) --- ## Quick Start -Get started in 5 minutes: - ```bash # Install git clone https://github.com/ambient-code/mcp -claude mcp add mcp-acp -t stdio mcp/dist/mcp_acp-*.whl +pip install dist/mcp_acp-*.whl # Configure mkdir -p ~/.config/acp cat > ~/.config/acp/clusters.yaml < **Note**: Direct OpenShift CLI authentication is required for testing until the frontend API is available (tracked in PR #558). +### Using uvx ---- +[uvx](https://docs.astral.sh/uv/) provides zero-install execution — no global Python pollution, auto-caching, and fast startup. -## Usage Examples +```bash +# Install uv (if needed) +curl -LsSf https://astral.sh/uv/install.sh | sh +``` -### List Sessions with Filtering +Claude Desktop config for uvx: +```json +{ + "mcpServers": { + "acp": { + "command": "uvx", + "args": ["mcp-acp"] + } + } +} ``` -# List only running sessions -List running sessions in my-workspace - -# List sessions older than 7 days -Show me sessions older than 7 days in my-workspace -# List sessions by label -List sessions with env=test and team=qa labels in my-workspace +For a local wheel (before PyPI publish): -# List sessions sorted by creation date -List sessions in my-workspace, sorted by creation date, limit 20 +```json +{ + "mcpServers": { + "acp": { + "command": "uvx", + "args": ["--from", "/full/path/to/dist/mcp_acp-0.1.0-py3-none-any.whl", "mcp-acp"] + } + } +} ``` -### Label Management +--- -``` -# Add labels to a session -Add env=test and team=qa labels to my-session in my-workspace +## Usage -# List sessions by label -List sessions with env=test label in my-workspace +### Examples -# Bulk delete sessions by label -Delete all sessions with env=test label in my-workspace (dry-run first) ``` +# List sessions +List my ACP sessions +Show running sessions in my-workspace +List sessions older than 7 days in my-workspace +List sessions sorted by creation date, limit 20 -### Delete Session with Dry-Run +# Session details +Get details for ACP session session-name +Show AgenticSession session-name in my-workspace -``` -# Preview what would be deleted +# Create a session +Create a new ACP session with prompt "Run all unit tests and report results" + +# Delete with dry-run (safe!) Delete test-session from my-workspace in dry-run mode # Actually delete Delete test-session from my-workspace -``` -### Bulk Operations +# Bulk delete (dry-run first) +Delete these sessions: session-1, session-2, session-3 from my-workspace (dry-run first) +# Cluster operations +Check my ACP authentication +List my ACP clusters +Switch to ACP cluster vteam-prod ``` -# Stop multiple sessions -Stop session-1, session-2, and session-3 in my-workspace -# Delete old sessions with dry-run -Delete old-session-1 and old-session-2 from my-workspace in dry-run mode -``` +### Trigger Keywords -### Get Session Logs +Include one of these keywords so your MCP client routes the request to ACP: **ACP**, **ambient**, **AgenticSession**, or use tool names directly (e.g., `acp_list_sessions`, `acp_whoami`). Without a keyword, generic phrases like "list sessions" may not trigger the server. -``` -# Get logs from runner container -Get logs from debug-session in my-workspace, runner container, last 100 lines -``` +### Quick Reference -See [QUICKSTART.md](QUICKSTART.md) for detailed examples and workflow patterns. +| Task | Command Pattern | +|------|----------------| +| Check auth | `Use acp_whoami` | +| List all | `List ACP sessions in PROJECT` | +| Filter status | `List running sessions in PROJECT` | +| Filter age | `List sessions older than 7d in PROJECT` | +| Get details | `Get details for ACP session SESSION` | +| Create | `Create ACP session with prompt "..."` | +| Delete (dry) | `Delete SESSION in PROJECT (dry-run)` | +| Delete (real) | `Delete SESSION in PROJECT` | +| Bulk delete | `Delete session-1, session-2 in PROJECT` | +| List clusters | `Use acp_list_clusters` | --- ## Tool Reference -For complete API specifications, see [API_REFERENCE.md](API_REFERENCE.md). - -### Quick Reference +For complete API specifications including input schemas, output formats, and behavior details, see [API_REFERENCE.md](API_REFERENCE.md). | Category | Tool | Description | |----------|------|-------------| -| **Session** | `acp_list_sessions` | List/filter sessions with advanced options | -| | `acp_delete_session` | Delete session with dry-run support | -| | `acp_restart_session` | Restart stopped sessions | -| | `acp_clone_session` | Clone session configuration | -| | `acp_update_session` | Update session metadata | -| **Labels** | `acp_label_resource` | Add labels to sessions | -| | `acp_unlabel_resource` | Remove labels from sessions | -| | `acp_list_sessions_by_label` | Find sessions by label | -| **Bulk Ops** | `acp_bulk_delete_sessions` | Delete multiple sessions (max 3) | -| | `acp_bulk_stop_sessions` | Stop multiple sessions (max 3) | -| | `acp_bulk_restart_sessions` | Restart multiple sessions (max 3) | -| | `acp_bulk_delete_sessions_by_label` | Delete sessions by label | -| **Debug** | `acp_get_session_logs` | Get container logs | -| | `acp_get_session_transcript` | Get conversation history | -| | `acp_get_session_metrics` | Get usage statistics | -| | `acp_export_session` | Export session data | +| **Session** | `acp_list_sessions` | List/filter sessions | +| | `acp_get_session` | Get session details | +| | `acp_create_session` | Create session with prompt | +| | `acp_delete_session` | Delete with dry-run support | +| **Bulk** | `acp_bulk_delete_sessions` | Delete multiple sessions (max 3) | | **Cluster** | `acp_list_clusters` | List configured clusters | | | `acp_whoami` | Check authentication status | -| | `acp_login` | Authenticate to cluster | | | `acp_switch_cluster` | Switch cluster context | -| | `acp_add_cluster` | Add cluster to config | -| **Workflows** | `acp_list_workflows` | Discover available workflows | -| | `acp_create_session_from_template` | Create from template | --- -## Architecture +## Troubleshooting -The server is built using: +### "No authentication token available" -- **MCP SDK**: Standard MCP protocol implementation -- **OpenShift CLI**: Underlying `oc` commands for ACP operations -- **Async I/O**: Non-blocking operations for performance -- **YAML Configuration**: Flexible cluster management +Your token is not configured. Either: -See [CLAUDE.md](CLAUDE.md#architecture-overview) for complete system design. +1. Add `token: your-token-here` to your cluster in `~/.config/acp/clusters.yaml` +2. Set the `ACP_TOKEN` environment variable ---- +### "HTTP 401: Unauthorized" -## Security +Your token is expired or invalid. Get a new token from the ACP platform administrator. -This server implements defense-in-depth security: +### "HTTP 403: Forbidden" -- **Input Validation**: DNS-1123 format validation for all resource names -- **Command Injection Prevention**: Secure subprocess execution (never shell=True) -- **Resource Exhaustion Protection**: Timeouts and limits on all operations -- **Secure Temporary Files**: Random prefixes, 0600 permissions -- **Path Traversal Prevention**: Configuration and workflow file validation -- **Resource Type Whitelist**: Only agenticsession, pods, event resources -- **Sensitive Data Filtering**: Tokens/passwords removed from logs +You don't have permission for this operation. Contact your ACP platform administrator. -See [SECURITY.md](SECURITY.md) for complete security documentation. +### "Direct Kubernetes API URLs (port 6443) are not supported" ---- +You're using a direct K8s API URL. Use the public-api gateway URL instead: -## Development +- **Wrong**: `https://api.cluster.example.com:6443` +- **Correct**: `https://public-api-ambient.apps.cluster.example.com` -### Running Tests +### "mcp-acp: command not found" -```bash -# Run all tests -pytest +Add Python user bin to PATH: -# Run with coverage -pytest --cov=src/mcp_acp --cov-report=html +- **macOS**: `export PATH="$HOME/Library/Python/3.*/bin:$PATH"` +- **Linux**: `export PATH="$HOME/.local/bin:$PATH"` -# Run security tests -pytest tests/test_security.py -v -``` +Then restart your shell. -### Code Quality +### MCP Tools Not Showing in Claude -```bash -# Format code -black src/ tests/ -ruff check src/ tests/ +1. Check Claude Desktop logs: Help → View Logs +2. Verify config file syntax is valid JSON +3. Make sure `mcp-acp` is in PATH +4. Restart Claude Desktop completely (quit, not just close) -# Type checking -mypy src/ +### "Permission denied" on clusters.yaml -# All checks -make check +```bash +chmod 600 ~/.config/acp/clusters.yaml +chmod 700 ~/.config/acp ``` -See [CLAUDE.md](CLAUDE.md#development-commands) for contributing guidelines. +--- + +## Architecture + +- **MCP SDK** — Standard MCP protocol implementation (stdio transport) +- **httpx** — Async HTTP REST client for the public-api gateway +- **Pydantic** — Settings management and input validation +- **Three-layer design** — Server (tool dispatch) → Client (HTTP + validation) → Formatters (output) + +See [CLAUDE.md](CLAUDE.md#architecture-overview) for complete system design. --- -## Documentation +## Security -- **[QUICKSTART.md](QUICKSTART.md)** - Complete usage guide with examples -- **[API_REFERENCE.md](API_REFERENCE.md)** - Full API specifications for all 27 tools -- **[CLAUDE.md](CLAUDE.md)** - System architecture and design -- **[SECURITY.md](SECURITY.md)** - Security features and best practices -- **[CLAUDE.md](CLAUDE.md)** - Development and contributing guide +- **Input Validation** — DNS-1123 format validation for all resource names +- **Gateway URL Enforcement** — Direct K8s API URLs (port 6443) rejected +- **Bearer Token Security** — Tokens filtered from logs, sourced from config or environment +- **Resource Limits** — Bulk operations limited to 3 items with confirmation + +See [SECURITY.md](SECURITY.md) for complete security documentation including threat model and best practices. --- -## Roadmap +## Development + +```bash +# One-time setup +uv venv && uv pip install -e ".[dev]" + +# Pre-commit workflow +uv run ruff format . && uv run ruff check . && uv run pytest tests/ -Current implementation provides all planned features (19 tools). Future enhancements may include: +# Run with coverage +uv run pytest tests/ --cov=src/mcp_acp --cov-report=html -- **Rate Limiting**: Per-client request limits for HTTP exposure -- **Audit Logging**: Structured audit trail and SIEM integration -- **Enhanced Authentication**: OAuth2/OIDC support, MFA -- **Network Security**: mTLS for MCP transport, certificate pinning -- **Advanced Metrics**: Cost analysis, performance tracking +# Build wheel +uvx --from build pyproject-build --installer uv +``` -See the [GitHub issue tracker](https://github.com/ambient-code/mcp/issues) for planned features and community requests. +See [CLAUDE.md](CLAUDE.md#development-commands) for contributing guidelines. --- -## Contributing +## Roadmap + +Current implementation provides 8 core tools. See [issue #27](https://github.com/ambient-code/mcp/issues/27) for 21 planned additional tools covering: -Contributions are welcome! Please: +- Session lifecycle (restart, clone, templates, update, export) +- Observability (logs, transcripts, metrics) +- Label management and advanced bulk operations +- Cluster and workflow management + +--- + +## Contributing 1. Fork the repository 2. Create a feature branch 3. Add tests for new functionality -4. Ensure all tests pass (`pytest`) -5. Ensure code quality checks pass (`make check`) +4. Ensure all tests pass (`uv run pytest tests/`) +5. Ensure code quality checks pass (`uv run ruff format . && uv run ruff check .`) 6. Submit a pull request -See [CLAUDE.md](CLAUDE.md#development-commands) for detailed guidelines. - --- -## License +## Status -MIT License - See LICENSE file for details +**Code**: Production-Ready | +**Tests**: All Passing | +**Security**: Input validation, gateway enforcement, token security | +**Tools**: 8 implemented ([21 more planned](https://github.com/ambient-code/mcp/issues/27)) --- -## Support - -For issues and feature requests, please use the [GitHub issue tracker](https://github.com/ambient-code/mcp/issues). - -For usage questions, see: +## Documentation -- [QUICKSTART.md](QUICKSTART.md) - Complete usage guide -- [API_REFERENCE.md](API_REFERENCE.md) - API specifications -- [SECURITY.md](SECURITY.md) - Security features +- **[API_REFERENCE.md](API_REFERENCE.md)** — Full API specifications for all 8 tools +- **[SECURITY.md](SECURITY.md)** — Security features, threat model, and best practices +- **[CLAUDE.md](CLAUDE.md)** — System architecture and development guide ---- +## License -## Status +MIT License — See LICENSE file for details. -**Code**: ✅ Production-Ready -**Tests**: ✅ All Passing (13/13 security tests) -**Documentation**: ✅ Complete -**Security**: ✅ Hardened with defense-in-depth -**Tools**: ✅ 27 tools fully implemented -**Features**: ✅ Label management, bulk operations, advanced filtering +## Support -**Ready for production use** 🚀 +For issues and feature requests, use the [GitHub issue tracker](https://github.com/ambient-code/mcp/issues). diff --git a/SECURITY.md b/SECURITY.md index 2af5b08..f27dec8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,19 +4,18 @@ This document outlines the security measures implemented in the MCP ACP server to ensure safe operation in production environments. -**Security Version:** 1.0.0 -**Last Updated:** 2026-01-29 +**Security Version:** 2.0.0 +**Last Updated:** 2026-02-15 --- ## Table of Contents 1. [Security Features](#security-features) -2. [Security Improvements Summary](#security-improvements-summary) -3. [Threat Model](#threat-model) -4. [Best Practices](#best-practices) -5. [Security Checklist](#security-checklist) -6. [Reporting Security Issues](#reporting-security-issues) +2. [Threat Model](#threat-model) +3. [Best Practices](#best-practices) +4. [Security Checklist](#security-checklist) +5. [Reporting Security Issues](#reporting-security-issues) --- @@ -25,93 +24,54 @@ This document outlines the security measures implemented in the MCP ACP server t ### 1. Input Validation and Sanitization **Kubernetes Resource Name Validation** -- All resource names (sessions, projects, containers) are validated against DNS-1123 subdomain format +- All resource names (sessions, projects) are validated against DNS-1123 subdomain format - Pattern: `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` - Maximum length: 253 characters -- Prevents: Path traversal, command injection, SQL injection +- Prevents: Injection attacks via malformed names **URL Validation** - Server URLs must start with `https://` or `http://` -- Repository URLs validated before git clone operations -- Prevents: SSRF attacks, malicious URL injection +- Direct Kubernetes API URLs (port 6443) are rejected +- Prevents: Direct K8s API access bypassing the gateway -**Label Selector Validation** -- Pattern: `^[a-zA-Z0-9=,_-]+$` -- Prevents: Label injection attacks +### 2. Gateway URL Enforcement -### 2. Command Injection Prevention - -**Subprocess Execution** -- All subprocess calls use `asyncio.create_subprocess_exec()` with argument arrays -- Never uses shell=True which would enable shell injection -- Arguments validated for suspicious characters: `; | & $ \` \n \r` +**Port 6443 Rejection** +- The `ClusterConfig` validator rejects any server URL containing `:6443` +- Users must connect through the public-api gateway, not directly to the Kubernetes API - Example: ```python - # Secure - uses argument array - process = await asyncio.create_subprocess_exec( - "oc", "get", resource_type, name, "-n", namespace, "-o", "json" - ) + # Rejected — direct K8s API + server: "https://api.cluster.example.com:6443" - # Never done - shell injection risk - # os.system(f"oc get {resource_type} {name}") # DANGEROUS! + # Accepted — public-api gateway + server: "https://public-api-ambient.apps.cluster.example.com" ``` -**Git Clone Security** -- Repository URLs validated before cloning -- Uses `git clone --depth 1 -- ` with explicit separator -- Timeouts prevent DoS via slow repositories -- Temporary directories use secure random names - -### 3. Resource Exhaustion Protection - -**Timeout Controls** -- Maximum command timeout: 300 seconds (5 minutes) -- Git clone timeout: 60 seconds -- All subprocess operations have timeouts via `asyncio.wait_for()` -- Timed-out processes are killed to prevent zombie processes - -**Log Line Limits** -- Maximum log lines: 10,000 per request -- Default log retrieval: 1,000 lines -- Prevents: Memory exhaustion attacks - -**Workflow File Limits** -- Maximum workflow files parsed: 100 -- Prevents: DoS via repositories with thousands of files - -**Resource Type Whitelist** -- Only allowed types: `agenticsession`, `pods`, `event` -- Prevents: Unauthorized access to other Kubernetes resources - -### 4. File System Security - -**Configuration File Protection** -- Config file must be within user's home directory -- Path traversal validation via `Path.resolve()` -- File permissions set to 0600 (owner read/write only) -- No credentials stored in config (metadata only) - -**Temporary File Security** -- Created with `tempfile.mkstemp()` for secure permissions (0600) -- Random prefixes using `secrets.token_hex(8)` prevent predictability -- Cleanup in finally blocks ensures no sensitive data left behind -- Example: - ```python - fd, path = tempfile.mkstemp( - suffix='.yaml', - prefix=f'acp-clone-{secrets.token_hex(8)}-' - ) - try: - with os.fdopen(fd, 'w') as f: - yaml.dump(manifest, f) - # Use file... - finally: - os.unlink(path) - ``` +### 3. HTTP Client Security + +**Bearer Token Authentication** +- All API calls include `Authorization: Bearer ` header +- Tokens sourced from `clusters.yaml` or `ACP_TOKEN` environment variable +- Token resolution: config file → environment variable → error + +**TLS** +- Server URLs must use `https://` (or `http://` for development) +- httpx client follows redirects securely -**Directory Traversal Prevention** -- Workflow files validated to be within expected directory -- Uses `Path.relative_to()` to ensure containment +**Request Timeouts** +- Default HTTP timeout: 30 seconds +- Prevents hung connections from blocking operations + +### 4. Bulk Operation Safety + +**Item Limits** +- Maximum 3 items per bulk operation +- Prevents accidental mass deletion + +**Confirmation Requirement** +- Destructive bulk operations require `confirm=true` +- Dry-run mode available for preview before execution ### 5. Data Protection @@ -123,10 +83,9 @@ This document outlines the security measures implemented in the MCP ACP server t if k not in ['token', 'password', 'secret']} ``` -**Configuration Validation** -- YAML config validated on load -- Type checking for all config fields -- Server URLs validated for proper format +**Configuration Security** +- `clusters.yaml` contains Bearer tokens — must be secured with 0600 permissions +- Tokens should not be committed to version control ### 6. Error Handling @@ -136,192 +95,58 @@ This document outlines the security measures implemented in the MCP ACP server t - Structured error responses for LLMs **Exception Categories** -- `ValueError`: Input validation failures (expected, logged as warnings) -- `asyncio.TimeoutError`: Timeout exceeded (logged as errors) +- `ValueError`: Input validation failures (logged as warnings) +- `TimeoutError`: HTTP request timeouts (logged as errors) - `Exception`: Unexpected errors (logged with full stack trace) -**Error Message Sanitization** -- No sensitive data in error messages -- Generic messages for authentication failures - --- -## Security Improvements Summary - -This section summarizes the comprehensive security hardening completed on 2026-01-29. - -### Critical Vulnerabilities Fixed - -#### 1. Command Injection Prevention ✅ - -**Issue:** Subprocess calls were vulnerable to shell injection attacks through malicious user input. - -**Fix:** -- Added validation for all subprocess arguments to detect shell metacharacters: `; | & $ \` \n \r` -- All subprocess calls use `asyncio.create_subprocess_exec()` with argument arrays (never shell=True) -- Git clone operations secured with `--` separator and URL validation - -**Files Modified:** -- `src/mcp_acp/client.py` (lines 114-120, 1179-1187) - -**Test Coverage:** `tests/test_security.py::TestCommandInjectionPrevention` - -#### 2. Input Validation and Sanitization ✅ - -**Issue:** User-supplied names, URLs, and other inputs were not validated, allowing injection attacks. - -**Fix:** -- Added `_validate_input()` method enforcing DNS-1123 subdomain format -- Validation pattern: `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` -- Maximum length: 253 characters (Kubernetes limit) -- URL validation for server and repository URLs -- Label selector validation - -**Files Modified:** -- `src/mcp_acp/client.py` (lines 73-90, 1160-1167) - -**Test Coverage:** `tests/test_security.py::TestInputValidation` - -#### 3. Resource Exhaustion Protection ✅ - -**Issue:** No limits on log retrieval, command execution time, or file processing could lead to DoS attacks. - -**Fix:** -- Maximum command timeout: 300 seconds (5 minutes) -- Git clone timeout: 60 seconds -- Maximum log lines: 10,000 per request -- Default log limit: 1,000 lines -- Maximum workflow files: 100 per repository -- All subprocess operations use `asyncio.wait_for()` with timeouts -- Timed-out processes are properly killed - -**Files Modified:** -- `src/mcp_acp/client.py` (lines 19-22, 133-158, 671-692, 1200-1206) - -**Test Coverage:** `tests/test_security.py::TestResourceLimits` - -#### 4. Path Traversal Prevention ✅ - -**Issue:** Config file paths and workflow files not validated, allowing access outside intended directories. - -**Fix:** -- Config file must be within user's home directory -- Path resolution with `Path.resolve()` and validation -- Workflow files validated with `Path.relative_to()` to ensure containment -- Strict Kubernetes naming prevents path traversal in resource names - -**Files Modified:** -- `src/mcp_acp/client.py` (lines 42-48, 1208-1212) - -#### 5. Temporary File Security ✅ - -**Issue:** Temporary files created with predictable names and insecure permissions. - -**Fix:** -- Use `tempfile.mkstemp()` for secure file creation (0600 permissions) -- Random prefixes using `secrets.token_hex(8)` prevent prediction -- Cleanup in `finally` blocks ensures no sensitive data leakage - -**Files Modified:** -- `src/mcp_acp/client.py` (lines 852-884, 1175, 1329-1361) - -#### 6. Configuration Validation ✅ - -**Issue:** YAML config loaded without validation, allowing malformed or malicious configs. - -**Fix:** -- Added `_validate_config()` method called on initialization -- Validates config structure (must be dict) -- Validates cluster configs (must have 'server' field) -- Validates server URLs (must be http:// or https://) -- Type checking for all config fields -- Config file permissions set to 0600 on write - -**Files Modified:** -- `src/mcp_acp/client.py` (lines 36-71, 1526-1531) - -**Test Coverage:** `tests/test_security.py::TestInputValidation::test_config_validation_*` - -#### 7. Resource Type Whitelist ✅ - -**Issue:** No restriction on which Kubernetes resource types could be accessed. - -**Fix:** -- Added `ALLOWED_RESOURCE_TYPES` whitelist: `{"agenticsession", "pods", "event"}` -- All resource operations validate against whitelist -- Prevents unauthorized access to secrets, configmaps, etc. - -**Files Modified:** -- `src/mcp_acp/client.py` (line 22, 187-189, 221-223) - -**Test Coverage:** `tests/test_security.py::TestCommandInjectionPrevention::test_resource_type_whitelist` - -### Stability and Observability Improvements - -#### 8. Enhanced Error Handling ✅ - -**Fix:** -- Specific exception types for different failure modes -- Structured error responses in dictionaries -- Error categorization in server.py with appropriate logging levels -- Better error messages for LLM consumption - -**Files Modified:** -- `src/mcp_acp/server.py` (lines 527-539) +## Threat Model -#### 9. Comprehensive Logging ✅ +### Threats Mitigated -**Fix:** -- Tool call start/completion logging with execution times -- Sensitive data filtering (tokens, passwords, secrets) from logs -- Warning logs for validation errors -- Error logs with stack traces for unexpected failures +1. **Input Injection** ✅ + - Prevented via DNS-1123 validation on all resource names + - No shell execution — all operations via HTTP REST -**Files Modified:** -- `src/mcp_acp/server.py` (lines 491-539, 112-121) +2. **Direct K8s API Access** ✅ + - Port 6443 URLs rejected at configuration validation + - Forces traffic through the public-api gateway -#### 10. Data Protection ✅ +3. **Resource Exhaustion** ✅ + - HTTP request timeouts (30 seconds) + - Bulk operation limits (max 3 items) -**Fix:** -- Argument sanitization removes `token`, `password`, `secret` from logs -- No credentials stored in config files (metadata only) -- Generic error messages for authentication failures -- Config file permissions enforced (0600) +4. **Information Disclosure** ✅ + - Sensitive data filtered from logs + - Error messages sanitized -**Files Modified:** -- `src/mcp_acp/server.py` (line 495) -- `src/mcp_acp/client.py` (lines 1529-1531) +5. **Token Compromise** ⚠️ + - Mitigation: Secure clusters.yaml with 0600 permissions + - Mitigation: Use short-lived tokens, rotate regularly + - Mitigation: Use ACP_TOKEN env var instead of file storage where possible -### Impact Summary +6. **Man-in-the-Middle** ✅ + - Mitigation: TLS required for server URLs + - Mitigation: httpx validates certificates by default -**Before Security Hardening:** -- ❌ Vulnerable to command injection -- ❌ No input validation -- ❌ No timeout controls -- ❌ Insecure temporary files -- ❌ No resource limits -- ❌ Minimal logging +7. **Privilege Escalation** ✅ + - Limited to gateway RBAC permissions + - No direct cluster access -**After Security Hardening:** -- ✅ Command injection prevented via argument validation -- ✅ Comprehensive input validation on all user inputs -- ✅ Timeouts on all operations with process cleanup -- ✅ Secure temporary files with random names and 0600 permissions -- ✅ Resource limits prevent DoS attacks -- ✅ Detailed logging with sensitive data filtering +### Residual Risks -### Test Results +1. **Compromised Bearer Token** + - Mitigation: Short token lifetimes, token rotation + - Monitor for unusual activity -**Security Test Suite:** `tests/test_security.py` +2. **Gateway Vulnerabilities** + - Mitigation: Keep gateway service updated + - Rely on gateway's built-in security -✅ 13 security tests passing -- Input validation (valid and invalid inputs) -- Configuration validation -- Command injection prevention -- Resource type whitelist -- Resource limits enforcement -- URL validation -- Data protection +3. **Dependency Vulnerabilities** + - Mitigation: Regular dependency updates + - Security scanning in CI/CD --- @@ -329,28 +154,23 @@ This section summarizes the comprehensive security hardening completed on 2026-0 ### For Deployment -1. **Network Security** - - Run MCP server in isolated network namespace if possible - - Use firewall rules to restrict outbound connections - - Consider running in containers with network policies +1. **Configuration Security** + - Ensure `~/.config/acp/` has 0700 permissions + - Ensure `clusters.yaml` has 0600 permissions (it contains tokens) + - Never commit `clusters.yaml` to version control 2. **Authentication** - - Use OpenShift token authentication + - Use Bearer token authentication - Rotate tokens regularly - - Never store tokens in config files - -3. **File Permissions** - - Ensure `~/.config/acp/` has 0700 permissions - - Config file should have 0600 permissions - - Never commit config files to version control + - Prefer `ACP_TOKEN` environment variable over file storage in CI/CD -4. **Logging** +3. **Logging** - Monitor logs for validation errors (potential attack attempts) - Set up alerts for repeated timeout errors - Review error logs regularly -5. **Updates** - - Keep dependencies updated (mcp, pyyaml, etc.) +4. **Updates** + - Keep dependencies updated (mcp, httpx, pydantic, etc.) - Monitor security advisories for Python and dependencies - Test updates in staging before production @@ -358,94 +178,31 @@ This section summarizes the comprehensive security hardening completed on 2026-0 1. **Adding New Tools** - Always validate inputs with `_validate_input()` - - Use resource type whitelist - Implement dry-run mode for mutating operations - - Add timeout parameters - -2. **Subprocess Calls** - - Never use `shell=True` - - Always use argument arrays - - Validate all arguments - - Set timeouts - -3. **File Operations** - - Use `tempfile.mkstemp()` for temporary files - - Add random prefixes to prevent prediction - - Clean up in finally blocks - - Validate paths to prevent traversal + - All API calls go through `_request()` method + - Never log tokens or credentials -4. **Testing** +2. **Testing** - Test with malicious inputs - Test timeout scenarios - - Test resource exhaustion - - Test concurrent requests - -## Threat Model - -### Threats Mitigated - -1. **Command Injection** ✅ - - Prevented via argument arrays and validation - - No shell interpretation of user input + - Test bulk operation limits -2. **Path Traversal** ✅ - - Config file path validation - - Workflow file containment checks - - Resource name validation - -3. **Resource Exhaustion** ✅ - - Timeouts on all operations - - Limits on log retrieval - - Limits on workflow file parsing - -4. **SSRF (Server-Side Request Forgery)** ✅ - - URL validation before git clone - - Resource type whitelist - -5. **Information Disclosure** ✅ - - Sensitive data filtered from logs - - Error messages sanitized - - No credentials in config - -6. **Privilege Escalation** ✅ - - Limited to OpenShift RBAC permissions - - Resource type whitelist - - No sudo or elevated privileges - -### Residual Risks - -1. **OpenShift/Kubernetes Vulnerabilities** - - Mitigation: Keep OpenShift cluster updated - - Rely on cluster's built-in security - -2. **Compromised OpenShift Token** - - Mitigation: Short token lifetimes, token rotation - - Monitor for unusual activity - -3. **Malicious Git Repositories** - - Mitigation: Only clone trusted repositories - - Workflow file limits prevent some attacks - - Consider sandboxing git operations - -4. **Dependency Vulnerabilities** - - Mitigation: Regular dependency updates - - Security scanning in CI/CD +--- ## Security Checklist Before deploying to production: -- [ ] Config file permissions set to 0600 -- [ ] Config directory permissions set to 0700 -- [ ] No tokens or credentials in config +- [ ] `clusters.yaml` has 0600 permissions (contains tokens) +- [ ] Config directory has 0700 permissions - [ ] Logging configured and monitored - [ ] All dependencies updated to latest secure versions -- [ ] Network policies configured (if using Kubernetes) - [ ] Token rotation policy in place -- [ ] Incident response plan documented -- [ ] Security scanning enabled in CI/CD +- [ ] No tokens committed to version control - [ ] Rate limiting configured (if exposing via HTTP) +--- + ## Reporting Security Issues If you discover a security vulnerability, please: @@ -459,12 +216,9 @@ If you discover a security vulnerability, please: ## Future Security Enhancements -### Recommended Next Steps - 1. **Rate Limiting** (if exposing via HTTP) - Per-client request limits - Token bucket algorithm - - Configurable thresholds 2. **Audit Logging** - Structured audit trail @@ -474,24 +228,16 @@ If you discover a security vulnerability, please: 3. **Authentication Enhancements** - OAuth2/OIDC support - JWT token validation - - Multi-factor authentication 4. **Network Security** - mTLS for MCP transport - Certificate pinning - - Network policy enforcement - -5. **Sandboxing** - - Run git clone in isolated container - - Seccomp profiles - - AppArmor/SELinux policies --- ## References - [OWASP Top 10](https://owasp.org/www-project-top-ten/) -- [CWE-78: OS Command Injection](https://cwe.mitre.org/data/definitions/78.html) - [Kubernetes Security Best Practices](https://kubernetes.io/docs/concepts/security/) - [Python Security Best Practices](https://python.readthedocs.io/en/stable/library/security_warnings.html) @@ -499,14 +245,15 @@ If you discover a security vulnerability, please: ## Version History +**v2.0.0 (2026-02-15)** - Gateway architecture +- Replaced subprocess/oc CLI with httpx REST client +- Added gateway URL enforcement (port 6443 rejection) +- Bearer token authentication +- Simplified security model (no subprocess, no temp files) + **v1.0.0 (2026-01-29)** - Initial security hardening -- Command injection prevention - Input validation and sanitization - Resource exhaustion protection -- Secure temporary file handling -- Path traversal prevention -- Resource type whitelist - Sensitive data filtering -- Comprehensive security test suite -All 19 MCP tools now operate with defense-in-depth security controls while maintaining full functionality and backwards compatibility. +All 8 MCP tools operate with defense-in-depth security controls. diff --git a/TRIGGER_PHRASES.md b/TRIGGER_PHRASES.md deleted file mode 100644 index ab2270e..0000000 --- a/TRIGGER_PHRASES.md +++ /dev/null @@ -1,138 +0,0 @@ -# MCP-ACP Trigger Phrases - -These phrases will reliably trigger the MCP-ACP server in Claude Desktop/Code. - -## ✅ Phrases That Work - -### Viewing Sessions -- "Show my **ACP** sessions" -- "List my **AgenticSessions**" -- "Get my **ambient** sessions" -- "What **ACP** sessions are in jeder-workspace?" -- "Show me all **AgenticSession** resources" -- "List **ambient** container platform sessions" - -### Session Details -- "Get details for **ACP** session-name" -- "Show **AgenticSession** session-name" -- "What's the status of my **ambient** session?" -- "Get transcript for **ACP** session-name" - -### Managing Sessions -- "Stop **ACP** session-name" -- "Restart my **AgenticSession** session-name" -- "Delete **ambient** session-name" -- "Create a new **ACP** session" - -### Authentication & Cluster -- "Check my **ACP** authentication" -- "Show **OpenShift** cluster info" -- "What **ACP** projects do I have access to?" -- "Login to **ACP** cluster" - -### Using Tool Names Directly -- "Use **acp_list_sessions**" -- "Use **acp_whoami**" -- "Use **acp_get_session** with session-name" -- "Use **acp_list_projects**" - -## ❌ Phrases That Don't Work - -These are too generic and won't trigger the MCP-ACP server: - -- "view my workspace" (too generic) -- "show my sessions" (no trigger keyword) -- "list sessions" (no trigger keyword) -- "what's in my project?" (no trigger keyword) -- "check my cluster" (no trigger keyword) - -## 🎯 The Pattern - -**Add one of these keywords**: -- **ACP** -- **ambient** -- **AgenticSession** (or "agentic session") -- **OpenShift** (for cluster operations) -- **acp_toolname** (explicit tool names) - -## Examples for Your Setup - -Based on your config (`jeder-workspace` on `vteam-stage`): - -### List Your Sessions -``` -Show my ACP sessions in jeder-workspace -``` - -### Check Authentication -``` -Use acp_whoami to check my ACP authentication -``` - -### Get Session Details -``` -Get details for ACP session session-1769698517 -``` - -### View Specific Session -``` -Show AgenticSession session-name in jeder-workspace -``` - -### Create New Session -``` -Create a new ACP session in jeder-workspace -``` - -## Pro Tips - -1. **Always include a trigger keyword** - ACP, ambient, AgenticSession, or OpenShift -2. **Be specific about the project** - Include "in jeder-workspace" if needed -3. **Use tool names directly** - "Use acp_list_sessions" always works -4. **Check connection first** - Start with "Use acp_whoami" to verify setup - -## Your Config - -Your `~/.config/acp/clusters.yaml`: -```yaml -clusters: - vteam-stage: - server: https://api.vteam-stage.7fpc.p3.openshiftapps.com:443 - description: "ACP Stage" - default_project: jeder-workspace - -default_cluster: vteam-stage -``` - -This means: -- ✅ Default project is set to `jeder-workspace` -- ✅ You don't need to specify project in every command -- ✅ The server will auto-fill `jeder-workspace` when project is not provided - -## Testing - -Try this to verify the MCP-ACP server is working: - -``` -Use acp_whoami to check my authentication -``` - -You should see: -``` -Current Authentication Status: - -Authenticated: Yes -User: [your-username] -Cluster: vteam-stage -Server: https://api.vteam-stage.7fpc.p3.openshiftapps.com:443 -Project: jeder-workspace -Token Valid: Yes -``` - -If that works, then try: - -``` -Show my ACP sessions -``` - -This should list all AgenticSessions in your jeder-workspace project. diff --git a/check-config.sh b/check-config.sh index dd99ae0..9716c12 100755 --- a/check-config.sh +++ b/check-config.sh @@ -10,8 +10,15 @@ CONFIG_FILE="$HOME/.config/acp/clusters.yaml" if [ -f "$CONFIG_FILE" ]; then echo " ✓ Config file exists: $CONFIG_FILE" echo "" - echo " Contents:" - cat "$CONFIG_FILE" + + # Check file permissions + PERMS=$(stat -f "%Lp" "$CONFIG_FILE" 2>/dev/null || stat -c "%a" "$CONFIG_FILE" 2>/dev/null) + if [ "$PERMS" = "600" ]; then + echo " ✓ File permissions: $PERMS (secure)" + else + echo " ⚠ File permissions: $PERMS (should be 600 — file contains tokens)" + echo " Fix: chmod 600 $CONFIG_FILE" + fi echo "" # Parse and show key values @@ -28,56 +35,75 @@ with open(config_path) as f: default_cluster = config.get("default_cluster") print(f" - default_cluster: {default_cluster}") +clusters = config.get("clusters", {}) +print(f" - clusters configured: {len(clusters)}") + if default_cluster: - cluster_config = config.get("clusters", {}).get(default_cluster, {}) - print(f" - server: {cluster_config.get('server')}") - print(f" - default_project: {cluster_config.get('default_project')}") - print(f" - description: {cluster_config.get('description')}") + cluster_config = clusters.get(default_cluster, {}) + server = cluster_config.get("server", "not set") + project = cluster_config.get("default_project", "not set") + has_token = "yes" if cluster_config.get("token") else "no" + description = cluster_config.get("description", "") + + print(f" - server: {server}") + print(f" - default_project: {project}") + print(f" - token configured: {has_token}") + if description: + print(f" - description: {description}") + + # Check for port 6443 (direct K8s API) + if ":6443" in server: + print() + print(" ⚠ WARNING: Server URL uses port 6443 (direct K8s API)") + print(" Use the public-api gateway URL instead:") + print(" e.g., https://public-api-ambient.apps.cluster.example.com") else: print(" ⚠ No default_cluster set!") PYEOF fi else echo " ✗ Config file not found: $CONFIG_FILE" + echo " Create it with: mkdir -p ~/.config/acp && cp clusters.yaml.example ~/.config/acp/clusters.yaml" fi echo "" -# Check current oc context -echo "2. Current OpenShift context:" -if command -v oc &> /dev/null; then - echo " Current user: $(oc whoami 2>/dev/null || echo 'not logged in')" - echo " Current server: $(oc whoami --show-server 2>/dev/null || echo 'unknown')" - echo " Current project (oc): $(oc project -q 2>/dev/null || echo 'unknown')" +# Check ACP_TOKEN environment variable +echo "2. Checking ACP_TOKEN environment variable:" +if [ -n "$ACP_TOKEN" ]; then + echo " ✓ ACP_TOKEN is set (${#ACP_TOKEN} characters)" else - echo " ✗ oc command not found" + echo " - ACP_TOKEN is not set (will use token from clusters.yaml)" fi echo "" -# Test MCP server config reading -echo "3. Test MCP server config reading:" +# Test MCP server config loading +echo "3. Test MCP server config loading:" if command -v python3 &> /dev/null; then python3 << 'PYEOF' import sys try: - # Import the client - from mcp_acp.client import ACPClient - - # Create client - client = ACPClient() + from mcp_acp.settings import load_settings, load_clusters_config - print(f" ✓ MCP client initialized") - print(f" Config loaded: {len(client.config.get('clusters', {}))} cluster(s)") + settings = load_settings() + config = load_clusters_config(settings) - default_cluster = client.config.get("default_cluster") - print(f" Default cluster: {default_cluster}") + print(f" ✓ Config loaded successfully") + print(f" Clusters: {list(config.clusters.keys())}") + print(f" Default cluster: {config.default_cluster}") - if default_cluster: - cluster_config = client.config.get("clusters", {}).get(default_cluster, {}) - default_project = cluster_config.get("default_project") - print(f" Default project from config: {default_project}") + if config.default_cluster: + cluster = config.clusters.get(config.default_cluster) + if cluster: + has_token = bool(cluster.token) + print(f" Token configured: {'yes' if has_token else 'no'}") + print(f" Server: {cluster.server}") + print(f" Default project: {cluster.default_project}") except ImportError as e: print(f" ✗ Cannot import mcp_acp: {e}") + print(f" Install with: uv pip install -e '.[dev]'") +except FileNotFoundError as e: + print(f" ✗ Config file not found: {e}") except Exception as e: print(f" ✗ Error: {e}") PYEOF @@ -86,71 +112,19 @@ else fi echo "" -# Check permissions -echo "4. Check permissions in configured project:" -if command -v oc &> /dev/null && command -v python3 &> /dev/null; then - python3 << 'PYEOF' -import yaml -import subprocess -from pathlib import Path - -config_path = Path.home() / ".config" / "acp" / "clusters.yaml" -try: - with open(config_path) as f: - config = yaml.safe_load(f) - - default_cluster = config.get("default_cluster") - if default_cluster: - cluster_config = config.get("clusters", {}).get(default_cluster, {}) - default_project = cluster_config.get("default_project") - - if default_project: - print(f" Checking permissions in project: {default_project}") - - # Check if can get agenticsessions - result = subprocess.run( - ["oc", "auth", "can-i", "get", "agenticsessions.vteam.ambient-code", "-n", default_project], - capture_output=True, - text=True - ) - can_get = result.stdout.strip() == "yes" - print(f" - Can GET agenticsessions: {'✓ yes' if can_get else '✗ no'}") - - # Check if can list - result = subprocess.run( - ["oc", "auth", "can-i", "list", "agenticsessions.vteam.ambient-code", "-n", default_project], - capture_output=True, - text=True - ) - can_list = result.stdout.strip() == "yes" - print(f" - Can LIST agenticsessions: {'✓ yes' if can_list else '✗ no'}") - - if not can_get or not can_list: - print(f"\n ⚠ You need RBAC permissions for agenticsessions in {default_project}") - print(f" Contact your cluster admin or check: oc describe rolebinding -n {default_project}") - else: - print(" ⚠ No default_project configured") - else: - print(" ⚠ No default_cluster configured") -except Exception as e: - print(f" ✗ Error: {e}") -PYEOF -fi -echo "" - echo "=== Summary ===" echo "If you see issues above, check:" -echo "1. clusters.yaml has correct default_cluster and default_project" -echo "2. You're logged into the correct OpenShift cluster" -echo "3. You have permissions in the configured default_project" +echo "1. clusters.yaml has correct server (gateway URL), token, and default_project" +echo "2. File permissions are 600 (chmod 600 ~/.config/acp/clusters.yaml)" +echo "3. Bearer token is valid and not expired" echo "" echo "Example clusters.yaml:" echo "---" echo "clusters:" echo " my-cluster:" -echo " server: https://api.your-cluster.com:6443" -echo " default_project: jeder-workspace" -echo " description: 'My Cluster'" +echo " server: https://public-api-ambient.apps.cluster.example.com" +echo " token: your-bearer-token-here" +echo " default_project: my-workspace" echo "" echo "default_cluster: my-cluster" echo "---" diff --git a/clusters.yaml.example b/clusters.yaml.example index ec39a29..19d3009 100644 --- a/clusters.yaml.example +++ b/clusters.yaml.example @@ -4,25 +4,22 @@ clusters: # Example staging cluster vteam-stage: - server: https://api.vteam-stage.xxxx.p3.openshiftapps.com:443 + server: https://public-api-ambient.apps.vteam-stage.example.com + token: your-bearer-token-here description: "ACP Staging Environment" default_project: jeder-workspace # Example production cluster vteam-prod: - server: https://api.vteam-prod.xxxx.p3.openshiftapps.com:443 + server: https://public-api-ambient.apps.vteam-prod.example.com + token: your-bearer-token-here description: "ACP Production" default_project: jeder-workspace - # Example development cluster - local-crc: - server: https://api.crc.testing:6443 - description: "Local CodeReady Containers" - default_project: default - # Add more clusters as needed # cluster-name: - # server: https://api.cluster.example.com:443 + # server: https://public-api-ambient.apps.cluster.example.com + # token: your-bearer-token-here # description: "Cluster Description" # default_project: my-project @@ -30,17 +27,17 @@ clusters: default_cluster: vteam-stage # Configuration Notes: -# - server: Full OpenShift API server URL including port +# - server: Public API gateway URL (NOT the direct K8s API URL with port 6443) +# - token: Bearer token for authentication (keep this file secure!) # - description: Human-readable description (optional) -# - default_project: Default project/namespace for this cluster (optional) +# - default_project: Default project/namespace for this cluster # - default_cluster: The cluster to use by default (optional) # Authentication: -# Authentication is handled separately via 'oc login' commands. -# This config file only stores cluster aliases and metadata. -# You must authenticate to each cluster before using it: -# oc login --server=https://api.cluster.example.com:443 +# Each cluster needs a Bearer token for API access. +# You can also set the ACP_TOKEN environment variable as an override. # Security: -# This file does NOT contain credentials or tokens. -# It's safe to commit to version control (with sensitive URLs removed). +# This file CONTAINS credentials (Bearer tokens). +# Set permissions to 0600: chmod 600 ~/.config/acp/clusters.yaml +# Do NOT commit this file to version control. diff --git a/demos/ADVANCED_DEMOS.md b/demos/ADVANCED_DEMOS.md index 02a78bd..3ea99b3 100644 --- a/demos/ADVANCED_DEMOS.md +++ b/demos/ADVANCED_DEMOS.md @@ -2,6 +2,8 @@ **Based on actual Claude Code sessions with 200-5000+ messages** +> **Warning:** The tools demonstrated in these advanced demos (e.g., `acp_list_workflows`, `acp_create_session_from_template`, `acp_clone_session`, `acp_get_session_transcript`, `acp_get_session_metrics`, `acp_export_session`, `acp_bulk_create_sessions`, `acp_bulk_stop_sessions`) are **not yet implemented**. These demos showcase aspirational workflows based on real Claude Code sessions. See [issue #27](https://github.com/ambient-code/mcp/issues/27) for the implementation roadmap. + These advanced demos showcase the **11 missing MCP-ACP tools** using real workflow patterns extracted from your Claude Code transcripts. --- @@ -164,7 +166,7 @@ Session crashed after analyzing 6/13 features ## Advanced Tools Coverage -### ✅ Tools Demonstrated in Advanced Demos (11/11 - 100%) +### Tools Shown in Advanced Demos (aspirational — not yet implemented) **Session Templates & Workflows:** 1. acp_list_workflows ✓ (Demo 5) @@ -185,28 +187,18 @@ Session crashed after analyzing 6/13 features 10. acp_update_session ✓ (Demo 7) 11. acp_export_session ✓ (Demo 7) -**NOT Demonstrated:** -- acp_bulk_delete_sessions (similar to bulk_stop, not needed for demos) -- acp_delete_session (already shown in Demo 4 as missing feature) - --- -## Combined Coverage: All 7 Demos +## Combined Coverage -### Basic Demos (1-4): 8 tools -- acp_whoami -- acp_list_projects -- acp_list_sessions -- acp_get_session -- acp_create_session -- acp_stop_session -- acp_get_events -- acp_send_message (deprecated) +### Implemented Tools (shown across all demos) +- `acp_list_sessions`, `acp_get_session`, `acp_create_session`, `acp_delete_session` +- `acp_bulk_delete_sessions`, `acp_list_clusters`, `acp_whoami`, `acp_switch_cluster` -### Advanced Demos (5-7): 11 tools -- All 11 advanced features covered +### Aspirational Tools (shown in advanced demos 5-8, not yet implemented) +- All 11 advanced tools listed above -### **Total: 19/19 tools (100% coverage)** +**Note: Basic demos cover the 8 implemented tools. Advanced demos showcase planned features (see [issue #27](https://github.com/ambient-code/mcp/issues/27)).** --- diff --git a/demos/README.md b/demos/README.md index e7b6ad7..8a1b085 100644 --- a/demos/README.md +++ b/demos/README.md @@ -2,7 +2,7 @@ > 8 terminal recordings demonstrating MCP-ACP workflows based on real Claude Code sessions -**Coverage:** 19/19 tools (100%) | **Formats:** `.sh`, `.cast`, `.gif`, `.mp4` | **Size:** ~4.2MB +**Coverage:** 8/8 implemented tools | Demos also showcase aspirational workflows for planned tools | **Formats:** `.sh`, `.cast`, `.gif`, `.mp4` | **Size:** ~4.2MB **Visual Style:** Black background (`#000000`), white text (`#ffffff`), IBM Plex Mono 14pt, 108×35 terminal @@ -13,7 +13,7 @@ - [Demo Gallery](#demo-gallery) - [Basic Workflows (Demos 1-4)](#basic-workflows-demos-1-4) - [Advanced Workflows (Demos 5-8)](#advanced-workflows-demos-5-8) -- [Tool Coverage (19/19)](#tool-coverage-1919) +- [Tool Coverage](#tool-coverage) - [Business Impact](#business-impact) - [Recording Setup](#recording-setup) - [Terminal Configuration](#terminal-configuration) @@ -36,7 +36,7 @@ Your browser does not support the video tag. -

Tools: acp_list_projects, acp_list_sessions, acp_get_events

+

Tools: acp_list_projects (aspirational), acp_list_sessions, acp_get_events (aspirational)

Daily project navigation and session monitoring.

@@ -46,7 +46,7 @@ Your browser does not support the video tag. -

Tools: acp_create_session, acp_get_session, acp_stop_session

+

Tools: acp_create_session, acp_get_session, acp_stop_session (aspirational)

Create, check status, and stop sessions.

@@ -58,7 +58,7 @@ Your browser does not support the video tag. -

Tools: acp_list_sessions, acp_get_events

+

Tools: acp_list_sessions, acp_get_events (aspirational)

Monitor active sessions and retrieve event history.

@@ -132,17 +132,11 @@ --- -## Tool Coverage (19/19) +## Tool Coverage -**Basic (8):** `acp_whoami`, `acp_list_projects`, `acp_list_sessions`, `acp_get_session`, `acp_create_session`, `acp_stop_session`, `acp_get_events`, `acp_send_message` +**Implemented tools shown in demos:** `acp_whoami`, `acp_list_sessions`, `acp_get_session`, `acp_create_session`, `acp_delete_session`, `acp_bulk_delete_sessions`, `acp_list_clusters`, `acp_switch_cluster` -**Templates (2):** `acp_list_workflows`, `acp_create_session_from_template` - -**Recovery (3):** `acp_get_session_transcript`, `acp_restart_session`, `acp_clone_session` - -**Bulk (4):** `acp_bulk_create_sessions`, `acp_bulk_stop_sessions`, `acp_bulk_send_message`, `acp_bulk_get_session_metrics` - -**Observability (4):** `acp_get_session_metrics`, `acp_get_session_logs`, `acp_update_session`, `acp_export_session` +**Note:** Demos 5-8 showcase aspirational workflows for tools that are not yet implemented. See [issue #27](https://github.com/ambient-code/mcp/issues/27) for the planned tools roadmap. --- diff --git a/demos/hello-acp-workflow.py b/demos/hello-acp-workflow.py new file mode 100644 index 0000000..8b6303a --- /dev/null +++ b/demos/hello-acp-workflow.py @@ -0,0 +1,212 @@ +"""Hello ACP workflow demo — compile a plan, submit it, check status, see success.""" + +import asyncio +import sys +import time + +from mcp_acp.client import ACPClient + +# --- Colors --- +CYAN = "\033[0;36m" +GREEN = "\033[0;32m" +YELLOW = "\033[0;33m" +DIM = "\033[2;37m" +BOLD = "\033[1m" +RESET = "\033[0m" + +PLAN = """\ +# Plan: Hello ACP + +## Objective +Prove the full ACP pipeline works end-to-end. + +## Steps +1. Create `/workspace/hello_acp.py` with: `print('HELLO_ACP_SUCCESS')` +2. Run it: `python3 /workspace/hello_acp.py` +3. Confirm output contains `HELLO_ACP_SUCCESS` +""" + +PROMPT = ( + "You are executing a plan that was compiled and submitted to ACP.\n\n" + "---\n\n" + f"{PLAN}\n" + "Do nothing else beyond what the plan says." +) + +POLL_INTERVAL = 10 +POLL_TIMEOUT = 300 + + +def step(n: int, text: str) -> None: + print(f"\n{CYAN}{'─' * 80}") + print(f" Step {n}: {text}") + print(f"{'─' * 80}{RESET}\n") + time.sleep(0.5) + + +def info(label: str, value: str) -> None: + print(f" {DIM}{label}:{RESET} {value}") + + +def ok(text: str) -> None: + print(f"\n {GREEN}✓ {text}{RESET}") + + +def warn(text: str) -> None: + print(f"\n {YELLOW}⚠ {text}{RESET}") + + +def progress(msg: str) -> None: + print(f" {DIM}… {msg}{RESET}", flush=True) + + +def _default_project(client: ACPClient) -> str: + """Resolve default project from clusters.yaml config.""" + default_cluster = client.clusters_config.default_cluster + if not default_cluster: + raise ValueError("No default_cluster configured") + cluster = client.clusters_config.clusters.get(default_cluster) + if not cluster or not cluster.default_project: + raise ValueError(f"No default_project configured for cluster '{default_cluster}'") + return cluster.default_project + + +async def wait_for_status( + client: ACPClient, + project: str, + session_name: str, + target: str, +) -> tuple[str | None, dict]: + """Poll until session reaches target status. Returns (status, session_data).""" + elapsed = 0 + last_status = None + session_data = {} + while elapsed < POLL_TIMEOUT: + session_data = await client.get_session(project=project, session=session_name) + status = session_data.get("status", "") + if status != last_status: + progress(f"Status: {status}") + last_status = status + if status.lower() == target.lower(): + return status, session_data + if status.lower() in ("failed", "error"): + return status, session_data + await asyncio.sleep(POLL_INTERVAL) + elapsed += POLL_INTERVAL + return last_status, session_data + + +async def wait_for_completion( + client: ACPClient, + project: str, + session_name: str, +) -> tuple[str | None, dict]: + """Poll until session completes (stopped/completed/failed).""" + elapsed = 0 + last_status = None + session_data = {} + while elapsed < POLL_TIMEOUT: + session_data = await client.get_session(project=project, session=session_name) + status = session_data.get("status", "") + if status != last_status: + progress(f"Status: {status}") + last_status = status + if status.lower() in ("completed", "stopped", "failed", "error"): + return status, session_data + await asyncio.sleep(POLL_INTERVAL) + elapsed += POLL_INTERVAL + return last_status, session_data + + +async def main() -> int: + client = ACPClient() + project = _default_project(client) + + # ── Step 1: Show the plan ── + step(1, "The plan") + print(f"{DIM}{PLAN}{RESET}") + + # ── Step 2: Compile and submit ── + step(2, "Compile this plan with ACP") + print(f" {DIM}acp_create_session(project={project!r}, display_name='hello-acp-demo', ...){RESET}\n") + + result = await client.create_session( + project=project, + initial_prompt=PROMPT, + display_name="hello-acp-demo", + timeout=300, + ) + + if not result.get("created"): + print(f" Session creation failed: {result}") + return 1 + + session_name = result["session"] + ok("Session created") + info("Session", session_name) + info("Project", project) + info("Display name", "hello-acp-demo") + print() + + try: + # ── Step 3: Disconnect ── + step(3, "Disconnect — session runs autonomously on the cluster") + print(f" {DIM}The session is now running on a pod in the cluster.") + print(" You can close your laptop, go to lunch, whatever.") + print(f" The work continues without you.{RESET}\n") + info("Check status", f"acp_get_session(session={session_name!r})") + info("List all", f"acp_list_sessions(project={project!r})") + print() + + # ── Step 4: Check session status ── + step(4, "Check session status") + print(f" {DIM}acp_get_session(session={session_name!r}){RESET}\n") + + status, session_data = await wait_for_status(client, project, session_name, "running") + if not status or status.lower() in ("failed", "error"): + warn(f"Session entered status '{status}'") + return 1 + if status.lower() != "running": + warn(f"Session never reached running (last: {status})") + return 1 + + # Show session metadata once running + ok("Session is running") + info("Session", session_data.get("id", session_name)) + info("Display name", session_data.get("displayName", "")) + info("Status", f"{BOLD}{session_data.get('status', '')}{RESET}") + info("Created", session_data.get("createdAt", "")) + model = session_data.get("model", "") + if model: + info("Model", model) + print() + + # ── Step 5: Wait for completion ── + step(5, "Wait for completion") + print(f" {DIM}Polling acp_get_session until session finishes...{RESET}\n") + + final_status, final_data = await wait_for_completion(client, project, session_name) + + if final_status and final_status.lower() in ("completed", "stopped"): + ok(f"Session finished — status: {final_status}") + completed_at = final_data.get("completedAt", "") + if completed_at: + info("Completed at", completed_at) + else: + warn(f"Session did not complete within {POLL_TIMEOUT}s (last: {final_status})") + return 1 + + # ── Done ── + print(f"\n{GREEN}{'═' * 80}") + print(" SUCCESS — Full pipeline verified") + print(" Plan -> acp_create_session -> Public API -> K8s CR -> Operator -> Runner -> Done") + print(f"{'═' * 80}{RESET}\n") + return 0 + + finally: + await client.delete_session(project=project, session=session_name) + await client.close() + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/demos/hello-acp.cast b/demos/hello-acp.cast new file mode 100644 index 0000000..286013d --- /dev/null +++ b/demos/hello-acp.cast @@ -0,0 +1,19 @@ +{"version": 3, "term": {"cols": 100, "rows": 35, "type": "xterm-ghostty"}, "timestamp": 1771210628, "command": "bash demos/hello-acp.sh", "title": "Hello ACP \u2014 Compile a Plan with ACP", "env": {"SHELL": "/opt/homebrew/bin/bash"}} +[0.175, "o", "\r\n\u001b[0;36m────────────────────────────────────────────────────────────────────────────────\r\n Step 1: The plan\r\n────────────────────────────────────────────────────────────────────────────────\u001b[0m\r\n\r\n"] +[0.504, "o", "\u001b[2;37m# Plan: Hello ACP\r\n\r\n## Objective\r\nProve the full ACP pipeline works end-to-end.\r\n\r\n## Steps\r\n1. Create `/workspace/hello_acp.py` with: `print('HELLO_ACP_SUCCESS')`\r\n2. Run it: `python3 /workspace/hello_acp.py`\r\n3. Confirm output contains `HELLO_ACP_SUCCESS`\r\n\u001b[0m\r\n\r\n\u001b[0;36m────────────────────────────────────────────────────────────────────────────────\r\n Step 2: Compile this plan with ACP\r\n────────────────────────────────────────────────────────────────────────────────\u001b[0m\r\n\r\n"] +[0.503, "o", " \u001b[2;37macp_create_session(project='jeder-workspace', display_name='hello-acp-demo', ...)\u001b[0m\r\n\r\n"] +[0.390, "o", "\r\n \u001b[0;32m✓ Session created\u001b[0m\r\n \u001b[2;37mSession:\u001b[0m compiled-4q765\r\n \u001b[2;37mProject:\u001b[0m jeder-workspace\r\n \u001b[2;37mDisplay name:\u001b[0m hello-acp-demo\r\n\r\n\r\n\u001b[0;36m────────────────────────────────────────────────────────────────────────────────\r\n Step 3: Disconnect — session runs autonomously on the cluster\r\n────────────────────────────────────────────────────────────────────────────────\u001b[0m\r\n\r\n"] +[0.502, "o", " \u001b[2;37mThe session is now running on a pod in the cluster.\r\n You can close your laptop, go to lunch, whatever.\r\n The work continues without you.\u001b[0m\r\n\r\n \u001b[2;37mCheck status:\u001b[0m acp_list_sessions(project='jeder-workspace')\r\n \u001b[2;37mView logs:\u001b[0m acp_get_session_logs(session='compiled-4q765')\r\n\r\n\r\n\u001b[0;36m────────────────────────────────────────────────────────────────────────────────\r\n Step 4: Check session status\r\n────────────────────────────────────────────────────────────────────────────────\u001b[0m\r\n\r\n"] +[0.505, "o", " \u001b[2;37macp_list_sessions(project='jeder-workspace')\u001b[0m\r\n\r\n"] +[0.268, "o", " \u001b[2;37m… Phase: Creating\u001b[0m\r\n"] +[10.244, "o", " \u001b[2;37m… Phase: Running\u001b[0m\r\n"] +[0.233, "o", "\r\n \u001b[0;32m✓ Session is running\u001b[0m\r\n \u001b[2;37mSession:\u001b[0m compiled-4q765\r\n \u001b[2;37mDisplay name:\u001b[0m hello-acp-demo\r\n \u001b[2;37mPhase:\u001b[0m \u001b[1mRunning\u001b[0m\r\n \u001b[2;37mCreated:\u001b[0m 2026-02-16T02:57:10Z\r\n \u001b[2;37mModel:\u001b[0m \r\n\r\n\r\n\u001b[0;36m────────────────────────────────────────────────────────────────────────────────"] +[0.000, "o", "\r\n Step 5: Verify output\r\n────────────────────────────────────────────────────────────────────────────────\u001b[0m\r\n\r\n"] +[0.500, "o", " \u001b[2;37macp_get_session_logs(session='compiled-4q765')\u001b[0m\r\n\r\n"] +[0.440, "o", " \u001b[2;37m… Logs: 4759 chars\u001b[0m\r\n"] +[10.443, "o", " \u001b[2;37m… Logs: 30750 chars\u001b[0m\r\n\r\n \u001b[0;32m✓ Found marker: HELLO_ACP_SUCCESS\u001b[0m\r\n"] +[0.001, "o", "\r\n\u001b[0;32m════════════════════════════════════════════════════════════════════════════════\r\n SUCCESS — Full pipeline verified\r\n"] +[0.000, "o", " Plan -> acp_create_session -> K8s CR -> Operator -> Runner Pod -> Claude -> Done\r\n"] +[0.000, "o", "════════════════════════════════════════════════════════════════════════════════\u001b[0m\r\n"] +[0.000, "o", "\r\n"] +[0.245, "x", "0"] diff --git a/demos/hello-acp.gif b/demos/hello-acp.gif new file mode 100644 index 0000000..ebdd061 Binary files /dev/null and b/demos/hello-acp.gif differ diff --git a/demos/hello-acp.sh b/demos/hello-acp.sh new file mode 100755 index 0000000..b3e008f --- /dev/null +++ b/demos/hello-acp.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /Users/jeder/repos/mcp +PYTHONPATH=".:src:$PYTHONPATH" .venv/bin/python demos/hello-acp-workflow.py diff --git a/pyproject.toml b/pyproject.toml index 81b33f6..4fe07a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,9 +100,13 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] +markers = [ + "integration: requires live cluster access (Bearer token)", +] addopts = [ "-v", "--strict-markers", + "-m", "not integration", "--cov=src/mcp_acp", "--cov-report=term-missing", "--cov-report=html", diff --git a/src/mcp_acp/client.py b/src/mcp_acp/client.py index 587a666..f27cf19 100644 --- a/src/mcp_acp/client.py +++ b/src/mcp_acp/client.py @@ -265,6 +265,65 @@ async def get_session(self, project: str, session: str) -> dict[str, Any]: return await self._request("GET", f"/v1/sessions/{session}", project) + async def create_session( + self, + project: str, + initial_prompt: str, + display_name: str | None = None, + repos: list[str] | None = None, + interactive: bool = False, + model: str = "claude-sonnet-4", + timeout: int = 900, + dry_run: bool = False, + ) -> dict[str, Any]: + """Create an AgenticSession with a custom prompt. + + Args: + project: Project/namespace name + initial_prompt: The prompt to send to the session + display_name: Optional display name for the session + repos: Optional list of repository URLs + interactive: Whether to create an interactive session + model: LLM model to use + timeout: Session timeout in seconds + dry_run: Preview without creating + """ + self._validate_input(project, "project") + + session_data: dict[str, Any] = { + "initialPrompt": initial_prompt, + "interactive": interactive, + "llmConfig": {"model": model}, + "timeout": timeout, + } + + if display_name: + session_data["displayName"] = display_name + + if repos: + session_data["repos"] = repos + + if dry_run: + return { + "dry_run": True, + "success": True, + "message": "Would create session with custom prompt", + "manifest": session_data, + "project": project, + } + + try: + result = await self._request("POST", "/v1/sessions", project, json_data=session_data) + session_id = result.get("id", "unknown") + return { + "created": True, + "session": session_id, + "project": project, + "message": f"Session '{session_id}' created in project '{project}'", + } + except (ValueError, TimeoutError) as e: + return {"created": False, "message": str(e)} + async def delete_session(self, project: str, session: str, dry_run: bool = False) -> dict[str, Any]: """Delete a session. diff --git a/src/mcp_acp/formatters.py b/src/mcp_acp/formatters.py index 5a5cafa..2a327a7 100644 --- a/src/mcp_acp/formatters.py +++ b/src/mcp_acp/formatters.py @@ -136,3 +136,27 @@ def format_whoami(result: dict[str, Any]) -> str: output += f"\nError: {result['error']}\n" return output + + +def format_session_created(result: dict[str, Any]) -> str: + """Format session creation result with follow-up commands.""" + if result.get("dry_run"): + output = "DRY RUN MODE - No changes made\n\n" + output += result.get("message", "") + if "manifest" in result: + output += f"\n\nManifest:\n{json.dumps(result['manifest'], indent=2)}" + return output + + if not result.get("created"): + return f"Failed to create session: {result.get('message', 'unknown error')}" + + session = result.get("session", "unknown") + project = result.get("project", "unknown") + + output = f"Session created: {session}\n" + output += f"Project: {project}\n\n" + output += "Check status:\n" + output += f' acp_list_sessions(project="{project}")\n' + output += f' acp_get_session(project="{project}", session="{session}")\n' + + return output diff --git a/src/mcp_acp/server.py b/src/mcp_acp/server.py index b97b199..e42cf18 100644 --- a/src/mcp_acp/server.py +++ b/src/mcp_acp/server.py @@ -15,6 +15,7 @@ format_bulk_result, format_clusters, format_result, + format_session_created, format_sessions_list, format_whoami, ) @@ -99,6 +100,54 @@ async def list_tools() -> list[Tool]: "required": ["session"], }, ), + Tool( + name="acp_create_session", + description="Create an ACP AgenticSession with a custom prompt. Useful for offloading plan execution to the cluster. Supports dry-run mode.", + inputSchema={ + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Project/namespace name (uses default if not provided)", + }, + "initial_prompt": { + "type": "string", + "description": "The prompt/instructions to send to the session", + }, + "display_name": { + "type": "string", + "description": "Human-readable display name for the session", + }, + "repos": { + "type": "array", + "items": {"type": "string"}, + "description": "List of repository URLs to clone into the session", + }, + "interactive": { + "type": "boolean", + "description": "Create an interactive session (default: false)", + "default": False, + }, + "model": { + "type": "string", + "description": "LLM model to use (default: claude-sonnet-4)", + "default": "claude-sonnet-4", + }, + "timeout": { + "type": "integer", + "description": "Session timeout in seconds (default: 900)", + "default": 900, + "minimum": 60, + }, + "dry_run": { + "type": "boolean", + "description": "Preview without creating (default: false)", + "default": False, + }, + }, + "required": ["initial_prompt"], + }, + ), Tool( name="acp_delete_session", description="Delete an AgenticSession. Supports dry-run mode for preview.", @@ -225,6 +274,19 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) text = format_result(result) + elif name == "acp_create_session": + result = await client.create_session( + project=arguments.get("project", ""), + initial_prompt=arguments["initial_prompt"], + display_name=arguments.get("display_name"), + repos=arguments.get("repos"), + interactive=arguments.get("interactive", False), + model=arguments.get("model", "claude-sonnet-4"), + timeout=arguments.get("timeout", 900), + dry_run=arguments.get("dry_run", False), + ) + text = format_session_created(result) + elif name == "acp_delete_session": result = await client.delete_session( project=arguments.get("project", ""), diff --git a/src/mcp_acp/settings.py b/src/mcp_acp/settings.py index 8beb701..d5bb4c9 100644 --- a/src/mcp_acp/settings.py +++ b/src/mcp_acp/settings.py @@ -7,7 +7,7 @@ import yaml from pydantic import Field, field_validator -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict from utils.pylogger import get_python_logger @@ -50,6 +50,13 @@ def validate_server_url(cls, v: str) -> str: """Validate server URL format.""" if not v.startswith(("https://", "http://")): raise ValueError("Server URL must start with https:// or http://") + # Reject direct Kubernetes API URLs (port 6443) + if ":6443" in v: + raise ValueError( + "Direct Kubernetes API URLs (port 6443) are not supported. " + "Use the public-api gateway URL instead " + "(e.g., https://public-api-ambient.apps.cluster.example.com)." + ) # Strip trailing slash for consistency return v.rstrip("/") @@ -172,11 +179,10 @@ def validate_log_level(cls, v: str) -> str: raise ValueError(f"log_level must be one of {valid_levels}") return v_upper - class Config: - """Pydantic config.""" - - env_prefix = "MCP_ACP_" - case_sensitive = False + model_config = SettingsConfigDict( + env_prefix="MCP_ACP_", + case_sensitive=False, + ) def load_settings() -> Settings: diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_hello_acp.py b/tests/integration/test_hello_acp.py new file mode 100644 index 0000000..53f909e --- /dev/null +++ b/tests/integration/test_hello_acp.py @@ -0,0 +1,86 @@ +"""Hello ACP — first live session submission via acp_create_session. + +This integration test proves the full pipeline: + MCP tool → public API → K8s CR → operator → runner pod → Claude execution → completion + +Requires: live cluster access and a valid clusters.yaml config with token. + +Run with: + pytest tests/integration/test_hello_acp.py -m integration -v +""" + +import asyncio + +import pytest + +from mcp_acp.client import ACPClient + +MARKER = "HELLO_ACP_SUCCESS" + +PROMPT = ( + "Write a Python script at /workspace/hello_acp.py containing exactly:\n" + f"print('{MARKER}')\n" + "Then run it with: python3 /workspace/hello_acp.py\n" + "Do nothing else." +) + +POLL_INTERVAL_SECONDS = 10 +POLL_TIMEOUT_SECONDS = 300 # 5 minutes +SESSION_TIMEOUT_SECONDS = 300 + + +@pytest.fixture +def client() -> ACPClient: + """Create a live ACPClient using real cluster config.""" + return ACPClient() + + +def _default_project(client: ACPClient) -> str: + """Resolve default project from clusters.yaml config.""" + default_cluster = client.clusters_config.default_cluster + if not default_cluster: + raise ValueError("No default_cluster configured") + cluster = client.clusters_config.clusters.get(default_cluster) + if not cluster or not cluster.default_project: + raise ValueError(f"No default_project configured for cluster '{default_cluster}'") + return cluster.default_project + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_hello_acp(client: ACPClient) -> None: + """Submit a hello-world session and verify it runs and produces marker output.""" + project = _default_project(client) + + # 1. Create session + result = await client.create_session( + project=project, + initial_prompt=PROMPT, + display_name="hello-acp-test", + timeout=SESSION_TIMEOUT_SECONDS, + ) + assert result.get("created"), f"Session creation failed: {result}" + + session_name: str = result["session"] + + try: + # 2. Poll until session status changes from "creating" + status = None + elapsed = 0 + while elapsed < POLL_TIMEOUT_SECONDS: + session_data = await client.get_session(project=project, session=session_name) + status = session_data.get("status", "") + if status.lower() == "running": + break + if status.lower() in ("failed", "stopped"): + pytest.fail(f"Session '{session_name}' entered status '{status}' before running") + await asyncio.sleep(POLL_INTERVAL_SECONDS) + elapsed += POLL_INTERVAL_SECONDS + + assert status and status.lower() == "running", ( + f"Session '{session_name}' never reached running (last status: '{status}')" + ) + + finally: + # 3. Cleanup — delete session regardless of outcome + await client.delete_session(project=project, session=session_name) diff --git a/tests/test_client.py b/tests/test_client.py index 847d476..df328d6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,7 +20,7 @@ def mock_settings(): def mock_clusters_config(): """Create mock clusters config.""" cluster = MagicMock() - cluster.server = "https://api.test.example.com" + cluster.server = "https://public-api-test.apps.example.com" cluster.default_project = "test-project" cluster.description = "Test Cluster" cluster.token = "test-token" @@ -79,6 +79,40 @@ def test_validate_bulk_operation_exceeds_limit(self, client: ACPClient) -> None: client._validate_bulk_operation(["s1", "s2", "s3", "s4"], "delete") +class TestServerURLValidation: + """Tests for server URL validation rejecting K8s API URLs.""" + + def test_reject_k8s_api_port(self) -> None: + """Direct K8s API URL (port 6443) should be rejected.""" + from mcp_acp.settings import ClusterConfig + + with pytest.raises(ValueError, match="port 6443"): + ClusterConfig( + server="https://api.test.example.com:6443", + default_project="test-project", + ) + + def test_accept_gateway_url(self) -> None: + """Gateway URL should be accepted.""" + from mcp_acp.settings import ClusterConfig + + config = ClusterConfig( + server="https://public-api-ambient.apps.cluster.example.com", + default_project="test-project", + ) + assert config.server == "https://public-api-ambient.apps.cluster.example.com" + + def test_accept_port_443(self) -> None: + """Standard HTTPS port should be accepted.""" + from mcp_acp.settings import ClusterConfig + + config = ClusterConfig( + server="https://api.example.com:443", + default_project="test-project", + ) + assert config.server == "https://api.example.com:443" + + class TestTimeParsing: """Tests for time parsing utilities.""" @@ -155,7 +189,7 @@ async def test_whoami_authenticated(self, client: ACPClient) -> None: assert result["authenticated"] is True assert result["token_valid"] is True assert result["cluster"] == "test-cluster" - assert result["server"] == "https://api.test.example.com" + assert result["server"] == "https://public-api-test.apps.example.com" class TestHTTPRequests: @@ -231,3 +265,100 @@ async def test_bulk_delete_sessions(self, client: ACPClient) -> None: assert len(result["deleted"]) == 2 assert "s1" in result["deleted"] assert "s2" in result["deleted"] + + +class TestCreateSession: + """Tests for create_session.""" + + @pytest.mark.asyncio + async def test_create_session_dry_run(self, client: ACPClient) -> None: + """Dry run should return manifest without hitting API.""" + result = await client.create_session( + project="test-project", + initial_prompt="Run all tests", + display_name="Test Run", + repos=["https://github.com/org/repo"], + dry_run=True, + ) + + assert result["dry_run"] is True + assert result["success"] is True + assert result["project"] == "test-project" + + manifest = result["manifest"] + assert manifest["initialPrompt"] == "Run all tests" + assert manifest["displayName"] == "Test Run" + assert manifest["repos"] == ["https://github.com/org/repo"] + assert manifest["interactive"] is False + assert manifest["llmConfig"]["model"] == "claude-sonnet-4" + assert manifest["timeout"] == 900 + + @pytest.mark.asyncio + async def test_create_session_dry_run_minimal(self, client: ACPClient) -> None: + """Dry run with only required fields should omit optional keys.""" + result = await client.create_session( + project="test-project", + initial_prompt="hello", + dry_run=True, + ) + + manifest = result["manifest"] + assert "displayName" not in manifest + assert "repos" not in manifest + + @pytest.mark.asyncio + async def test_create_session_success(self, client: ACPClient) -> None: + """Successful creation should return session id and project.""" + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "compiled-abc12", "status": "creating"} + + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_http_client + + result = await client.create_session( + project="test-project", + initial_prompt="Implement feature X", + ) + + assert result["created"] is True + assert result["session"] == "compiled-abc12" + assert result["project"] == "test-project" + + @pytest.mark.asyncio + async def test_create_session_api_failure(self, client: ACPClient) -> None: + """API failure should return created=False with error message.""" + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {"error": "invalid session spec"} + mock_response.text = "invalid session spec" + + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_http_client + + result = await client.create_session( + project="test-project", + initial_prompt="hello", + ) + + assert result["created"] is False + assert "invalid session spec" in result["message"] + + @pytest.mark.asyncio + async def test_create_session_custom_model_and_timeout(self, client: ACPClient) -> None: + """Custom model and timeout should appear in dry-run manifest.""" + result = await client.create_session( + project="test-project", + initial_prompt="hello", + model="claude-opus-4", + timeout=3600, + dry_run=True, + ) + + manifest = result["manifest"] + assert manifest["llmConfig"]["model"] == "claude-opus-4" + assert manifest["timeout"] == 3600 diff --git a/tests/test_server.py b/tests/test_server.py index b3dcbb3..852a48b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -19,6 +19,7 @@ async def test_list_tools_returns_all_tools(self) -> None: # Session tools assert "acp_list_sessions" in tool_names assert "acp_get_session" in tool_names + assert "acp_create_session" in tool_names assert "acp_delete_session" in tool_names assert "acp_bulk_delete_sessions" in tool_names @@ -31,7 +32,7 @@ async def test_list_tools_returns_all_tools(self) -> None: async def test_list_tools_count(self) -> None: """Test correct number of tools.""" tools = await list_tools() - assert len(tools) == 7 + assert len(tools) == 8 class TestCallTool: @@ -73,6 +74,31 @@ async def test_call_tool_delete_session(self) -> None: assert len(result) == 1 assert "Success" in result[0].text + @pytest.mark.asyncio + async def test_call_tool_create_session(self) -> None: + """Test calling create session tool.""" + mock_client = MagicMock() + mock_client.clusters_config = MagicMock() + mock_client.clusters_config.default_cluster = "test" + mock_client.clusters_config.clusters = {"test": MagicMock(default_project="test-project")} + mock_client.create_session = AsyncMock( + return_value={ + "created": True, + "session": "compiled-abc12", + "project": "test-project", + "message": "Session 'compiled-abc12' created in project 'test-project'", + } + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_create_session", + {"project": "test-project", "initial_prompt": "Run tests"}, + ) + + assert len(result) == 1 + assert "compiled-abc12" in result[0].text + @pytest.mark.asyncio async def test_call_tool_bulk_delete_requires_confirm(self) -> None: """Test bulk delete requires confirm flag."""