edda-framework 0.1.0__py3-none-any.whl → 0.3.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.
edda/__init__.py CHANGED
@@ -30,6 +30,7 @@ from edda.hooks import HooksBase, WorkflowHooks
30
30
  from edda.outbox import OutboxRelayer, send_event_transactional
31
31
  from edda.retry import RetryPolicy
32
32
  from edda.workflow import workflow
33
+ from edda.wsgi import create_wsgi_app
33
34
 
34
35
  __version__ = "0.1.0"
35
36
 
@@ -53,4 +54,5 @@ __all__ = [
53
54
  "RetryPolicy",
54
55
  "RetryExhaustedError",
55
56
  "TerminalError",
57
+ "create_wsgi_app",
56
58
  ]
edda/activity.py CHANGED
@@ -13,6 +13,8 @@ import time
13
13
  from collections.abc import Callable
14
14
  from typing import Any, TypeVar, cast
15
15
 
16
+ import anyio
17
+
16
18
  from edda.context import WorkflowContext
17
19
  from edda.exceptions import RetryExhaustedError, TerminalError, WorkflowCancelledException
18
20
  from edda.pydantic_utils import (
@@ -40,12 +42,13 @@ class Activity:
40
42
  Initialize activity wrapper.
41
43
 
42
44
  Args:
43
- func: The async function to wrap
45
+ func: The async or sync function to wrap
44
46
  retry_policy: Optional retry policy for this activity.
45
47
  If None, uses the default policy from EddaApp.
46
48
  """
47
49
  self.func = func
48
50
  self.name = func.__name__
51
+ self.is_async = inspect.iscoroutinefunction(func)
49
52
  self.retry_policy = retry_policy
50
53
  functools.update_wrapper(self, func)
51
54
 
@@ -284,8 +287,12 @@ class Activity:
284
287
  "kwargs": {k: to_json_dict(v) for k, v in kwargs.items()},
285
288
  }
286
289
 
287
- # Execute the activity function
288
- result = await self.func(ctx, *args, **kwargs)
290
+ # Execute the activity function (sync or async)
291
+ if self.is_async:
292
+ result = await self.func(ctx, *args, **kwargs)
293
+ else:
294
+ # Run sync function in thread pool to avoid blocking
295
+ result = await anyio.to_thread.run_sync(self.func, ctx, *args, **kwargs)
289
296
 
290
297
  # Convert Pydantic model result to JSON dict for storage
291
298
  result_for_storage = to_json_dict(result)
@@ -369,7 +376,7 @@ class Activity:
369
376
  return self.retry_policy
370
377
 
371
378
  # Priority 2: App-level policy (EddaApp default_retry_policy)
372
- # Note: This will be implemented in Phase 5 (edda/app.py)
379
+ # Set by ReplayEngine when creating WorkflowContext (edda/replay.py)
373
380
  if hasattr(ctx, "_app_retry_policy") and ctx._app_retry_policy is not None:
374
381
  return cast(RetryPolicy, ctx._app_retry_policy)
375
382
 
@@ -426,8 +433,9 @@ def activity(
426
433
  """
427
434
  Decorator for defining activities (atomic units of work) with automatic retry.
428
435
 
429
- Activities are async functions that take a WorkflowContext as the first
430
- parameter, followed by any other parameters.
436
+ Activities can be async or sync functions that take a WorkflowContext as the first
437
+ parameter, followed by any other parameters. Sync functions are executed in a
438
+ thread pool to avoid blocking the event loop.
431
439
 
432
440
  Activities are automatically wrapped in a transaction, ensuring that
433
441
  activity execution, history recording, and event sending are atomic.
@@ -443,34 +451,39 @@ def activity(
443
451
  Workflow function.
444
452
 
445
453
  Example:
446
- >>> @activity # Uses default retry policy (5 attempts, exponential backoff)
447
- ... async def reserve_inventory(ctx: WorkflowContext, order_id: str) -> dict:
448
- ... # Your business logic here
454
+ >>> @activity # Sync activity (no async/await)
455
+ ... def reserve_inventory(ctx: WorkflowContext, order_id: str) -> dict:
456
+ ... # Your business logic here (executed in thread pool)
457
+ ... return {"reservation_id": "123"}
458
+
459
+ >>> @activity # Async activity (recommended for I/O-bound operations)
460
+ ... async def reserve_inventory_async(ctx: WorkflowContext, order_id: str) -> dict:
461
+ ... # Async I/O operations
449
462
  ... return {"reservation_id": "123"}
450
463
 
451
464
  >>> from edda.retry import RetryPolicy, AGGRESSIVE_RETRY
452
465
  >>> @activity(retry_policy=AGGRESSIVE_RETRY) # Custom retry policy
453
- ... async def process_payment(ctx: WorkflowContext, amount: float) -> dict:
466
+ ... def process_payment(ctx: WorkflowContext, amount: float) -> dict:
454
467
  ... # Fast retries for low-latency services
455
468
  ... return {"status": "completed"}
456
469
 
457
470
  >>> @activity # Non-idempotent operations cached during replay
458
- ... async def charge_credit_card(ctx: WorkflowContext, amount: float) -> dict:
471
+ ... def charge_credit_card(ctx: WorkflowContext, amount: float) -> dict:
459
472
  ... # External API call - result is cached, won't be called again on replay
460
473
  ... # If this fails, automatic retry with exponential backoff
461
474
  ... return {"transaction_id": "txn_123"}
462
475
 
463
476
  >>> from edda.exceptions import TerminalError
464
477
  >>> @activity
465
- ... async def validate_user(ctx: WorkflowContext, user_id: str) -> dict:
466
- ... user = await fetch_user(user_id)
478
+ ... def validate_user(ctx: WorkflowContext, user_id: str) -> dict:
479
+ ... user = fetch_user(user_id) # No await needed for sync
467
480
  ... if not user:
468
481
  ... # Don't retry - user doesn't exist
469
482
  ... raise TerminalError(f"User {user_id} not found")
470
483
  ... return {"user_id": user_id, "name": user.name}
471
484
 
472
485
  Args:
473
- func: Async function to wrap as an activity
486
+ func: Async or sync function to wrap as an activity
474
487
  retry_policy: Optional retry policy for this activity.
475
488
  If None, uses the default policy from EddaApp.
476
489
 
@@ -480,14 +493,14 @@ def activity(
480
493
  Raises:
481
494
  RetryExhaustedError: When all retry attempts are exhausted
482
495
  TerminalError: For non-retryable errors (no retry attempted)
496
+
497
+ Sync activities are executed in a thread pool. For I/O-bound operations
498
+ (database queries, HTTP requests, etc.), async activities are recommended
499
+ for better performance.
483
500
  """
484
501
 
485
502
  def decorator(f: F) -> F:
486
- # Verify the function is async
487
- if not inspect.iscoroutinefunction(f):
488
- raise TypeError(f"Activity {f.__name__} must be an async function")
489
-
490
- # Create the Activity wrapper with retry policy
503
+ # Create the Activity wrapper with retry policy (supports both sync and async)
491
504
  activity_wrapper = Activity(f, retry_policy=retry_policy)
492
505
 
493
506
  # Mark as activity for introspection
@@ -0,0 +1 @@
1
+ """Edda integrations package."""
@@ -0,0 +1,40 @@
1
+ """
2
+ Edda MCP (Model Context Protocol) Integration.
3
+
4
+ Provides MCP server functionality for Edda durable workflows,
5
+ enabling long-running workflow tools via the MCP protocol.
6
+
7
+ Example:
8
+ ```python
9
+ from edda.integrations.mcp import EddaMCPServer
10
+ from edda import WorkflowContext, activity
11
+
12
+ server = EddaMCPServer(
13
+ name="Order Service",
14
+ db_url="postgresql://user:pass@localhost/orders",
15
+ )
16
+
17
+ @activity
18
+ async def reserve_inventory(ctx, items):
19
+ return {"reserved": True}
20
+
21
+ @server.durable_tool(description="Process order workflow")
22
+ async def process_order(ctx: WorkflowContext, order_id: str):
23
+ await reserve_inventory(ctx, [order_id], activity_id="reserve:1")
24
+ return {"status": "completed"}
25
+
26
+ # Deploy with uvicorn
27
+ if __name__ == "__main__":
28
+ import uvicorn
29
+ uvicorn.run(server.asgi_app(), host="0.0.0.0", port=8000)
30
+ ```
31
+
32
+ The server automatically generates three MCP tools for each @durable_tool:
33
+ - `tool_name`: Start the workflow, returns instance_id
34
+ - `tool_name_status`: Check workflow status
35
+ - `tool_name_result`: Get workflow result (if completed)
36
+ """
37
+
38
+ from edda.integrations.mcp.server import EddaMCPServer
39
+
40
+ __all__ = ["EddaMCPServer"]
@@ -0,0 +1,168 @@
1
+ """Decorators for MCP durable tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from edda.workflow import workflow
10
+
11
+ if TYPE_CHECKING:
12
+ from edda.integrations.mcp.server import EddaMCPServer
13
+ from edda.workflow import Workflow
14
+
15
+
16
+ def create_durable_tool(
17
+ server: EddaMCPServer,
18
+ func: Callable,
19
+ *,
20
+ description: str = "",
21
+ ) -> Workflow:
22
+ """
23
+ Create a durable workflow tool with auto-generated status/result tools.
24
+
25
+ This function:
26
+ 1. Wraps the function as an Edda @workflow
27
+ 2. Registers three MCP tools:
28
+ - {name}: Start workflow, return instance_id
29
+ - {name}_status: Check workflow status
30
+ - {name}_result: Get workflow result
31
+
32
+ Args:
33
+ server: EddaMCPServer instance
34
+ func: Async workflow function
35
+ description: Tool description
36
+
37
+ Returns:
38
+ Workflow instance
39
+ """
40
+ # 1. Create Edda workflow
41
+ workflow_instance: Workflow = workflow(func, event_handler=False)
42
+ workflow_name = func.__name__
43
+
44
+ # Register in server's workflow registry
45
+ server._workflows[workflow_name] = workflow_instance
46
+
47
+ # 2. Generate main tool (start workflow)
48
+ tool_description = description or func.__doc__ or f"Start {workflow_name} workflow"
49
+
50
+ # Extract parameters from workflow function (excluding ctx)
51
+ sig = inspect.signature(func)
52
+ params = [
53
+ param
54
+ for name, param in sig.parameters.items()
55
+ if name != "ctx" # Exclude WorkflowContext parameter
56
+ ]
57
+
58
+ # Create the tool function
59
+ async def start_tool(**kwargs: Any) -> dict:
60
+ """
61
+ Start workflow and return instance_id.
62
+
63
+ This is the main entry point for the durable tool.
64
+ """
65
+ # Remove 'ctx' if provided by client (workflow will inject it)
66
+ kwargs.pop("ctx", None)
67
+
68
+ # Start Edda workflow
69
+ instance_id = await workflow_instance.start(**kwargs)
70
+
71
+ # Return MCP-compliant response
72
+ return {
73
+ "content": [
74
+ {
75
+ "type": "text",
76
+ "text": (
77
+ f"Workflow '{workflow_name}' started successfully.\n"
78
+ f"Instance ID: {instance_id}\n\n"
79
+ f"Use '{workflow_name}_status' tool with instance_id='{instance_id}' to check progress.\n"
80
+ f"Use '{workflow_name}_result' tool to get the final result once completed."
81
+ ),
82
+ }
83
+ ],
84
+ "isError": False,
85
+ }
86
+
87
+ # Override the function's signature for introspection (FastMCP uses this for schema generation)
88
+ start_tool.__signature__ = inspect.Signature(parameters=params) # type: ignore[attr-defined]
89
+
90
+ # Register with FastMCP (call as function, not decorator syntax)
91
+ server._mcp.tool(name=workflow_name, description=tool_description)(start_tool)
92
+
93
+ # 3. Generate status tool
94
+ status_tool_name = f"{workflow_name}_status"
95
+ status_tool_description = f"Check status of {workflow_name} workflow"
96
+
97
+ @server._mcp.tool(name=status_tool_name, description=status_tool_description)
98
+ async def status_tool(instance_id: str) -> dict:
99
+ """Check workflow status."""
100
+ try:
101
+ instance = await server._edda_app.storage.get_instance(instance_id)
102
+
103
+ status = instance["status"]
104
+ current_activity_id = instance.get("current_activity_id", "N/A")
105
+
106
+ status_text = (
107
+ f"Workflow Status: {status}\n"
108
+ f"Current Activity: {current_activity_id}\n"
109
+ f"Instance ID: {instance_id}"
110
+ )
111
+
112
+ return {
113
+ "content": [{"type": "text", "text": status_text}],
114
+ "isError": False,
115
+ }
116
+ except Exception as e:
117
+ return {
118
+ "content": [
119
+ {
120
+ "type": "text",
121
+ "text": f"Error checking status: {str(e)}",
122
+ }
123
+ ],
124
+ "isError": True,
125
+ }
126
+
127
+ # 4. Generate result tool
128
+ result_tool_name = f"{workflow_name}_result"
129
+ result_tool_description = f"Get result of {workflow_name} workflow (if completed)"
130
+
131
+ @server._mcp.tool(name=result_tool_name, description=result_tool_description)
132
+ async def result_tool(instance_id: str) -> dict:
133
+ """Get workflow result (if completed)."""
134
+ try:
135
+ instance = await server._edda_app.storage.get_instance(instance_id)
136
+
137
+ status = instance["status"]
138
+
139
+ if status != "completed":
140
+ return {
141
+ "content": [
142
+ {
143
+ "type": "text",
144
+ "text": f"Workflow not completed yet. Current status: {status}",
145
+ }
146
+ ],
147
+ "isError": True,
148
+ }
149
+
150
+ output_data = instance.get("output_data")
151
+ result_text = f"Workflow Result:\n{output_data}"
152
+
153
+ return {
154
+ "content": [{"type": "text", "text": result_text}],
155
+ "isError": False,
156
+ }
157
+ except Exception as e:
158
+ return {
159
+ "content": [
160
+ {
161
+ "type": "text",
162
+ "text": f"Error getting result: {str(e)}",
163
+ }
164
+ ],
165
+ "isError": True,
166
+ }
167
+
168
+ return workflow_instance
@@ -0,0 +1,257 @@
1
+ """MCP Server implementation for Edda workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from edda.app import EddaApp
9
+ from edda.workflow import Workflow
10
+
11
+ try:
12
+ from mcp.server.fastmcp import FastMCP
13
+ except ImportError as e:
14
+ raise ImportError(
15
+ "MCP Python SDK is required for MCP integration. "
16
+ "Install it with: pip install edda-framework[mcp]"
17
+ ) from e
18
+
19
+
20
+ class EddaMCPServer:
21
+ """
22
+ MCP (Model Context Protocol) server for Edda durable workflows.
23
+
24
+ Integrates EddaApp (CloudEvents + Workflows) with FastMCP to provide
25
+ long-running workflow tools via the MCP protocol.
26
+
27
+ Example:
28
+ ```python
29
+ from edda.integrations.mcp import EddaMCPServer
30
+ from edda import WorkflowContext, activity
31
+
32
+ server = EddaMCPServer(
33
+ name="Order Service",
34
+ db_url="postgresql://user:pass@localhost/orders",
35
+ )
36
+
37
+ @activity
38
+ async def reserve_inventory(ctx, items):
39
+ return {"reserved": True}
40
+
41
+ @server.durable_tool(description="Process order workflow")
42
+ async def process_order(ctx: WorkflowContext, order_id: str):
43
+ await reserve_inventory(ctx, [order_id], activity_id="reserve:1")
44
+ return {"status": "completed"}
45
+
46
+ # Deploy with uvicorn (HTTP transport)
47
+ if __name__ == "__main__":
48
+ import uvicorn
49
+ uvicorn.run(server.asgi_app(), host="0.0.0.0", port=8000)
50
+
51
+ # Or deploy with stdio (for MCP clients, e.g., Claude Desktop)
52
+ if __name__ == "__main__":
53
+ import asyncio
54
+
55
+ async def main():
56
+ await server.initialize()
57
+ await server.run_stdio()
58
+
59
+ asyncio.run(main())
60
+ ```
61
+
62
+ The server automatically generates three MCP tools for each @durable_tool:
63
+ - `tool_name`: Start the workflow, returns instance_id
64
+ - `tool_name_status`: Check workflow status
65
+ - `tool_name_result`: Get workflow result (if completed)
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ name: str,
71
+ db_url: str,
72
+ *,
73
+ outbox_enabled: bool = False,
74
+ broker_url: str | None = None,
75
+ token_verifier: Callable[[str], bool] | None = None,
76
+ ):
77
+ """
78
+ Initialize MCP server.
79
+
80
+ Args:
81
+ name: Service name (shown in MCP client)
82
+ db_url: Database URL for workflow storage
83
+ outbox_enabled: Enable transactional outbox pattern
84
+ broker_url: Message broker URL (if outbox enabled)
85
+ token_verifier: Optional function to verify authentication tokens
86
+ """
87
+ self._name = name
88
+ self._edda_app = EddaApp(
89
+ service_name=name,
90
+ db_url=db_url,
91
+ outbox_enabled=outbox_enabled,
92
+ broker_url=broker_url,
93
+ )
94
+ self._mcp = FastMCP(name, json_response=True, stateless_http=True)
95
+ self._token_verifier = token_verifier
96
+
97
+ # Registry of durable tools (workflow_name -> Workflow instance)
98
+ self._workflows: dict[str, Workflow] = {}
99
+
100
+ def durable_tool(
101
+ self,
102
+ func: Callable | None = None,
103
+ *,
104
+ description: str = "",
105
+ ) -> Callable:
106
+ """
107
+ Decorator to define a durable workflow tool.
108
+
109
+ Automatically generates three MCP tools:
110
+ 1. Main tool: Starts the workflow, returns instance_id
111
+ 2. Status tool: Checks workflow status
112
+ 3. Result tool: Gets workflow result (if completed)
113
+
114
+ Args:
115
+ func: Workflow function (async)
116
+ description: Tool description for MCP clients
117
+
118
+ Returns:
119
+ Decorated workflow instance
120
+
121
+ Example:
122
+ ```python
123
+ @server.durable_tool(description="Long-running order processing")
124
+ async def process_order(ctx, order_id: str):
125
+ # Workflow logic
126
+ return {"status": "completed"}
127
+ ```
128
+ """
129
+ from edda.integrations.mcp.decorators import create_durable_tool
130
+
131
+ def decorator(f: Callable) -> Workflow:
132
+ return create_durable_tool(self, f, description=description)
133
+
134
+ if func is None:
135
+ return decorator
136
+ return decorator(func)
137
+
138
+ def asgi_app(self) -> Callable:
139
+ """
140
+ Create ASGI application with MCP + CloudEvents support.
141
+
142
+ This method uses the Issue #1367 workaround: instead of using Mount,
143
+ we get the MCP's Starlette app directly and add Edda endpoints to it.
144
+
145
+ Routing:
146
+ - POST / -> FastMCP (MCP tools via streamable HTTP)
147
+ - POST /cancel/{instance_id} -> Workflow cancellation
148
+ - Other POST -> CloudEvents
149
+
150
+ Returns:
151
+ ASGI callable (Starlette app)
152
+ """
153
+ from starlette.requests import Request
154
+ from starlette.responses import Response
155
+
156
+ # Get MCP's Starlette app (Issue #1367 workaround: use directly)
157
+ app = self._mcp.streamable_http_app()
158
+
159
+ # Add Edda endpoints to Starlette router BEFORE wrapping with middleware
160
+ # Note: MCP's streamable HTTP is already mounted at "/" by default
161
+ # We add additional routes for Edda's CloudEvents and cancellation
162
+
163
+ async def edda_cancel_handler(request: Request) -> Response:
164
+ """Handle workflow cancellation."""
165
+ instance_id = request.path_params["instance_id"]
166
+
167
+ # Create ASGI scope for EddaApp
168
+ scope = dict(request.scope)
169
+ scope["path"] = f"/cancel/{instance_id}"
170
+
171
+ # Capture response
172
+ response_data = {"status": 200, "headers": [], "body": b""}
173
+
174
+ async def send(message: dict) -> None:
175
+ if message["type"] == "http.response.start":
176
+ response_data["status"] = message["status"]
177
+ response_data["headers"] = message.get("headers", [])
178
+ elif message["type"] == "http.response.body":
179
+ response_data["body"] += message.get("body", b"")
180
+
181
+ # Forward to EddaApp
182
+ await self._edda_app(scope, request.receive, send)
183
+
184
+ # Return response
185
+ return Response(
186
+ content=response_data["body"],
187
+ status_code=response_data["status"],
188
+ headers=dict(response_data["headers"]),
189
+ )
190
+
191
+ # Add cancel route
192
+ app.router.add_route("/cancel/{instance_id}", edda_cancel_handler, methods=["POST"])
193
+
194
+ # Add authentication middleware if token_verifier provided (AFTER adding routes)
195
+ if self._token_verifier is not None:
196
+ from starlette.middleware.base import BaseHTTPMiddleware
197
+
198
+ class AuthMiddleware(BaseHTTPMiddleware):
199
+ def __init__(self, app: Any, token_verifier: Callable):
200
+ super().__init__(app)
201
+ self.token_verifier = token_verifier
202
+
203
+ async def dispatch(self, request: Request, call_next: Callable):
204
+ auth_header = request.headers.get("authorization", "")
205
+ if auth_header.startswith("Bearer "):
206
+ token = auth_header[7:]
207
+ if not self.token_verifier(token):
208
+ return Response("Unauthorized", status_code=401)
209
+ return await call_next(request)
210
+
211
+ # Wrap app with auth middleware
212
+ app = AuthMiddleware(app, self._token_verifier)
213
+
214
+ return app
215
+
216
+ async def initialize(self) -> None:
217
+ """
218
+ Initialize the EddaApp (setup replay engine, storage, etc.).
219
+
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.
223
+
224
+ Example:
225
+ ```python
226
+ async def main():
227
+ await server.initialize()
228
+ await server.run_stdio()
229
+
230
+ if __name__ == "__main__":
231
+ import asyncio
232
+ asyncio.run(main())
233
+ ```
234
+ """
235
+ await self._edda_app.initialize()
236
+
237
+ async def run_stdio(self) -> None:
238
+ """
239
+ Run MCP server with stdio transport (for MCP clients, e.g., Claude Desktop).
240
+
241
+ This method uses stdin/stdout for JSON-RPC communication.
242
+ stderr can be used for diagnostic messages.
243
+
244
+ The server will block until terminated (Ctrl+C or SIGTERM).
245
+
246
+ Example:
247
+ ```python
248
+ async def main():
249
+ await server.initialize()
250
+ await server.run_stdio()
251
+
252
+ if __name__ == "__main__":
253
+ import asyncio
254
+ asyncio.run(main())
255
+ ```
256
+ """
257
+ await self._mcp.run_stdio_async()
edda/wsgi.py ADDED
@@ -0,0 +1,77 @@
1
+ """
2
+ WSGI adapter for Edda framework.
3
+
4
+ This module provides a WSGI adapter that wraps EddaApp (ASGI) for use with
5
+ WSGI servers like gunicorn or uWSGI.
6
+
7
+ The adapter uses a2wsgi to convert the ASGI interface to WSGI.
8
+ """
9
+
10
+ from typing import Any
11
+
12
+ from a2wsgi import ASGIMiddleware
13
+
14
+ from edda.app import EddaApp
15
+
16
+
17
+ def create_wsgi_app(edda_app: EddaApp) -> Any:
18
+ """
19
+ Create a WSGI-compatible application from an EddaApp instance.
20
+
21
+ This function wraps an EddaApp (ASGI) with a2wsgi's ASGIMiddleware,
22
+ making it compatible with WSGI servers like gunicorn or uWSGI.
23
+
24
+ Args:
25
+ edda_app: An initialized EddaApp instance
26
+
27
+ Returns:
28
+ A WSGI-compatible application callable
29
+
30
+ Example:
31
+ Basic usage with EddaApp::
32
+
33
+ from edda import EddaApp
34
+ from edda.wsgi import create_wsgi_app
35
+ from edda.storage.sqlalchemy_storage import SQLAlchemyStorage
36
+
37
+ # Create storage and EddaApp
38
+ storage = SQLAlchemyStorage("sqlite:///edda.db")
39
+ app = EddaApp(storage=storage)
40
+
41
+ # Create WSGI application
42
+ wsgi_app = create_wsgi_app(app)
43
+
44
+ Running with gunicorn::
45
+
46
+ # In your module (e.g., demo_app.py):
47
+ from edda import EddaApp
48
+ from edda.wsgi import create_wsgi_app
49
+
50
+ application = EddaApp(...) # ASGI
51
+ wsgi_application = create_wsgi_app(application) # WSGI
52
+
53
+ # Command line:
54
+ $ gunicorn demo_app:wsgi_application --workers 4
55
+
56
+ Running with uWSGI::
57
+
58
+ $ uwsgi --http :8000 --wsgi-file demo_app.py --callable wsgi_application
59
+
60
+ Background tasks (auto-resume, timer checks, etc.) will run in each
61
+ worker process.
62
+
63
+ For production deployments, ASGI servers (uvicorn, hypercorn) are
64
+ recommended for better performance with Edda's async architecture.
65
+ WSGI support is provided for compatibility with existing infrastructure
66
+ and for users who prefer synchronous programming with sync activities.
67
+
68
+ See Also:
69
+ - :class:`edda.app.EddaApp`: The main ASGI application class
70
+ - :func:`edda.activity.activity`: Decorator supporting sync activities
71
+ """
72
+ # Type ignore due to a2wsgi's strict ASGI type checking
73
+ # EddaApp implements ASGI 3.0 interface correctly
74
+ return ASGIMiddleware(edda_app) # type: ignore[arg-type]
75
+
76
+
77
+ __all__ = ["create_wsgi_app"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edda-framework
3
- Version: 0.1.0
3
+ Version: 0.3.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
@@ -20,7 +20,9 @@ Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
21
21
  Classifier: Topic :: System :: Distributed Computing
22
22
  Requires-Python: >=3.11
23
+ Requires-Dist: a2wsgi>=1.10.0
23
24
  Requires-Dist: aiosqlite>=0.21.0
25
+ Requires-Dist: anyio>=4.0.0
24
26
  Requires-Dist: cloudevents>=1.12.0
25
27
  Requires-Dist: httpx>=0.28.1
26
28
  Requires-Dist: pydantic>=2.0.0
@@ -36,6 +38,8 @@ Requires-Dist: ruff>=0.14.2; extra == 'dev'
36
38
  Requires-Dist: testcontainers[mysql]>=4.0.0; extra == 'dev'
37
39
  Requires-Dist: testcontainers[postgres]>=4.0.0; extra == 'dev'
38
40
  Requires-Dist: tsuno>=0.1.3; extra == 'dev'
41
+ Provides-Extra: mcp
42
+ Requires-Dist: mcp>=1.22.0; extra == 'mcp'
39
43
  Provides-Extra: mysql
40
44
  Requires-Dist: aiomysql>=0.2.0; extra == 'mysql'
41
45
  Provides-Extra: postgresql
@@ -75,6 +79,8 @@ For detailed documentation, visit [https://i2y.github.io/edda/](https://i2y.gith
75
79
  - 📦 **Transactional Outbox**: Reliable event publishing with guaranteed delivery
76
80
  - ☁️ **CloudEvents Support**: Native support for CloudEvents protocol
77
81
  - ⏱️ **Event & Timer Waiting**: Free up worker resources while waiting for events or timers, resume on any available worker
82
+ - 🤖 **MCP Integration**: Expose durable workflows as AI tools via Model Context Protocol
83
+ - 🌍 **ASGI/WSGI Support**: Deploy with your preferred server (uvicorn, gunicorn, uWSGI)
78
84
 
79
85
  ## Use Cases
80
86
 
@@ -638,6 +644,94 @@ api.mount("/workflows", edda_app)
638
644
 
639
645
  This works with any ASGI framework (Starlette, FastAPI, Quart, etc.)
640
646
 
647
+ ### WSGI Integration
648
+
649
+ For WSGI environments (gunicorn, uWSGI, Flask, Django), use the WSGI adapter:
650
+
651
+ ```python
652
+ from edda import EddaApp
653
+ from edda.wsgi import create_wsgi_app
654
+
655
+ # Create Edda app
656
+ edda_app = EddaApp(db_url="sqlite:///workflow.db")
657
+
658
+ # Convert to WSGI
659
+ wsgi_application = create_wsgi_app(edda_app)
660
+ ```
661
+
662
+ **Running with WSGI servers:**
663
+
664
+ ```bash
665
+ # With Gunicorn
666
+ gunicorn demo_app:wsgi_application --workers 4
667
+
668
+ # With uWSGI
669
+ uwsgi --http :8000 --wsgi-file demo_app.py --callable wsgi_application
670
+ ```
671
+
672
+ **Sync Activities**: For WSGI environments or legacy codebases, you can write synchronous activities:
673
+
674
+ ```python
675
+ from edda import activity, WorkflowContext
676
+
677
+ @activity
678
+ def process_payment(ctx: WorkflowContext, amount: float) -> dict:
679
+ # Sync function - automatically executed in thread pool
680
+ # No async/await needed!
681
+ return {"status": "paid", "amount": amount}
682
+
683
+ @workflow
684
+ async def payment_workflow(ctx: WorkflowContext, order_id: str) -> dict:
685
+ # Workflows still use async (for deterministic replay)
686
+ result = await process_payment(ctx, 99.99, activity_id="pay:1")
687
+ return result
688
+ ```
689
+
690
+ **Performance note**: ASGI servers (uvicorn, hypercorn) are recommended for better performance with Edda's async architecture. WSGI support is provided for compatibility with existing infrastructure and users who prefer synchronous programming.
691
+
692
+ ## MCP Integration
693
+
694
+ Edda integrates with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), allowing AI assistants like Claude to interact with your durable workflows as long-running tools.
695
+
696
+ ### Quick Example
697
+
698
+ ```python
699
+ from edda.integrations.mcp import EddaMCPServer
700
+ from edda import WorkflowContext, activity
701
+
702
+ # Create MCP server
703
+ server = EddaMCPServer(
704
+ name="Order Service",
705
+ db_url="postgresql://user:pass@localhost/orders",
706
+ )
707
+
708
+ @activity
709
+ async def process_payment(ctx: WorkflowContext, amount: float):
710
+ return {"status": "paid", "amount": amount}
711
+
712
+ @server.durable_tool(description="Process customer order")
713
+ async def process_order(ctx: WorkflowContext, order_id: str):
714
+ await process_payment(ctx, 99.99)
715
+ return {"status": "completed", "order_id": order_id}
716
+
717
+ # Deploy with uvicorn
718
+ if __name__ == "__main__":
719
+ import uvicorn
720
+ uvicorn.run(server.asgi_app(), host="0.0.0.0", port=8000)
721
+ ```
722
+
723
+ ### Auto-Generated Tools
724
+
725
+ Each `@durable_tool` automatically generates **three MCP tools**:
726
+
727
+ 1. **Main tool** (`process_order`): Starts the workflow, returns instance ID
728
+ 2. **Status tool** (`process_order_status`): Checks workflow progress
729
+ 3. **Result tool** (`process_order_result`): Gets final result when completed
730
+
731
+ This enables AI assistants to work with workflows that take minutes, hours, or even days to complete.
732
+
733
+ **For detailed documentation**, see [MCP Integration Guide](docs/integrations/mcp.md).
734
+
641
735
  ## Observability Hooks
642
736
 
643
737
  Extend Edda with custom observability without coupling to specific tools:
@@ -1,5 +1,5 @@
1
- edda/__init__.py,sha256=a8Rp16Qmj4iuO1MXmrpO_oGIMLY-vkg-vW6z-CATBas,1596
2
- edda/activity.py,sha256=pDKs3rh4QpnRnIEAHi3rxEXkDL71pBE_La7skde9vJg,20320
1
+ edda/__init__.py,sha256=gmJd0ooVbGNMOLlSj-r6rEt3IkY-FZYCFjhWjIltqlk,1657
2
+ edda/activity.py,sha256=nRm9eBrr0lFe4ZRQ2whyZ6mo5xd171ITIVhqytUhOpw,21025
3
3
  edda/app.py,sha256=YxURvhaC1dKcymOwuS1PHU6_iOl_5cZWse2vAKSyX8w,37471
4
4
  edda/compensation.py,sha256=CmnyJy4jAklVrtLJodNOcj6vxET6pdarxM1Yx2RHlL4,11898
5
5
  edda/context.py,sha256=YZKBNtblRcaFqte1Y9t2cIP3JHzK-5Tu40x5i5FHtnU,17789
@@ -11,6 +11,11 @@ edda/pydantic_utils.py,sha256=dGVPNrrttDeq1k233PopCtjORYjZitsgASPfPnO6R10,9056
11
11
  edda/replay.py,sha256=5RIRd0q2ZrH9iiiy35eOUii2cipYg9dlua56OAXvIk4,32499
12
12
  edda/retry.py,sha256=t4_E1skrhotA1XWHTLbKi-DOgCMasOUnhI9OT-O_eCE,6843
13
13
  edda/workflow.py,sha256=daSppYAzgXkjY_9-HS93Zi7_tPR6srmchxY5YfwgU-4,7239
14
+ edda/wsgi.py,sha256=1pGE5fhHpcsYnDR8S3NEFKWUs5P0JK4roTAzX9BsIj0,2391
15
+ edda/integrations/__init__.py,sha256=F_CaTvlDEbldfOpPKq_U9ve1E573tS6XzqXnOtyHcXI,33
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
14
19
  edda/outbox/__init__.py,sha256=azXG1rtheJEjOyoWmMsBeR2jp8Bz02R3wDEd5tQnaWA,424
15
20
  edda/outbox/relayer.py,sha256=2tnN1aOQ8pKWfwEGIlYwYLLwyOKXBjZ4XZsIr1HjgK4,9454
16
21
  edda/outbox/transactional.py,sha256=LFfUjunqRlGibaINi-efGXFFivWGO7v3mhqrqyGW6Nw,3808
@@ -28,8 +33,8 @@ edda/viewer_ui/data_service.py,sha256=mXV6bL6REa_UKsk8xMGBIFbsbLpIxe91lX3wgn-FOj
28
33
  edda/visualizer/__init__.py,sha256=DOpDstNhR0VcXAs_eMKxaL30p_0u4PKZ4o2ndnYhiRo,343
29
34
  edda/visualizer/ast_analyzer.py,sha256=plmx7C9X_X35xLY80jxOL3ljg3afXxBePRZubqUIkxY,13663
30
35
  edda/visualizer/mermaid_generator.py,sha256=XWa2egoOTNDfJEjPcwoxwQmblUqXf7YInWFjFRI1QGo,12457
31
- edda_framework-0.1.0.dist-info/METADATA,sha256=6T8Pt-0Y-SkZwsqKVSvMqEhkVlutu_XHbUUtWSF5Urw,26318
32
- edda_framework-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
33
- edda_framework-0.1.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
34
- edda_framework-0.1.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
35
- edda_framework-0.1.0.dist-info/RECORD,,
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,,