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 +80 -0
- mcp_celery/backend/__init__.py +21 -0
- mcp_celery/backend/base.py +52 -0
- mcp_celery/backend/celery.py +68 -0
- mcp_celery/errors.py +32 -0
- mcp_celery/exposure/__init__.py +17 -0
- mcp_celery/exposure/base.py +49 -0
- mcp_celery/exposure/operation_resource.py +378 -0
- mcp_celery/exposure/polling.py +169 -0
- mcp_celery/lifecycle.py +50 -0
- mcp_celery/py.typed +0 -0
- mcp_celery/registry.py +31 -0
- mcp_celery/schema.py +73 -0
- mcp_celery/server.py +202 -0
- mcp_celery/stores/__init__.py +3 -0
- mcp_celery/stores/celery_task_store.py +58 -0
- mcp_celery-0.1.0.dist-info/METADATA +407 -0
- mcp_celery-0.1.0.dist-info/RECORD +20 -0
- mcp_celery-0.1.0.dist-info/WHEEL +4 -0
- mcp_celery-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
"""
|