diff --git a/API_REFERENCE.md b/API_REFERENCE.md index df19f46..12ed585 100644 --- a/API_REFERENCE.md +++ b/API_REFERENCE.md @@ -1,8 +1,6 @@ # ACP MCP Server - API Reference -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. +Complete reference for all 26 tools available in the ACP MCP Server. --- @@ -12,15 +10,37 @@ See [issue #27](https://github.com/ambient-code/mcp/issues/27) for planned addit - [acp_list_sessions](#acp_list_sessions) - [acp_get_session](#acp_get_session) - [acp_create_session](#acp_create_session) + - [acp_create_session_from_template](#acp_create_session_from_template) - [acp_delete_session](#acp_delete_session) - -2. [Bulk Operations](#bulk-operations) + - [acp_restart_session](#acp_restart_session) + - [acp_clone_session](#acp_clone_session) + - [acp_update_session](#acp_update_session) + +2. [Observability](#observability) + - [acp_get_session_logs](#acp_get_session_logs) + - [acp_get_session_transcript](#acp_get_session_transcript) + - [acp_get_session_metrics](#acp_get_session_metrics) + +3. [Labels](#labels) + - [acp_label_resource](#acp_label_resource) + - [acp_unlabel_resource](#acp_unlabel_resource) + - [acp_list_sessions_by_label](#acp_list_sessions_by_label) + - [acp_bulk_label_resources](#acp_bulk_label_resources) + - [acp_bulk_unlabel_resources](#acp_bulk_unlabel_resources) + +4. [Bulk Operations](#bulk-operations) - [acp_bulk_delete_sessions](#acp_bulk_delete_sessions) + - [acp_bulk_stop_sessions](#acp_bulk_stop_sessions) + - [acp_bulk_restart_sessions](#acp_bulk_restart_sessions) + - [acp_bulk_delete_sessions_by_label](#acp_bulk_delete_sessions_by_label) + - [acp_bulk_stop_sessions_by_label](#acp_bulk_stop_sessions_by_label) + - [acp_bulk_restart_sessions_by_label](#acp_bulk_restart_sessions_by_label) -3. [Cluster Management](#cluster-management) +5. [Cluster Management](#cluster-management) - [acp_list_clusters](#acp_list_clusters) - [acp_whoami](#acp_whoami) - [acp_switch_cluster](#acp_switch_cluster) + - [acp_login](#acp_login) --- @@ -185,6 +205,377 @@ Delete a session with optional dry-run. --- +### acp_create_session_from_template + +Create a session from a predefined template with optimized settings. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "template": "string (required) - triage|bugfix|feature|exploration", + "display_name": "string (required) - Display name for the session", + "repos": "array[string] (optional) - Repository URLs to clone", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "created": true, + "session": "compiled-abc12", + "project": "my-workspace", + "template": "bugfix", + "message": "Session 'compiled-abc12' created from template 'bugfix'" +} +``` + +**Behavior:** +- Available templates: `triage` (temp 0.7), `bugfix` (temp 0.3), `feature` (temp 0.5), `exploration` (temp 0.8) +- Each template sets workflow type, model, and temperature +- Calls `POST /v1/sessions` on the public-api gateway + +--- + +### acp_restart_session + +Restart a stopped session. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "session": "string (required)", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "restarted": true, + "message": "Successfully restarted session 'my-session'" +} +``` + +**Behavior:** +- Calls `PATCH /v1/sessions/{session}` with `{"stopped": false}` +- If dry_run: Calls `GET /v1/sessions/{session}` to verify existence +- Validates session name (DNS-1123 format) + +--- + +### acp_clone_session + +Clone an existing session's configuration into a new session. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "source_session": "string (required) - Session ID to clone from", + "new_display_name": "string (required) - Display name for the cloned session", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "created": true, + "session": "compiled-xyz99", + "source_session": "compiled-abc12", + "project": "my-workspace", + "message": "Session 'compiled-xyz99' cloned from 'compiled-abc12'" +} +``` + +**Behavior:** +- Fetches source session configuration via `GET /v1/sessions/{source_session}` +- Copies prompt, interactive flag, timeout, llmConfig, and repos +- Creates new session via `POST /v1/sessions` + +--- + +### acp_update_session + +Update session metadata (display name, timeout). + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "session": "string (required)", + "display_name": "string (optional) - New display name", + "timeout": "integer (optional, minimum: 60) - New timeout in seconds", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "updated": true, + "message": "Successfully updated session 'my-session'", + "session": { "id": "my-session", "displayName": "New Name", "timeout": 1800 } +} +``` + +**Behavior:** +- At least one of `display_name` or `timeout` must be provided +- Calls `PATCH /v1/sessions/{session}` with the patch data +- If dry_run: Shows current state and proposed patch + +--- + +## Observability + +### acp_get_session_logs + +Retrieve container logs for a session. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "session": "string (required)", + "container": "string (optional) - Container name", + "tail_lines": "integer (optional, default: 1000, max: 10000)" +} +``` + +**Output:** +```json +{ + "logs": "2026-01-29T10:00:00Z Starting session...\n2026-01-29T10:00:01Z Running prompt...", + "session": "my-session", + "tail_lines": 1000 +} +``` + +**Behavior:** +- Calls `GET /v1/sessions/{session}/logs` with `tailLines` query parameter +- Returns plain text logs from the session container +- Optionally filter by container name + +--- + +### acp_get_session_transcript + +Retrieve conversation history for a session. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "session": "string (required)", + "format": "string (optional, default: 'json') - json|markdown" +} +``` + +**Output (JSON format):** +```json +{ + "messages": [ + {"role": "user", "content": "Run unit tests"}, + {"role": "assistant", "content": "Running pytest..."} + ], + "session": "my-session", + "format": "json" +} +``` + +**Behavior:** +- Calls `GET /v1/sessions/{session}/transcript` with `format` query parameter +- JSON format returns structured message objects +- Markdown format returns rendered conversation text + +--- + +### acp_get_session_metrics + +Get usage statistics for a session (tokens, duration, tool calls). + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "session": "string (required)" +} +``` + +**Output:** +```json +{ + "session": "my-session", + "total_tokens": 15420, + "input_tokens": 8200, + "output_tokens": 7220, + "duration_seconds": 342, + "tool_calls": 12 +} +``` + +**Behavior:** +- Calls `GET /v1/sessions/{session}/metrics` +- Returns all available usage statistics from the API + +--- + +## Labels + +### acp_label_resource + +Add labels to a session. Labels are key-value pairs for organizing and filtering. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "name": "string (required) - Session name", + "resource_type": "string (optional, default: 'agenticsession')", + "labels": "object (required) - Labels as key-value pairs, e.g. {\"env\": \"test\"}" +} +``` + +**Output:** +```json +{ + "labeled": true, + "session": "my-session", + "labels_added": {"env": "test", "team": "platform"}, + "message": "Added 2 label(s) to session 'my-session'" +} +``` + +**Behavior:** +- Validates label keys and values (1-63 chars, alphanumeric/dashes/dots/underscores) +- Calls `PATCH /v1/sessions/{session}` with `{"labels": {...}}` + +--- + +### acp_unlabel_resource + +Remove labels from a session by key. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "name": "string (required) - Session name", + "resource_type": "string (optional, default: 'agenticsession')", + "label_keys": "array[string] (required) - List of label keys to remove" +} +``` + +**Output:** +```json +{ + "unlabeled": true, + "session": "my-session", + "labels_removed": ["env", "team"], + "message": "Removed 2 label(s) from session 'my-session'" +} +``` + +**Behavior:** +- Calls `PATCH /v1/sessions/{session}` with `{"removeLabels": [...]}` +- Validates that label_keys is not empty + +--- + +### acp_list_sessions_by_label + +List sessions matching label selectors. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "labels": "object (required) - Labels to match, e.g. {\"env\": \"test\"}" +} +``` + +**Output:** +```json +{ + "sessions": [ + {"id": "session-1", "status": "running", "createdAt": "2026-01-29T10:00:00Z"} + ], + "total": 1, + "labels_filter": {"env": "test"} +} +``` + +**Behavior:** +- Builds label selector query string: `key1=value1,key2=value2` +- Calls `GET /v1/sessions?labelSelector=...` +- Validates label keys and values + +--- + +### acp_bulk_label_resources + +Add labels to multiple sessions (max 3). Requires `confirm=true`. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "sessions": "array[string] (required) - max 3 items", + "labels": "object (required) - Labels to add", + "confirm": "boolean (optional, default: false)", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "labeled": ["session-1", "session-2"], + "failed": [], + "labels": {"env": "test"} +} +``` + +**Behavior:** +- Validates bulk limit (max 3 sessions) +- Validates label keys and values +- Server enforces `confirm=true` for non-dry-run execution + +--- + +### acp_bulk_unlabel_resources + +Remove labels from multiple sessions (max 3). Requires `confirm=true`. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "sessions": "array[string] (required) - max 3 items", + "label_keys": "array[string] (required) - Label keys to remove", + "confirm": "boolean (optional, default: false)", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "unlabeled": ["session-1", "session-2"], + "failed": [], + "label_keys": ["env"] +} +``` + +**Behavior:** +- Validates bulk limit (max 3 sessions) +- Server enforces `confirm=true` for non-dry-run execution + +--- + ## Bulk Operations ### acp_bulk_delete_sessions @@ -218,6 +609,154 @@ Delete multiple sessions (max 3). Requires `confirm=true` for non-dry-run execut --- +### acp_bulk_stop_sessions + +Stop multiple running sessions (max 3). Requires `confirm=true`. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "sessions": "array[string] (required) - max 3 items", + "confirm": "boolean (optional, default: false)", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "stopped": ["session-1", "session-2"], + "failed": [] +} +``` + +**Behavior:** +- Validates bulk limit (max 3 sessions) +- Server enforces `confirm=true` for non-dry-run execution +- Iterates through sessions, calling `stop_session()` for each + +--- + +### acp_bulk_restart_sessions + +Restart multiple stopped sessions (max 3). Requires `confirm=true`. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "sessions": "array[string] (required) - max 3 items", + "confirm": "boolean (optional, default: false)", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "restarted": ["session-1", "session-2"], + "failed": [] +} +``` + +**Behavior:** +- Validates bulk limit (max 3 sessions) +- Server enforces `confirm=true` for non-dry-run execution +- Iterates through sessions, calling `restart_session()` for each + +--- + +### acp_bulk_delete_sessions_by_label + +Delete sessions matching label selectors (max 3 matches). Requires `confirm=true`. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "labels": "object (required) - Label selectors, e.g. {\"env\": \"test\"}", + "confirm": "boolean (optional, default: false)", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "deleted": ["session-1"], + "failed": [], + "labels_filter": {"env": "test"} +} +``` + +**Behavior:** +- Finds sessions matching labels via `list_sessions_by_label()` +- Validates bulk limit (max 3 matching sessions) +- Iterates through matched sessions, calling `delete_session()` for each + +--- + +### acp_bulk_stop_sessions_by_label + +Stop sessions matching label selectors (max 3 matches). Requires `confirm=true`. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "labels": "object (required) - Label selectors", + "confirm": "boolean (optional, default: false)", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "stopped": ["session-1"], + "failed": [], + "labels_filter": {"env": "test"} +} +``` + +**Behavior:** +- Finds sessions matching labels via `list_sessions_by_label()` +- Validates bulk limit (max 3 matching sessions) +- Iterates through matched sessions, calling `stop_session()` for each + +--- + +### acp_bulk_restart_sessions_by_label + +Restart sessions matching label selectors (max 3 matches). Requires `confirm=true`. + +**Input Schema:** +```json +{ + "project": "string (optional, uses default if not provided)", + "labels": "object (required) - Label selectors", + "confirm": "boolean (optional, default: false)", + "dry_run": "boolean (optional, default: false)" +} +``` + +**Output:** +```json +{ + "restarted": ["session-1"], + "failed": [], + "labels_filter": {"env": "test"} +} +``` + +**Behavior:** +- Finds sessions matching labels via `list_sessions_by_label()` +- Validates bulk limit (max 3 matching sessions) +- Iterates through matched sessions, calling `restart_session()` for each + +--- + ## Cluster Management ### acp_list_clusters @@ -306,6 +845,35 @@ Switch to a different cluster context. --- +### acp_login + +Authenticate to a cluster with a Bearer token. + +**Input Schema:** +```json +{ + "cluster": "string (required) - Cluster alias name", + "token": "string (optional) - Bearer token for authentication" +} +``` + +**Output:** +```json +{ + "authenticated": true, + "cluster": "vteam-stage", + "server": "https://public-api-ambient.apps.vteam-stage.example.com", + "message": "Successfully authenticated to cluster 'vteam-stage'" +} +``` + +**Behavior:** +- Sets the token on the cluster config in memory (not persisted to disk) +- Verifies the token is valid by checking configuration +- If no token provided, checks for existing token or `ACP_TOKEN` environment variable + +--- + ## Error Handling **Validation errors:** @@ -349,15 +917,15 @@ default_cluster: vteam-stage ## Tool Inventory Summary -**Total: 8 Tools** +**Total: 26 Tools** | 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 | - -See [issue #27](https://github.com/ambient-code/mcp/issues/27) for 21 planned additional tools. +| Session Management | 8 | list_sessions, get_session, create_session, create_session_from_template, delete_session, restart_session, clone_session, update_session | +| Observability | 3 | get_session_logs, get_session_transcript, get_session_metrics | +| Labels | 5 | label_resource, unlabel_resource, list_sessions_by_label, bulk_label_resources, bulk_unlabel_resources | +| Bulk Operations | 6 | bulk_delete_sessions, bulk_stop_sessions, bulk_restart_sessions, bulk_delete_sessions_by_label, bulk_stop_sessions_by_label, bulk_restart_sessions_by_label | +| Cluster Management | 4 | list_clusters, whoami, switch_cluster, login | --- diff --git a/CLAUDE.md b/CLAUDE.md index 068b676..7295f45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ uv run python -m mcp_acp.server ### Three-Layer Design **1. MCP Server Layer (`server.py`)** -- Exposes 8 MCP tools via stdio protocol +- Exposes 26 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 @@ -98,7 +98,7 @@ uv run python -m mcp_acp.server **3. Formatting Layer (`formatters.py`)** - 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. +- Format functions: `format_result()`, `format_bulk_result()`, `format_sessions_list()`, `format_session_created()`, `format_logs()`, `format_transcript()`, `format_metrics()`, `format_labels()`, `format_login()`, etc. ### Data Flow @@ -382,6 +382,15 @@ From `client.py`: ```python MAX_BULK_ITEMS = 3 DEFAULT_TIMEOUT = 30.0 # seconds (httpx request timeout) + +LABEL_VALUE_PATTERN = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$") + +SESSION_TEMPLATES = { + "triage": {"workflow": "triage", "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.7}}, + "bugfix": {"workflow": "bugfix", "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.3}}, + "feature": {"workflow": "feature-development", "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.5}}, + "exploration": {"workflow": "codebase-exploration", "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.8}}, +} ``` --- @@ -412,11 +421,9 @@ DEFAULT_TIMEOUT = 30.0 # seconds (httpx request timeout) ## Documentation - **[README.md](README.md)** - Project overview, quick start, and usage guide -- **[API_REFERENCE.md](API_REFERENCE.md)** - Complete tool specifications (8 tools) +- **[API_REFERENCE.md](API_REFERENCE.md)** - Complete tool specifications (26 tools) - **[SECURITY.md](SECURITY.md)** - Security features and threat model -See [issue #27](https://github.com/ambient-code/mcp/issues/27) for 21 planned additional tools. - --- ## Notes for Future Claude Instances @@ -437,9 +444,17 @@ See [issue #27](https://github.com/ambient-code/mcp/issues/27) for 21 planned ad ### When Adding New Tools -- See [issue #27](https://github.com/ambient-code/mcp/issues/27) for the backlog of planned tools +- See issues [#28](https://github.com/ambient-code/mcp/issues/28) and [#29](https://github.com/ambient-code/mcp/issues/29) for the remaining planned tools - Follow the 4-step pattern: client method -> tool definition -> dispatch branch -> tests -- All API calls go through `_request()` method +- All API calls go through `_request()` or `_request_text()` methods + +### When Working with Label Operations + +- Labels are validated via `_validate_labels()` using `LABEL_VALUE_PATTERN` +- Label keys and values: 1-63 chars, alphanumeric, dashes, dots, underscores +- Label selectors are built as `key1=value1,key2=value2` query strings +- Bulk label/unlabel operations use `_validate_bulk_operation()` for the 3-item limit +- "By label" bulk operations first resolve labels to session names via `_run_bulk_by_label()` ### Code Quality Standards diff --git a/README.md b/README.md index 939fc2f..8d97b40 100644 --- a/README.md +++ b/README.md @@ -52,21 +52,62 @@ List my ACP sessions ## Features +### Session Management + | Tool | Description | |------|-------------| | `acp_list_sessions` | List/filter sessions by status, age, with sorting and limits | | `acp_get_session` | Get detailed session information by ID | | `acp_create_session` | Create sessions with custom prompts, repos, model selection, and timeout | +| `acp_create_session_from_template` | Create sessions from predefined templates (triage/bugfix/feature/exploration) | | `acp_delete_session` | Delete sessions with dry-run preview | +| `acp_restart_session` | Restart a stopped session | +| `acp_clone_session` | Clone an existing session's configuration into a new session | +| `acp_update_session` | Update session metadata (display name, timeout) | + +### Observability + +| Tool | Description | +|------|-------------| +| `acp_get_session_logs` | Retrieve container logs for a session | +| `acp_get_session_transcript` | Retrieve conversation history (JSON or Markdown) | +| `acp_get_session_metrics` | Get usage statistics (tokens, duration, tool calls) | + +### Labels + +| Tool | Description | +|------|-------------| +| `acp_label_resource` | Add labels to a session for organizing and filtering | +| `acp_unlabel_resource` | Remove labels from a session by key | +| `acp_list_sessions_by_label` | List sessions matching label selectors | +| `acp_bulk_label_resources` | Add labels to multiple sessions (max 3) | +| `acp_bulk_unlabel_resources` | Remove labels from multiple sessions (max 3) | + +### Bulk Operations + +| Tool | Description | +|------|-------------| | `acp_bulk_delete_sessions` | Delete multiple sessions (max 3) with confirmation and dry-run | +| `acp_bulk_stop_sessions` | Stop multiple running sessions (max 3) | +| `acp_bulk_restart_sessions` | Restart multiple stopped sessions (max 3) | +| `acp_bulk_delete_sessions_by_label` | Delete sessions matching label selectors (max 3 matches) | +| `acp_bulk_stop_sessions_by_label` | Stop sessions matching label selectors (max 3 matches) | +| `acp_bulk_restart_sessions_by_label` | Restart sessions matching label selectors (max 3 matches) | + +### Cluster Management + +| Tool | Description | +|------|-------------| | `acp_list_clusters` | List configured cluster aliases | | `acp_whoami` | Check current configuration and authentication status | | `acp_switch_cluster` | Switch between configured clusters | +| `acp_login` | Authenticate to a cluster with a Bearer token | **Safety Features:** - **Dry-Run Mode** — All mutating operations support `dry_run` for safe preview before executing - **Bulk Operation Limits** — Maximum 3 items per bulk operation with confirmation requirement +- **Label Validation** — Labels must be 1-63 alphanumeric characters, dashes, dots, or underscores --- @@ -218,19 +259,42 @@ Show AgenticSession session-name in my-workspace # Create a session Create a new ACP session with prompt "Run all unit tests and report results" +# Create from template +Create an ACP session from the bugfix template called "fix-auth-issue" + +# Restart / clone +Restart ACP session my-stopped-session +Clone ACP session my-session as "my-session-v2" + +# Update session metadata +Update ACP session my-session display name to "Production Test Runner" + +# Observability +Show logs for ACP session my-session +Get transcript for ACP session my-session in markdown format +Show metrics for ACP session my-session + +# Labels +Label ACP session my-session with env=staging and team=platform +Remove label env from ACP session my-session +List ACP sessions with label team=platform + # Delete with dry-run (safe!) Delete test-session from my-workspace in dry-run mode # Actually delete Delete test-session from my-workspace -# Bulk delete (dry-run first) +# Bulk operations (dry-run first) Delete these sessions: session-1, session-2, session-3 from my-workspace (dry-run first) +Stop all sessions with label env=test +Restart sessions with label team=platform # Cluster operations Check my ACP authentication List my ACP clusters Switch to ACP cluster vteam-prod +Login to ACP cluster vteam-stage with token ``` ### Trigger Keywords @@ -247,10 +311,22 @@ Include one of these keywords so your MCP client routes the request to ACP: **AC | Filter age | `List sessions older than 7d in PROJECT` | | Get details | `Get details for ACP session SESSION` | | Create | `Create ACP session with prompt "..."` | +| Create from template | `Create ACP session from bugfix template` | +| Restart | `Restart ACP session SESSION` | +| Clone | `Clone ACP session SESSION as "new-name"` | +| Update | `Update ACP session SESSION timeout to 1800` | +| View logs | `Show logs for ACP session SESSION` | +| View transcript | `Get transcript for ACP session SESSION` | +| View metrics | `Show metrics for ACP session SESSION` | +| Add labels | `Label ACP session SESSION with env=test` | +| Remove labels | `Remove label env from ACP session SESSION` | +| Filter by label | `List ACP sessions with label team=platform` | | Delete (dry) | `Delete SESSION in PROJECT (dry-run)` | | Delete (real) | `Delete SESSION in PROJECT` | | Bulk delete | `Delete session-1, session-2 in PROJECT` | +| Bulk by label | `Stop sessions with label env=test` | | List clusters | `Use acp_list_clusters` | +| Login | `Login to ACP cluster CLUSTER` | --- @@ -263,11 +339,29 @@ For complete API specifications including input schemas, output formats, and beh | **Session** | `acp_list_sessions` | List/filter sessions | | | `acp_get_session` | Get session details | | | `acp_create_session` | Create session with prompt | +| | `acp_create_session_from_template` | Create from template | | | `acp_delete_session` | Delete with dry-run support | +| | `acp_restart_session` | Restart stopped session | +| | `acp_clone_session` | Clone session configuration | +| | `acp_update_session` | Update display name or timeout | +| **Observability** | `acp_get_session_logs` | Retrieve container logs | +| | `acp_get_session_transcript` | Get conversation history | +| | `acp_get_session_metrics` | Get usage statistics | +| **Labels** | `acp_label_resource` | Add labels to session | +| | `acp_unlabel_resource` | Remove labels by key | +| | `acp_list_sessions_by_label` | Filter sessions by labels | +| | `acp_bulk_label_resources` | Bulk add labels (max 3) | +| | `acp_bulk_unlabel_resources` | Bulk remove labels (max 3) | | **Bulk** | `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 by label (max 3) | +| | `acp_bulk_stop_sessions_by_label` | Stop by label (max 3) | +| | `acp_bulk_restart_sessions_by_label` | Restart by label (max 3) | | **Cluster** | `acp_list_clusters` | List configured clusters | | | `acp_whoami` | Check authentication status | | | `acp_switch_cluster` | Switch cluster context | +| | `acp_login` | Authenticate with Bearer token | --- @@ -364,12 +458,10 @@ See [CLAUDE.md](CLAUDE.md#development-commands) for contributing guidelines. ## Roadmap -Current implementation provides 8 core tools. See [issue #27](https://github.com/ambient-code/mcp/issues/27) for 21 planned additional tools covering: +Current implementation provides 26 tools. 3 tools remain planned: -- Session lifecycle (restart, clone, templates, update, export) -- Observability (logs, transcripts, metrics) -- Label management and advanced bulk operations -- Cluster and workflow management +- Session export ([issue #28](https://github.com/ambient-code/mcp/issues/28)) +- Workflow management ([issue #29](https://github.com/ambient-code/mcp/issues/29)) --- @@ -389,13 +481,13 @@ Current implementation provides 8 core tools. See [issue #27](https://github.com **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)) +**Tools**: 26 implemented ([3 more planned](https://github.com/ambient-code/mcp/issues/28)) --- ## Documentation -- **[API_REFERENCE.md](API_REFERENCE.md)** — Full API specifications for all 8 tools +- **[API_REFERENCE.md](API_REFERENCE.md)** — Full API specifications for all 26 tools - **[SECURITY.md](SECURITY.md)** — Security features, threat model, and best practices - **[CLAUDE.md](CLAUDE.md)** — System architecture and development guide diff --git a/src/mcp_acp/client.py b/src/mcp_acp/client.py index f27cf19..7f937c5 100644 --- a/src/mcp_acp/client.py +++ b/src/mcp_acp/client.py @@ -16,6 +16,27 @@ logger = get_python_logger() +LABEL_VALUE_PATTERN = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$") + +SESSION_TEMPLATES: dict[str, dict[str, Any]] = { + "triage": { + "workflow": "triage", + "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.7}, + }, + "bugfix": { + "workflow": "bugfix", + "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.3}, + }, + "feature": { + "workflow": "feature-development", + "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.5}, + }, + "exploration": { + "workflow": "codebase-exploration", + "llmConfig": {"model": "claude-sonnet-4", "temperature": 0.8}, + }, +} + class ACPClient: """Client for interacting with Ambient Code Platform via public API. @@ -56,6 +77,8 @@ def __init__(self, config_path: str | None = None, settings=None): default_cluster=self.clusters_config.default_cluster, ) + # ── HTTP infrastructure ────────────────────────────────────────────── + def _get_cluster_config(self, cluster_name: str | None = None) -> dict[str, Any]: """Get cluster configuration.""" name = cluster_name or self.clusters_config.default_cluster @@ -102,7 +125,7 @@ async def _request( json_data: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Make an HTTP request to the public API.""" + """Make an HTTP request to the public API expecting JSON response.""" cluster_config = self._get_cluster_config(cluster_name) token = self._get_token(cluster_config) base_url = cluster_config["server"] @@ -154,6 +177,45 @@ async def _request( logger.error("api_request_error", method=method, path=path, error=str(e)) raise ValueError(f"Request failed: {str(e)}") from e + async def _request_text( + self, + method: str, + path: str, + project: str, + cluster_name: str | None = None, + params: dict[str, Any] | None = None, + ) -> str: + """Make an HTTP request expecting text response (e.g., logs).""" + cluster_config = self._get_cluster_config(cluster_name) + token = self._get_token(cluster_config) + base_url = cluster_config["server"] + + url = f"{base_url}{path}" + headers = { + "Authorization": f"Bearer {token}", + "X-Ambient-Project": project, + "Accept": "text/plain", + } + + client = await self._get_http_client() + + try: + response = await client.request(method=method, url=url, headers=headers, params=params) + + if response.status_code >= 400: + raise ValueError(f"HTTP {response.status_code}: {response.text}") + + return response.text + + except httpx.TimeoutException as e: + logger.error("api_request_timeout", method=method, path=path, error=str(e)) + raise TimeoutError(f"Request timed out: {path}") from e + except httpx.RequestError as e: + logger.error("api_request_error", method=method, path=path, error=str(e)) + raise ValueError(f"Request failed: {str(e)}") from e + + # ── Validation ─────────────────────────────────────────────────────── + def _validate_input(self, value: str, field_name: str, max_length: int = 253) -> None: """Validate input to prevent injection attacks.""" if not isinstance(value, str): @@ -171,6 +233,56 @@ def _validate_bulk_operation(self, items: list[str], operation_name: str) -> Non f"You requested {len(items)}. Split into multiple operations." ) + def _validate_labels(self, labels: dict[str, str]) -> None: + """Validate label keys and values.""" + if not labels: + raise ValueError("Labels must not be empty") + for key, value in labels.items(): + if len(key) > 63 or not LABEL_VALUE_PATTERN.match(key): + raise ValueError( + f"Invalid label key '{key}'. Must be 1-63 alphanumeric chars, dashes, dots, or underscores." + ) + if len(value) > 63 or not LABEL_VALUE_PATTERN.match(value): + raise ValueError( + f"Invalid label value '{value}' for key '{key}'. Must be 1-63 alphanumeric chars, dashes, dots, or underscores." + ) + + # ── Time utilities ─────────────────────────────────────────────────── + + def _parse_time_delta(self, time_str: str) -> datetime: + """Parse time delta string to datetime.""" + match = re.match(r"(\d+)([dhm])", time_str.lower()) + if not match: + raise ValueError(f"Invalid time format: {time_str}. Use '7d', '24h', or '30m'") + + value, unit = int(match.group(1)), match.group(2) + now = datetime.utcnow() + + deltas = {"d": timedelta(days=value), "h": timedelta(hours=value), "m": timedelta(minutes=value)} + return now - deltas[unit] + + def _is_older_than(self, timestamp_str: str | None, cutoff: datetime) -> bool: + """Check if timestamp is older than cutoff.""" + if not timestamp_str: + return False + timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) + return timestamp.replace(tzinfo=None) < cutoff + + def _sort_sessions(self, sessions: list[dict], sort_by: str) -> list[dict]: + """Sort sessions by field.""" + sort_keys = { + "created": lambda s: s.get("createdAt", ""), + "stopped": lambda s: s.get("completedAt", ""), + "name": lambda s: s.get("id", ""), + } + + key_fn = sort_keys.get(sort_by) + if key_fn: + return sorted(sessions, key=key_fn, reverse=(sort_by != "name")) + return sessions + + # ── Session CRUD ───────────────────────────────────────────────────── + async def list_sessions( self, project: str, @@ -179,15 +291,7 @@ async def list_sessions( sort_by: str | None = None, limit: int | None = None, ) -> dict[str, Any]: - """List sessions with filtering. - - Args: - project: Project/namespace name - status: Filter by status (running, stopped, creating, failed) - older_than: Filter by age (e.g., "7d", "24h") - sort_by: Sort field (created, stopped, name) - limit: Maximum results - """ + """List sessions with filtering.""" self._validate_input(project, "project") response = await self._request("GET", "/v1/sessions", project) @@ -221,45 +325,8 @@ async def list_sessions( "filters_applied": filters_applied, } - def _sort_sessions(self, sessions: list[dict], sort_by: str) -> list[dict]: - """Sort sessions by field.""" - sort_keys = { - "created": lambda s: s.get("createdAt", ""), - "stopped": lambda s: s.get("completedAt", ""), - "name": lambda s: s.get("id", ""), - } - - key_fn = sort_keys.get(sort_by) - if key_fn: - return sorted(sessions, key=key_fn, reverse=(sort_by != "name")) - return sessions - - def _parse_time_delta(self, time_str: str) -> datetime: - """Parse time delta string to datetime.""" - match = re.match(r"(\d+)([dhm])", time_str.lower()) - if not match: - raise ValueError(f"Invalid time format: {time_str}. Use '7d', '24h', or '30m'") - - value, unit = int(match.group(1)), match.group(2) - now = datetime.utcnow() - - deltas = {"d": timedelta(days=value), "h": timedelta(hours=value), "m": timedelta(minutes=value)} - return now - deltas[unit] - - def _is_older_than(self, timestamp_str: str | None, cutoff: datetime) -> bool: - """Check if timestamp is older than cutoff.""" - if not timestamp_str: - return False - timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) - return timestamp.replace(tzinfo=None) < cutoff - async def get_session(self, project: str, session: str) -> dict[str, Any]: - """Get a session by ID. - - Args: - project: Project/namespace name - session: Session ID - """ + """Get a session by ID.""" self._validate_input(project, "project") self._validate_input(session, "session") @@ -276,18 +343,7 @@ async def create_session( 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 - """ + """Create an AgenticSession with a custom prompt.""" self._validate_input(project, "project") session_data: dict[str, Any] = { @@ -324,14 +380,53 @@ async def create_session( 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. + async def create_session_from_template( + self, + project: str, + template: str, + display_name: str, + repos: list[str] | None = None, + dry_run: bool = False, + ) -> dict[str, Any]: + """Create a session from a predefined template (triage/bugfix/feature/exploration).""" + self._validate_input(project, "project") - Args: - project: Project/namespace name - session: Session name - dry_run: Preview without deleting - """ + if template not in SESSION_TEMPLATES: + raise ValueError(f"Unknown template '{template}'. Available: {', '.join(SESSION_TEMPLATES.keys())}") + + template_config = SESSION_TEMPLATES[template] + session_data: dict[str, Any] = { + "displayName": display_name, + **template_config, + } + + if repos: + session_data["repos"] = repos + + if dry_run: + return { + "dry_run": True, + "success": True, + "message": f"Would create session from template '{template}'", + "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, + "template": template, + "message": f"Session '{session_id}' created from template '{template}'", + } + 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.""" self._validate_input(project, "project") self._validate_input(session, "session") @@ -367,51 +462,400 @@ async def delete_session(self, project: str, session: str, dry_run: bool = False "message": f"Failed to delete session: {str(e)}", } - async def bulk_delete_sessions(self, project: str, sessions: list[str], dry_run: bool = False) -> dict[str, Any]: - """Delete multiple sessions (max 3). + async def restart_session(self, project: str, session: str, dry_run: bool = False) -> dict[str, Any]: + """Restart a stopped session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") - Args: - project: Project/namespace name - sessions: List of session names - dry_run: Preview without deleting - """ - self._validate_bulk_operation(sessions, "delete") + if dry_run: + try: + session_data = await self._request("GET", f"/v1/sessions/{session}", project) + return { + "dry_run": True, + "success": True, + "message": f"Would restart session '{session}' in project '{project}'", + "session_info": { + "name": session_data.get("id"), + "status": session_data.get("status"), + }, + } + except ValueError: + return {"dry_run": True, "success": False, "message": f"Session '{session}' not found"} - success = [] - failed = [] - dry_run_info = {"would_execute": [], "skipped": []} + try: + await self._request("PATCH", f"/v1/sessions/{session}", project, json_data={"stopped": False}) + return {"restarted": True, "message": f"Successfully restarted session '{session}'"} + except ValueError as e: + return {"restarted": False, "message": f"Failed to restart session: {str(e)}"} - for session in sessions: - result = await self.delete_session(project, session, dry_run=dry_run) + async def stop_session(self, project: str, session: str, dry_run: bool = False) -> dict[str, Any]: + """Stop a running session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + if dry_run: + try: + session_data = await self._request("GET", f"/v1/sessions/{session}", project) + return { + "dry_run": True, + "success": True, + "message": f"Would stop session '{session}' in project '{project}'", + "session_info": { + "name": session_data.get("id"), + "status": session_data.get("status"), + }, + } + except ValueError: + return {"dry_run": True, "success": False, "message": f"Session '{session}' not found"} + + try: + await self._request("PATCH", f"/v1/sessions/{session}", project, json_data={"stopped": True}) + return {"stopped": True, "message": f"Successfully stopped session '{session}'"} + except ValueError as e: + return {"stopped": False, "message": f"Failed to stop session: {str(e)}"} + + async def clone_session( + self, project: str, source_session: str, new_display_name: str, dry_run: bool = False + ) -> dict[str, Any]: + """Clone an existing session with its configuration.""" + self._validate_input(project, "project") + self._validate_input(source_session, "source_session") + + source = await self._request("GET", f"/v1/sessions/{source_session}", project) + + clone_data: dict[str, Any] = { + "displayName": new_display_name, + "initialPrompt": source.get("initialPrompt", ""), + "interactive": source.get("interactive", False), + "timeout": source.get("timeout", 900), + } + + if source.get("llmConfig"): + clone_data["llmConfig"] = source["llmConfig"] + if source.get("repos"): + clone_data["repos"] = source["repos"] + + if dry_run: + return { + "dry_run": True, + "success": True, + "message": f"Would clone session '{source_session}' as '{new_display_name}'", + "manifest": clone_data, + "source_session": source_session, + "project": project, + } + + try: + result = await self._request("POST", "/v1/sessions", project, json_data=clone_data) + session_id = result.get("id", "unknown") + return { + "created": True, + "session": session_id, + "source_session": source_session, + "project": project, + "message": f"Session '{session_id}' cloned from '{source_session}'", + } + except (ValueError, TimeoutError) as e: + return {"created": False, "message": str(e)} + + async def update_session( + self, + project: str, + session: str, + display_name: str | None = None, + timeout: int | None = None, + dry_run: bool = False, + ) -> dict[str, Any]: + """Update session metadata (display name, timeout).""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + patch_data: dict[str, Any] = {} + if display_name is not None: + patch_data["displayName"] = display_name + if timeout is not None: + patch_data["timeout"] = timeout + + if not patch_data: + raise ValueError("No fields to update. Provide display_name or timeout.") + + if dry_run: + try: + current = await self._request("GET", f"/v1/sessions/{session}", project) + return { + "dry_run": True, + "success": True, + "message": f"Would update session '{session}'", + "current": current, + "patch": patch_data, + } + except ValueError: + return {"dry_run": True, "success": False, "message": f"Session '{session}' not found"} + + try: + result = await self._request("PATCH", f"/v1/sessions/{session}", project, json_data=patch_data) + return { + "updated": True, + "message": f"Successfully updated session '{session}'", + "session": result, + } + except ValueError as e: + return {"updated": False, "message": f"Failed to update session: {str(e)}"} + + # ── Observability ──────────────────────────────────────────────────── + + async def get_session_logs( + self, + project: str, + session: str, + container: str | None = None, + tail_lines: int = 1000, + ) -> dict[str, Any]: + """Retrieve container logs for a session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + if tail_lines > 10000: + raise ValueError("tail_lines cannot exceed 10000") + + params: dict[str, Any] = {"tailLines": tail_lines} + if container: + params["container"] = container + + try: + text = await self._request_text("GET", f"/v1/sessions/{session}/logs", project, params=params) + return {"logs": text, "session": session, "tail_lines": tail_lines} + except (ValueError, TimeoutError) as e: + return {"logs": "", "error": str(e), "session": session} + + async def get_session_transcript( + self, + project: str, + session: str, + format: str = "json", + ) -> dict[str, Any]: + """Retrieve conversation history for a session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + if format not in ("json", "markdown"): + raise ValueError("format must be 'json' or 'markdown'") + + params = {"format": format} + result = await self._request("GET", f"/v1/sessions/{session}/transcript", project, params=params) + result["session"] = session + result["format"] = format + return result + + async def get_session_metrics(self, project: str, session: str) -> dict[str, Any]: + """Get usage statistics for a session (tokens, duration, tool calls).""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + result = await self._request("GET", f"/v1/sessions/{session}/metrics", project) + result["session"] = session + return result + + # ── Labels ─────────────────────────────────────────────────────────── + + async def label_session(self, project: str, session: str, labels: dict[str, str]) -> dict[str, Any]: + """Add labels to a session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + self._validate_labels(labels) + + await self._request("PATCH", f"/v1/sessions/{session}", project, json_data={"labels": labels}) + return { + "labeled": True, + "session": session, + "labels_added": labels, + "message": f"Added {len(labels)} label(s) to session '{session}'", + } + + async def unlabel_session(self, project: str, session: str, label_keys: list[str]) -> dict[str, Any]: + """Remove labels from a session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + if not label_keys: + raise ValueError("label_keys must not be empty") + + await self._request("PATCH", f"/v1/sessions/{session}", project, json_data={"removeLabels": label_keys}) + return { + "unlabeled": True, + "session": session, + "labels_removed": label_keys, + "message": f"Removed {len(label_keys)} label(s) from session '{session}'", + } + + async def list_sessions_by_label(self, project: str, labels: dict[str, str]) -> dict[str, Any]: + """List sessions matching label selectors.""" + self._validate_input(project, "project") + self._validate_labels(labels) + + # Build label selector query: key1=value1,key2=value2 + selector = ",".join(f"{k}={v}" for k, v in labels.items()) + params = {"labelSelector": selector} + + response = await self._request("GET", "/v1/sessions", project, params=params) + sessions = response.get("items", []) + + return { + "sessions": sessions, + "total": len(sessions), + "labels_filter": labels, + } + + # ── Bulk operations ────────────────────────────────────────────────── + + async def _run_bulk( + self, + project: str, + sessions: list[str], + op_fn, + operation_name: str, + success_key: str, + dry_run: bool = False, + ) -> dict[str, Any]: + """Shared bulk operation runner. Iterates sessions, calls op_fn, collects results.""" + self._validate_bulk_operation(sessions, operation_name) + + success: list[str] = [] + failed: list[dict[str, str]] = [] + dry_run_info: dict[str, list] = {"would_execute": [], "skipped": []} + + for session_name in sessions: + result = await op_fn(project=project, session=session_name, dry_run=dry_run) if dry_run: if result.get("success", True): - dry_run_info["would_execute"].append( - { - "session": session, - "info": result.get("session_info"), - } - ) + dry_run_info["would_execute"].append({"session": session_name, "info": result.get("session_info")}) else: - dry_run_info["skipped"].append( - { - "session": session, - "reason": result.get("message"), - } - ) + dry_run_info["skipped"].append({"session": session_name, "reason": result.get("message")}) else: - if result.get("deleted"): - success.append(session) + if result.get(success_key): + success.append(session_name) else: - failed.append({"session": session, "error": result.get("message")}) + failed.append({"session": session_name, "error": result.get("message", "unknown error")}) - response = {"deleted": success, "failed": failed} + response: dict[str, Any] = {success_key: success, "failed": failed} if dry_run: response["dry_run"] = True response["dry_run_info"] = dry_run_info - return response + async def _run_bulk_by_label( + self, + project: str, + labels: dict[str, str], + op_fn, + operation_name: str, + success_key: str, + dry_run: bool = False, + ) -> dict[str, Any]: + """Find sessions by label, then run bulk operation on them.""" + matched = await self.list_sessions_by_label(project, labels) + sessions = matched.get("sessions", []) + names = [s.get("id", s.get("metadata", {}).get("name", "")) for s in sessions] + + if not names: + return { + success_key: [], + "failed": [], + "message": "No sessions match the given labels", + "labels_filter": labels, + } + + self._validate_bulk_operation(names, operation_name) + result = await self._run_bulk(project, names, op_fn, operation_name, success_key, dry_run) + result["labels_filter"] = labels + return result + + async def bulk_delete_sessions(self, project: str, sessions: list[str], dry_run: bool = False) -> dict[str, Any]: + """Delete multiple sessions (max 3).""" + return await self._run_bulk(project, sessions, self.delete_session, "delete", "deleted", dry_run) + + async def bulk_stop_sessions(self, project: str, sessions: list[str], dry_run: bool = False) -> dict[str, Any]: + """Stop multiple running sessions (max 3).""" + return await self._run_bulk(project, sessions, self.stop_session, "stop", "stopped", dry_run) + + async def bulk_restart_sessions(self, project: str, sessions: list[str], dry_run: bool = False) -> dict[str, Any]: + """Restart multiple stopped sessions (max 3).""" + return await self._run_bulk(project, sessions, self.restart_session, "restart", "restarted", dry_run) + + async def bulk_label_sessions( + self, project: str, sessions: list[str], labels: dict[str, str], dry_run: bool = False + ) -> dict[str, Any]: + """Add labels to multiple sessions (max 3).""" + self._validate_bulk_operation(sessions, "label") + self._validate_labels(labels) + + success: list[str] = [] + failed: list[dict[str, str]] = [] + + if dry_run: + return { + "dry_run": True, + "sessions": sessions, + "labels": labels, + "message": f"Would add {len(labels)} label(s) to {len(sessions)} session(s)", + } + + for session_name in sessions: + try: + await self.label_session(project, session_name, labels) + success.append(session_name) + except (ValueError, TimeoutError) as e: + failed.append({"session": session_name, "error": str(e)}) + + return {"labeled": success, "failed": failed, "labels": labels} + + async def bulk_unlabel_sessions( + self, project: str, sessions: list[str], label_keys: list[str], dry_run: bool = False + ) -> dict[str, Any]: + """Remove labels from multiple sessions (max 3).""" + self._validate_bulk_operation(sessions, "unlabel") + + if dry_run: + return { + "dry_run": True, + "sessions": sessions, + "label_keys": label_keys, + "message": f"Would remove {len(label_keys)} label(s) from {len(sessions)} session(s)", + } + + success: list[str] = [] + failed: list[dict[str, str]] = [] + + for session_name in sessions: + try: + await self.unlabel_session(project, session_name, label_keys) + success.append(session_name) + except (ValueError, TimeoutError) as e: + failed.append({"session": session_name, "error": str(e)}) + + return {"unlabeled": success, "failed": failed, "label_keys": label_keys} + + async def bulk_delete_sessions_by_label( + self, project: str, labels: dict[str, str], dry_run: bool = False + ) -> dict[str, Any]: + """Delete sessions matching label selectors (max 3 matches).""" + return await self._run_bulk_by_label(project, labels, self.delete_session, "delete", "deleted", dry_run) + + async def bulk_stop_sessions_by_label( + self, project: str, labels: dict[str, str], dry_run: bool = False + ) -> dict[str, Any]: + """Stop sessions matching label selectors (max 3 matches).""" + return await self._run_bulk_by_label(project, labels, self.stop_session, "stop", "stopped", dry_run) + + async def bulk_restart_sessions_by_label( + self, project: str, labels: dict[str, str], dry_run: bool = False + ) -> dict[str, Any]: + """Restart sessions matching label selectors (max 3 matches).""" + return await self._run_bulk_by_label(project, labels, self.restart_session, "restart", "restarted", dry_run) + + # ── Cluster & auth ─────────────────────────────────────────────────── + def list_clusters(self) -> dict[str, Any]: """List configured clusters.""" clusters = [] @@ -460,11 +904,7 @@ async def whoami(self) -> dict[str, Any]: } async def switch_cluster(self, cluster: str) -> dict[str, Any]: - """Switch to a different cluster context. - - Args: - cluster: Cluster alias name - """ + """Switch to a different cluster context.""" if cluster not in self.clusters_config.clusters: return { "switched": False, @@ -481,6 +921,42 @@ async def switch_cluster(self, cluster: str) -> dict[str, Any]: "message": f"Switched from {previous_cluster} to {cluster}", } + async def login(self, cluster: str, token: str | None = None) -> dict[str, Any]: + """Authenticate to a cluster with a token. + + Sets the token on the cluster config (in memory) and verifies it works. + """ + if cluster not in self.clusters_config.clusters: + return { + "authenticated": False, + "message": f"Unknown cluster: {cluster}. Use acp_list_clusters to see available clusters.", + } + + cluster_obj = self.clusters_config.clusters[cluster] + + if token: + cluster_obj.token = token + + # Verify the token works by calling whoami + previous = self.clusters_config.default_cluster + self.clusters_config.default_cluster = cluster + + try: + cluster_config = self._get_cluster_config(cluster) + self._get_token(cluster_config) + return { + "authenticated": True, + "cluster": cluster, + "server": cluster_config["server"], + "message": f"Successfully authenticated to cluster '{cluster}'", + } + except ValueError as e: + return {"authenticated": False, "cluster": cluster, "message": str(e)} + finally: + self.clusters_config.default_cluster = previous + + # ── Cleanup ────────────────────────────────────────────────────────── + async def close(self) -> None: """Close the HTTP client.""" if self._http_client and not self._http_client.is_closed: diff --git a/src/mcp_acp/formatters.py b/src/mcp_acp/formatters.py index 2a327a7..f2b2519 100644 --- a/src/mcp_acp/formatters.py +++ b/src/mcp_acp/formatters.py @@ -11,6 +11,10 @@ def format_result(result: dict[str, Any]) -> str: output += result.get("message", "") if "session_info" in result: output += f"\n\nSession Info:\n{json.dumps(result['session_info'], indent=2)}" + if "patch" in result: + output += f"\n\nPatch:\n{json.dumps(result['patch'], indent=2)}" + if "current" in result: + output += f"\n\nCurrent:\n{json.dumps(result['current'], indent=2)}" return output return result.get("message", json.dumps(result, indent=2)) @@ -21,8 +25,12 @@ def format_sessions_list(result: dict[str, Any]) -> str: output = f"Found {result['total']} session(s)" filters = result.get("filters_applied", {}) + labels_filter = result.get("labels_filter") + if filters: output += f"\nFilters applied: {json.dumps(filters)}" + if labels_filter: + output += f"\nLabel filter: {json.dumps(labels_filter)}" output += "\n\nSessions:\n" @@ -67,14 +75,27 @@ def format_bulk_result(result: dict[str, Any], operation: str) -> str: output += f": {item['reason']}" output += "\n" + # Handle label/unlabel dry run (different format) + if result.get("message") and not would_execute and not skipped: + output += result["message"] + "\n" + + if result.get("labels_filter"): + output += f"\nLabel filter: {json.dumps(result['labels_filter'])}\n" + return output - success_key_map = {"delete": "deleted", "stop": "stopped", "restart": "restarted"} + success_key_map = { + "delete": "deleted", + "stop": "stopped", + "restart": "restarted", + "label": "labeled", + "unlabel": "unlabeled", + } success_key = success_key_map.get(operation, operation) success = result.get(success_key, []) failed = result.get("failed", []) - output = f"Successfully {operation}d {len(success)} session(s)" + output = f"Successfully {success_key} {len(success)} session(s)" if success: output += ":\n" @@ -86,6 +107,12 @@ def format_bulk_result(result: dict[str, Any], operation: str) -> str: for item in failed: output += f" - {item['session']}: {item['error']}\n" + if result.get("labels_filter"): + output += f"\nLabel filter: {json.dumps(result['labels_filter'])}\n" + + if not success and result.get("message"): + output = result["message"] + return output @@ -154,9 +181,94 @@ def format_session_created(result: dict[str, Any]) -> str: project = result.get("project", "unknown") output = f"Session created: {session}\n" - output += f"Project: {project}\n\n" - output += "Check status:\n" + output += f"Project: {project}\n" + + if result.get("template"): + output += f"Template: {result['template']}\n" + if result.get("source_session"): + output += f"Cloned from: {result['source_session']}\n" + + output += "\nCheck status:\n" output += f' acp_list_sessions(project="{project}")\n' output += f' acp_get_session(project="{project}", session="{session}")\n' return output + + +def format_logs(result: dict[str, Any]) -> str: + """Format session logs output.""" + if result.get("error"): + return f"Error retrieving logs for session '{result.get('session', 'unknown')}': {result['error']}" + + session = result.get("session", "unknown") + tail_lines = result.get("tail_lines", "unknown") + logs = result.get("logs", "") + + output = f"Logs for session '{session}' (tail: {tail_lines}):\n\n" + output += logs if logs else "(no logs available)" + return output + + +def format_transcript(result: dict[str, Any]) -> str: + """Format session transcript output.""" + session = result.get("session", "unknown") + fmt = result.get("format", "json") + + output = f"Transcript for session '{session}' (format: {fmt}):\n\n" + + if fmt == "markdown": + output += result.get("transcript", result.get("text", "(no transcript available)")) + else: + # JSON format — pretty-print the transcript data + transcript_data = result.get("messages", result.get("transcript", [])) + if transcript_data: + output += json.dumps(transcript_data, indent=2) + else: + output += "(no transcript available)" + + return output + + +def format_metrics(result: dict[str, Any]) -> str: + """Format session metrics output.""" + session = result.get("session", "unknown") + + output = f"Metrics for session '{session}':\n\n" + + for key, value in result.items(): + if key == "session": + continue + label = key.replace("_", " ").title() + output += f" {label}: {value}\n" + + return output + + +def format_labels(result: dict[str, Any]) -> str: + """Format label operation results.""" + if result.get("labeled"): + if isinstance(result["labeled"], bool): + return result.get("message", "Labels updated") + # bulk label result + return format_bulk_result(result, "label") + + if result.get("unlabeled"): + if isinstance(result["unlabeled"], bool): + return result.get("message", "Labels removed") + return format_bulk_result(result, "unlabel") + + return result.get("message", json.dumps(result, indent=2)) + + +def format_login(result: dict[str, Any]) -> str: + """Format login result.""" + if result.get("authenticated"): + output = "Authentication successful\n\n" + output += f"Cluster: {result.get('cluster', 'unknown')}\n" + output += f"Server: {result.get('server', 'unknown')}\n" + else: + output = "Authentication failed\n\n" + output += f"Cluster: {result.get('cluster', 'unknown')}\n" + output += f"Error: {result.get('message', 'unknown error')}\n" + + return output diff --git a/src/mcp_acp/server.py b/src/mcp_acp/server.py index e42cf18..d9c08b1 100644 --- a/src/mcp_acp/server.py +++ b/src/mcp_acp/server.py @@ -14,9 +14,14 @@ from .formatters import ( format_bulk_result, format_clusters, + format_labels, + format_login, + format_logs, + format_metrics, format_result, format_session_created, format_sessions_list, + format_transcript, format_whoami, ) @@ -45,39 +50,41 @@ def get_client() -> ACPClient: return _client +# ── Shared schema fragments ───────────────────────────────────────────── + +_PROJECT = {"type": "string", "description": "Project/namespace name (uses default if not provided)"} +_SESSION = {"type": "string", "description": "Session ID"} +_DRY_RUN = {"type": "boolean", "description": "Preview without executing (default: false)", "default": False} +_CONFIRM = {"type": "boolean", "description": "Required for destructive operations (default: false)", "default": False} +_SESSIONS_ARRAY = {"type": "array", "items": {"type": "string"}, "description": "List of session names (max 3)"} +_LABELS_OBJECT = { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": 'Labels as key-value pairs (e.g., {"env": "test", "team": "qa"})', +} +_LABEL_KEYS_ARRAY = {"type": "array", "items": {"type": "string"}, "description": "List of label keys to remove"} + + @app.list_tools() async def list_tools() -> list[Tool]: """List available ACP tools for managing AgenticSession resources.""" return [ + # ── Session Management ─────────────────────────────────────────── Tool( name="acp_list_sessions", description="List and filter AgenticSessions in a project. Filter by status (running/stopped/failed), age. Sort and limit results.", inputSchema={ "type": "object", "properties": { - "project": { - "type": "string", - "description": "Project/namespace name (uses default if not provided)", - }, + "project": _PROJECT, "status": { "type": "string", "description": "Filter by status", "enum": ["running", "stopped", "creating", "failed"], }, - "older_than": { - "type": "string", - "description": "Filter by age (e.g., '7d', '24h', '30m')", - }, - "sort_by": { - "type": "string", - "description": "Sort field", - "enum": ["created", "stopped", "name"], - }, - "limit": { - "type": "integer", - "description": "Maximum number of results", - "minimum": 1, - }, + "older_than": {"type": "string", "description": "Filter by age (e.g., '7d', '24h', '30m')"}, + "sort_by": {"type": "string", "description": "Sort field", "enum": ["created", "stopped", "name"]}, + "limit": {"type": "integer", "description": "Maximum number of results", "minimum": 1}, }, "required": [], }, @@ -87,119 +94,297 @@ async def list_tools() -> list[Tool]: description="Get details of a specific session by ID.", inputSchema={ "type": "object", - "properties": { - "project": { - "type": "string", - "description": "Project/namespace name (uses default if not provided)", - }, - "session": { - "type": "string", - "description": "Session ID", - }, - }, + "properties": {"project": _PROJECT, "session": _SESSION}, "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.", + description="Create an ACP AgenticSession with a custom prompt. Supports dry-run mode.", inputSchema={ "type": "object", "properties": { - "project": { - "type": "string", - "description": "Project/namespace name (uses default if not provided)", - }, + "project": _PROJECT, "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", - }, + "display_name": {"type": "string", "description": "Human-readable display name"}, + "repos": {"type": "array", "items": {"type": "string"}, "description": "Repository URLs to clone"}, "interactive": { "type": "boolean", - "description": "Create an interactive session (default: false)", + "description": "Create an interactive session", "default": False, }, - "model": { + "model": {"type": "string", "description": "LLM model to use", "default": "claude-sonnet-4"}, + "timeout": {"type": "integer", "description": "Timeout in seconds", "default": 900, "minimum": 60}, + "dry_run": _DRY_RUN, + }, + "required": ["initial_prompt"], + }, + ), + Tool( + name="acp_create_session_from_template", + description="Create a session from a predefined template (triage/bugfix/feature/exploration). Each template has optimized settings.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "template": { "type": "string", - "description": "LLM model to use (default: claude-sonnet-4)", - "default": "claude-sonnet-4", + "description": "Template name", + "enum": ["triage", "bugfix", "feature", "exploration"], }, - "timeout": { + "display_name": {"type": "string", "description": "Display name for the session"}, + "repos": {"type": "array", "items": {"type": "string"}, "description": "Repository URLs to clone"}, + "dry_run": _DRY_RUN, + }, + "required": ["template", "display_name"], + }, + ), + Tool( + name="acp_delete_session", + description="Delete an AgenticSession. Supports dry-run mode.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "session": _SESSION, "dry_run": _DRY_RUN}, + "required": ["session"], + }, + ), + Tool( + name="acp_restart_session", + description="Restart a stopped session. Supports dry-run mode.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "session": _SESSION, "dry_run": _DRY_RUN}, + "required": ["session"], + }, + ), + Tool( + name="acp_clone_session", + description="Clone an existing session's configuration into a new session.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "source_session": {"type": "string", "description": "Session ID to clone from"}, + "new_display_name": {"type": "string", "description": "Display name for the cloned session"}, + "dry_run": _DRY_RUN, + }, + "required": ["source_session", "new_display_name"], + }, + ), + Tool( + name="acp_update_session", + description="Update session metadata (display name, timeout). Supports dry-run mode.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "session": _SESSION, + "display_name": {"type": "string", "description": "New display name"}, + "timeout": {"type": "integer", "description": "New timeout in seconds", "minimum": 60}, + "dry_run": _DRY_RUN, + }, + "required": ["session"], + }, + ), + # ── Observability ──────────────────────────────────────────────── + Tool( + name="acp_get_session_logs", + description="Retrieve container logs for a session. Useful for debugging.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "session": _SESSION, + "container": {"type": "string", "description": "Container name (optional)"}, + "tail_lines": { "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, + "description": "Number of log lines (default: 1000, max: 10000)", + "default": 1000, + "maximum": 10000, }, }, - "required": ["initial_prompt"], + "required": ["session"], }, ), Tool( - name="acp_delete_session", - description="Delete an AgenticSession. Supports dry-run mode for preview.", + name="acp_get_session_transcript", + description="Retrieve conversation history for a session in JSON or Markdown format.", inputSchema={ "type": "object", "properties": { - "project": { + "project": _PROJECT, + "session": _SESSION, + "format": { "type": "string", - "description": "Project/namespace name (uses default if not provided)", + "description": "Output format", + "enum": ["json", "markdown"], + "default": "json", }, - "session": { + }, + "required": ["session"], + }, + ), + Tool( + name="acp_get_session_metrics", + description="Get usage statistics for a session (tokens, duration, tool calls).", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "session": _SESSION}, + "required": ["session"], + }, + ), + # ── Labels ─────────────────────────────────────────────────────── + Tool( + name="acp_label_resource", + description="Add labels to a session. Labels are key-value pairs for organizing and filtering.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "name": {"type": "string", "description": "Session name"}, + "resource_type": { "type": "string", - "description": "Session name", + "description": "Resource type", + "enum": ["agenticsession"], + "default": "agenticsession", }, - "dry_run": { - "type": "boolean", - "description": "Preview without deleting (default: false)", - "default": False, + "labels": _LABELS_OBJECT, + }, + "required": ["name", "labels"], + }, + ), + Tool( + name="acp_unlabel_resource", + description="Remove labels from a session by key.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "name": {"type": "string", "description": "Session name"}, + "resource_type": { + "type": "string", + "description": "Resource type", + "enum": ["agenticsession"], + "default": "agenticsession", }, + "label_keys": _LABEL_KEYS_ARRAY, }, - "required": ["session"], + "required": ["name", "label_keys"], + }, + ), + Tool( + name="acp_list_sessions_by_label", + description="List sessions matching label selectors.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "labels": _LABELS_OBJECT}, + "required": ["labels"], + }, + ), + Tool( + name="acp_bulk_label_resources", + description="Add labels to multiple sessions (max 3). DESTRUCTIVE: requires confirm=true.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "sessions": _SESSIONS_ARRAY, + "labels": _LABELS_OBJECT, + "confirm": _CONFIRM, + "dry_run": _DRY_RUN, + }, + "required": ["sessions", "labels"], + }, + ), + Tool( + name="acp_bulk_unlabel_resources", + description="Remove labels from multiple sessions (max 3). DESTRUCTIVE: requires confirm=true.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "sessions": _SESSIONS_ARRAY, + "label_keys": _LABEL_KEYS_ARRAY, + "confirm": _CONFIRM, + "dry_run": _DRY_RUN, + }, + "required": ["sessions", "label_keys"], }, ), + # ── Bulk Operations ────────────────────────────────────────────── Tool( name="acp_bulk_delete_sessions", description="Delete multiple sessions (max 3). DESTRUCTIVE: requires confirm=true. Use dry_run=true first!", inputSchema={ "type": "object", "properties": { - "project": { - "type": "string", - "description": "Project/namespace name (uses default if not provided)", - }, - "sessions": { - "type": "array", - "items": {"type": "string"}, - "description": "List of session names", - }, - "confirm": { - "type": "boolean", - "description": "Required for destructive operations (default: false)", - "default": False, - }, - "dry_run": { - "type": "boolean", - "description": "Preview without deleting (default: false)", - "default": False, - }, + "project": _PROJECT, + "sessions": _SESSIONS_ARRAY, + "confirm": _CONFIRM, + "dry_run": _DRY_RUN, + }, + "required": ["sessions"], + }, + ), + Tool( + name="acp_bulk_stop_sessions", + description="Stop multiple running sessions (max 3). DESTRUCTIVE: requires confirm=true.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "sessions": _SESSIONS_ARRAY, + "confirm": _CONFIRM, + "dry_run": _DRY_RUN, + }, + "required": ["sessions"], + }, + ), + Tool( + name="acp_bulk_restart_sessions", + description="Restart multiple stopped sessions (max 3). Requires confirm=true.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "sessions": _SESSIONS_ARRAY, + "confirm": _CONFIRM, + "dry_run": _DRY_RUN, }, "required": ["sessions"], }, ), + Tool( + name="acp_bulk_delete_sessions_by_label", + description="Delete sessions matching label selectors (max 3 matches). DESTRUCTIVE: requires confirm=true.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "labels": _LABELS_OBJECT, "confirm": _CONFIRM, "dry_run": _DRY_RUN}, + "required": ["labels"], + }, + ), + Tool( + name="acp_bulk_stop_sessions_by_label", + description="Stop sessions matching label selectors (max 3 matches). DESTRUCTIVE: requires confirm=true.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "labels": _LABELS_OBJECT, "confirm": _CONFIRM, "dry_run": _DRY_RUN}, + "required": ["labels"], + }, + ), + Tool( + name="acp_bulk_restart_sessions_by_label", + description="Restart sessions matching label selectors (max 3 matches). Requires confirm=true.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "labels": _LABELS_OBJECT, "confirm": _CONFIRM, "dry_run": _DRY_RUN}, + "required": ["labels"], + }, + ), + # ── Cluster Management ─────────────────────────────────────────── Tool( name="acp_list_clusters", description="List configured cluster aliases from clusters.yaml.", @@ -213,13 +398,20 @@ async def list_tools() -> list[Tool]: Tool( name="acp_switch_cluster", description="Switch to a different cluster context.", + inputSchema={ + "type": "object", + "properties": {"cluster": {"type": "string", "description": "Cluster alias name"}}, + "required": ["cluster"], + }, + ), + Tool( + name="acp_login", + description="Authenticate to a cluster with a Bearer token. Sets the token in memory and verifies it works.", inputSchema={ "type": "object", "properties": { - "cluster": { - "type": "string", - "description": "Cluster alias name", - }, + "cluster": {"type": "string", "description": "Cluster alias name"}, + "token": {"type": "string", "description": "Bearer token for authentication"}, }, "required": ["cluster"], }, @@ -231,6 +423,19 @@ async def list_tools() -> list[Tool]: "acp_list_clusters", "acp_whoami", "acp_switch_cluster", + "acp_login", +} + +# Tools that require confirm=true for non-dry-run execution +TOOLS_REQUIRING_CONFIRMATION = { + "acp_bulk_delete_sessions", + "acp_bulk_stop_sessions", + "acp_bulk_restart_sessions", + "acp_bulk_delete_sessions_by_label", + "acp_bulk_stop_sessions_by_label", + "acp_bulk_restart_sessions_by_label", + "acp_bulk_label_resources", + "acp_bulk_unlabel_resources", } @@ -256,10 +461,19 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: arguments["project"] = cluster.default_project logger.info("project_autofilled", project=cluster.default_project) - # Dispatch to handler + # Confirmation enforcement for destructive bulk operations + if name in TOOLS_REQUIRING_CONFIRMATION: + if not arguments.get("dry_run") and not arguments.get("confirm"): + op = name.replace("acp_bulk_", "").replace("_", " ") + raise ValueError(f"Bulk {op} requires confirm=true. Use dry_run=true to preview first.") + + # ── Dispatch ───────────────────────────────────────────────── + project = arguments.get("project", "") + + # Session management if name == "acp_list_sessions": result = await client.list_sessions( - project=arguments.get("project", ""), + project=project, status=arguments.get("status"), older_than=arguments.get("older_than"), sort_by=arguments.get("sort_by"), @@ -268,15 +482,12 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: text = format_sessions_list(result) elif name == "acp_get_session": - result = await client.get_session( - project=arguments.get("project", ""), - session=arguments["session"], - ) + result = await client.get_session(project=project, session=arguments["session"]) text = format_result(result) elif name == "acp_create_session": result = await client.create_session( - project=arguments.get("project", ""), + project=project, initial_prompt=arguments["initial_prompt"], display_name=arguments.get("display_name"), repos=arguments.get("repos"), @@ -287,24 +498,141 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: ) text = format_session_created(result) + elif name == "acp_create_session_from_template": + result = await client.create_session_from_template( + project=project, + template=arguments["template"], + display_name=arguments["display_name"], + repos=arguments.get("repos"), + 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", ""), + project=project, session=arguments["session"], dry_run=arguments.get("dry_run", False) + ) + text = format_result(result) + + elif name == "acp_restart_session": + result = await client.restart_session( + project=project, session=arguments["session"], dry_run=arguments.get("dry_run", False) + ) + text = format_result(result) + + elif name == "acp_clone_session": + result = await client.clone_session( + project=project, + source_session=arguments["source_session"], + new_display_name=arguments["new_display_name"], + dry_run=arguments.get("dry_run", False), + ) + text = format_session_created(result) + + elif name == "acp_update_session": + result = await client.update_session( + project=project, session=arguments["session"], + display_name=arguments.get("display_name"), + timeout=arguments.get("timeout"), dry_run=arguments.get("dry_run", False), ) text = format_result(result) - elif name == "acp_bulk_delete_sessions": - 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.") - result = await client.bulk_delete_sessions( - project=arguments.get("project", ""), + # Observability + elif name == "acp_get_session_logs": + result = await client.get_session_logs( + project=project, + session=arguments["session"], + container=arguments.get("container"), + tail_lines=arguments.get("tail_lines", 1000), + ) + text = format_logs(result) + + elif name == "acp_get_session_transcript": + result = await client.get_session_transcript( + project=project, + session=arguments["session"], + format=arguments.get("format", "json"), + ) + text = format_transcript(result) + + elif name == "acp_get_session_metrics": + result = await client.get_session_metrics(project=project, session=arguments["session"]) + text = format_metrics(result) + + # Labels + elif name == "acp_label_resource": + result = await client.label_session(project=project, session=arguments["name"], labels=arguments["labels"]) + text = format_labels(result) + + elif name == "acp_unlabel_resource": + result = await client.unlabel_session( + project=project, session=arguments["name"], label_keys=arguments["label_keys"] + ) + text = format_labels(result) + + elif name == "acp_list_sessions_by_label": + result = await client.list_sessions_by_label(project=project, labels=arguments["labels"]) + text = format_sessions_list(result) + + elif name == "acp_bulk_label_resources": + result = await client.bulk_label_sessions( + project=project, sessions=arguments["sessions"], + labels=arguments["labels"], dry_run=arguments.get("dry_run", False), ) + text = format_labels(result) + + elif name == "acp_bulk_unlabel_resources": + result = await client.bulk_unlabel_sessions( + project=project, + sessions=arguments["sessions"], + label_keys=arguments["label_keys"], + dry_run=arguments.get("dry_run", False), + ) + text = format_labels(result) + + # Bulk operations (named) + elif name == "acp_bulk_delete_sessions": + result = await client.bulk_delete_sessions( + project=project, sessions=arguments["sessions"], dry_run=arguments.get("dry_run", False) + ) text = format_bulk_result(result, "delete") + elif name == "acp_bulk_stop_sessions": + result = await client.bulk_stop_sessions( + project=project, sessions=arguments["sessions"], dry_run=arguments.get("dry_run", False) + ) + text = format_bulk_result(result, "stop") + + elif name == "acp_bulk_restart_sessions": + result = await client.bulk_restart_sessions( + project=project, sessions=arguments["sessions"], dry_run=arguments.get("dry_run", False) + ) + text = format_bulk_result(result, "restart") + + # Bulk operations (by label) + elif name == "acp_bulk_delete_sessions_by_label": + result = await client.bulk_delete_sessions_by_label( + project=project, labels=arguments["labels"], dry_run=arguments.get("dry_run", False) + ) + text = format_bulk_result(result, "delete") + + elif name == "acp_bulk_stop_sessions_by_label": + result = await client.bulk_stop_sessions_by_label( + project=project, labels=arguments["labels"], dry_run=arguments.get("dry_run", False) + ) + text = format_bulk_result(result, "stop") + + elif name == "acp_bulk_restart_sessions_by_label": + result = await client.bulk_restart_sessions_by_label( + project=project, labels=arguments["labels"], dry_run=arguments.get("dry_run", False) + ) + text = format_bulk_result(result, "restart") + + # Cluster management elif name == "acp_list_clusters": result = client.list_clusters() text = format_clusters(result) @@ -317,6 +645,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: result = await client.switch_cluster(arguments["cluster"]) text = format_result(result) + elif name == "acp_login": + result = await client.login(cluster=arguments["cluster"], token=arguments.get("token")) + text = format_login(result) + else: logger.warning("unknown_tool_requested", tool=name) return [TextContent(type="text", text=f"Unknown tool: {name}")] diff --git a/tests/test_client.py b/tests/test_client.py index df328d6..019d4a0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -362,3 +362,714 @@ async def test_create_session_custom_model_and_timeout(self, client: ACPClient) manifest = result["manifest"] assert manifest["llmConfig"]["model"] == "claude-opus-4" assert manifest["timeout"] == 3600 + + +class TestRestartSession: + """Tests for restart_session.""" + + @pytest.mark.asyncio + async def test_restart_session_dry_run(self, client: ACPClient) -> None: + """Dry run should preview restart without executing.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "session-1", "status": "stopped"} + + 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.restart_session("test-project", "session-1", dry_run=True) + + assert result["dry_run"] is True + assert result["success"] is True + assert "Would restart" in result["message"] + + @pytest.mark.asyncio + async def test_restart_session_success(self, client: ACPClient) -> None: + """Restart should PATCH with stopped=False.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "session-1", "status": "running"} + + 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.restart_session("test-project", "session-1") + + assert result["restarted"] is True + assert "Successfully restarted" in result["message"] + + call_args = mock_http_client.request.call_args + assert call_args.kwargs["method"] == "PATCH" + assert call_args.kwargs["json"] == {"stopped": False} + + +class TestStopSession: + """Tests for stop_session.""" + + @pytest.mark.asyncio + async def test_stop_session_dry_run(self, client: ACPClient) -> None: + """Dry run should preview stop without executing.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "session-1", "status": "running"} + + 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.stop_session("test-project", "session-1", dry_run=True) + + assert result["dry_run"] is True + assert result["success"] is True + assert "Would stop" in result["message"] + + @pytest.mark.asyncio + async def test_stop_session_success(self, client: ACPClient) -> None: + """Stop should PATCH with stopped=True.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "session-1", "status": "stopped"} + + 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.stop_session("test-project", "session-1") + + assert result["stopped"] is True + assert "Successfully stopped" in result["message"] + + call_args = mock_http_client.request.call_args + assert call_args.kwargs["method"] == "PATCH" + assert call_args.kwargs["json"] == {"stopped": True} + + +class TestCloneSession: + """Tests for clone_session.""" + + @pytest.mark.asyncio + async def test_clone_session_dry_run(self, client: ACPClient) -> None: + """Dry run should GET source and return manifest without POSTing.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "source-1", + "initialPrompt": "original prompt", + "interactive": False, + "timeout": 900, + "llmConfig": {"model": "claude-sonnet-4"}, + "repos": ["https://github.com/org/repo"], + } + + 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.clone_session("test-project", "source-1", "clone-name", dry_run=True) + + assert result["dry_run"] is True + assert result["success"] is True + assert "Would clone" in result["message"] + assert result["manifest"]["displayName"] == "clone-name" + assert result["source_session"] == "source-1" + + @pytest.mark.asyncio + async def test_clone_session_success(self, client: ACPClient) -> None: + """Clone should GET source then POST new session.""" + source_response = MagicMock() + source_response.status_code = 200 + source_response.json.return_value = { + "id": "source-1", + "initialPrompt": "original prompt", + "interactive": False, + "timeout": 900, + "llmConfig": {"model": "claude-sonnet-4"}, + } + + create_response = MagicMock() + create_response.status_code = 201 + create_response.json.return_value = {"id": "cloned-abc12"} + + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(side_effect=[source_response, create_response]) + mock_get_client.return_value = mock_http_client + + result = await client.clone_session("test-project", "source-1", "my-clone") + + assert result["created"] is True + assert result["session"] == "cloned-abc12" + assert result["source_session"] == "source-1" + + +class TestUpdateSession: + """Tests for update_session.""" + + @pytest.mark.asyncio + async def test_update_session_display_name(self, client: ACPClient) -> None: + """Should update display name via PATCH.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "session-1", "displayName": "new-name"} + + 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.update_session("test-project", "session-1", display_name="new-name") + + assert result["updated"] is True + assert "Successfully updated" in result["message"] + + @pytest.mark.asyncio + async def test_update_session_timeout(self, client: ACPClient) -> None: + """Should update timeout via PATCH.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "session-1", "timeout": 1800} + + 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.update_session("test-project", "session-1", timeout=1800) + + assert result["updated"] is True + + @pytest.mark.asyncio + async def test_update_session_dry_run(self, client: ACPClient) -> None: + """Dry run should preview update without executing.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "session-1", "displayName": "old-name"} + + 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.update_session("test-project", "session-1", display_name="new-name", dry_run=True) + + assert result["dry_run"] is True + assert result["success"] is True + assert result["patch"] == {"displayName": "new-name"} + + @pytest.mark.asyncio + async def test_update_session_no_fields_raises(self, client: ACPClient) -> None: + """Should raise ValueError when no fields provided.""" + with pytest.raises(ValueError, match="No fields to update"): + await client.update_session("test-project", "session-1") + + +class TestCreateSessionFromTemplate: + """Tests for create_session_from_template.""" + + @pytest.mark.asyncio + async def test_create_from_template_dry_run(self, client: ACPClient) -> None: + """Dry run should return template manifest.""" + result = await client.create_session_from_template( + project="test-project", + template="bugfix", + display_name="Fix login bug", + dry_run=True, + ) + + assert result["dry_run"] is True + assert result["success"] is True + assert "bugfix" in result["message"] + manifest = result["manifest"] + assert manifest["displayName"] == "Fix login bug" + assert manifest["workflow"] == "bugfix" + assert manifest["llmConfig"]["model"] == "claude-sonnet-4" + + @pytest.mark.asyncio + async def test_create_from_template_invalid_raises(self, client: ACPClient) -> None: + """Invalid template name should raise ValueError.""" + with pytest.raises(ValueError, match="Unknown template"): + await client.create_session_from_template( + project="test-project", + template="nonexistent", + display_name="test", + ) + + @pytest.mark.asyncio + async def test_create_from_template_success(self, client: ACPClient) -> None: + """Successful template creation should return session info.""" + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "template-abc12"} + + 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_from_template( + project="test-project", + template="triage", + display_name="Triage issue", + ) + + assert result["created"] is True + assert result["session"] == "template-abc12" + assert result["template"] == "triage" + + +class TestGetSessionLogs: + """Tests for get_session_logs.""" + + @pytest.mark.asyncio + async def test_get_session_logs_success(self, client: ACPClient) -> None: + """Should return logs text from _request_text.""" + with patch.object(client, "_request_text", new_callable=AsyncMock, return_value="log line 1\nlog line 2"): + result = await client.get_session_logs("test-project", "session-1") + + assert result["logs"] == "log line 1\nlog line 2" + assert result["session"] == "session-1" + assert result["tail_lines"] == 1000 + + @pytest.mark.asyncio + async def test_get_session_logs_error(self, client: ACPClient) -> None: + """Should return error dict when request fails.""" + with patch.object(client, "_request_text", new_callable=AsyncMock, side_effect=ValueError("Not found")): + result = await client.get_session_logs("test-project", "session-1") + + assert result["logs"] == "" + assert "Not found" in result["error"] + + @pytest.mark.asyncio + async def test_get_session_logs_tail_lines_limit(self, client: ACPClient) -> None: + """Should reject tail_lines > 10000.""" + with pytest.raises(ValueError, match="tail_lines cannot exceed 10000"): + await client.get_session_logs("test-project", "session-1", tail_lines=10001) + + +class TestGetSessionTranscript: + """Tests for get_session_transcript.""" + + @pytest.mark.asyncio + async def test_get_session_transcript_success(self, client: ACPClient) -> None: + """Should return transcript data.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "messages": [{"role": "user", "content": "hello"}], + } + + 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.get_session_transcript("test-project", "session-1", format="json") + + assert result["session"] == "session-1" + assert result["format"] == "json" + assert result["messages"] == [{"role": "user", "content": "hello"}] + + @pytest.mark.asyncio + async def test_get_session_transcript_invalid_format(self, client: ACPClient) -> None: + """Invalid format should raise ValueError.""" + with pytest.raises(ValueError, match="format must be"): + await client.get_session_transcript("test-project", "session-1", format="xml") + + +class TestGetSessionMetrics: + """Tests for get_session_metrics.""" + + @pytest.mark.asyncio + async def test_get_session_metrics_success(self, client: ACPClient) -> None: + """Should return metrics data.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "total_tokens": 5000, + "duration_seconds": 120, + "tool_calls": 15, + } + + 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.get_session_metrics("test-project", "session-1") + + assert result["session"] == "session-1" + assert result["total_tokens"] == 5000 + assert result["duration_seconds"] == 120 + assert result["tool_calls"] == 15 + + +class TestLabelSession: + """Tests for label_session.""" + + @pytest.mark.asyncio + async def test_label_session_success(self, client: ACPClient) -> None: + """Should add labels via PATCH.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "session-1"} + + 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.label_session("test-project", "session-1", {"env": "test", "team": "qa"}) + + assert result["labeled"] is True + assert result["labels_added"] == {"env": "test", "team": "qa"} + assert "2 label(s)" in result["message"] + + +class TestUnlabelSession: + """Tests for unlabel_session.""" + + @pytest.mark.asyncio + async def test_unlabel_session_success(self, client: ACPClient) -> None: + """Should remove labels via PATCH.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "session-1"} + + 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.unlabel_session("test-project", "session-1", ["env", "team"]) + + assert result["unlabeled"] is True + assert result["labels_removed"] == ["env", "team"] + assert "2 label(s)" in result["message"] + + @pytest.mark.asyncio + async def test_unlabel_session_empty_keys_raises(self, client: ACPClient) -> None: + """Should raise ValueError when label_keys is empty.""" + with pytest.raises(ValueError, match="label_keys must not be empty"): + await client.unlabel_session("test-project", "session-1", []) + + +class TestListSessionsByLabel: + """Tests for list_sessions_by_label.""" + + @pytest.mark.asyncio + async def test_list_sessions_by_label(self, client: ACPClient) -> None: + """Should list sessions matching label selectors.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "items": [{"id": "session-1", "status": "running"}], + } + + 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_by_label("test-project", {"env": "test"}) + + assert result["total"] == 1 + assert result["sessions"][0]["id"] == "session-1" + assert result["labels_filter"] == {"env": "test"} + + +class TestLabelValidation: + """Tests for _validate_labels.""" + + def test_validate_labels_valid(self, client: ACPClient) -> None: + """Valid labels should pass validation.""" + client._validate_labels({"env": "production", "team": "qa"}) + client._validate_labels({"app.version": "v1.2.3"}) + + def test_validate_labels_empty_raises(self, client: ACPClient) -> None: + """Empty labels dict should raise ValueError.""" + with pytest.raises(ValueError, match="Labels must not be empty"): + client._validate_labels({}) + + def test_validate_labels_invalid_key(self, client: ACPClient) -> None: + """Invalid label key should raise ValueError.""" + with pytest.raises(ValueError, match="Invalid label key"): + client._validate_labels({"invalid key!": "value"}) + + def test_validate_labels_key_too_long(self, client: ACPClient) -> None: + """Label key exceeding 63 chars should raise ValueError.""" + with pytest.raises(ValueError, match="Invalid label key"): + client._validate_labels({"a" * 64: "value"}) + + def test_validate_labels_invalid_value(self, client: ACPClient) -> None: + """Invalid label value should raise ValueError.""" + with pytest.raises(ValueError, match="Invalid label value"): + client._validate_labels({"key": "invalid value!"}) + + +class TestBulkDeleteDryRun: + """Tests for bulk delete dry_run path.""" + + @pytest.mark.asyncio + async def test_bulk_delete_dry_run(self, client: ACPClient) -> None: + """Dry run should preview deletes without executing.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "s1", "status": "running"} + + 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.bulk_delete_sessions("test-project", ["s1", "s2"], dry_run=True) + + assert result["dry_run"] is True + assert len(result["dry_run_info"]["would_execute"]) == 2 + assert result["dry_run_info"]["would_execute"][0]["session"] == "s1" + + +class TestBulkDeleteFailure: + """Tests for _run_bulk failure path.""" + + @pytest.mark.asyncio + async def test_bulk_delete_partial_failure(self, client: ACPClient) -> None: + """Individual failures in bulk delete should be collected.""" + success_response = MagicMock() + success_response.status_code = 204 + + fail_response = MagicMock() + fail_response.status_code = 404 + fail_response.text = "not found" + fail_response.json.return_value = {"error": "not found"} + + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(side_effect=[success_response, fail_response]) + mock_get_client.return_value = mock_http_client + + result = await client.bulk_delete_sessions("test-project", ["s1", "s2"]) + + assert "s1" in result["deleted"] + assert len(result["failed"]) == 1 + assert result["failed"][0]["session"] == "s2" + + +class TestBulkByLabel: + """Tests for _run_bulk_by_label pipeline.""" + + @pytest.mark.asyncio + async def test_bulk_delete_by_label(self, client: ACPClient) -> None: + """Should resolve labels to sessions, then delete them.""" + list_response = MagicMock() + list_response.status_code = 200 + list_response.json.return_value = {"items": [{"id": "s1"}, {"id": "s2"}]} + + delete_response = MagicMock() + delete_response.status_code = 204 + + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(side_effect=[list_response, delete_response, delete_response]) + mock_get_client.return_value = mock_http_client + + result = await client.bulk_delete_sessions_by_label("test-project", {"env": "test"}) + + assert "s1" in result["deleted"] + assert "s2" in result["deleted"] + assert result["labels_filter"] == {"env": "test"} + + @pytest.mark.asyncio + async def test_bulk_delete_by_label_no_matches(self, client: ACPClient) -> None: + """Should return empty results when no sessions match labels.""" + list_response = MagicMock() + list_response.status_code = 200 + list_response.json.return_value = {"items": []} + + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(return_value=list_response) + mock_get_client.return_value = mock_http_client + + result = await client.bulk_delete_sessions_by_label("test-project", {"env": "nonexistent"}) + + assert result["deleted"] == [] + assert result["failed"] == [] + assert "No sessions match" in result["message"] + + @pytest.mark.asyncio + async def test_bulk_stop_by_label(self, client: ACPClient) -> None: + """Should resolve labels to sessions, then stop them.""" + list_response = MagicMock() + list_response.status_code = 200 + list_response.json.return_value = {"items": [{"id": "s1"}]} + + stop_response = MagicMock() + stop_response.status_code = 200 + stop_response.json.return_value = {"id": "s1", "status": "stopped"} + + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(side_effect=[list_response, stop_response]) + mock_get_client.return_value = mock_http_client + + result = await client.bulk_stop_sessions_by_label("test-project", {"team": "qa"}) + + assert "s1" in result["stopped"] + assert result["labels_filter"] == {"team": "qa"} + + @pytest.mark.asyncio + async def test_bulk_restart_by_label(self, client: ACPClient) -> None: + """Should resolve labels to sessions, then restart them.""" + list_response = MagicMock() + list_response.status_code = 200 + list_response.json.return_value = {"items": [{"id": "s1"}]} + + restart_response = MagicMock() + restart_response.status_code = 200 + restart_response.json.return_value = {"id": "s1", "status": "running"} + + with patch.object(client, "_get_http_client") as mock_get_client: + mock_http_client = AsyncMock() + mock_http_client.request = AsyncMock(side_effect=[list_response, restart_response]) + mock_get_client.return_value = mock_http_client + + result = await client.bulk_restart_sessions_by_label("test-project", {"team": "qa"}) + + assert "s1" in result["restarted"] + assert result["labels_filter"] == {"team": "qa"} + + +class TestBulkLabelSessions: + """Tests for bulk_label_sessions.""" + + @pytest.mark.asyncio + async def test_bulk_label_sessions_success(self, client: ACPClient) -> None: + """Should label multiple sessions.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "s1"} + + 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.bulk_label_sessions("test-project", ["s1", "s2"], {"env": "test"}) + + assert "s1" in result["labeled"] + assert "s2" in result["labeled"] + assert result["labels"] == {"env": "test"} + + @pytest.mark.asyncio + async def test_bulk_label_sessions_dry_run(self, client: ACPClient) -> None: + """Dry run should preview labeling without executing.""" + result = await client.bulk_label_sessions("test-project", ["s1", "s2"], {"env": "test"}, dry_run=True) + + assert result["dry_run"] is True + assert result["sessions"] == ["s1", "s2"] + assert result["labels"] == {"env": "test"} + assert "Would add 1 label(s) to 2 session(s)" in result["message"] + + +class TestBulkUnlabelSessions: + """Tests for bulk_unlabel_sessions.""" + + @pytest.mark.asyncio + async def test_bulk_unlabel_sessions_success(self, client: ACPClient) -> None: + """Should remove labels from multiple sessions.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "s1"} + + 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.bulk_unlabel_sessions("test-project", ["s1", "s2"], ["env", "team"]) + + assert "s1" in result["unlabeled"] + assert "s2" in result["unlabeled"] + assert result["label_keys"] == ["env", "team"] + + @pytest.mark.asyncio + async def test_bulk_unlabel_sessions_dry_run(self, client: ACPClient) -> None: + """Dry run should preview unlabeling without executing.""" + result = await client.bulk_unlabel_sessions("test-project", ["s1", "s2"], ["env"], dry_run=True) + + assert result["dry_run"] is True + assert result["sessions"] == ["s1", "s2"] + assert result["label_keys"] == ["env"] + assert "Would remove 1 label(s) from 2 session(s)" in result["message"] + + +class TestBulkStopSessions: + """Tests for bulk_stop_sessions.""" + + @pytest.mark.asyncio + async def test_bulk_stop_sessions(self, client: ACPClient) -> None: + """Should stop multiple sessions.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "s1", "status": "stopped"} + + 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.bulk_stop_sessions("test-project", ["s1", "s2"]) + + assert len(result["stopped"]) == 2 + assert "s1" in result["stopped"] + assert "s2" in result["stopped"] + + +class TestBulkRestartSessions: + """Tests for bulk_restart_sessions.""" + + @pytest.mark.asyncio + async def test_bulk_restart_sessions(self, client: ACPClient) -> None: + """Should restart multiple sessions.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "s1", "status": "running"} + + 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.bulk_restart_sessions("test-project", ["s1", "s2"]) + + assert len(result["restarted"]) == 2 + assert "s1" in result["restarted"] + assert "s2" in result["restarted"] + + +class TestLogin: + """Tests for login.""" + + @pytest.mark.asyncio + async def test_login_success(self, client: ACPClient) -> None: + """Should authenticate to a cluster with a token.""" + result = await client.login("test-cluster", token="new-token") + + assert result["authenticated"] is True + assert result["cluster"] == "test-cluster" + assert "Successfully authenticated" in result["message"] + + @pytest.mark.asyncio + async def test_login_unknown_cluster(self, client: ACPClient) -> None: + """Should fail for unknown cluster.""" + result = await client.login("nonexistent-cluster") + + assert result["authenticated"] is False + assert "Unknown cluster" in result["message"] diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 3131743..8568a66 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -3,8 +3,14 @@ from mcp_acp.formatters import ( format_bulk_result, format_clusters, + format_labels, + format_login, + format_logs, + format_metrics, format_result, + format_session_created, format_sessions_list, + format_transcript, format_whoami, ) @@ -183,3 +189,239 @@ def test_format_whoami_not_authenticated(self) -> None: assert "Token Configured: No" in output assert "Set token" in output + + +class TestFormatLogs: + """Tests for format_logs.""" + + def test_format_logs_with_content(self) -> None: + """Test formatting logs with actual content.""" + result = { + "logs": "2024-01-20 Starting session\n2024-01-20 Running tests", + "session": "session-1", + "tail_lines": 1000, + } + + output = format_logs(result) + + assert "Logs for session 'session-1'" in output + assert "tail: 1000" in output + assert "Starting session" in output + assert "Running tests" in output + + def test_format_logs_empty(self) -> None: + """Test formatting logs with no content.""" + result = {"logs": "", "session": "session-1", "tail_lines": 100} + + output = format_logs(result) + + assert "(no logs available)" in output + + def test_format_logs_error(self) -> None: + """Test formatting logs with error.""" + result = {"logs": "", "error": "Session not found", "session": "session-1"} + + output = format_logs(result) + + assert "Error retrieving logs" in output + assert "Session not found" in output + + +class TestFormatTranscript: + """Tests for format_transcript.""" + + def test_format_transcript_json(self) -> None: + """Test formatting transcript in JSON format.""" + result = { + "session": "session-1", + "format": "json", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + ], + } + + output = format_transcript(result) + + assert "Transcript for session 'session-1'" in output + assert "format: json" in output + assert '"role": "user"' in output + assert '"content": "hello"' in output + + def test_format_transcript_markdown(self) -> None: + """Test formatting transcript in Markdown format.""" + result = { + "session": "session-1", + "format": "markdown", + "transcript": "## User\nhello\n\n## Assistant\nhi there", + } + + output = format_transcript(result) + + assert "Transcript for session 'session-1'" in output + assert "format: markdown" in output + assert "## User" in output + + def test_format_transcript_empty(self) -> None: + """Test formatting empty transcript.""" + result = {"session": "session-1", "format": "json"} + + output = format_transcript(result) + + assert "(no transcript available)" in output + + +class TestFormatMetrics: + """Tests for format_metrics.""" + + def test_format_metrics(self) -> None: + """Test formatting session metrics.""" + result = { + "session": "session-1", + "total_tokens": 5000, + "duration_seconds": 120, + "tool_calls": 15, + } + + output = format_metrics(result) + + assert "Metrics for session 'session-1'" in output + assert "Total Tokens: 5000" in output + assert "Duration Seconds: 120" in output + assert "Tool Calls: 15" in output + + +class TestFormatLabels: + """Tests for format_labels.""" + + def test_format_labels_single_label(self) -> None: + """Test formatting single label result.""" + result = { + "labeled": True, + "session": "session-1", + "labels_added": {"env": "test"}, + "message": "Added 1 label(s) to session 'session-1'", + } + + output = format_labels(result) + + assert "Added 1 label(s)" in output + + def test_format_labels_single_unlabel(self) -> None: + """Test formatting single unlabel result.""" + result = { + "unlabeled": True, + "session": "session-1", + "labels_removed": ["env"], + "message": "Removed 1 label(s) from session 'session-1'", + } + + output = format_labels(result) + + assert "Removed 1 label(s)" in output + + def test_format_labels_bulk(self) -> None: + """Test formatting bulk label result.""" + result = { + "labeled": ["s1", "s2"], + "failed": [], + "labels": {"env": "test"}, + } + + output = format_labels(result) + + assert "Successfully labeled 2 session(s)" in output + + +class TestFormatLogin: + """Tests for format_login.""" + + def test_format_login_success(self) -> None: + """Test formatting successful login.""" + result = { + "authenticated": True, + "cluster": "test-cluster", + "server": "https://api.test.example.com", + "message": "Successfully authenticated", + } + + output = format_login(result) + + assert "Authentication successful" in output + assert "Cluster: test-cluster" in output + assert "Server: https://api.test.example.com" in output + + def test_format_login_failure(self) -> None: + """Test formatting failed login.""" + result = { + "authenticated": False, + "cluster": "test-cluster", + "message": "Invalid token", + } + + output = format_login(result) + + assert "Authentication failed" in output + assert "Invalid token" in output + + +class TestFormatSessionCreated: + """Tests for format_session_created.""" + + def test_format_session_created_from_template(self) -> None: + """Test formatting session created from template.""" + result = { + "created": True, + "session": "template-abc12", + "project": "test-project", + "template": "bugfix", + "message": "Session created from template", + } + + output = format_session_created(result) + + assert "Session created: template-abc12" in output + assert "Project: test-project" in output + assert "Template: bugfix" in output + + def test_format_session_created_from_clone(self) -> None: + """Test formatting session created from clone.""" + result = { + "created": True, + "session": "cloned-abc12", + "project": "test-project", + "source_session": "source-1", + "message": "Session cloned", + } + + output = format_session_created(result) + + assert "Session created: cloned-abc12" in output + assert "Cloned from: source-1" in output + + def test_format_session_created_dry_run(self) -> None: + """Test formatting session creation dry run.""" + result = { + "dry_run": True, + "success": True, + "message": "Would create session", + "manifest": {"initialPrompt": "test", "timeout": 900}, + } + + output = format_session_created(result) + + assert "DRY RUN MODE" in output + assert "Would create session" in output + assert "Manifest:" in output + + def test_format_session_created_failure(self) -> None: + """Test formatting session creation failure.""" + result = { + "created": False, + "message": "Invalid spec", + } + + output = format_session_created(result) + + assert "Failed to create session" in output + assert "Invalid spec" in output diff --git a/tests/test_server.py b/tests/test_server.py index 852a48b..7d21c39 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -16,23 +16,48 @@ async def test_list_tools_returns_all_tools(self) -> None: tools = await list_tools() tool_names = [t.name for t in tools] - # Session tools + # Session management tools assert "acp_list_sessions" in tool_names assert "acp_get_session" in tool_names assert "acp_create_session" in tool_names + assert "acp_create_session_from_template" in tool_names assert "acp_delete_session" in tool_names + assert "acp_restart_session" in tool_names + assert "acp_clone_session" in tool_names + assert "acp_update_session" in tool_names + assert "acp_stop_session" not in tool_names # stop is via PATCH, no dedicated tool name + + # Observability tools + assert "acp_get_session_logs" in tool_names + assert "acp_get_session_transcript" in tool_names + assert "acp_get_session_metrics" in tool_names + + # Label tools + assert "acp_label_resource" in tool_names + assert "acp_unlabel_resource" in tool_names + assert "acp_list_sessions_by_label" in tool_names + assert "acp_bulk_label_resources" in tool_names + assert "acp_bulk_unlabel_resources" in tool_names + + # Bulk operation tools assert "acp_bulk_delete_sessions" in tool_names + assert "acp_bulk_stop_sessions" in tool_names + assert "acp_bulk_restart_sessions" in tool_names + assert "acp_bulk_delete_sessions_by_label" in tool_names + assert "acp_bulk_stop_sessions_by_label" in tool_names + assert "acp_bulk_restart_sessions_by_label" in tool_names # Cluster tools assert "acp_list_clusters" in tool_names assert "acp_whoami" in tool_names assert "acp_switch_cluster" in tool_names + assert "acp_login" in tool_names @pytest.mark.asyncio async def test_list_tools_count(self) -> None: """Test correct number of tools.""" tools = await list_tools() - assert len(tools) == 8 + assert len(tools) == 26 class TestCallTool: @@ -221,3 +246,557 @@ async def test_call_tool_error_handling(self) -> None: assert len(result) == 1 assert "Test error" in result[0].text + + +class TestCallToolBulkStop: + """Tests for bulk stop tool dispatch.""" + + @pytest.mark.asyncio + async def test_bulk_stop_requires_confirm(self) -> None: + """Bulk stop without confirm should return validation error.""" + 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")} + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_stop_sessions", + {"project": "test-project", "sessions": ["s1", "s2"]}, + ) + + assert "requires confirm=true" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_stop_with_confirm(self) -> None: + """Bulk stop with confirm should dispatch to client.""" + 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.bulk_stop_sessions = AsyncMock(return_value={"stopped": ["s1", "s2"], "failed": []}) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_stop_sessions", + {"project": "test-project", "sessions": ["s1", "s2"], "confirm": True}, + ) + + assert "Successfully stopped 2" in result[0].text + + +class TestCallToolRestartSession: + """Tests for restart session tool dispatch.""" + + @pytest.mark.asyncio + async def test_restart_session_dispatch(self) -> None: + """Restart session should dispatch to client.restart_session.""" + 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.restart_session = AsyncMock( + return_value={"restarted": True, "message": "Successfully restarted session 's1'"} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_restart_session", + {"project": "test-project", "session": "s1"}, + ) + + assert len(result) == 1 + assert "Successfully restarted" in result[0].text + + +class TestCallToolLogin: + """Tests for login tool dispatch.""" + + @pytest.mark.asyncio + async def test_login_dispatch(self) -> None: + """Login should dispatch to client.login.""" + mock_client = MagicMock() + mock_client.clusters_config = MagicMock() + mock_client.clusters_config.default_cluster = "test" + mock_client.clusters_config.clusters = {} + mock_client.login = AsyncMock( + return_value={ + "authenticated": True, + "cluster": "test", + "server": "https://test.com", + "message": "Successfully authenticated to cluster 'test'", + } + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_login", + {"cluster": "test", "token": "my-token"}, + ) + + assert len(result) == 1 + assert "Authentication successful" in result[0].text + + +class TestCallToolGetSession: + """Tests for get session tool dispatch.""" + + @pytest.mark.asyncio + async def test_get_session_dispatch(self) -> None: + """Get session should dispatch to client.get_session.""" + 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.get_session = AsyncMock( + return_value={"id": "session-1", "status": "running", "displayName": "My Session"} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool("acp_get_session", {"project": "test-project", "session": "session-1"}) + + assert len(result) == 1 + assert "session-1" in result[0].text + + +class TestCallToolCreateSessionFromTemplate: + """Tests for create session from template tool dispatch.""" + + @pytest.mark.asyncio + async def test_create_from_template_dispatch(self) -> None: + """Should dispatch to client.create_session_from_template.""" + 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_from_template = AsyncMock( + return_value={ + "created": True, + "session": "template-abc12", + "project": "test-project", + "template": "bugfix", + "message": "Session 'template-abc12' created from template 'bugfix'", + } + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_create_session_from_template", + {"project": "test-project", "template": "bugfix", "display_name": "Fix bug"}, + ) + + assert len(result) == 1 + assert "template-abc12" in result[0].text + + +class TestCallToolCloneSession: + """Tests for clone session tool dispatch.""" + + @pytest.mark.asyncio + async def test_clone_session_dispatch(self) -> None: + """Should dispatch to client.clone_session.""" + 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.clone_session = AsyncMock( + return_value={ + "created": True, + "session": "cloned-abc12", + "source_session": "source-1", + "project": "test-project", + "message": "Session 'cloned-abc12' cloned from 'source-1'", + } + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_clone_session", + {"project": "test-project", "source_session": "source-1", "new_display_name": "my-clone"}, + ) + + assert len(result) == 1 + assert "cloned-abc12" in result[0].text + + +class TestCallToolUpdateSession: + """Tests for update session tool dispatch.""" + + @pytest.mark.asyncio + async def test_update_session_dispatch(self) -> None: + """Should dispatch to client.update_session.""" + 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.update_session = AsyncMock( + return_value={ + "updated": True, + "message": "Successfully updated session 'session-1'", + "session": {"id": "session-1"}, + } + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_update_session", + {"project": "test-project", "session": "session-1", "display_name": "new-name"}, + ) + + assert len(result) == 1 + assert "updated" in result[0].text.lower() + + +class TestCallToolObservability: + """Tests for observability tool dispatch (logs, transcript, metrics).""" + + @pytest.mark.asyncio + async def test_get_session_logs_dispatch(self) -> None: + """Should dispatch to client.get_session_logs.""" + 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.get_session_logs = AsyncMock( + return_value={"logs": "INFO: started\nINFO: running", "session": "session-1", "tail_lines": 1000} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_get_session_logs", + {"project": "test-project", "session": "session-1"}, + ) + + assert len(result) == 1 + assert "started" in result[0].text + + @pytest.mark.asyncio + async def test_get_session_transcript_dispatch(self) -> None: + """Should dispatch to client.get_session_transcript.""" + 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.get_session_transcript = AsyncMock( + return_value={ + "session": "session-1", + "format": "json", + "messages": [{"role": "user", "content": "hello"}], + } + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_get_session_transcript", + {"project": "test-project", "session": "session-1"}, + ) + + assert len(result) == 1 + assert "hello" in result[0].text + + @pytest.mark.asyncio + async def test_get_session_metrics_dispatch(self) -> None: + """Should dispatch to client.get_session_metrics.""" + 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.get_session_metrics = AsyncMock( + return_value={"session": "session-1", "total_tokens": 5000, "duration_seconds": 120, "tool_calls": 15} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_get_session_metrics", + {"project": "test-project", "session": "session-1"}, + ) + + assert len(result) == 1 + assert "5000" in result[0].text or "5,000" in result[0].text + + +class TestCallToolLabels: + """Tests for label tool dispatch.""" + + @pytest.mark.asyncio + async def test_label_resource_dispatch(self) -> None: + """Should dispatch to client.label_session.""" + 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.label_session = AsyncMock( + return_value={"labeled": True, "labels_added": {"env": "test"}, "message": "Added 1 label(s)"} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_label_resource", + {"project": "test-project", "name": "session-1", "labels": {"env": "test"}}, + ) + + assert len(result) == 1 + assert "label" in result[0].text.lower() + + @pytest.mark.asyncio + async def test_unlabel_resource_dispatch(self) -> None: + """Should dispatch to client.unlabel_session.""" + 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.unlabel_session = AsyncMock( + return_value={"unlabeled": True, "labels_removed": ["env"], "message": "Removed 1 label(s)"} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_unlabel_resource", + {"project": "test-project", "name": "session-1", "label_keys": ["env"]}, + ) + + assert len(result) == 1 + assert "label" in result[0].text.lower() + + @pytest.mark.asyncio + async def test_list_sessions_by_label_dispatch(self) -> None: + """Should dispatch to client.list_sessions_by_label.""" + 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.list_sessions_by_label = AsyncMock( + return_value={ + "total": 1, + "sessions": [{"id": "session-1", "status": "running", "createdAt": "2024-01-01T00:00:00Z"}], + "labels_filter": {"env": "test"}, + } + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_list_sessions_by_label", + {"project": "test-project", "labels": {"env": "test"}}, + ) + + assert len(result) == 1 + assert "session-1" in result[0].text + + +class TestCallToolBulkLabels: + """Tests for bulk label/unlabel tool dispatch.""" + + @pytest.mark.asyncio + async def test_bulk_label_requires_confirm(self) -> None: + """Bulk label without confirm should return validation error.""" + 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")} + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_label_resources", + {"project": "test-project", "sessions": ["s1"], "labels": {"env": "test"}}, + ) + + assert "requires confirm=true" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_label_with_confirm(self) -> None: + """Bulk label with confirm should dispatch to client.""" + 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.bulk_label_sessions = AsyncMock( + return_value={"labeled": ["s1"], "failed": [], "labels": {"env": "test"}} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_label_resources", + {"project": "test-project", "sessions": ["s1"], "labels": {"env": "test"}, "confirm": True}, + ) + + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_bulk_unlabel_requires_confirm(self) -> None: + """Bulk unlabel without confirm should return validation error.""" + 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")} + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_unlabel_resources", + {"project": "test-project", "sessions": ["s1"], "label_keys": ["env"]}, + ) + + assert "requires confirm=true" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_unlabel_with_confirm(self) -> None: + """Bulk unlabel with confirm should dispatch to client.""" + 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.bulk_unlabel_sessions = AsyncMock( + return_value={"unlabeled": ["s1"], "failed": [], "label_keys": ["env"]} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_unlabel_resources", + {"project": "test-project", "sessions": ["s1"], "label_keys": ["env"], "confirm": True}, + ) + + assert len(result) == 1 + + +class TestCallToolBulkByLabel: + """Tests for bulk-by-label tool dispatch.""" + + @pytest.mark.asyncio + async def test_bulk_restart_requires_confirm(self) -> None: + """Bulk restart without confirm should return validation error.""" + 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")} + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_restart_sessions", + {"project": "test-project", "sessions": ["s1", "s2"], "confirm": False}, + ) + + assert "requires confirm=true" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_restart_with_confirm(self) -> None: + """Bulk restart with confirm should dispatch to client.""" + 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.bulk_restart_sessions = AsyncMock(return_value={"restarted": ["s1", "s2"], "failed": []}) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_restart_sessions", + {"project": "test-project", "sessions": ["s1", "s2"], "confirm": True}, + ) + + assert "Successfully restarted 2" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_delete_by_label_requires_confirm(self) -> None: + """Bulk delete by label without confirm should return validation error.""" + 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")} + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_delete_sessions_by_label", + {"project": "test-project", "labels": {"env": "test"}}, + ) + + assert "requires confirm=true" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_delete_by_label_with_confirm(self) -> None: + """Bulk delete by label with confirm should dispatch to client.""" + 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.bulk_delete_sessions_by_label = AsyncMock( + return_value={"deleted": ["s1"], "failed": [], "labels_filter": {"env": "test"}} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_delete_sessions_by_label", + {"project": "test-project", "labels": {"env": "test"}, "confirm": True}, + ) + + assert len(result) == 1 + assert "Successfully deleted 1" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_stop_by_label_requires_confirm(self) -> None: + """Bulk stop by label without confirm should return validation error.""" + 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")} + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_stop_sessions_by_label", + {"project": "test-project", "labels": {"env": "test"}}, + ) + + assert "requires confirm=true" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_stop_by_label_with_confirm(self) -> None: + """Bulk stop by label with confirm should dispatch to client.""" + 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.bulk_stop_sessions_by_label = AsyncMock( + return_value={"stopped": ["s1"], "failed": [], "labels_filter": {"env": "test"}} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_stop_sessions_by_label", + {"project": "test-project", "labels": {"env": "test"}, "confirm": True}, + ) + + assert len(result) == 1 + assert "Successfully stopped 1" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_restart_by_label_requires_confirm(self) -> None: + """Bulk restart by label without confirm should return validation error.""" + 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")} + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_restart_sessions_by_label", + {"project": "test-project", "labels": {"env": "test"}}, + ) + + assert "requires confirm=true" in result[0].text + + @pytest.mark.asyncio + async def test_bulk_restart_by_label_with_confirm(self) -> None: + """Bulk restart by label with confirm should dispatch to client.""" + 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.bulk_restart_sessions_by_label = AsyncMock( + return_value={"restarted": ["s1"], "failed": [], "labels_filter": {"env": "test"}} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_bulk_restart_sessions_by_label", + {"project": "test-project", "labels": {"env": "test"}, "confirm": True}, + ) + + assert len(result) == 1 + assert "Successfully restarted 1" in result[0].text