mcp-celery 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.
mcp_celery/__init__.py ADDED
@@ -0,0 +1,80 @@
1
+ """mcp-celery — Expose Celery tasks as async MCP tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as _version
7
+
8
+ try:
9
+ __version__ = _version("mcp-celery")
10
+ except PackageNotFoundError:
11
+ __version__ = "0.1.0.dev0"
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ # These modules have no external dependencies — safe to import eagerly
16
+ from mcp_celery.errors import BackendError, McpCeleryError, TaskNotFound, TaskNotReady
17
+ from mcp_celery.lifecycle import map_celery_state
18
+ from mcp_celery.registry import ToolRegistry
19
+ from mcp_celery.schema import AsyncToolDef, TaskInfo, TaskStatus, format_response
20
+
21
+ if TYPE_CHECKING:
22
+ from mcp_celery.backend.base import AbstractBackend
23
+ from mcp_celery.backend.celery import CeleryBackend
24
+ from mcp_celery.exposure.base import AbstractExposureStrategy, GeneratedTool
25
+ from mcp_celery.exposure.operation_resource import OperationResourceStrategy
26
+ from mcp_celery.exposure.polling import PollingExposureStrategy
27
+ from mcp_celery.server import AsyncToolServer
28
+ from mcp_celery.stores.celery_task_store import CeleryTaskStore
29
+
30
+
31
+ def __getattr__(name: str):
32
+ """Lazy-load modules that depend on celery / mcp at runtime."""
33
+ _lazy_imports = {
34
+ "AbstractBackend": "mcp_celery.backend.base",
35
+ "CeleryBackend": "mcp_celery.backend.celery",
36
+ "AbstractExposureStrategy": "mcp_celery.exposure.base",
37
+ "GeneratedTool": "mcp_celery.exposure.base",
38
+ "OperationResourceStrategy": "mcp_celery.exposure.operation_resource",
39
+ "PollingExposureStrategy": "mcp_celery.exposure.polling",
40
+ "AsyncToolServer": "mcp_celery.server",
41
+ "CeleryTaskStore": "mcp_celery.stores.celery_task_store",
42
+ }
43
+ if name in _lazy_imports:
44
+ import importlib
45
+
46
+ module = importlib.import_module(_lazy_imports[name])
47
+ return getattr(module, name)
48
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
49
+
50
+
51
+ __all__ = [
52
+ # Version
53
+ "__version__",
54
+ # Main entry point
55
+ "AsyncToolServer",
56
+ # Schema / types
57
+ "AsyncToolDef",
58
+ "TaskInfo",
59
+ "TaskStatus",
60
+ "format_response",
61
+ # Backend
62
+ "AbstractBackend",
63
+ "CeleryBackend",
64
+ # Exposure strategies
65
+ "AbstractExposureStrategy",
66
+ "GeneratedTool",
67
+ "OperationResourceStrategy",
68
+ "PollingExposureStrategy",
69
+ # Stores
70
+ "CeleryTaskStore",
71
+ # Registry
72
+ "ToolRegistry",
73
+ # Lifecycle
74
+ "map_celery_state",
75
+ # Errors
76
+ "McpCeleryError",
77
+ "TaskNotReady",
78
+ "TaskNotFound",
79
+ "BackendError",
80
+ ]
@@ -0,0 +1,21 @@
1
+ """Execution backend layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from mcp_celery.backend.base import AbstractBackend
8
+
9
+ if TYPE_CHECKING:
10
+ from mcp_celery.backend.celery import CeleryBackend
11
+
12
+
13
+ def __getattr__(name: str):
14
+ if name == "CeleryBackend":
15
+ from mcp_celery.backend.celery import CeleryBackend
16
+
17
+ return CeleryBackend
18
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
19
+
20
+
21
+ __all__ = ["AbstractBackend", "CeleryBackend"]
@@ -0,0 +1,52 @@
1
+ """Abstract execution backend interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from mcp_celery.schema import TaskInfo
9
+
10
+
11
+ class AbstractBackend(ABC):
12
+ """Interface that every execution backend must implement.
13
+
14
+ The exposure layer depends only on this interface, never on a
15
+ concrete backend. This allows swapping Celery for another task
16
+ runner (Dramatiq, ARQ, ...) without touching the rest of the
17
+ package.
18
+ """
19
+
20
+ @abstractmethod
21
+ def dispatch(
22
+ self,
23
+ task_ref: Any,
24
+ kwargs: dict[str, Any],
25
+ *,
26
+ result_ttl: int | None = None,
27
+ ) -> str:
28
+ """Start task execution.
29
+
30
+ Returns a token (task ID) that can be used to track the task.
31
+ """
32
+
33
+ @abstractmethod
34
+ def get_status(self, token: str) -> TaskInfo:
35
+ """Query the current state of a task."""
36
+
37
+ @abstractmethod
38
+ def get_result(self, token: str) -> TaskInfo:
39
+ """Retrieve the final result.
40
+
41
+ Should return a ``TaskInfo`` with status ``COMPLETED`` and a
42
+ populated ``result`` field, or ``FAILED`` with an ``error``
43
+ field. If the task is still running, return the current status
44
+ without a result.
45
+ """
46
+
47
+ @abstractmethod
48
+ def cancel(self, token: str) -> TaskInfo:
49
+ """Request cancellation of a running task.
50
+
51
+ Cancellation is best-effort. Returns the updated ``TaskInfo``.
52
+ """
@@ -0,0 +1,68 @@
1
+ """Celery execution backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from celery import Celery
8
+ from celery.result import AsyncResult
9
+
10
+ from mcp_celery.backend.base import AbstractBackend
11
+ from mcp_celery.lifecycle import map_celery_state
12
+ from mcp_celery.schema import TaskInfo, TaskStatus
13
+
14
+
15
+ class CeleryBackend(AbstractBackend):
16
+ """Dispatches and tracks tasks using a Celery application."""
17
+
18
+ def __init__(self, app: Celery) -> None:
19
+ self._app = app
20
+
21
+ def dispatch(
22
+ self,
23
+ task_ref: Any,
24
+ kwargs: dict[str, Any],
25
+ *,
26
+ result_ttl: int | None = None,
27
+ ) -> str:
28
+ result = task_ref.apply_async(kwargs=kwargs)
29
+ return result.id
30
+
31
+ def get_status(self, token: str) -> TaskInfo:
32
+ result = AsyncResult(token, app=self._app)
33
+ status = map_celery_state(result.state)
34
+
35
+ progress: float | None = None
36
+ metadata: dict[str, Any] = {}
37
+
38
+ if isinstance(result.info, dict) and status == TaskStatus.RUNNING:
39
+ progress = result.info.get("progress")
40
+ metadata = {k: v for k, v in result.info.items() if k != "progress"}
41
+
42
+ error: str | None = None
43
+ if status == TaskStatus.FAILED:
44
+ error = str(result.result)
45
+
46
+ return TaskInfo(
47
+ token=token,
48
+ status=status,
49
+ progress=progress,
50
+ metadata=metadata,
51
+ error=error,
52
+ )
53
+
54
+ def get_result(self, token: str) -> TaskInfo:
55
+ result = AsyncResult(token, app=self._app)
56
+ status = map_celery_state(result.state)
57
+
58
+ if status == TaskStatus.COMPLETED:
59
+ return TaskInfo(token=token, status=status, result=result.result)
60
+ if status == TaskStatus.FAILED:
61
+ return TaskInfo(token=token, status=status, error=str(result.result))
62
+
63
+ return TaskInfo(token=token, status=status)
64
+
65
+ def cancel(self, token: str) -> TaskInfo:
66
+ result = AsyncResult(token, app=self._app)
67
+ result.revoke(terminate=True)
68
+ return TaskInfo(token=token, status=TaskStatus.CANCELLED)
mcp_celery/errors.py ADDED
@@ -0,0 +1,32 @@
1
+ """Custom exceptions for mcp-celery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp_celery.schema import TaskStatus
6
+
7
+
8
+ class McpCeleryError(Exception):
9
+ """Base exception for all mcp-celery errors."""
10
+
11
+
12
+ class TaskNotReady(McpCeleryError):
13
+ """Raised when a result is requested but the task has not completed."""
14
+
15
+ def __init__(self, token: str, current_status: TaskStatus):
16
+ self.token = token
17
+ self.current_status = current_status
18
+ super().__init__(
19
+ f"Task {token!r} not complete. Current status: {current_status.value}"
20
+ )
21
+
22
+
23
+ class TaskNotFound(McpCeleryError):
24
+ """Raised when a token does not correspond to a known task."""
25
+
26
+ def __init__(self, token: str):
27
+ self.token = token
28
+ super().__init__(f"Task {token!r} not found")
29
+
30
+
31
+ class BackendError(McpCeleryError):
32
+ """Raised when the execution backend encounters an unexpected error."""
@@ -0,0 +1,17 @@
1
+ from mcp_celery.exposure.base import AbstractExposureStrategy, GeneratedTool
2
+ from mcp_celery.exposure.polling import PollingExposureStrategy
3
+
4
+
5
+ def __getattr__(name: str):
6
+ if name == "OperationResourceStrategy":
7
+ from mcp_celery.exposure.operation_resource import OperationResourceStrategy
8
+ return OperationResourceStrategy
9
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
10
+
11
+
12
+ __all__ = [
13
+ "AbstractExposureStrategy",
14
+ "GeneratedTool",
15
+ "OperationResourceStrategy",
16
+ "PollingExposureStrategy",
17
+ ]
@@ -0,0 +1,49 @@
1
+ """Abstract exposure strategy interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any, Callable
8
+
9
+ from mcp_celery.backend.base import AbstractBackend
10
+ from mcp_celery.schema import AsyncToolDef
11
+
12
+ if TYPE_CHECKING:
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+
16
+ @dataclass
17
+ class GeneratedTool:
18
+ """An MCP tool definition ready to be registered on the server."""
19
+
20
+ name: str
21
+ description: str
22
+ input_schema: dict[str, Any]
23
+ handler: Callable[..., Any]
24
+
25
+
26
+ class AbstractExposureStrategy(ABC):
27
+ """Defines how an ``AsyncToolDef`` is exposed as one or more MCP tools.
28
+
29
+ Each strategy implementation decides:
30
+ - how many MCP tools to create per async tool
31
+ - what their schemas and handlers look like
32
+ - how the client interacts with them (polling, resources, etc.)
33
+ """
34
+
35
+ @abstractmethod
36
+ def generate_tools(
37
+ self,
38
+ tool_def: AsyncToolDef,
39
+ backend: AbstractBackend,
40
+ ) -> list[GeneratedTool]:
41
+ """Produce MCP tool definitions for the given async tool."""
42
+
43
+ def setup(self, mcp: FastMCP, backend: AbstractBackend) -> None:
44
+ """Optional server-level wiring (task infrastructure, resources, etc.).
45
+
46
+ Called once after all tools have been registered. The default
47
+ implementation is a no-op. Override in strategies that need
48
+ server-level setup (e.g. ``OperationResourceStrategy``).
49
+ """