edda-framework 0.3.0__py3-none-any.whl → 0.4.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.
@@ -4,18 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  import inspect
6
6
  from collections.abc import Callable
7
- from typing import TYPE_CHECKING, Any
7
+ from typing import TYPE_CHECKING, Any, cast
8
8
 
9
- from edda.workflow import workflow
9
+ from edda.workflow import Workflow, workflow
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from edda.integrations.mcp.server import EddaMCPServer
13
- from edda.workflow import Workflow
14
13
 
15
14
 
16
15
  def create_durable_tool(
17
16
  server: EddaMCPServer,
18
- func: Callable,
17
+ func: Callable[..., Any],
19
18
  *,
20
19
  description: str = "",
21
20
  ) -> Workflow:
@@ -38,7 +37,7 @@ def create_durable_tool(
38
37
  Workflow instance
39
38
  """
40
39
  # 1. Create Edda workflow
41
- workflow_instance: Workflow = workflow(func, event_handler=False)
40
+ workflow_instance = cast(Workflow, workflow(func, event_handler=False))
42
41
  workflow_name = func.__name__
43
42
 
44
43
  # Register in server's workflow registry
@@ -56,7 +55,7 @@ def create_durable_tool(
56
55
  ]
57
56
 
58
57
  # Create the tool function
59
- async def start_tool(**kwargs: Any) -> dict:
58
+ async def start_tool(**kwargs: Any) -> dict[str, Any]:
60
59
  """
61
60
  Start workflow and return instance_id.
62
61
 
@@ -94,11 +93,21 @@ def create_durable_tool(
94
93
  status_tool_name = f"{workflow_name}_status"
95
94
  status_tool_description = f"Check status of {workflow_name} workflow"
96
95
 
97
- @server._mcp.tool(name=status_tool_name, description=status_tool_description)
98
- async def status_tool(instance_id: str) -> dict:
96
+ @server._mcp.tool(name=status_tool_name, description=status_tool_description) # type: ignore[misc]
97
+ async def status_tool(instance_id: str) -> dict[str, Any]:
99
98
  """Check workflow status."""
100
99
  try:
101
- instance = await server._edda_app.storage.get_instance(instance_id)
100
+ instance = await server.storage.get_instance(instance_id)
101
+ if instance is None:
102
+ return {
103
+ "content": [
104
+ {
105
+ "type": "text",
106
+ "text": f"Workflow instance not found: {instance_id}",
107
+ }
108
+ ],
109
+ "isError": True,
110
+ }
102
111
 
103
112
  status = instance["status"]
104
113
  current_activity_id = instance.get("current_activity_id", "N/A")
@@ -128,11 +137,21 @@ def create_durable_tool(
128
137
  result_tool_name = f"{workflow_name}_result"
129
138
  result_tool_description = f"Get result of {workflow_name} workflow (if completed)"
130
139
 
131
- @server._mcp.tool(name=result_tool_name, description=result_tool_description)
132
- async def result_tool(instance_id: str) -> dict:
140
+ @server._mcp.tool(name=result_tool_name, description=result_tool_description) # type: ignore[misc]
141
+ async def result_tool(instance_id: str) -> dict[str, Any]:
133
142
  """Get workflow result (if completed)."""
134
143
  try:
135
- instance = await server._edda_app.storage.get_instance(instance_id)
144
+ instance = await server.storage.get_instance(instance_id)
145
+ if instance is None:
146
+ return {
147
+ "content": [
148
+ {
149
+ "type": "text",
150
+ "text": f"Workflow instance not found: {instance_id}",
151
+ }
152
+ ],
153
+ "isError": True,
154
+ }
136
155
 
137
156
  status = instance["status"]
138
157
 
@@ -3,13 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
- from typing import Any
6
+ from typing import TYPE_CHECKING, Any, cast
7
7
 
8
8
  from edda.app import EddaApp
9
9
  from edda.workflow import Workflow
10
10
 
11
+ if TYPE_CHECKING:
12
+ from edda.storage.protocol import StorageProtocol
13
+
11
14
  try:
12
- from mcp.server.fastmcp import FastMCP
15
+ from mcp.server.fastmcp import FastMCP # type: ignore[import-not-found]
13
16
  except ImportError as e:
14
17
  raise ImportError(
15
18
  "MCP Python SDK is required for MCP integration. "
@@ -45,7 +48,13 @@ class EddaMCPServer:
45
48
 
46
49
  # Deploy with uvicorn (HTTP transport)
47
50
  if __name__ == "__main__":
51
+ import asyncio
48
52
  import uvicorn
53
+
54
+ async def startup():
55
+ await server.initialize()
56
+
57
+ asyncio.run(startup())
49
58
  uvicorn.run(server.asgi_app(), host="0.0.0.0", port=8000)
50
59
 
51
60
  # Or deploy with stdio (for MCP clients, e.g., Claude Desktop)
@@ -89,7 +98,7 @@ class EddaMCPServer:
89
98
  service_name=name,
90
99
  db_url=db_url,
91
100
  outbox_enabled=outbox_enabled,
92
- broker_url=broker_url,
101
+ broker_url=broker_url or "",
93
102
  )
94
103
  self._mcp = FastMCP(name, json_response=True, stateless_http=True)
95
104
  self._token_verifier = token_verifier
@@ -97,12 +106,28 @@ class EddaMCPServer:
97
106
  # Registry of durable tools (workflow_name -> Workflow instance)
98
107
  self._workflows: dict[str, Workflow] = {}
99
108
 
109
+ @property
110
+ def storage(self) -> StorageProtocol:
111
+ """
112
+ Access workflow storage for querying instances and history.
113
+
114
+ Returns:
115
+ StorageProtocol: Storage backend for workflow state
116
+
117
+ Example:
118
+ ```python
119
+ instance = await server.storage.get_instance(instance_id)
120
+ history = await server.storage.get_history(instance_id)
121
+ ```
122
+ """
123
+ return self._edda_app.storage
124
+
100
125
  def durable_tool(
101
126
  self,
102
- func: Callable | None = None,
127
+ func: Callable[..., Any] | None = None,
103
128
  *,
104
129
  description: str = "",
105
- ) -> Callable:
130
+ ) -> Callable[..., Any]:
106
131
  """
107
132
  Decorator to define a durable workflow tool.
108
133
 
@@ -128,14 +153,67 @@ class EddaMCPServer:
128
153
  """
129
154
  from edda.integrations.mcp.decorators import create_durable_tool
130
155
 
131
- def decorator(f: Callable) -> Workflow:
156
+ def decorator(f: Callable[..., Any]) -> Workflow:
132
157
  return create_durable_tool(self, f, description=description)
133
158
 
134
159
  if func is None:
135
160
  return decorator
136
161
  return decorator(func)
137
162
 
138
- def asgi_app(self) -> Callable:
163
+ def prompt(
164
+ self,
165
+ func: Callable[..., Any] | None = None,
166
+ *,
167
+ description: str = "",
168
+ ) -> Callable[..., Any]:
169
+ """
170
+ Decorator to define a prompt template.
171
+
172
+ Prompts can access workflow state to generate dynamic, context-aware
173
+ prompts for AI clients (Claude Desktop, etc.).
174
+
175
+ Args:
176
+ func: Prompt function (async or sync)
177
+ description: Prompt description for MCP clients
178
+
179
+ Returns:
180
+ Decorated function
181
+
182
+ Example:
183
+ ```python
184
+ from fastmcp.prompts.prompt import PromptMessage, TextContent
185
+
186
+ @server.prompt(description="Analyze workflow results")
187
+ async def analyze_workflow(instance_id: str) -> PromptMessage:
188
+ '''Generate a prompt to analyze a specific workflow execution.'''
189
+ instance = await server.storage.get_instance(instance_id)
190
+ history = await server.storage.get_history(instance_id)
191
+
192
+ text = f'''Analyze this workflow:
193
+
194
+ Instance ID: {instance_id}
195
+ Status: {instance['status']}
196
+ Activities: {len(history)}
197
+
198
+ Please identify any issues or optimization opportunities.'''
199
+
200
+ return PromptMessage(
201
+ role="user",
202
+ content=TextContent(type="text", text=text)
203
+ )
204
+ ```
205
+ """
206
+
207
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
208
+ # Use FastMCP's native prompt decorator
209
+ prompt_desc = description or f.__doc__ or f"Prompt: {f.__name__}"
210
+ return cast(Callable[..., Any], self._mcp.prompt(description=prompt_desc)(f))
211
+
212
+ if func is None:
213
+ return decorator
214
+ return decorator(func)
215
+
216
+ def asgi_app(self) -> Callable[..., Any]:
139
217
  """
140
218
  Create ASGI application with MCP + CloudEvents support.
141
219
 
@@ -150,8 +228,8 @@ class EddaMCPServer:
150
228
  Returns:
151
229
  ASGI callable (Starlette app)
152
230
  """
153
- from starlette.requests import Request
154
- from starlette.responses import Response
231
+ from starlette.requests import Request # type: ignore[import-not-found]
232
+ from starlette.responses import Response # type: ignore[import-not-found]
155
233
 
156
234
  # Get MCP's Starlette app (Issue #1367 workaround: use directly)
157
235
  app = self._mcp.streamable_http_app()
@@ -169,9 +247,9 @@ class EddaMCPServer:
169
247
  scope["path"] = f"/cancel/{instance_id}"
170
248
 
171
249
  # Capture response
172
- response_data = {"status": 200, "headers": [], "body": b""}
250
+ response_data: dict[str, Any] = {"status": 200, "headers": [], "body": b""}
173
251
 
174
- async def send(message: dict) -> None:
252
+ async def send(message: dict[str, Any]) -> None:
175
253
  if message["type"] == "http.response.start":
176
254
  response_data["status"] = message["status"]
177
255
  response_data["headers"] = message.get("headers", [])
@@ -185,7 +263,7 @@ class EddaMCPServer:
185
263
  return Response(
186
264
  content=response_data["body"],
187
265
  status_code=response_data["status"],
188
- headers=dict(response_data["headers"]),
266
+ headers=cast(dict[str, str], dict(response_data["headers"])),
189
267
  )
190
268
 
191
269
  # Add cancel route
@@ -193,14 +271,18 @@ class EddaMCPServer:
193
271
 
194
272
  # Add authentication middleware if token_verifier provided (AFTER adding routes)
195
273
  if self._token_verifier is not None:
196
- from starlette.middleware.base import BaseHTTPMiddleware
274
+ from starlette.middleware.base import ( # type: ignore[import-not-found]
275
+ BaseHTTPMiddleware,
276
+ )
197
277
 
198
- class AuthMiddleware(BaseHTTPMiddleware):
199
- def __init__(self, app: Any, token_verifier: Callable):
278
+ class AuthMiddleware(BaseHTTPMiddleware): # type: ignore[misc]
279
+ def __init__(self, app: Any, token_verifier: Callable[[str], bool]):
200
280
  super().__init__(app)
201
281
  self.token_verifier = token_verifier
202
282
 
203
- async def dispatch(self, request: Request, call_next: Callable):
283
+ async def dispatch(
284
+ self, request: Request, call_next: Callable[..., Any]
285
+ ) -> Response:
204
286
  auth_header = request.headers.get("authorization", "")
205
287
  if auth_header.startswith("Bearer "):
206
288
  token = auth_header[7:]
@@ -211,17 +293,15 @@ class EddaMCPServer:
211
293
  # Wrap app with auth middleware
212
294
  app = AuthMiddleware(app, self._token_verifier)
213
295
 
214
- return app
296
+ return cast(Callable[..., Any], app)
215
297
 
216
298
  async def initialize(self) -> None:
217
299
  """
218
300
  Initialize the EddaApp (setup replay engine, storage, etc.).
219
301
 
220
- This method must be called before running the server in stdio mode.
221
- For HTTP mode (asgi_app()), initialization happens automatically
222
- when the ASGI app is deployed.
302
+ This method must be called before running the server in either stdio or HTTP mode.
223
303
 
224
- Example:
304
+ Example (stdio mode):
225
305
  ```python
226
306
  async def main():
227
307
  await server.initialize()
@@ -231,9 +311,85 @@ class EddaMCPServer:
231
311
  import asyncio
232
312
  asyncio.run(main())
233
313
  ```
314
+
315
+ Example (HTTP mode):
316
+ ```python
317
+ import asyncio
318
+ import uvicorn
319
+
320
+ async def startup():
321
+ await server.initialize()
322
+
323
+ asyncio.run(startup())
324
+ uvicorn.run(server.asgi_app(), host="0.0.0.0", port=8000)
325
+ ```
234
326
  """
235
327
  await self._edda_app.initialize()
236
328
 
329
+ async def shutdown(self) -> None:
330
+ """
331
+ Shutdown the server and cleanup resources.
332
+
333
+ Stops background tasks (auto-resume, timer checks, event timeouts),
334
+ closes storage connections, and performs graceful shutdown.
335
+
336
+ This method should be called when the server is shutting down.
337
+
338
+ Example (stdio mode):
339
+ ```python
340
+ import signal
341
+ import asyncio
342
+
343
+ async def main():
344
+ server = EddaMCPServer(...)
345
+ await server.initialize()
346
+
347
+ # Setup signal handlers for graceful shutdown
348
+ loop = asyncio.get_running_loop()
349
+ shutdown_event = asyncio.Event()
350
+
351
+ def signal_handler():
352
+ shutdown_event.set()
353
+
354
+ for sig in (signal.SIGTERM, signal.SIGINT):
355
+ loop.add_signal_handler(sig, signal_handler)
356
+
357
+ # Run server
358
+ try:
359
+ await server.run_stdio()
360
+ finally:
361
+ await server.shutdown()
362
+
363
+ if __name__ == "__main__":
364
+ asyncio.run(main())
365
+ ```
366
+
367
+ Example (HTTP mode with uvicorn):
368
+ ```python
369
+ import asyncio
370
+ import uvicorn
371
+
372
+ async def startup():
373
+ await server.initialize()
374
+
375
+ async def shutdown_handler():
376
+ await server.shutdown()
377
+
378
+ # Use uvicorn lifecycle events
379
+ config = uvicorn.Config(
380
+ server.asgi_app(),
381
+ host="0.0.0.0",
382
+ port=8000,
383
+ )
384
+ server_instance = uvicorn.Server(config)
385
+
386
+ # Uvicorn handles SIGTERM/SIGINT automatically
387
+ await server_instance.serve()
388
+ await shutdown_handler()
389
+ ```
390
+ """
391
+ await self._edda_app.shutdown()
392
+
237
393
  async def run_stdio(self) -> None:
238
394
  """
239
395
  Run MCP server with stdio transport (for MCP clients, e.g., Claude Desktop).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edda-framework
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Lightweight Durable Execution Framework
5
5
  Project-URL: Homepage, https://github.com/i2y/edda
6
6
  Project-URL: Documentation, https://github.com/i2y/edda#readme
@@ -730,6 +730,32 @@ Each `@durable_tool` automatically generates **three MCP tools**:
730
730
 
731
731
  This enables AI assistants to work with workflows that take minutes, hours, or even days to complete.
732
732
 
733
+ ### MCP Prompts
734
+
735
+ Define reusable prompt templates that can access workflow state:
736
+
737
+ ```python
738
+ from mcp.server.fastmcp.prompts.base import UserMessage
739
+ from mcp.types import TextContent
740
+
741
+ @server.prompt(description="Analyze a workflow execution")
742
+ async def analyze_workflow(instance_id: str) -> UserMessage:
743
+ """Generate analysis prompt for a specific workflow."""
744
+ instance = await server.storage.get_instance(instance_id)
745
+ history = await server.storage.get_history(instance_id)
746
+
747
+ text = f"""Analyze this workflow:
748
+ **Status**: {instance['status']}
749
+ **Activities**: {len(history)}
750
+ **Result**: {instance.get('output_data')}
751
+
752
+ Please provide insights and optimization suggestions."""
753
+
754
+ return UserMessage(content=TextContent(type="text", text=text))
755
+ ```
756
+
757
+ AI clients can use these prompts to generate context-aware analysis of your workflows.
758
+
733
759
  **For detailed documentation**, see [MCP Integration Guide](docs/integrations/mcp.md).
734
760
 
735
761
  ## Observability Hooks
@@ -14,8 +14,8 @@ edda/workflow.py,sha256=daSppYAzgXkjY_9-HS93Zi7_tPR6srmchxY5YfwgU-4,7239
14
14
  edda/wsgi.py,sha256=1pGE5fhHpcsYnDR8S3NEFKWUs5P0JK4roTAzX9BsIj0,2391
15
15
  edda/integrations/__init__.py,sha256=F_CaTvlDEbldfOpPKq_U9ve1E573tS6XzqXnOtyHcXI,33
16
16
  edda/integrations/mcp/__init__.py,sha256=YK-8m0DIdP-RSqewlIX7xnWU7TD3NioCiW2_aZSgnn8,1232
17
- edda/integrations/mcp/decorators.py,sha256=HYZi6Ic_w-ffqMzO0cmuio88OSm1FVQ5jzjePcW48QM,5541
18
- edda/integrations/mcp/server.py,sha256=N-QTebRKd05LB9r2Cwywm4xU9wsbO_UAdYofoEhArms,8785
17
+ edda/integrations/mcp/decorators.py,sha256=UTBb-Un2JK938pDZmANOvfsdKOMI2AF9yGtfSuy8VrE,6284
18
+ edda/integrations/mcp/server.py,sha256=pzCG46Zko9hHmQ3REbo1w3A23SjrGFLiZupwSkIPhOA,13942
19
19
  edda/outbox/__init__.py,sha256=azXG1rtheJEjOyoWmMsBeR2jp8Bz02R3wDEd5tQnaWA,424
20
20
  edda/outbox/relayer.py,sha256=2tnN1aOQ8pKWfwEGIlYwYLLwyOKXBjZ4XZsIr1HjgK4,9454
21
21
  edda/outbox/transactional.py,sha256=LFfUjunqRlGibaINi-efGXFFivWGO7v3mhqrqyGW6Nw,3808
@@ -33,8 +33,8 @@ edda/viewer_ui/data_service.py,sha256=mXV6bL6REa_UKsk8xMGBIFbsbLpIxe91lX3wgn-FOj
33
33
  edda/visualizer/__init__.py,sha256=DOpDstNhR0VcXAs_eMKxaL30p_0u4PKZ4o2ndnYhiRo,343
34
34
  edda/visualizer/ast_analyzer.py,sha256=plmx7C9X_X35xLY80jxOL3ljg3afXxBePRZubqUIkxY,13663
35
35
  edda/visualizer/mermaid_generator.py,sha256=XWa2egoOTNDfJEjPcwoxwQmblUqXf7YInWFjFRI1QGo,12457
36
- edda_framework-0.3.0.dist-info/METADATA,sha256=fVS73kMWDrVBU3BYcRKFNMGlFhMB12dvaZbOoz2T_LI,29421
37
- edda_framework-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
38
- edda_framework-0.3.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
39
- edda_framework-0.3.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
40
- edda_framework-0.3.0.dist-info/RECORD,,
36
+ edda_framework-0.4.0.dist-info/METADATA,sha256=YEsevYgVMtBEBJlAhQL1lALOOiNrejsC9Z6QpyZsFRg,30272
37
+ edda_framework-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
38
+ edda_framework-0.4.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
39
+ edda_framework-0.4.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
40
+ edda_framework-0.4.0.dist-info/RECORD,,