Skip to content

Commit 28a0822

Browse files
authored
add servers workflow subcommand (#428)
# Add servers workflows subcommand This PR adds a new `workflows` subcommand to the `mcp-agent cloud servers` command that allows users to list workflows associated with a specific server. The command supports: - Filtering by workflow status - Limiting the number of results - Multiple output formats (text, JSON, YAML) - Accepting server IDs, app config IDs, or server URLs as input Examples: ``` mcp-agent cloud servers workflows app_abc123 mcp-agent cloud servers workflows https://server.example.com --status running mcp-agent cloud servers workflows apcnf_xyz789 --limit 10 --format json ``` The PR also cleans up the examples in the existing workflow commands and adds the necessary API client support for listing workflows.
1 parent 7316480 commit 28a0822

File tree

9 files changed

+273
-7
lines changed

9 files changed

+273
-7
lines changed

src/mcp_agent/cli/cloud/commands/servers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
from .list.main import list_servers
44
from .describe.main import describe_server
55
from .delete.main import delete_server
6+
from .workflows.main import list_workflows_for_server
67

78
__all__ = [
89
"list_servers",
910
"describe_server",
1011
"delete_server",
12+
"list_workflows_for_server",
1113
]

src/mcp_agent/cli/cloud/commands/servers/list/main.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,13 @@ def list_servers(
2828
"""List MCP Servers with optional filtering and sorting.
2929
3030
Examples:
31-
# Filter servers containing 'api'
31+
3232
mcp-agent cloud servers list --filter api
3333
34-
# Sort by creation date (newest first)
3534
mcp-agent cloud servers list --sort-by -created
3635
37-
# Filter active servers and sort by name
3836
mcp-agent cloud servers list --filter active --sort-by name
3937
40-
# Get JSON output with filtering
4138
mcp-agent cloud servers list --filter production --format json
4239
"""
4340
validate_output_format(format)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Workflows subcommand for servers."""
2+
3+
from .main import list_workflows_for_server
4+
5+
__all__ = [
6+
"list_workflows_for_server",
7+
]
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Server workflows command implementation."""
2+
3+
import json
4+
from typing import Optional
5+
6+
import typer
7+
import yaml
8+
from rich.table import Table
9+
10+
from mcp_agent.cli.core.utils import run_async
11+
from mcp_agent.cli.mcp_app.api_client import WorkflowExecutionStatus
12+
from ...utils import (
13+
setup_authenticated_client,
14+
validate_output_format,
15+
handle_server_api_errors,
16+
resolve_server,
17+
)
18+
from mcp_agent.cli.utils.ux import console, print_info
19+
20+
21+
@handle_server_api_errors
22+
def list_workflows_for_server(
23+
server_id_or_url: str = typer.Argument(..., help="Server ID, app config ID, or server URL to list workflows for"),
24+
limit: Optional[int] = typer.Option(None, "--limit", help="Maximum number of results to return"),
25+
status: Optional[str] = typer.Option(None, "--status", help="Filter by status: running|failed|timed_out|canceled|terminated|completed|continued"),
26+
format: Optional[str] = typer.Option("text", "--format", help="Output format (text|json|yaml)"),
27+
) -> None:
28+
"""List workflows for an MCP Server.
29+
30+
Examples:
31+
32+
mcp-agent cloud servers workflows app_abc123
33+
34+
mcp-agent cloud servers workflows https://server.example.com --status running
35+
36+
mcp-agent cloud servers workflows apcnf_xyz789 --limit 10 --format json
37+
"""
38+
validate_output_format(format)
39+
client = setup_authenticated_client()
40+
41+
if server_id_or_url.startswith(('http://', 'https://')):
42+
resolved_server = resolve_server(client, server_id_or_url)
43+
44+
if hasattr(resolved_server, 'appId'):
45+
app_id_or_config_id = resolved_server.appId
46+
elif hasattr(resolved_server, 'appConfigurationId'):
47+
app_id_or_config_id = resolved_server.appConfigurationId
48+
else:
49+
raise ValueError(f"Could not extract app ID or config ID from server: {server_id_or_url}")
50+
else:
51+
app_id_or_config_id = server_id_or_url
52+
53+
max_results = limit or 100
54+
55+
status_filter = None
56+
if status:
57+
status_map = {
58+
"running": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_RUNNING,
59+
"failed": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_FAILED,
60+
"timed_out": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TIMED_OUT,
61+
"timeout": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TIMED_OUT, # alias
62+
"canceled": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED,
63+
"cancelled": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED, # alias
64+
"terminated": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TERMINATED,
65+
"completed": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_COMPLETED,
66+
"continued": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW,
67+
"continued_as_new": WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW,
68+
}
69+
status_filter = status_map.get(status.lower())
70+
if not status_filter:
71+
valid_statuses = "running|failed|timed_out|timeout|canceled|cancelled|terminated|completed|continued|continued_as_new"
72+
raise typer.BadParameter(f"Invalid status '{status}'. Valid options: {valid_statuses}")
73+
74+
async def list_workflows_async():
75+
return await client.list_workflows(
76+
app_id_or_config_id=app_id_or_config_id,
77+
max_results=max_results
78+
)
79+
80+
response = run_async(list_workflows_async())
81+
workflows = response.workflows or []
82+
83+
if status_filter:
84+
workflows = [w for w in workflows if w.execution_status == status_filter]
85+
86+
if format == "json":
87+
_print_workflows_json(workflows)
88+
elif format == "yaml":
89+
_print_workflows_yaml(workflows)
90+
else:
91+
_print_workflows_text(workflows, status, server_id_or_url)
92+
93+
94+
def _print_workflows_text(workflows, status_filter, server_id_or_url):
95+
"""Print workflows in text format."""
96+
server_name = server_id_or_url
97+
98+
console.print(f"\n[bold blue]📊 Workflows for Server: {server_name}[/bold blue]")
99+
100+
if not workflows:
101+
print_info("No workflows found for this server.")
102+
return
103+
104+
console.print(f"\nFound {len(workflows)} workflow(s):")
105+
106+
table = Table(show_header=True, header_style="bold blue")
107+
table.add_column("Workflow ID", style="cyan", width=20)
108+
table.add_column("Name", style="green", width=20)
109+
table.add_column("Status", style="yellow", width=15)
110+
table.add_column("Run ID", style="dim", width=15)
111+
table.add_column("Created", style="dim", width=20)
112+
table.add_column("Principal", style="dim", width=15)
113+
114+
for workflow in workflows:
115+
status_display = _get_status_display(workflow.execution_status)
116+
created_display = workflow.created_at.strftime('%Y-%m-%d %H:%M:%S') if workflow.created_at else "N/A"
117+
run_id_display = _truncate_string(workflow.run_id or "N/A", 15)
118+
119+
table.add_row(
120+
_truncate_string(workflow.workflow_id, 20),
121+
_truncate_string(workflow.name, 20),
122+
status_display,
123+
run_id_display,
124+
created_display,
125+
_truncate_string(workflow.principal_id, 15),
126+
)
127+
128+
console.print(table)
129+
130+
if status_filter:
131+
console.print(f"\n[dim]Filtered by status: {status_filter}[/dim]")
132+
133+
134+
def _print_workflows_json(workflows):
135+
"""Print workflows in JSON format."""
136+
workflows_data = [_workflow_to_dict(workflow) for workflow in workflows]
137+
print(json.dumps({"workflows": workflows_data}, indent=2, default=str))
138+
139+
140+
def _print_workflows_yaml(workflows):
141+
"""Print workflows in YAML format."""
142+
workflows_data = [_workflow_to_dict(workflow) for workflow in workflows]
143+
print(yaml.dump({"workflows": workflows_data}, default_flow_style=False))
144+
145+
146+
def _workflow_to_dict(workflow):
147+
"""Convert WorkflowInfo to dictionary."""
148+
return {
149+
"workflow_id": workflow.workflow_id,
150+
"run_id": workflow.run_id,
151+
"name": workflow.name,
152+
"created_at": workflow.created_at.isoformat() if workflow.created_at else None,
153+
"principal_id": workflow.principal_id,
154+
"execution_status": workflow.execution_status.value if workflow.execution_status else None,
155+
}
156+
157+
158+
def _truncate_string(text: str, max_length: int) -> str:
159+
"""Truncate string to max_length, adding ellipsis if truncated."""
160+
if len(text) <= max_length:
161+
return text
162+
return text[:max_length-3] + "..."
163+
164+
165+
def _get_status_display(status):
166+
"""Convert WorkflowExecutionStatus to display string with emoji."""
167+
if not status:
168+
return "❓ Unknown"
169+
170+
status_map = {
171+
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_RUNNING: "[green]🟢 Running[/green]",
172+
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_COMPLETED: "[blue]✅ Completed[/blue]",
173+
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_FAILED: "[red]❌ Failed[/red]",
174+
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED: "[yellow]🟡 Canceled[/yellow]",
175+
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TERMINATED: "[red]🔴 Terminated[/red]",
176+
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_TIMED_OUT: "[orange]⏰ Timed Out[/orange]",
177+
WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW: "[purple]🔄 Continued[/purple]",
178+
}
179+
180+
return status_map.get(status, "❓ Unknown")

src/mcp_agent/cli/cloud/commands/workflows/cancel/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ def cancel_workflow(
8181
cannot be resumed and will be marked as cancelled.
8282
8383
Examples:
84+
8485
mcp-agent cloud workflows cancel app_abc123 run_xyz789
85-
mcp-agent cloud workflows cancel https://server.example.com run_xyz789 --reason "User requested cancellation"
86+
87+
mcp-agent cloud workflows cancel app_abc123 run_xyz789 --reason "User requested"
8688
"""
8789
run_async(_cancel_workflow_async(server_id_or_url, run_id, reason))

src/mcp_agent/cli/cloud/commands/workflows/describe/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,10 @@ def describe_workflow(
8686
creation time, and other metadata.
8787
8888
Examples:
89+
8990
mcp-agent cloud workflows describe app_abc123 run_xyz789
90-
mcp-agent cloud workflows describe https://server.example.com run_xyz789 --format json
91+
92+
mcp-agent cloud workflows describe app_abc123 run_xyz789 --format json
9193
"""
9294
if format not in ["text", "json", "yaml"]:
9395
console.print("[red]Error: --format must be 'text', 'json', or 'yaml'[/red]")

src/mcp_agent/cli/cloud/commands/workflows/resume/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,11 @@ def resume_workflow(
8686
a payload (JSON or text) to pass data to the resumed workflow.
8787
8888
Examples:
89+
8990
mcp-agent cloud workflows resume app_abc123 run_xyz789
90-
mcp-agent cloud workflows resume https://server.example.com run_xyz789 --payload '{"data": "value"}'
91+
92+
mcp-agent cloud workflows resume app_abc123 run_xyz789 --payload '{"data": "value"}'
93+
9194
mcp-agent cloud workflows resume app_abc123 run_xyz789 --payload "simple text"
9295
"""
9396
if payload:

src/mcp_agent/cli/cloud/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
list_servers,
3838
describe_server,
3939
delete_server,
40+
list_workflows_for_server,
4041
)
4142
from mcp_agent.cli.exceptions import CLIError
4243
from mcp_agent.cli.utils.ux import print_error
@@ -163,6 +164,7 @@ def invoke(self, ctx):
163164
app_cmd_servers.command(name="list")(list_servers)
164165
app_cmd_servers.command(name="describe")(describe_server)
165166
app_cmd_servers.command(name="delete")(delete_server)
167+
app_cmd_servers.command(name="workflows")(list_workflows_for_server)
166168
app.add_typer(app_cmd_servers, name="servers", help="Manage MCP Servers")
167169

168170
# Alias for servers - apps should behave identically

src/mcp_agent/cli/mcp_app/api_client.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime
44
from typing import Any, Dict, List, Literal, Optional, Union
55
from urllib.parse import urlparse
6+
from enum import Enum
67

78
from pydantic import BaseModel
89

@@ -63,6 +64,35 @@ class CanDoActionsResponse(BaseModel):
6364
canDoActions: Optional[List[CanDoActionCheck]] = []
6465

6566

67+
class WorkflowExecutionStatus(Enum):
68+
"""From temporal.api.enums.v1.WorkflowExecutionStatus"""
69+
WORKFLOW_EXECUTION_STATUS_UNSPECIFIED = "WORKFLOW_EXECUTION_STATUS_UNSPECIFIED"
70+
WORKFLOW_EXECUTION_STATUS_RUNNING = "WORKFLOW_EXECUTION_STATUS_RUNNING"
71+
WORKFLOW_EXECUTION_STATUS_FAILED = "WORKFLOW_EXECUTION_STATUS_FAILED"
72+
WORKFLOW_EXECUTION_STATUS_TIMED_OUT = "WORKFLOW_EXECUTION_STATUS_TIMED_OUT"
73+
WORKFLOW_EXECUTION_STATUS_CANCELED = "WORKFLOW_EXECUTION_STATUS_CANCELED"
74+
WORKFLOW_EXECUTION_STATUS_TERMINATED = "WORKFLOW_EXECUTION_STATUS_TERMINATED"
75+
WORKFLOW_EXECUTION_STATUS_COMPLETED = "WORKFLOW_EXECUTION_STATUS_COMPLETED"
76+
WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW = "WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW"
77+
78+
79+
class WorkflowInfo(BaseModel):
80+
"""Information about a workflow execution instance"""
81+
workflow_id: str
82+
run_id: Optional[str] = None
83+
name: str
84+
created_at: datetime
85+
principal_id: str
86+
execution_status: Optional[WorkflowExecutionStatus] = None
87+
88+
89+
class ListWorkflowsResponse(BaseModel):
90+
"""Response for listing workflows"""
91+
workflows: Optional[List[WorkflowInfo]] = []
92+
next_page_token: Optional[str] = None
93+
total_count: Optional[int] = 0
94+
95+
6696
APP_ID_PREFIX = "app_"
6797
APP_CONFIG_ID_PREFIX = "apcnf_"
6898

@@ -464,6 +494,47 @@ async def list_app_configurations(
464494
response = await self.post("/mcp_app/list_app_configurations", payload)
465495
return ListAppConfigurationsResponse(**response.json())
466496

497+
async def list_workflows(
498+
self,
499+
app_id_or_config_id: str,
500+
name_filter: Optional[str] = None,
501+
max_results: int = 100,
502+
page_token: Optional[str] = None,
503+
) -> ListWorkflowsResponse:
504+
"""List workflows for a specific app or app configuration.
505+
506+
Args:
507+
app_id_or_config_id: The app ID (e.g. app_abc123) or app config ID (e.g. apcnf_xyz789)
508+
name_filter: Optional workflow name filter
509+
max_results: Maximum number of results to return
510+
page_token: Pagination token
511+
512+
Returns:
513+
ListWorkflowsResponse: The list of workflows
514+
515+
Raises:
516+
ValueError: If the app_id_or_config_id is invalid
517+
httpx.HTTPError: If the API request fails
518+
"""
519+
payload: Dict[str, Any] = {
520+
"max_results": max_results,
521+
}
522+
523+
if is_valid_app_id_format(app_id_or_config_id):
524+
payload["app_specifier"] = {"app_id": app_id_or_config_id}
525+
elif is_valid_app_config_id_format(app_id_or_config_id):
526+
payload["app_specifier"] = {"app_config_id": app_id_or_config_id}
527+
else:
528+
raise ValueError(f"Invalid app ID or app config ID format: {app_id_or_config_id}. Expected format: app_xxx or apcnf_xxx")
529+
530+
if name_filter:
531+
payload["name"] = name_filter
532+
if page_token:
533+
payload["page_token"] = page_token
534+
535+
response = await self.post("/workflow/list", payload)
536+
return ListWorkflowsResponse(**response.json())
537+
467538
async def delete_app(self, app_id: str) -> str:
468539
"""Delete an MCP App via the API.
469540

0 commit comments

Comments
 (0)