diff --git a/CLAUDE.md b/CLAUDE.md index 7295f45..777be53 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 26 MCP tools via stdio protocol +- Exposes 41 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 diff --git a/src/mcp_acp/client.py b/src/mcp_acp/client.py index 9d5e02d..26aa5b9 100644 --- a/src/mcp_acp/client.py +++ b/src/mcp_acp/client.py @@ -622,6 +622,163 @@ async def update_session( except ValueError as e: return {"updated": False, "message": f"Failed to update session: {str(e)}"} + # ── Scheduled Sessions ────────────────────────────────────────────── + + async def list_scheduled_sessions(self, project: str) -> dict[str, Any]: + """List all scheduled sessions.""" + self._validate_input(project, "project") + response = await self._request("GET", "/v1/scheduled-sessions", project) + items = response.get("items", []) + return {"scheduled_sessions": items, "total": len(items)} + + async def get_scheduled_session(self, project: str, name: str) -> dict[str, Any]: + """Get a specific scheduled session by name.""" + self._validate_input(project, "project") + self._validate_input(name, "name") + return await self._request("GET", f"/v1/scheduled-sessions/{name}", project) + + async def create_scheduled_session( + self, + project: str, + schedule: str, + session_template: dict[str, Any], + display_name: str | None = None, + suspend: bool = False, + dry_run: bool = False, + ) -> dict[str, Any]: + """Create a scheduled session backed by a Kubernetes CronJob.""" + self._validate_input(project, "project") + + payload: dict[str, Any] = { + "schedule": schedule, + "sessionTemplate": session_template, + "suspend": suspend, + } + if display_name: + payload["displayName"] = display_name + + if dry_run: + return { + "dry_run": True, + "success": True, + "message": f"Would create scheduled session with schedule '{schedule}'", + "manifest": payload, + "project": project, + } + + try: + result = await self._request("POST", "/v1/scheduled-sessions", project, json_data=payload) + name = result.get("name", "unknown") + return { + "created": True, + "name": name, + "project": project, + "message": f"Scheduled session '{name}' created with schedule '{schedule}'", + } + except (ValueError, TimeoutError) as e: + return {"created": False, "message": str(e)} + + async def update_scheduled_session( + self, + project: str, + name: str, + schedule: str | None = None, + display_name: str | None = None, + session_template: dict[str, Any] | None = None, + suspend: bool | None = None, + dry_run: bool = False, + ) -> dict[str, Any]: + """Update a scheduled session (partial update).""" + self._validate_input(project, "project") + self._validate_input(name, "name") + + payload: dict[str, Any] = {} + if schedule is not None: + payload["schedule"] = schedule + if display_name is not None: + payload["displayName"] = display_name + if session_template is not None: + payload["sessionTemplate"] = session_template + if suspend is not None: + payload["suspend"] = suspend + + if not payload: + raise ValueError("No fields to update. Provide schedule, display_name, session_template, or suspend.") + + if dry_run: + return { + "dry_run": True, + "success": True, + "message": f"Would update scheduled session '{name}'", + "patch": payload, + } + + try: + await self._request("PUT", f"/v1/scheduled-sessions/{name}", project, json_data=payload) + return {"updated": True, "message": f"Successfully updated scheduled session '{name}'"} + except ValueError as e: + return {"updated": False, "message": f"Failed to update: {str(e)}"} + + async def delete_scheduled_session(self, project: str, name: str, dry_run: bool = False) -> dict[str, Any]: + """Delete a scheduled session.""" + self._validate_input(project, "project") + self._validate_input(name, "name") + + if dry_run: + try: + data = await self._request("GET", f"/v1/scheduled-sessions/{name}", project) + return { + "dry_run": True, + "success": True, + "message": f"Would delete scheduled session '{name}'", + "session_info": { + "name": data.get("name"), + "schedule": data.get("schedule"), + "suspend": data.get("suspend"), + }, + } + except ValueError: + return {"dry_run": True, "success": False, "message": f"Scheduled session '{name}' not found"} + + try: + await self._request("DELETE", f"/v1/scheduled-sessions/{name}", project) + return {"deleted": True, "message": f"Successfully deleted scheduled session '{name}'"} + except ValueError as e: + return {"deleted": False, "message": f"Failed to delete: {str(e)}"} + + async def suspend_scheduled_session(self, project: str, name: str) -> dict[str, Any]: + """Suspend (pause) a scheduled session.""" + self._validate_input(project, "project") + self._validate_input(name, "name") + + await self._request("POST", f"/v1/scheduled-sessions/{name}/suspend", project) + return {"suspended": True, "message": f"Scheduled session '{name}' suspended"} + + async def resume_scheduled_session(self, project: str, name: str) -> dict[str, Any]: + """Resume a suspended scheduled session.""" + self._validate_input(project, "project") + self._validate_input(name, "name") + + await self._request("POST", f"/v1/scheduled-sessions/{name}/resume", project) + return {"resumed": True, "message": f"Scheduled session '{name}' resumed"} + + async def trigger_scheduled_session(self, project: str, name: str) -> dict[str, Any]: + """Manually trigger a scheduled session to run immediately.""" + self._validate_input(project, "project") + self._validate_input(name, "name") + + await self._request("POST", f"/v1/scheduled-sessions/{name}/trigger", project) + return {"triggered": True, "message": f"Scheduled session '{name}' triggered"} + + async def list_scheduled_session_runs(self, project: str, name: str) -> dict[str, Any]: + """List past runs (AgenticSessions) created by a scheduled session.""" + self._validate_input(project, "project") + self._validate_input(name, "name") + + response = await self._request("GET", f"/v1/scheduled-sessions/{name}/runs", project) + items = response.get("items", []) + return {"runs": items, "total": len(items), "scheduled_session": name} + # ── Observability ──────────────────────────────────────────────────── async def get_session_logs( @@ -676,6 +833,14 @@ async def get_session_metrics(self, project: str, session: str) -> dict[str, Any result["session"] = session return result + async def export_session(self, project: str, session: str) -> dict[str, Any]: + """Export session chat as markdown.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + text = await self._request_text("GET", f"/v1/sessions/{session}/export", project) + return {"export": text, "session": session} + # ── Labels ─────────────────────────────────────────────────────────── async def label_session(self, project: str, session: str, labels: dict[str, str]) -> dict[str, Any]: @@ -874,6 +1039,53 @@ async def bulk_restart_sessions_by_label( """Restart sessions matching label selectors (max 3 matches).""" return await self._run_bulk_by_label(project, labels, self.restart_session, "restart", "restarted", dry_run) + # ── Workflow Management ───────────────────────────────────────────── + + async def set_workflow(self, project: str, session: str, workflow: str) -> dict[str, Any]: + """Set the active workflow on a session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + return await self._request( + "POST", f"/v1/sessions/{session}/workflow", project, json_data={"workflow": workflow} + ) + + async def get_workflow_metadata(self, project: str, session: str) -> dict[str, Any]: + """Get workflow metadata for a session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + result = await self._request("GET", f"/v1/sessions/{session}/workflow/metadata", project) + result["session"] = session + return result + + # ── Repo Management ───────────────────────────────────────────────── + + async def add_repo(self, project: str, session: str, repo_url: str) -> dict[str, Any]: + """Add a repository to a running session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + return await self._request("POST", f"/v1/sessions/{session}/repos", project, json_data={"url": repo_url}) + + async def remove_repo(self, project: str, session: str, repo_name: str) -> dict[str, Any]: + """Remove a repository from a session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + self._validate_input(repo_name, "repo_name") + + await self._request("DELETE", f"/v1/sessions/{session}/repos/{repo_name}", project) + return {"removed": True, "message": f"Repo '{repo_name}' removed from session '{session}'"} + + async def get_repos_status(self, project: str, session: str) -> dict[str, Any]: + """Get repository clone status for a session.""" + self._validate_input(project, "project") + self._validate_input(session, "session") + + result = await self._request("GET", f"/v1/sessions/{session}/repos/status", project) + result["session"] = session + return result + # ── Cluster & auth ─────────────────────────────────────────────────── def list_clusters(self) -> dict[str, Any]: diff --git a/src/mcp_acp/formatters.py b/src/mcp_acp/formatters.py index f2b2519..54b041d 100644 --- a/src/mcp_acp/formatters.py +++ b/src/mcp_acp/formatters.py @@ -272,3 +272,59 @@ def format_login(result: dict[str, Any]) -> str: output += f"Error: {result.get('message', 'unknown error')}\n" return output + + +def format_scheduled_sessions_list(result: dict[str, Any]) -> str: + """Format scheduled sessions list.""" + output = f"Found {result['total']} scheduled session(s)\n\n" + + for ss in result["scheduled_sessions"]: + name = ss.get("name", "unknown") + schedule = ss.get("schedule", "unknown") + suspended = ss.get("suspend", False) + active = ss.get("activeCount", 0) + display = ss.get("displayName", "") + + output += f"- {name}" + if display: + output += f" ({display})" + output += f"\n Schedule: {schedule}" + output += f"\n Suspended: {suspended}" + output += f"\n Active runs: {active}\n" + + return output + + +def format_scheduled_session_created(result: dict[str, Any]) -> str: + """Format scheduled session creation result.""" + if result.get("dry_run"): + output = "DRY RUN MODE - No changes made\n\n" + output += result.get("message", "") + if "manifest" in result: + output += f"\n\nManifest:\n{json.dumps(result['manifest'], indent=2)}" + return output + + if not result.get("created"): + return f"Failed to create scheduled session: {result.get('message', 'unknown error')}" + + name = result.get("name", "unknown") + output = f"Scheduled session created: {name}\n" + output += result.get("message", "") + return output + + +def format_scheduled_session_runs(result: dict[str, Any]) -> str: + """Format list of past runs for a scheduled session.""" + ss_name = result.get("scheduled_session", "unknown") + output = f"Runs for scheduled session '{ss_name}': {result['total']} total\n\n" + + for run in result["runs"]: + run_id = run.get("id", run.get("name", "unknown")) + status = run.get("status", "unknown") + created = run.get("createdAt", run.get("creationTimestamp", "unknown")) + + output += f"- {run_id}\n" + output += f" Status: {status}\n" + output += f" Created: {created}\n" + + return output diff --git a/src/mcp_acp/server.py b/src/mcp_acp/server.py index e8c2aa0..21065ba 100644 --- a/src/mcp_acp/server.py +++ b/src/mcp_acp/server.py @@ -19,6 +19,9 @@ format_logs, format_metrics, format_result, + format_scheduled_session_created, + format_scheduled_session_runs, + format_scheduled_sessions_list, format_session_created, format_sessions_list, format_transcript, @@ -65,6 +68,23 @@ def get_client() -> ACPClient: "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"} +_SCHEDULED_SESSION_NAME = {"type": "string", "description": "Scheduled session name"} +_SCHEDULE = {"type": "string", "description": "Cron expression (e.g., '0 2 * * *' for nightly at 2am)"} +_SESSION_TEMPLATE = { + "type": "object", + "description": "Session template (same as create_session: task, model, repos, displayName, etc.)", + "properties": { + "task": {"type": "string", "description": "The prompt/instructions"}, + "model": {"type": "string", "description": "LLM model", "default": "claude-sonnet-4"}, + "repos": { + "type": "array", + "items": {"type": "object", "properties": {"url": {"type": "string"}}}, + "description": "Repositories to clone", + }, + "displayName": {"type": "string", "description": "Display name for created sessions"}, + }, + "required": ["task"], +} @app.list_tools() @@ -412,6 +432,172 @@ async def list_tools() -> list[Tool]: "required": ["cluster"], }, ), + # ── Scheduled Sessions ────────────────────────────────────────── + Tool( + name="acp_list_scheduled_sessions", + description="List all scheduled sessions (cron-based recurring sessions) in a project.", + inputSchema={"type": "object", "properties": {"project": _PROJECT}, "required": []}, + ), + Tool( + name="acp_get_scheduled_session", + description="Get details of a specific scheduled session by name.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "name": _SCHEDULED_SESSION_NAME}, + "required": ["name"], + }, + ), + Tool( + name="acp_create_scheduled_session", + description="Create a scheduled session backed by a Kubernetes CronJob. Requires a cron schedule and a session template.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "schedule": _SCHEDULE, + "session_template": _SESSION_TEMPLATE, + "display_name": {"type": "string", "description": "Human-readable name for this schedule"}, + "suspend": { + "type": "boolean", + "description": "Create in suspended state (default: false)", + "default": False, + }, + "dry_run": _DRY_RUN, + }, + "required": ["schedule", "session_template"], + }, + ), + Tool( + name="acp_update_scheduled_session", + description="Update a scheduled session (schedule, template, display name, or suspend state).", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "name": _SCHEDULED_SESSION_NAME, + "schedule": _SCHEDULE, + "display_name": {"type": "string", "description": "New display name"}, + "session_template": _SESSION_TEMPLATE, + "suspend": {"type": "boolean", "description": "Suspend or unsuspend"}, + "dry_run": _DRY_RUN, + }, + "required": ["name"], + }, + ), + Tool( + name="acp_delete_scheduled_session", + description="Delete a scheduled session and its CronJob. Supports dry-run mode.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "name": _SCHEDULED_SESSION_NAME, "dry_run": _DRY_RUN}, + "required": ["name"], + }, + ), + Tool( + name="acp_suspend_scheduled_session", + description="Suspend (pause) a scheduled session. The CronJob will stop creating new sessions.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "name": _SCHEDULED_SESSION_NAME}, + "required": ["name"], + }, + ), + Tool( + name="acp_resume_scheduled_session", + description="Resume a suspended scheduled session. The CronJob will start creating sessions again.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "name": _SCHEDULED_SESSION_NAME}, + "required": ["name"], + }, + ), + Tool( + name="acp_trigger_scheduled_session", + description="Manually trigger a scheduled session to run immediately, regardless of its cron schedule.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "name": _SCHEDULED_SESSION_NAME}, + "required": ["name"], + }, + ), + Tool( + name="acp_list_scheduled_session_runs", + description="List past runs (AgenticSessions) created by a scheduled session.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "name": _SCHEDULED_SESSION_NAME}, + "required": ["name"], + }, + ), + # ── Session Export ────────────────────────────────────────────── + Tool( + name="acp_export_session", + description="Export session chat history as markdown.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "session": _SESSION}, + "required": ["session"], + }, + ), + # ── Workflow Management ───────────────────────────────────────── + Tool( + name="acp_set_workflow", + description="Set the active workflow on a running session.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "session": _SESSION, + "workflow": {"type": "string", "description": "Workflow name to activate"}, + }, + "required": ["session", "workflow"], + }, + ), + Tool( + name="acp_get_workflow_metadata", + description="Get workflow metadata (steps, configuration) for a session.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "session": _SESSION}, + "required": ["session"], + }, + ), + # ── Repo Management ───────────────────────────────────────────── + Tool( + name="acp_add_repo", + description="Add a repository to a running session.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "session": _SESSION, + "repo_url": {"type": "string", "description": "Repository URL to clone"}, + }, + "required": ["session", "repo_url"], + }, + ), + Tool( + name="acp_remove_repo", + description="Remove a repository from a session.", + inputSchema={ + "type": "object", + "properties": { + "project": _PROJECT, + "session": _SESSION, + "repo_name": {"type": "string", "description": "Repository name to remove"}, + }, + "required": ["session", "repo_name"], + }, + ), + Tool( + name="acp_get_repos_status", + description="Check repository clone status for a session.", + inputSchema={ + "type": "object", + "properties": {"project": _PROJECT, "session": _SESSION}, + "required": ["session"], + }, + ), ] @@ -651,6 +837,93 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: result = await client.login(cluster=arguments["cluster"], token=arguments.get("token")) text = format_login(result) + # Scheduled sessions + elif name == "acp_list_scheduled_sessions": + result = await client.list_scheduled_sessions(project=project) + text = format_scheduled_sessions_list(result) + + elif name == "acp_get_scheduled_session": + result = await client.get_scheduled_session(project=project, name=arguments["name"]) + text = format_result(result) + + elif name == "acp_create_scheduled_session": + result = await client.create_scheduled_session( + project=project, + schedule=arguments["schedule"], + session_template=arguments["session_template"], + display_name=arguments.get("display_name"), + suspend=arguments.get("suspend", False), + dry_run=arguments.get("dry_run", False), + ) + text = format_scheduled_session_created(result) + + elif name == "acp_update_scheduled_session": + result = await client.update_scheduled_session( + project=project, + name=arguments["name"], + schedule=arguments.get("schedule"), + display_name=arguments.get("display_name"), + session_template=arguments.get("session_template"), + suspend=arguments.get("suspend"), + dry_run=arguments.get("dry_run", False), + ) + text = format_result(result) + + elif name == "acp_delete_scheduled_session": + result = await client.delete_scheduled_session( + project=project, name=arguments["name"], dry_run=arguments.get("dry_run", False) + ) + text = format_result(result) + + elif name == "acp_suspend_scheduled_session": + result = await client.suspend_scheduled_session(project=project, name=arguments["name"]) + text = format_result(result) + + elif name == "acp_resume_scheduled_session": + result = await client.resume_scheduled_session(project=project, name=arguments["name"]) + text = format_result(result) + + elif name == "acp_trigger_scheduled_session": + result = await client.trigger_scheduled_session(project=project, name=arguments["name"]) + text = format_result(result) + + elif name == "acp_list_scheduled_session_runs": + result = await client.list_scheduled_session_runs(project=project, name=arguments["name"]) + text = format_scheduled_session_runs(result) + + # Session export + elif name == "acp_export_session": + result = await client.export_session(project=project, session=arguments["session"]) + text = result["export"] + + # Workflow management + elif name == "acp_set_workflow": + result = await client.set_workflow( + project=project, session=arguments["session"], workflow=arguments["workflow"] + ) + text = format_result(result) + + elif name == "acp_get_workflow_metadata": + result = await client.get_workflow_metadata(project=project, session=arguments["session"]) + text = format_result(result) + + # Repo management + elif name == "acp_add_repo": + result = await client.add_repo( + project=project, session=arguments["session"], repo_url=arguments["repo_url"] + ) + text = format_result(result) + + elif name == "acp_remove_repo": + result = await client.remove_repo( + project=project, session=arguments["session"], repo_name=arguments["repo_name"] + ) + text = format_result(result) + + elif name == "acp_get_repos_status": + result = await client.get_repos_status(project=project, session=arguments["session"]) + text = format_result(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 cd0595c..85533b8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1066,3 +1066,317 @@ async def test_login_unknown_cluster(self, client: ACPClient) -> None: assert result["authenticated"] is False assert "Unknown cluster" in result["message"] + + +class TestScheduledSessions: + """Tests for scheduled session operations.""" + + @pytest.mark.asyncio + async def test_list_scheduled_sessions(self, client: ACPClient) -> None: + """Test listing scheduled sessions.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "items": [{"name": "nightly-triage", "schedule": "0 2 * * *", "suspend": False}] + } + + 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_scheduled_sessions("test-project") + + assert result["total"] == 1 + assert result["scheduled_sessions"][0]["name"] == "nightly-triage" + + @pytest.mark.asyncio + async def test_get_scheduled_session(self, client: ACPClient) -> None: + """Test getting a specific scheduled session.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "name": "nightly-triage", + "schedule": "0 2 * * *", + "suspend": False, + "activeCount": 0, + } + + 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_scheduled_session("test-project", "nightly-triage") + + assert result["name"] == "nightly-triage" + assert result["schedule"] == "0 2 * * *" + + @pytest.mark.asyncio + async def test_create_scheduled_session(self, client: ACPClient) -> None: + """Test creating a scheduled session.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"name": "nightly-triage"} + + 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_scheduled_session( + project="test-project", + schedule="0 2 * * *", + session_template={"task": "Nightly triage", "model": "claude-sonnet-4"}, + display_name="Nightly Triage", + ) + + assert result["created"] is True + assert result["name"] == "nightly-triage" + + @pytest.mark.asyncio + async def test_create_scheduled_session_dry_run(self, client: ACPClient) -> None: + """Test creating a scheduled session in dry-run mode.""" + result = await client.create_scheduled_session( + project="test-project", + schedule="0 2 * * *", + session_template={"task": "Nightly triage", "model": "claude-sonnet-4"}, + display_name="Nightly Triage", + dry_run=True, + ) + + assert result["dry_run"] is True + assert result["manifest"]["schedule"] == "0 2 * * *" + + @pytest.mark.asyncio + async def test_update_scheduled_session(self, client: ACPClient) -> None: + """Test updating a scheduled session.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"name": "nightly-triage", "schedule": "0 3 * * *"} + + 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_scheduled_session( + project="test-project", + name="nightly-triage", + schedule="0 3 * * *", + ) + + assert result["updated"] is True + + @pytest.mark.asyncio + async def test_delete_scheduled_session(self, client: ACPClient) -> None: + """Test deleting a scheduled session.""" + mock_response = MagicMock() + mock_response.status_code = 204 + mock_response.json.return_value = {"success": True} + + 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.delete_scheduled_session("test-project", "nightly-triage") + + assert result["deleted"] is True + + @pytest.mark.asyncio + async def test_delete_scheduled_session_dry_run(self, client: ACPClient) -> None: + """Test deleting a scheduled session in dry-run mode.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "name": "nightly-triage", + "schedule": "0 2 * * *", + "suspend": False, + } + + 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.delete_scheduled_session("test-project", "nightly-triage", dry_run=True) + + assert result["dry_run"] is True + assert result["session_info"]["name"] == "nightly-triage" + + @pytest.mark.asyncio + async def test_suspend_scheduled_session(self, client: ACPClient) -> None: + """Test suspending a scheduled session.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True} + + 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.suspend_scheduled_session("test-project", "nightly-triage") + + assert result["suspended"] is True + + @pytest.mark.asyncio + async def test_resume_scheduled_session(self, client: ACPClient) -> None: + """Test resuming a scheduled session.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True} + + 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.resume_scheduled_session("test-project", "nightly-triage") + + assert result["resumed"] is True + + @pytest.mark.asyncio + async def test_trigger_scheduled_session(self, client: ACPClient) -> None: + """Test manually triggering a scheduled session.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True} + + 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.trigger_scheduled_session("test-project", "nightly-triage") + + assert result["triggered"] is True + + @pytest.mark.asyncio + async def test_list_scheduled_session_runs(self, client: ACPClient) -> None: + """Test listing past runs of a scheduled session.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"items": [{"id": "run-1", "status": "completed"}]} + + 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_scheduled_session_runs("test-project", "nightly-triage") + + assert result["total"] == 1 + assert result["runs"][0]["id"] == "run-1" + + +class TestSessionExport: + """Tests for session export.""" + + @pytest.mark.asyncio + async def test_export_session(self, client: ACPClient) -> None: + """Test exporting session chat as markdown.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "# Session Export\n\n## Messages\n..." + + 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.export_session("test-project", "session-1") + + assert result["export"] == "# Session Export\n\n## Messages\n..." + assert result["session"] == "session-1" + + +class TestWorkflowManagement: + """Tests for workflow management.""" + + @pytest.mark.asyncio + async def test_set_workflow(self, client: ACPClient) -> None: + """Test setting active workflow on a session.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True} + + 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.set_workflow("test-project", "session-1", "triage") + + assert result["success"] is True + + @pytest.mark.asyncio + async def test_get_workflow_metadata(self, client: ACPClient) -> None: + """Test getting workflow metadata.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"workflow": "triage", "steps": ["investigate", "classify"]} + + 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_workflow_metadata("test-project", "session-1") + + assert result["workflow"] == "triage" + + +class TestRepoManagement: + """Tests for repo management on running sessions.""" + + @pytest.mark.asyncio + async def test_add_repo(self, client: ACPClient) -> None: + """Test adding a repo to a running session.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True} + + 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.add_repo("test-project", "session-1", "https://github.com/org/repo") + + assert result["success"] is True + + @pytest.mark.asyncio + async def test_remove_repo(self, client: ACPClient) -> None: + """Test removing a repo from a session.""" + mock_response = MagicMock() + mock_response.status_code = 204 + mock_response.json.return_value = {"success": True} + + 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.remove_repo("test-project", "session-1", "my-repo") + + assert result["removed"] is True + + @pytest.mark.asyncio + async def test_get_repos_status(self, client: ACPClient) -> None: + """Test getting repo clone status.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "repos": [{"name": "my-repo", "status": "cloned", "url": "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.get_repos_status("test-project", "session-1") + + assert result["repos"][0]["name"] == "my-repo" diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 8568a66..9608608 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -8,6 +8,9 @@ format_logs, format_metrics, format_result, + format_scheduled_session_created, + format_scheduled_session_runs, + format_scheduled_sessions_list, format_session_created, format_sessions_list, format_transcript, @@ -425,3 +428,95 @@ def test_format_session_created_failure(self) -> None: assert "Failed to create session" in output assert "Invalid spec" in output + + +class TestFormatScheduledSessionsList: + """Tests for format_scheduled_sessions_list.""" + + def test_format_scheduled_sessions_list(self) -> None: + """Test formatting scheduled sessions list.""" + result = { + "total": 1, + "scheduled_sessions": [ + { + "name": "nightly-triage", + "schedule": "0 2 * * *", + "suspend": False, + "activeCount": 1, + "displayName": "Nightly Triage", + } + ], + } + + output = format_scheduled_sessions_list(result) + + assert "Found 1 scheduled session(s)" in output + assert "nightly-triage" in output + assert "0 2 * * *" in output + assert "Nightly Triage" in output + + def test_format_scheduled_sessions_list_empty(self) -> None: + """Test formatting empty scheduled sessions list.""" + result = {"total": 0, "scheduled_sessions": []} + + output = format_scheduled_sessions_list(result) + + assert "Found 0 scheduled session(s)" in output + + +class TestFormatScheduledSessionCreated: + """Tests for format_scheduled_session_created.""" + + def test_format_created(self) -> None: + """Test formatting successful creation.""" + result = { + "created": True, + "name": "nightly-triage", + "message": "Scheduled session 'nightly-triage' created with schedule '0 2 * * *'", + } + + output = format_scheduled_session_created(result) + + assert "Scheduled session created: nightly-triage" in output + + def test_format_created_dry_run(self) -> None: + """Test formatting dry run creation.""" + result = { + "dry_run": True, + "message": "Would create scheduled session", + "manifest": {"schedule": "0 2 * * *"}, + } + + output = format_scheduled_session_created(result) + + assert "DRY RUN MODE" in output + + def test_format_created_failure(self) -> None: + """Test formatting creation failure.""" + result = {"created": False, "message": "Invalid schedule"} + + output = format_scheduled_session_created(result) + + assert "Failed to create scheduled session" in output + + +class TestFormatScheduledSessionRuns: + """Tests for format_scheduled_session_runs.""" + + def test_format_runs(self) -> None: + """Test formatting runs list.""" + result = { + "scheduled_session": "nightly-triage", + "total": 2, + "runs": [ + {"id": "run-1", "status": "completed", "createdAt": "2026-03-12T02:00:00Z"}, + {"id": "run-2", "status": "running", "createdAt": "2026-03-13T02:00:00Z"}, + ], + } + + output = format_scheduled_session_runs(result) + + assert "nightly-triage" in output + assert "2 total" in output + assert "run-1" in output + assert "completed" in output diff --git a/tests/test_server.py b/tests/test_server.py index 7d21c39..de956bc 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -27,6 +27,25 @@ async def test_list_tools_returns_all_tools(self) -> None: assert "acp_update_session" in tool_names assert "acp_stop_session" not in tool_names # stop is via PATCH, no dedicated tool name + # Scheduled session tools + assert "acp_list_scheduled_sessions" in tool_names + assert "acp_get_scheduled_session" in tool_names + assert "acp_create_scheduled_session" in tool_names + assert "acp_update_scheduled_session" in tool_names + assert "acp_delete_scheduled_session" in tool_names + assert "acp_suspend_scheduled_session" in tool_names + assert "acp_resume_scheduled_session" in tool_names + assert "acp_trigger_scheduled_session" in tool_names + assert "acp_list_scheduled_session_runs" in tool_names + + # Export, workflow, repo tools + assert "acp_export_session" in tool_names + assert "acp_set_workflow" in tool_names + assert "acp_get_workflow_metadata" in tool_names + assert "acp_add_repo" in tool_names + assert "acp_remove_repo" in tool_names + assert "acp_get_repos_status" in tool_names + # Observability tools assert "acp_get_session_logs" in tool_names assert "acp_get_session_transcript" in tool_names @@ -57,7 +76,7 @@ async def test_list_tools_returns_all_tools(self) -> None: async def test_list_tools_count(self) -> None: """Test correct number of tools.""" tools = await list_tools() - assert len(tools) == 26 + assert len(tools) == 41 class TestCallTool: @@ -800,3 +819,129 @@ async def test_bulk_restart_by_label_with_confirm(self) -> None: assert len(result) == 1 assert "Successfully restarted 1" in result[0].text + + +class TestCallToolScheduledSessions: + """Tests for scheduled session tool dispatch.""" + + @pytest.mark.asyncio + async def test_list_scheduled_sessions_dispatch(self) -> None: + """Should dispatch to client.list_scheduled_sessions.""" + 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_scheduled_sessions = AsyncMock( + return_value={ + "total": 1, + "scheduled_sessions": [ + {"name": "nightly-triage", "schedule": "0 2 * * *", "suspend": False, "activeCount": 0} + ], + } + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool("acp_list_scheduled_sessions", {"project": "test-project"}) + + assert len(result) == 1 + assert "nightly-triage" in result[0].text + + @pytest.mark.asyncio + async def test_create_scheduled_session_dispatch(self) -> None: + """Should dispatch to client.create_scheduled_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.create_scheduled_session = AsyncMock( + return_value={ + "created": True, + "name": "nightly-triage", + "project": "test-project", + "message": "Scheduled session 'nightly-triage' created", + } + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_create_scheduled_session", + {"project": "test-project", "schedule": "0 2 * * *", "session_template": {"task": "Nightly triage"}}, + ) + + assert len(result) == 1 + assert "nightly-triage" in result[0].text + + @pytest.mark.asyncio + async def test_trigger_scheduled_session_dispatch(self) -> None: + """Should dispatch to client.trigger_scheduled_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.trigger_scheduled_session = AsyncMock( + return_value={"triggered": True, "message": "Scheduled session 'nightly-triage' triggered"} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_trigger_scheduled_session", + {"project": "test-project", "name": "nightly-triage"}, + ) + + assert len(result) == 1 + assert "triggered" in result[0].text.lower() + + +class TestCallToolExportWorkflowRepo: + """Tests for export, workflow, and repo tool dispatch.""" + + @pytest.mark.asyncio + async def test_export_session_dispatch(self) -> None: + """Should dispatch to client.export_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.export_session = AsyncMock( + return_value={"export": "# Session Export\n\nhello", "session": "session-1"} + ) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool("acp_export_session", {"project": "test-project", "session": "session-1"}) + + assert len(result) == 1 + assert "Session Export" in result[0].text + + @pytest.mark.asyncio + async def test_set_workflow_dispatch(self) -> None: + """Should dispatch to client.set_workflow.""" + 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.set_workflow = AsyncMock(return_value={"success": True, "message": "Workflow set to triage"}) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_set_workflow", + {"project": "test-project", "session": "session-1", "workflow": "triage"}, + ) + + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_add_repo_dispatch(self) -> None: + """Should dispatch to client.add_repo.""" + 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.add_repo = AsyncMock(return_value={"success": True, "message": "Repo added"}) + + with patch("mcp_acp.server.get_client", return_value=mock_client): + result = await call_tool( + "acp_add_repo", + {"project": "test-project", "session": "session-1", "repo_url": "https://github.com/org/repo"}, + ) + + assert len(result) == 1 diff --git a/tests/test_server_e2e.py b/tests/test_server_e2e.py index 7b129dc..28b8b90 100644 --- a/tests/test_server_e2e.py +++ b/tests/test_server_e2e.py @@ -138,9 +138,9 @@ class TestListToolsE2E: @pytest.mark.asyncio async def test_all_tools_registered(self): - """All 26 tools should be available.""" + """All 41 tools should be available.""" tools = await list_tools() - assert len(tools) == 26 + assert len(tools) == 41 tool_names = {t.name for t in tools} expected_tools = { @@ -175,6 +175,25 @@ async def test_all_tools_registered(self): "acp_whoami", "acp_switch_cluster", "acp_login", + # Scheduled sessions + "acp_list_scheduled_sessions", + "acp_get_scheduled_session", + "acp_create_scheduled_session", + "acp_update_scheduled_session", + "acp_delete_scheduled_session", + "acp_suspend_scheduled_session", + "acp_resume_scheduled_session", + "acp_trigger_scheduled_session", + "acp_list_scheduled_session_runs", + # Session export + "acp_export_session", + # Workflow management + "acp_set_workflow", + "acp_get_workflow_metadata", + # Repo management + "acp_add_repo", + "acp_remove_repo", + "acp_get_repos_status", } assert tool_names == expected_tools