temporal-mcp-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Temporal MCP Server package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,43 @@
1
+ """Entry point for Temporal MCP Server.
2
+
3
+ Supports:
4
+ - `python -m temporal_mcp`
5
+ - `temporal-mcp-server` CLI (installed via pip)
6
+ """
7
+
8
+ import asyncio
9
+ import os
10
+ import sys
11
+
12
+ from temporal_mcp.server import TemporalMCPServer
13
+
14
+
15
+ def main():
16
+ """Main entry point for the MCP server."""
17
+ temporal_host = os.environ.get("TEMPORAL_HOST", "localhost:7233")
18
+ namespace = os.environ.get("TEMPORAL_NAMESPACE", "default")
19
+
20
+ # Parse TLS setting: None (auto-detect), True (force enable), False (force disable)
21
+ tls_env = os.environ.get("TEMPORAL_TLS_ENABLED", "").lower()
22
+ if tls_env == "true":
23
+ tls_enabled = True
24
+ elif tls_env == "false":
25
+ tls_enabled = False
26
+ else:
27
+ tls_enabled = None # Auto-detect
28
+
29
+ print(
30
+ f"Starting MCP server with TEMPORAL_HOST={temporal_host}, TLS={tls_enabled}",
31
+ file=sys.stderr,
32
+ )
33
+
34
+ server = TemporalMCPServer(
35
+ temporal_host=temporal_host,
36
+ namespace=namespace,
37
+ tls_enabled=tls_enabled,
38
+ )
39
+ asyncio.run(server.run())
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
temporal_mcp/client.py ADDED
@@ -0,0 +1,125 @@
1
+ """Temporal client management and connection handling."""
2
+
3
+ import sys
4
+ from typing import Optional
5
+
6
+ from temporalio.client import Client, TLSConfig
7
+
8
+
9
+ class TemporalClientManager:
10
+ """Manages connection to Temporal server."""
11
+
12
+ def __init__(
13
+ self,
14
+ temporal_host: str = "localhost:7233",
15
+ namespace: str = "default",
16
+ tls_enabled: Optional[bool] = None
17
+ ):
18
+ """Initialize the Temporal client manager.
19
+
20
+ Args:
21
+ temporal_host: The Temporal server host and port
22
+ namespace: The Temporal namespace to use
23
+ tls_enabled: Whether to use TLS for connection (None = auto-detect, True = force enable, False = force disable)
24
+ """
25
+ self.temporal_host = temporal_host
26
+ self.namespace = namespace
27
+ self.tls_enabled = tls_enabled
28
+ self.client: Optional[Client] = None
29
+
30
+ async def connect(self) -> Client:
31
+ """Connect to Temporal server.
32
+
33
+ Returns:
34
+ Connected Temporal client
35
+
36
+ Raises:
37
+ Exception: If connection fails
38
+ """
39
+ if not self.client:
40
+ tls_config = self._determine_tls_config()
41
+
42
+ self._log_connection_info(tls_config)
43
+
44
+ try:
45
+ self.client = await Client.connect(
46
+ self.temporal_host,
47
+ namespace=self.namespace,
48
+ tls=tls_config,
49
+ )
50
+ print(f"Successfully connected to Temporal at {self.temporal_host}", file=sys.stderr)
51
+ except Exception as e:
52
+ print(f"Failed to connect to Temporal at {self.temporal_host}: {type(e).__name__}: {e}", file=sys.stderr)
53
+ import traceback
54
+ traceback.print_exc(file=sys.stderr)
55
+ raise
56
+
57
+ return self.client
58
+
59
+ async def disconnect(self):
60
+ """Disconnect from Temporal server."""
61
+ if self.client:
62
+ try:
63
+ await self.client.close()
64
+ except Exception as e:
65
+ print(f"Error closing Temporal client: {e}", file=sys.stderr)
66
+ finally:
67
+ self.client = None
68
+
69
+ def ensure_connected(self) -> Client:
70
+ """Ensure client is connected.
71
+
72
+ Returns:
73
+ The connected client
74
+
75
+ Raises:
76
+ RuntimeError: If not connected
77
+ """
78
+ if not self.client:
79
+ raise RuntimeError("Not connected to Temporal server. Connection may have failed or been lost.")
80
+ return self.client
81
+
82
+ def _determine_tls_config(self) -> Optional[TLSConfig]:
83
+ """Determine TLS configuration based on settings and hostname.
84
+
85
+ Returns:
86
+ TLS configuration or None
87
+ """
88
+ # Priority: explicit tls_enabled setting > auto-detect from hostname
89
+ if self.tls_enabled is True:
90
+ return TLSConfig()
91
+ elif self.tls_enabled is False:
92
+ return None
93
+ elif self._is_remote_host():
94
+ # Auto-detect: enable TLS for remote connections
95
+ return TLSConfig()
96
+ else:
97
+ # Auto-detect: disable TLS for local connections
98
+ return None
99
+
100
+ def _is_remote_host(self) -> bool:
101
+ """Check if the host is a remote (non-local) host.
102
+
103
+ Returns:
104
+ True if host is remote, False if local
105
+ """
106
+ local_hosts = ["localhost", "127.0.0.1", "host.docker.internal"]
107
+ return not any(local_host in self.temporal_host for local_host in local_hosts)
108
+
109
+ def _log_connection_info(self, tls_config: Optional[TLSConfig]):
110
+ """Log connection information for debugging.
111
+
112
+ Args:
113
+ tls_config: The TLS configuration being used
114
+ """
115
+ if self.tls_enabled is True:
116
+ print(f"Connecting to {self.temporal_host} with TLS enabled (explicit)", file=sys.stderr)
117
+ elif self.tls_enabled is False:
118
+ print(f"Connecting to {self.temporal_host} without TLS (explicit)", file=sys.stderr)
119
+ elif tls_config is not None:
120
+ print(f"Connecting to {self.temporal_host} with TLS enabled (auto-detected for remote host)", file=sys.stderr)
121
+ else:
122
+ print(f"Connecting to {self.temporal_host} without TLS (auto-detected for local host)", file=sys.stderr)
123
+
124
+ print(f"Namespace: {self.namespace}", file=sys.stderr)
125
+ print(f"TLS Enabled: {tls_config is not None}", file=sys.stderr)
@@ -0,0 +1 @@
1
+ """Handler modules for Temporal operations."""
@@ -0,0 +1,250 @@
1
+ """Handlers for batch workflow operations."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any
6
+
7
+ from mcp.types import TextContent
8
+ from temporalio.client import Client
9
+
10
+
11
+ async def batch_signal(client: Client, args: dict) -> list[TextContent]:
12
+ """Send signal to multiple workflows.
13
+
14
+ Args:
15
+ client: Connected Temporal client
16
+ args: Arguments containing query, signal_name, optional args, and optional limit
17
+
18
+ Returns:
19
+ Batch operation results with success and error counts
20
+ """
21
+ query = args["query"]
22
+ signal_name = args["signal_name"]
23
+ signal_args = args.get("args")
24
+ limit = args.get("limit", 100)
25
+
26
+ workflows_signaled = []
27
+ errors = []
28
+
29
+ async for workflow in client.list_workflows(query):
30
+ if len(workflows_signaled) + len(errors) >= limit:
31
+ break
32
+
33
+ try:
34
+ handle = client.get_workflow_handle(workflow.id)
35
+ await handle.signal(signal_name, signal_args)
36
+ workflows_signaled.append(workflow.id)
37
+ except Exception as e:
38
+ error_detail = {
39
+ "workflow_id": workflow.id,
40
+ "error": str(e),
41
+ "error_type": type(e).__name__
42
+ }
43
+ errors.append(error_detail)
44
+ print(f"Error signaling workflow {workflow.id}: {e}", file=sys.stderr)
45
+
46
+ result = {
47
+ "signal_name": signal_name,
48
+ "workflows_signaled": workflows_signaled,
49
+ "success_count": len(workflows_signaled),
50
+ "error_count": len(errors)
51
+ }
52
+
53
+ if errors:
54
+ result["errors"] = errors
55
+
56
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
57
+
58
+
59
+ async def batch_cancel(client: Client, args: dict) -> list[TextContent]:
60
+ """Cancel multiple workflows with concurrent processing.
61
+
62
+ Args:
63
+ client: Connected Temporal client
64
+ args: Arguments containing query and optional limit, concurrency
65
+
66
+ Returns:
67
+ Batch operation results with success and error counts
68
+ """
69
+ import asyncio
70
+
71
+ query = args["query"]
72
+ limit = args.get("limit", 100)
73
+ concurrency = args.get("concurrency", 50) # Process 50 workflows concurrently
74
+
75
+ print(f"Starting batch cancel with limit={limit}, concurrency={concurrency}", file=sys.stderr)
76
+
77
+ workflows_cancelled = []
78
+ errors = []
79
+
80
+ async def cancel_workflow(workflow_id: str) -> tuple[str, Exception | None]:
81
+ """Cancel a single workflow and return result."""
82
+ try:
83
+ handle = client.get_workflow_handle(workflow_id)
84
+ await handle.cancel()
85
+ return workflow_id, None
86
+ except Exception as e:
87
+ return workflow_id, e
88
+
89
+ # Collect workflows to cancel
90
+ workflows_to_cancel = []
91
+ async for workflow in client.list_workflows(query):
92
+ workflows_to_cancel.append(workflow.id)
93
+ if len(workflows_to_cancel) >= limit:
94
+ break
95
+
96
+ total = len(workflows_to_cancel)
97
+ print(f"Found {total} workflows to cancel. Starting cancellation...", file=sys.stderr)
98
+
99
+ # Process in batches for concurrency
100
+ for i in range(0, len(workflows_to_cancel), concurrency):
101
+ batch = workflows_to_cancel[i:i + concurrency]
102
+ batch_num = i // concurrency + 1
103
+ total_batches = (len(workflows_to_cancel) + concurrency - 1) // concurrency
104
+
105
+ print(f"Processing batch {batch_num}/{total_batches} ({len(batch)} workflows)...", file=sys.stderr)
106
+
107
+ # Cancel workflows concurrently in this batch
108
+ results = await asyncio.gather(
109
+ *[cancel_workflow(wf_id) for wf_id in batch],
110
+ return_exceptions=False
111
+ )
112
+
113
+ # Process results
114
+ for workflow_id, error in results:
115
+ if error is None:
116
+ workflows_cancelled.append(workflow_id)
117
+ else:
118
+ error_detail = {
119
+ "workflow_id": workflow_id,
120
+ "error": str(error),
121
+ "error_type": type(error).__name__
122
+ }
123
+ errors.append(error_detail)
124
+ print(f"Error cancelling workflow {workflow_id}: {error}", file=sys.stderr)
125
+
126
+ print(f"Batch {batch_num}/{total_batches} complete. Total cancelled: {len(workflows_cancelled)}, errors: {len(errors)}", file=sys.stderr)
127
+
128
+ print(f"Batch cancel complete! Cancelled: {len(workflows_cancelled)}, Errors: {len(errors)}", file=sys.stderr)
129
+
130
+ # Return only summary to avoid context overflow - do NOT include full list of IDs
131
+ result = {
132
+ "success_count": len(workflows_cancelled),
133
+ "error_count": len(errors),
134
+ "total_processed": len(workflows_cancelled) + len(errors),
135
+ "message": f"Successfully cancelled {len(workflows_cancelled)} workflows."
136
+ }
137
+
138
+ # Include first and last few IDs as samples only
139
+ if len(workflows_cancelled) > 0:
140
+ if len(workflows_cancelled) <= 10:
141
+ result["cancelled_workflows"] = workflows_cancelled
142
+ else:
143
+ result["sample_first"] = workflows_cancelled[:5]
144
+ result["sample_last"] = workflows_cancelled[-5:]
145
+ result["note"] = f"Showing first 5 and last 5 of {len(workflows_cancelled)} cancelled workflows to avoid context overflow"
146
+
147
+ if errors:
148
+ result["sample_errors"] = errors[:5] # Only show first 5 errors
149
+ if len(errors) > 5:
150
+ result["errors_note"] = f"Showing first 5 of {len(errors)} errors"
151
+
152
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
153
+
154
+
155
+ async def batch_terminate(client: Client, args: dict) -> list[TextContent]:
156
+ """Terminate multiple workflows with concurrent processing.
157
+
158
+ Args:
159
+ client: Connected Temporal client
160
+ args: Arguments containing query, optional reason, limit, and concurrency
161
+
162
+ Returns:
163
+ Batch operation results with success and error counts
164
+ """
165
+ import asyncio
166
+
167
+ query = args["query"]
168
+ reason = args.get("reason", "Batch termination via MCP")
169
+ limit = args.get("limit", 100)
170
+ concurrency = args.get("concurrency", 50) # Process 50 workflows concurrently
171
+
172
+ print(f"Starting batch terminate with limit={limit}, concurrency={concurrency}", file=sys.stderr)
173
+
174
+ workflows_terminated = []
175
+ errors = []
176
+
177
+ async def terminate_workflow(workflow_id: str) -> tuple[str, Exception | None]:
178
+ """Terminate a single workflow and return result."""
179
+ try:
180
+ handle = client.get_workflow_handle(workflow_id)
181
+ await handle.terminate(reason)
182
+ return workflow_id, None
183
+ except Exception as e:
184
+ return workflow_id, e
185
+
186
+ # Collect workflows to terminate
187
+ workflows_to_terminate = []
188
+ async for workflow in client.list_workflows(query):
189
+ workflows_to_terminate.append(workflow.id)
190
+ if len(workflows_to_terminate) >= limit:
191
+ break
192
+
193
+ total = len(workflows_to_terminate)
194
+ print(f"Found {total} workflows to terminate. Starting termination...", file=sys.stderr)
195
+
196
+ # Process in batches for concurrency
197
+ for i in range(0, len(workflows_to_terminate), concurrency):
198
+ batch = workflows_to_terminate[i:i + concurrency]
199
+ batch_num = i // concurrency + 1
200
+ total_batches = (len(workflows_to_terminate) + concurrency - 1) // concurrency
201
+
202
+ print(f"Processing batch {batch_num}/{total_batches} ({len(batch)} workflows)...", file=sys.stderr)
203
+
204
+ # Terminate workflows concurrently in this batch
205
+ results = await asyncio.gather(
206
+ *[terminate_workflow(wf_id) for wf_id in batch],
207
+ return_exceptions=False
208
+ )
209
+
210
+ # Process results
211
+ for workflow_id, error in results:
212
+ if error is None:
213
+ workflows_terminated.append(workflow_id)
214
+ else:
215
+ error_detail = {
216
+ "workflow_id": workflow_id,
217
+ "error": str(error),
218
+ "error_type": type(error).__name__
219
+ }
220
+ errors.append(error_detail)
221
+ print(f"Error terminating workflow {workflow_id}: {error}", file=sys.stderr)
222
+
223
+ print(f"Batch {batch_num}/{total_batches} complete. Total terminated: {len(workflows_terminated)}, errors: {len(errors)}", file=sys.stderr)
224
+
225
+ print(f"Batch terminate complete! Terminated: {len(workflows_terminated)}, Errors: {len(errors)}", file=sys.stderr)
226
+
227
+ # Return only summary to avoid context overflow - do NOT include full list of IDs
228
+ result = {
229
+ "reason": reason,
230
+ "success_count": len(workflows_terminated),
231
+ "error_count": len(errors),
232
+ "total_processed": len(workflows_terminated) + len(errors),
233
+ "message": f"Successfully terminated {len(workflows_terminated)} workflows."
234
+ }
235
+
236
+ # Include first and last few IDs as samples only
237
+ if len(workflows_terminated) > 0:
238
+ if len(workflows_terminated) <= 10:
239
+ result["terminated_workflows"] = workflows_terminated
240
+ else:
241
+ result["sample_first"] = workflows_terminated[:5]
242
+ result["sample_last"] = workflows_terminated[-5:]
243
+ result["note"] = f"Showing first 5 and last 5 of {len(workflows_terminated)} terminated workflows to avoid context overflow"
244
+
245
+ if errors:
246
+ result["sample_errors"] = errors[:5] # Only show first 5 errors
247
+ if len(errors) > 5:
248
+ result["errors_note"] = f"Showing first 5 of {len(errors)} errors"
249
+
250
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
@@ -0,0 +1,88 @@
1
+ """Handlers for workflow query and signal operations."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from mcp.types import TextContent
7
+ from temporalio.client import Client
8
+
9
+
10
+ async def query_workflow(client: Client, args: dict) -> list[TextContent]:
11
+ """Query a workflow execution.
12
+
13
+ Args:
14
+ client: Connected Temporal client
15
+ args: Arguments containing workflow_id, query_name, and optional args
16
+
17
+ Returns:
18
+ Query result
19
+ """
20
+ workflow_id = args["workflow_id"]
21
+ query_name = args["query_name"]
22
+ query_args = args.get("args")
23
+
24
+ handle = client.get_workflow_handle(workflow_id)
25
+ result = await handle.query(query_name, query_args)
26
+
27
+ return [TextContent(
28
+ type="text",
29
+ text=json.dumps({"query_result": result}, indent=2, default=str)
30
+ )]
31
+
32
+
33
+ async def signal_workflow(client: Client, args: dict) -> list[TextContent]:
34
+ """Send a signal to a workflow.
35
+
36
+ Args:
37
+ client: Connected Temporal client
38
+ args: Arguments containing workflow_id, signal_name, and optional args
39
+
40
+ Returns:
41
+ Success response
42
+ """
43
+ workflow_id = args["workflow_id"]
44
+ signal_name = args["signal_name"]
45
+ signal_args = args.get("args")
46
+
47
+ handle = client.get_workflow_handle(workflow_id)
48
+ await handle.signal(signal_name, signal_args)
49
+
50
+ return [TextContent(
51
+ type="text",
52
+ text=json.dumps({
53
+ "status": "signal_sent",
54
+ "workflow_id": workflow_id,
55
+ "signal_name": signal_name
56
+ }, indent=2)
57
+ )]
58
+
59
+
60
+ async def continue_as_new(client: Client, args: dict) -> list[TextContent]:
61
+ """Signal a workflow to continue as new.
62
+
63
+ Note: This sends a signal to the workflow. The workflow itself must be
64
+ designed to call workflow.continue_as_new() when it receives this signal.
65
+
66
+ Args:
67
+ client: Connected Temporal client
68
+ args: Arguments containing workflow_id, signal_name, and optional signal_args
69
+
70
+ Returns:
71
+ Success response
72
+ """
73
+ workflow_id = args["workflow_id"]
74
+ signal_name = args["signal_name"]
75
+ signal_args = args.get("signal_args", {})
76
+
77
+ handle = client.get_workflow_handle(workflow_id)
78
+ await handle.signal(signal_name, signal_args)
79
+
80
+ return [TextContent(
81
+ type="text",
82
+ text=json.dumps({
83
+ "status": "signal_sent",
84
+ "workflow_id": workflow_id,
85
+ "signal_name": signal_name,
86
+ "note": "Workflow must implement continue-as-new logic in signal handler"
87
+ }, indent=2)
88
+ )]