Skip to content

Commit 554fdac

Browse files
jeremyederclaude
andauthored
feat: add scheduled sessions, export, workflow, and repo management tools (#42)
Add 15 new MCP tools expanding the server from 26 to 41 tools: - Scheduled sessions (9): list, get, create, update, delete, suspend, resume, trigger, list_runs - Session export (1): export_session - Workflow management (2): set_workflow, get_workflow_metadata - Repo management (3): add_repo, remove_repo, get_repos_status All tools follow the 4-step pattern: client method, formatter, tool definition + dispatch, and unit tests. Closes #28 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1605b9c commit 554fdac

File tree

8 files changed

+1118
-4
lines changed

8 files changed

+1118
-4
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ uv run python -m mcp_acp.server
8484
### Three-Layer Design
8585

8686
**1. MCP Server Layer (`server.py`)**
87-
- Exposes 26 MCP tools via stdio protocol
87+
- Exposes 41 MCP tools via stdio protocol
8888
- Inline JSON Schema definitions per tool
8989
- if/elif dispatch in `call_tool()` maps tool names to handlers
9090
- Server-layer confirmation enforcement for destructive bulk operations

src/mcp_acp/client.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,163 @@ async def update_session(
622622
except ValueError as e:
623623
return {"updated": False, "message": f"Failed to update session: {str(e)}"}
624624

625+
# ── Scheduled Sessions ──────────────────────────────────────────────
626+
627+
async def list_scheduled_sessions(self, project: str) -> dict[str, Any]:
628+
"""List all scheduled sessions."""
629+
self._validate_input(project, "project")
630+
response = await self._request("GET", "/v1/scheduled-sessions", project)
631+
items = response.get("items", [])
632+
return {"scheduled_sessions": items, "total": len(items)}
633+
634+
async def get_scheduled_session(self, project: str, name: str) -> dict[str, Any]:
635+
"""Get a specific scheduled session by name."""
636+
self._validate_input(project, "project")
637+
self._validate_input(name, "name")
638+
return await self._request("GET", f"/v1/scheduled-sessions/{name}", project)
639+
640+
async def create_scheduled_session(
641+
self,
642+
project: str,
643+
schedule: str,
644+
session_template: dict[str, Any],
645+
display_name: str | None = None,
646+
suspend: bool = False,
647+
dry_run: bool = False,
648+
) -> dict[str, Any]:
649+
"""Create a scheduled session backed by a Kubernetes CronJob."""
650+
self._validate_input(project, "project")
651+
652+
payload: dict[str, Any] = {
653+
"schedule": schedule,
654+
"sessionTemplate": session_template,
655+
"suspend": suspend,
656+
}
657+
if display_name:
658+
payload["displayName"] = display_name
659+
660+
if dry_run:
661+
return {
662+
"dry_run": True,
663+
"success": True,
664+
"message": f"Would create scheduled session with schedule '{schedule}'",
665+
"manifest": payload,
666+
"project": project,
667+
}
668+
669+
try:
670+
result = await self._request("POST", "/v1/scheduled-sessions", project, json_data=payload)
671+
name = result.get("name", "unknown")
672+
return {
673+
"created": True,
674+
"name": name,
675+
"project": project,
676+
"message": f"Scheduled session '{name}' created with schedule '{schedule}'",
677+
}
678+
except (ValueError, TimeoutError) as e:
679+
return {"created": False, "message": str(e)}
680+
681+
async def update_scheduled_session(
682+
self,
683+
project: str,
684+
name: str,
685+
schedule: str | None = None,
686+
display_name: str | None = None,
687+
session_template: dict[str, Any] | None = None,
688+
suspend: bool | None = None,
689+
dry_run: bool = False,
690+
) -> dict[str, Any]:
691+
"""Update a scheduled session (partial update)."""
692+
self._validate_input(project, "project")
693+
self._validate_input(name, "name")
694+
695+
payload: dict[str, Any] = {}
696+
if schedule is not None:
697+
payload["schedule"] = schedule
698+
if display_name is not None:
699+
payload["displayName"] = display_name
700+
if session_template is not None:
701+
payload["sessionTemplate"] = session_template
702+
if suspend is not None:
703+
payload["suspend"] = suspend
704+
705+
if not payload:
706+
raise ValueError("No fields to update. Provide schedule, display_name, session_template, or suspend.")
707+
708+
if dry_run:
709+
return {
710+
"dry_run": True,
711+
"success": True,
712+
"message": f"Would update scheduled session '{name}'",
713+
"patch": payload,
714+
}
715+
716+
try:
717+
await self._request("PUT", f"/v1/scheduled-sessions/{name}", project, json_data=payload)
718+
return {"updated": True, "message": f"Successfully updated scheduled session '{name}'"}
719+
except ValueError as e:
720+
return {"updated": False, "message": f"Failed to update: {str(e)}"}
721+
722+
async def delete_scheduled_session(self, project: str, name: str, dry_run: bool = False) -> dict[str, Any]:
723+
"""Delete a scheduled session."""
724+
self._validate_input(project, "project")
725+
self._validate_input(name, "name")
726+
727+
if dry_run:
728+
try:
729+
data = await self._request("GET", f"/v1/scheduled-sessions/{name}", project)
730+
return {
731+
"dry_run": True,
732+
"success": True,
733+
"message": f"Would delete scheduled session '{name}'",
734+
"session_info": {
735+
"name": data.get("name"),
736+
"schedule": data.get("schedule"),
737+
"suspend": data.get("suspend"),
738+
},
739+
}
740+
except ValueError:
741+
return {"dry_run": True, "success": False, "message": f"Scheduled session '{name}' not found"}
742+
743+
try:
744+
await self._request("DELETE", f"/v1/scheduled-sessions/{name}", project)
745+
return {"deleted": True, "message": f"Successfully deleted scheduled session '{name}'"}
746+
except ValueError as e:
747+
return {"deleted": False, "message": f"Failed to delete: {str(e)}"}
748+
749+
async def suspend_scheduled_session(self, project: str, name: str) -> dict[str, Any]:
750+
"""Suspend (pause) a scheduled session."""
751+
self._validate_input(project, "project")
752+
self._validate_input(name, "name")
753+
754+
await self._request("POST", f"/v1/scheduled-sessions/{name}/suspend", project)
755+
return {"suspended": True, "message": f"Scheduled session '{name}' suspended"}
756+
757+
async def resume_scheduled_session(self, project: str, name: str) -> dict[str, Any]:
758+
"""Resume a suspended scheduled session."""
759+
self._validate_input(project, "project")
760+
self._validate_input(name, "name")
761+
762+
await self._request("POST", f"/v1/scheduled-sessions/{name}/resume", project)
763+
return {"resumed": True, "message": f"Scheduled session '{name}' resumed"}
764+
765+
async def trigger_scheduled_session(self, project: str, name: str) -> dict[str, Any]:
766+
"""Manually trigger a scheduled session to run immediately."""
767+
self._validate_input(project, "project")
768+
self._validate_input(name, "name")
769+
770+
await self._request("POST", f"/v1/scheduled-sessions/{name}/trigger", project)
771+
return {"triggered": True, "message": f"Scheduled session '{name}' triggered"}
772+
773+
async def list_scheduled_session_runs(self, project: str, name: str) -> dict[str, Any]:
774+
"""List past runs (AgenticSessions) created by a scheduled session."""
775+
self._validate_input(project, "project")
776+
self._validate_input(name, "name")
777+
778+
response = await self._request("GET", f"/v1/scheduled-sessions/{name}/runs", project)
779+
items = response.get("items", [])
780+
return {"runs": items, "total": len(items), "scheduled_session": name}
781+
625782
# ── Observability ────────────────────────────────────────────────────
626783

627784
async def get_session_logs(
@@ -676,6 +833,14 @@ async def get_session_metrics(self, project: str, session: str) -> dict[str, Any
676833
result["session"] = session
677834
return result
678835

836+
async def export_session(self, project: str, session: str) -> dict[str, Any]:
837+
"""Export session chat as markdown."""
838+
self._validate_input(project, "project")
839+
self._validate_input(session, "session")
840+
841+
text = await self._request_text("GET", f"/v1/sessions/{session}/export", project)
842+
return {"export": text, "session": session}
843+
679844
# ── Labels ───────────────────────────────────────────────────────────
680845

681846
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(
8741039
"""Restart sessions matching label selectors (max 3 matches)."""
8751040
return await self._run_bulk_by_label(project, labels, self.restart_session, "restart", "restarted", dry_run)
8761041

1042+
# ── Workflow Management ─────────────────────────────────────────────
1043+
1044+
async def set_workflow(self, project: str, session: str, workflow: str) -> dict[str, Any]:
1045+
"""Set the active workflow on a session."""
1046+
self._validate_input(project, "project")
1047+
self._validate_input(session, "session")
1048+
1049+
return await self._request(
1050+
"POST", f"/v1/sessions/{session}/workflow", project, json_data={"workflow": workflow}
1051+
)
1052+
1053+
async def get_workflow_metadata(self, project: str, session: str) -> dict[str, Any]:
1054+
"""Get workflow metadata for a session."""
1055+
self._validate_input(project, "project")
1056+
self._validate_input(session, "session")
1057+
1058+
result = await self._request("GET", f"/v1/sessions/{session}/workflow/metadata", project)
1059+
result["session"] = session
1060+
return result
1061+
1062+
# ── Repo Management ─────────────────────────────────────────────────
1063+
1064+
async def add_repo(self, project: str, session: str, repo_url: str) -> dict[str, Any]:
1065+
"""Add a repository to a running session."""
1066+
self._validate_input(project, "project")
1067+
self._validate_input(session, "session")
1068+
1069+
return await self._request("POST", f"/v1/sessions/{session}/repos", project, json_data={"url": repo_url})
1070+
1071+
async def remove_repo(self, project: str, session: str, repo_name: str) -> dict[str, Any]:
1072+
"""Remove a repository from a session."""
1073+
self._validate_input(project, "project")
1074+
self._validate_input(session, "session")
1075+
self._validate_input(repo_name, "repo_name")
1076+
1077+
await self._request("DELETE", f"/v1/sessions/{session}/repos/{repo_name}", project)
1078+
return {"removed": True, "message": f"Repo '{repo_name}' removed from session '{session}'"}
1079+
1080+
async def get_repos_status(self, project: str, session: str) -> dict[str, Any]:
1081+
"""Get repository clone status for a session."""
1082+
self._validate_input(project, "project")
1083+
self._validate_input(session, "session")
1084+
1085+
result = await self._request("GET", f"/v1/sessions/{session}/repos/status", project)
1086+
result["session"] = session
1087+
return result
1088+
8771089
# ── Cluster & auth ───────────────────────────────────────────────────
8781090

8791091
def list_clusters(self) -> dict[str, Any]:

src/mcp_acp/formatters.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,59 @@ def format_login(result: dict[str, Any]) -> str:
272272
output += f"Error: {result.get('message', 'unknown error')}\n"
273273

274274
return output
275+
276+
277+
def format_scheduled_sessions_list(result: dict[str, Any]) -> str:
278+
"""Format scheduled sessions list."""
279+
output = f"Found {result['total']} scheduled session(s)\n\n"
280+
281+
for ss in result["scheduled_sessions"]:
282+
name = ss.get("name", "unknown")
283+
schedule = ss.get("schedule", "unknown")
284+
suspended = ss.get("suspend", False)
285+
active = ss.get("activeCount", 0)
286+
display = ss.get("displayName", "")
287+
288+
output += f"- {name}"
289+
if display:
290+
output += f" ({display})"
291+
output += f"\n Schedule: {schedule}"
292+
output += f"\n Suspended: {suspended}"
293+
output += f"\n Active runs: {active}\n"
294+
295+
return output
296+
297+
298+
def format_scheduled_session_created(result: dict[str, Any]) -> str:
299+
"""Format scheduled session creation result."""
300+
if result.get("dry_run"):
301+
output = "DRY RUN MODE - No changes made\n\n"
302+
output += result.get("message", "")
303+
if "manifest" in result:
304+
output += f"\n\nManifest:\n{json.dumps(result['manifest'], indent=2)}"
305+
return output
306+
307+
if not result.get("created"):
308+
return f"Failed to create scheduled session: {result.get('message', 'unknown error')}"
309+
310+
name = result.get("name", "unknown")
311+
output = f"Scheduled session created: {name}\n"
312+
output += result.get("message", "")
313+
return output
314+
315+
316+
def format_scheduled_session_runs(result: dict[str, Any]) -> str:
317+
"""Format list of past runs for a scheduled session."""
318+
ss_name = result.get("scheduled_session", "unknown")
319+
output = f"Runs for scheduled session '{ss_name}': {result['total']} total\n\n"
320+
321+
for run in result["runs"]:
322+
run_id = run.get("id", run.get("name", "unknown"))
323+
status = run.get("status", "unknown")
324+
created = run.get("createdAt", run.get("creationTimestamp", "unknown"))
325+
326+
output += f"- {run_id}\n"
327+
output += f" Status: {status}\n"
328+
output += f" Created: {created}\n"
329+
330+
return output

0 commit comments

Comments
 (0)