mcp-celery 0.1.0__tar.gz
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-0.1.0/.gitignore +85 -0
- mcp_celery-0.1.0/CHANGELOG.md +25 -0
- mcp_celery-0.1.0/LICENSE +21 -0
- mcp_celery-0.1.0/PKG-INFO +407 -0
- mcp_celery-0.1.0/README.md +347 -0
- mcp_celery-0.1.0/docs/approach3-design.md +161 -0
- mcp_celery-0.1.0/docs/quickstart-example.py +87 -0
- mcp_celery-0.1.0/docs/transport-resumable.md +656 -0
- mcp_celery-0.1.0/pyproject.toml +98 -0
- mcp_celery-0.1.0/src/mcp_celery/__init__.py +80 -0
- mcp_celery-0.1.0/src/mcp_celery/backend/__init__.py +21 -0
- mcp_celery-0.1.0/src/mcp_celery/backend/base.py +52 -0
- mcp_celery-0.1.0/src/mcp_celery/backend/celery.py +68 -0
- mcp_celery-0.1.0/src/mcp_celery/errors.py +32 -0
- mcp_celery-0.1.0/src/mcp_celery/exposure/__init__.py +17 -0
- mcp_celery-0.1.0/src/mcp_celery/exposure/base.py +49 -0
- mcp_celery-0.1.0/src/mcp_celery/exposure/operation_resource.py +378 -0
- mcp_celery-0.1.0/src/mcp_celery/exposure/polling.py +169 -0
- mcp_celery-0.1.0/src/mcp_celery/lifecycle.py +50 -0
- mcp_celery-0.1.0/src/mcp_celery/py.typed +0 -0
- mcp_celery-0.1.0/src/mcp_celery/registry.py +31 -0
- mcp_celery-0.1.0/src/mcp_celery/schema.py +73 -0
- mcp_celery-0.1.0/src/mcp_celery/server.py +202 -0
- mcp_celery-0.1.0/src/mcp_celery/stores/__init__.py +3 -0
- mcp_celery-0.1.0/src/mcp_celery/stores/celery_task_store.py +58 -0
- mcp_celery-0.1.0/tests/__init__.py +0 -0
- mcp_celery-0.1.0/tests/mock_backend.py +53 -0
- mcp_celery-0.1.0/tests/run_client.py +114 -0
- mcp_celery-0.1.0/tests/server_for_test.py +34 -0
- mcp_celery-0.1.0/tests/test_operation_resource.py +782 -0
- mcp_celery-0.1.0/tests/test_tools.py +178 -0
- mcp_celery-0.1.0/tests/worker.py +7 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# ── Python bytecode ────────────────────────────────────────────────────────────
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.pyo
|
|
6
|
+
|
|
7
|
+
# ── Virtual environments ───────────────────────────────────────────────────────
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
env/
|
|
11
|
+
ENV/
|
|
12
|
+
.python-version
|
|
13
|
+
|
|
14
|
+
# ── Packaging / build artifacts ────────────────────────────────────────────────
|
|
15
|
+
build/
|
|
16
|
+
dist/
|
|
17
|
+
*.egg-info/
|
|
18
|
+
*.egg
|
|
19
|
+
*.whl
|
|
20
|
+
.eggs/
|
|
21
|
+
MANIFEST
|
|
22
|
+
pip-log.txt
|
|
23
|
+
pip-delete-this-directory.txt
|
|
24
|
+
|
|
25
|
+
# ── Hatch (build backend) ──────────────────────────────────────────────────────
|
|
26
|
+
.hatch/
|
|
27
|
+
|
|
28
|
+
# ── Test / coverage artifacts ──────────────────────────────────────────────────
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
.coverage
|
|
31
|
+
.coverage.*
|
|
32
|
+
htmlcov/
|
|
33
|
+
.tox/
|
|
34
|
+
.nox/
|
|
35
|
+
|
|
36
|
+
# ── Type-checker / linter caches ──────────────────────────────────────────────
|
|
37
|
+
.mypy_cache/
|
|
38
|
+
.ruff_cache/
|
|
39
|
+
.ty_cache/
|
|
40
|
+
.pytype/
|
|
41
|
+
|
|
42
|
+
# ── Secrets and credentials (NEVER commit these) ──────────────────────────────
|
|
43
|
+
.env
|
|
44
|
+
.env.*
|
|
45
|
+
*.env
|
|
46
|
+
*.pem
|
|
47
|
+
*.key
|
|
48
|
+
*.p12
|
|
49
|
+
*.pfx
|
|
50
|
+
secrets.toml
|
|
51
|
+
secrets.yaml
|
|
52
|
+
secrets.json
|
|
53
|
+
|
|
54
|
+
# ── Celery runtime files ───────────────────────────────────────────────────────
|
|
55
|
+
celerybeat-schedule*
|
|
56
|
+
celerybeat.pid
|
|
57
|
+
*.celerybeat
|
|
58
|
+
|
|
59
|
+
# ── Logs ───────────────────────────────────────────────────────────────────────
|
|
60
|
+
*.log
|
|
61
|
+
logs/
|
|
62
|
+
|
|
63
|
+
# ── Database / local state ────────────────────────────────────────────────────
|
|
64
|
+
*.sqlite3
|
|
65
|
+
*.db
|
|
66
|
+
|
|
67
|
+
# ── IDE / editor ───────────────────────────────────────────────────────────────
|
|
68
|
+
.vscode/
|
|
69
|
+
.idea/
|
|
70
|
+
*.swp
|
|
71
|
+
*.swo
|
|
72
|
+
*~
|
|
73
|
+
.project
|
|
74
|
+
.pydevproject
|
|
75
|
+
|
|
76
|
+
# ── OS files ───────────────────────────────────────────────────────────────────
|
|
77
|
+
.DS_Store
|
|
78
|
+
.DS_Store?
|
|
79
|
+
Thumbs.db
|
|
80
|
+
desktop.ini
|
|
81
|
+
|
|
82
|
+
# ── Local tool state (not for others) ─────────────────────────────────────────
|
|
83
|
+
.codex
|
|
84
|
+
.claude/
|
|
85
|
+
CLAUDE.md
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `mcp-celery` are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-04-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `OperationResourceStrategy` — Approach 3 exposure that surfaces each Celery task as a single MCP tool returning a `CreateTaskResult`, with the result exposed via a resource URI (`mcp://results/<tool>/<task_id>`) and lifecycle handled through `tasks/get` / `tasks/cancel`.
|
|
14
|
+
- `CeleryTaskStore` — `InMemoryTaskStore` subclass that live-syncs MCP task state with Celery on every read.
|
|
15
|
+
- Optional push notifications in `OperationResourceStrategy(notifications=True)` — the server emits `TaskStatusNotification` on every status change so clients can skip polling.
|
|
16
|
+
- `examples/run.sh` — one-command runner that handles Redis, the virtualenv, the Celery worker, and the client for all bundled examples.
|
|
17
|
+
- `py.typed` marker so downstream projects pick up type hints.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `AsyncToolServer` — main entry point for registering Celery tasks as MCP tools.
|
|
21
|
+
- `PollingExposureStrategy` — default strategy generating `_start`, `_status`, `_result`, `_cancel` tools per registered task.
|
|
22
|
+
- `CeleryBackend` / `AbstractBackend` — pluggable execution backend interface.
|
|
23
|
+
- `TaskStatus` canonical lifecycle enum and `format_response` response builder.
|
|
24
|
+
- `ToolRegistry`, `AsyncToolDef`, `TaskInfo` dataclasses.
|
|
25
|
+
- Baseline (hand-written MCP) and `with_package` example servers under `examples/`.
|
mcp_celery-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saleem
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-celery
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Expose Celery tasks as async MCP tools with automatic lifecycle management
|
|
5
|
+
Project-URL: Homepage, https://github.com/saleemasekrea000/mcp-celery
|
|
6
|
+
Project-URL: Repository, https://github.com/saleemasekrea000/mcp-celery
|
|
7
|
+
Project-URL: Issues, https://github.com/saleemasekrea000/mcp-celery/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/saleemasekrea000/mcp-celery/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Saleem <saleem@ancileo.com>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Saleem
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: async,celery,llm,long-running,mcp,model-context-protocol,tools
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Framework :: Celery
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python
|
|
39
|
+
Classifier: Programming Language :: Python :: 3
|
|
40
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
45
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
46
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
47
|
+
Classifier: Typing :: Typed
|
|
48
|
+
Requires-Python: >=3.10
|
|
49
|
+
Requires-Dist: celery>=5.3.0
|
|
50
|
+
Requires-Dist: mcp>=1.0.0
|
|
51
|
+
Provides-Extra: dev
|
|
52
|
+
Requires-Dist: build>=1.2.0; extra == 'dev'
|
|
53
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
54
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
55
|
+
Requires-Dist: redis>=5.0; extra == 'dev'
|
|
56
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
57
|
+
Requires-Dist: twine>=5.0.0; extra == 'dev'
|
|
58
|
+
Requires-Dist: ty>=0.0.29; extra == 'dev'
|
|
59
|
+
Description-Content-Type: text/markdown
|
|
60
|
+
|
|
61
|
+
# mcp-celery
|
|
62
|
+
|
|
63
|
+
**Expose Celery tasks as async MCP tools — without writing the boilerplate.**
|
|
64
|
+
|
|
65
|
+
Long-running work (reports, file processing, LLM pipelines) usually runs in Celery. Exposing those tasks to an MCP client means writing four tools per task by hand: one to start, one to poll status, one to fetch the result, one to cancel. `mcp-celery` does that generation for you. You register a Celery task once; four MCP tools (or a single MCP-native async operation) appear.
|
|
66
|
+
|
|
67
|
+
This README is written for a first-time reader. If you've never touched Celery or MCP before, start at [What this package does](#what-this-package-does) and follow it top to bottom.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Table of contents
|
|
72
|
+
|
|
73
|
+
- [What this package does](#what-this-package-does)
|
|
74
|
+
- [Core concepts in one page](#core-concepts-in-one-page)
|
|
75
|
+
- [Quickstart — one command](#quickstart--one-command)
|
|
76
|
+
- [The three examples](#the-three-examples)
|
|
77
|
+
- [How it works internally](#how-it-works-internally)
|
|
78
|
+
- [Public API](#public-api)
|
|
79
|
+
- [Project layout](#project-layout)
|
|
80
|
+
- [Running the unit tests](#running-the-unit-tests)
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## What this package does
|
|
85
|
+
|
|
86
|
+
### The problem
|
|
87
|
+
|
|
88
|
+
Without `mcp-celery`, exposing one long-running Celery task to an MCP client takes roughly 100 lines of boilerplate — one MCP tool for each lifecycle step:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
@mcp.tool(name="generate_report_start")
|
|
92
|
+
async def generate_report_start(user_id: str) -> str:
|
|
93
|
+
result = generate_report.apply_async(kwargs={"user_id": user_id})
|
|
94
|
+
return json.dumps({"task_id": result.id, "status": "PENDING"})
|
|
95
|
+
|
|
96
|
+
@mcp.tool(name="generate_report_status")
|
|
97
|
+
async def generate_report_status(task_id: str) -> str:
|
|
98
|
+
result = AsyncResult(task_id, app=celery_app)
|
|
99
|
+
status = CELERY_TO_STATUS.get(result.state, "PENDING")
|
|
100
|
+
# ...state mapping, progress extraction, error handling...
|
|
101
|
+
|
|
102
|
+
@mcp.tool(name="generate_report_result")
|
|
103
|
+
async def generate_report_result(task_id: str) -> str: ...
|
|
104
|
+
|
|
105
|
+
@mcp.tool(name="generate_report_cancel")
|
|
106
|
+
async def generate_report_cancel(task_id: str) -> str: ...
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Multiply that by every task and the surface area quickly becomes unmaintainable. The full baseline implementation is in [examples/baseline/server.py](examples/baseline/server.py).
|
|
110
|
+
|
|
111
|
+
### The solution
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from mcp_celery import AsyncToolServer
|
|
115
|
+
|
|
116
|
+
server = AsyncToolServer("my-server", celery_app=celery_app)
|
|
117
|
+
server.register_async_tool(generate_report, description="Generate a report")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
That single `register_async_tool` call produces four MCP tools:
|
|
121
|
+
|
|
122
|
+
| Tool name | Purpose |
|
|
123
|
+
|----------------------------|----------------------------------------------------|
|
|
124
|
+
| `generate_report_start` | Dispatch the Celery task, return a **token**. |
|
|
125
|
+
| `generate_report_status` | Poll the task with the token. |
|
|
126
|
+
| `generate_report_result` | Fetch the final result once the task is complete. |
|
|
127
|
+
| `generate_report_cancel` | Revoke a running task. |
|
|
128
|
+
|
|
129
|
+
A `token` is simply the Celery task id — the single handle a client carries across calls.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Core concepts in one page
|
|
134
|
+
|
|
135
|
+
| Concept | One-line explanation |
|
|
136
|
+
|---|---|
|
|
137
|
+
| **Celery task** | A regular Python function that has been decorated with `@celery_app.task`. It runs in a worker process, not in your MCP server. |
|
|
138
|
+
| **MCP tool** | A callable function that an LLM / MCP client can invoke through the Model Context Protocol. |
|
|
139
|
+
| **Async tool (mcp-celery)** | A Celery task that `mcp-celery` has auto-wrapped into a group of MCP tools (polling) or a single MCP task operation (Approach 3). |
|
|
140
|
+
| **Token** | A string identifier — in practice, the Celery task id. Returned by `_start`, passed into every follow-up call. |
|
|
141
|
+
| **Backend (`AbstractBackend`)** | The thing that actually runs a task. The only concrete implementation today is `CeleryBackend`, but the interface is narrow so you could swap in Dramatiq, ARQ, or a stub. |
|
|
142
|
+
| **Exposure strategy (`AbstractExposureStrategy`)** | How a task is surfaced over MCP. `PollingExposureStrategy` generates the four tools; `OperationResourceStrategy` uses MCP's experimental Task + Resource API. |
|
|
143
|
+
| **`AsyncToolServer`** | Public entry point. Owns a `FastMCP` instance, a backend, a strategy, and a registry. |
|
|
144
|
+
| **`TaskStatus`** | Canonical lifecycle enum (`PENDING`, `RUNNING`, `COMPLETED`, `FAILED`, `CANCELLED`, `RETRYING`). All backends map their native states into these values via [lifecycle.py](src/mcp_celery/lifecycle.py). |
|
|
145
|
+
|
|
146
|
+
The layering is strict: `server → exposure/backend → schema`. The exposure strategy never imports a concrete backend, and the backend never imports a strategy.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Quickstart — one command
|
|
151
|
+
|
|
152
|
+
Everything in `examples/` can be run through a single script: `examples/run.sh`. It handles Redis, the virtualenv, and the Celery worker for you.
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# From the repo root:
|
|
156
|
+
bash examples/run.sh # defaults to 'with_package'
|
|
157
|
+
bash examples/run.sh baseline # manual implementation (no mcp-celery)
|
|
158
|
+
bash examples/run.sh with_package # mcp-celery with polling (Approach 1)
|
|
159
|
+
bash examples/run.sh approach3 # MCP-native tasks + resource URI
|
|
160
|
+
bash examples/run.sh approach3_notify # Approach 3 with push notifications
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
What the script does on your behalf:
|
|
164
|
+
|
|
165
|
+
1. **Redis.** If `localhost:6379` isn't accepting connections, it starts a Docker container named `redis-test`. On subsequent runs it reuses the same container.
|
|
166
|
+
2. **Virtualenv.** On the first run it invokes [`examples/setup.sh`](examples/setup.sh), which creates `examples/.venv/` and installs `mcp-celery` in editable mode.
|
|
167
|
+
3. **Worker.** It launches `celery -A tasks worker` for the chosen example in the background and waits until Celery prints `ready`.
|
|
168
|
+
4. **Client.** It runs the example's `client.py`.
|
|
169
|
+
5. **Cleanup.** On exit (or Ctrl+C) it stops the worker. The Redis container is kept running for speed; remove it with `docker rm -f redis-test` when you're done.
|
|
170
|
+
|
|
171
|
+
Requirements: `python3`, `bash`, and (first-time only, if Redis isn't already running) `docker`.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## The three examples
|
|
176
|
+
|
|
177
|
+
Each example under `examples/` runs the *same* Celery task (`generate_report`, which sleeps for 6 seconds and reports progress at 25 % / 60 % / 90 %). Only the MCP wiring changes.
|
|
178
|
+
|
|
179
|
+
### 1. `baseline/` — without the package
|
|
180
|
+
|
|
181
|
+
Hand-written MCP tools. About 100 lines for a single task. This is the "before" picture — everything `mcp-celery` is designed to eliminate.
|
|
182
|
+
|
|
183
|
+
### 2. `with_package/` — polling strategy (Approach 1)
|
|
184
|
+
|
|
185
|
+
Uses the default [`PollingExposureStrategy`](src/mcp_celery/exposure/polling.py). Four tools per task, token-based polling. The client calls `_start`, polls `_status`, then calls `_result`.
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
LLM → generate_report_start(user_id="u_1") ← {"token": "abc", "status": "PENDING"}
|
|
189
|
+
LLM → generate_report_status(token="abc") ← {"token": "abc", "status": "RUNNING", "progress": 0.6}
|
|
190
|
+
LLM → generate_report_result(token="abc") ← {"token": "abc", "status": "COMPLETED", "result": {...}}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 3. `approach3/` — MCP task operations + resource URI
|
|
194
|
+
|
|
195
|
+
Uses [`OperationResourceStrategy`](src/mcp_celery/exposure/operation_resource.py). Instead of four tools, each task becomes:
|
|
196
|
+
|
|
197
|
+
- **One MCP tool** that returns a `CreateTaskResult` (an MCP `Task` object plus a `resourceUri` in `_meta`).
|
|
198
|
+
- **Standard MCP task endpoints** — `tasks/get`, `tasks/cancel` — backed by a [`CeleryTaskStore`](src/mcp_celery/stores/celery_task_store.py) that lazily syncs MCP Task state with Celery state on every read.
|
|
199
|
+
- **A resource template** `mcp://results/<tool>/<task_id>` that returns the final result via `resources/read`.
|
|
200
|
+
|
|
201
|
+
The result is no longer conflated with the task lifecycle — progress lives on the task, payload lives in a resource.
|
|
202
|
+
|
|
203
|
+
The `approach3_notify` variant adds `notifications=True`. The server spawns a watcher coroutine per task that pushes `TaskStatusNotification` to the client whenever the status changes, so the client can `await` instead of polling. See [`server_notifications.py`](examples/approach3/server_notifications.py) and [`client_notifications.py`](examples/approach3/client_notifications.py).
|
|
204
|
+
|
|
205
|
+
### Expected output (Approach 1)
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
Registered tools: ['generate_report_start', 'generate_report_status',
|
|
209
|
+
'generate_report_result', 'generate_report_cancel']
|
|
210
|
+
|
|
211
|
+
--- Calling generate_report_start ---
|
|
212
|
+
Response: { "token": "b16a...", "status": "PENDING" }
|
|
213
|
+
|
|
214
|
+
--- Polling generate_report_status ---
|
|
215
|
+
Poll 1: RUNNING (60%)
|
|
216
|
+
Poll 2: RUNNING (90%)
|
|
217
|
+
Poll 3: COMPLETED
|
|
218
|
+
|
|
219
|
+
--- Calling generate_report_result ---
|
|
220
|
+
Response: {
|
|
221
|
+
"token": "b16a...",
|
|
222
|
+
"status": "COMPLETED",
|
|
223
|
+
"result": { "user_id": "u_demo_42", "revenue": 42000, "items": 17 }
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The client spawns the MCP server as a subprocess over stdio — you only run `run.sh`.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## How it works internally
|
|
232
|
+
|
|
233
|
+
Reading order: start at [`server.py`](src/mcp_celery/server.py), then follow the data flow below.
|
|
234
|
+
|
|
235
|
+
### Registration
|
|
236
|
+
|
|
237
|
+
```text
|
|
238
|
+
server.register_async_tool(celery_task) # or @server.async_tool
|
|
239
|
+
├─ _schema_from_signature(celery_task.run) # build JSON schema from Python type hints
|
|
240
|
+
├─ AsyncToolDef(name, description, schema, task_ref, …)
|
|
241
|
+
├─ ToolRegistry.register(tool_def) # keep it around so we can list registrations
|
|
242
|
+
├─ strategy.generate_tools(tool_def, backend) # → list[GeneratedTool]
|
|
243
|
+
├─ for tool in generated: mcp.tool(...)(tool.handler)
|
|
244
|
+
└─ strategy.setup(mcp, backend) # optional: resources, handler overrides, etc.
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The strategy owns all decisions about *what* MCP surface to expose. Whether it's four tools or one tool + resources + notifications is entirely a strategy concern — neither the server nor the backend knows.
|
|
248
|
+
|
|
249
|
+
### Polling strategy (Approach 1) — runtime
|
|
250
|
+
|
|
251
|
+
```text
|
|
252
|
+
LLM → <name>_start(**kwargs)
|
|
253
|
+
PollingExposureStrategy._make_start → _build_start_handler
|
|
254
|
+
→ backend.dispatch(task_ref, kwargs) # CeleryBackend.apply_async
|
|
255
|
+
→ format_response(token, TaskStatus.PENDING)
|
|
256
|
+
|
|
257
|
+
LLM → <name>_status(token)
|
|
258
|
+
→ backend.get_status(token) # AsyncResult + map_celery_state
|
|
259
|
+
→ format_response(token, status, progress, metadata)
|
|
260
|
+
|
|
261
|
+
LLM → <name>_result(token)
|
|
262
|
+
→ backend.get_result(token)
|
|
263
|
+
→ if COMPLETED: format_response(..., result=...)
|
|
264
|
+
→ if FAILED: format_response(..., error=..., is_error=True)
|
|
265
|
+
→ otherwise: format_response(..., is_error=True, "not yet complete")
|
|
266
|
+
|
|
267
|
+
LLM → <name>_cancel(token)
|
|
268
|
+
→ backend.cancel(token) # result.revoke(terminate=True)
|
|
269
|
+
→ format_response(token, TaskStatus.CANCELLED)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`format_response` ([schema.py](src/mcp_celery/schema.py)) is the single source of truth for response shape. FastMCP serializes the returned dict into `TextContent` automatically — you never hand-build `TextContent`.
|
|
273
|
+
|
|
274
|
+
### Operation + Resource strategy (Approach 3) — runtime
|
|
275
|
+
|
|
276
|
+
Setup installs three handler overrides on the low-level MCP server:
|
|
277
|
+
|
|
278
|
+
- `CallToolRequest` — intercepted; for our tools it dispatches to Celery, creates an MCP `Task` in the `CeleryTaskStore`, and returns a `CreateTaskResult` with `_meta.resourceUri` pointing at the result resource.
|
|
279
|
+
- `CancelTaskRequest` — cancels the MCP task (so the store state becomes `cancelled`) *before* revoking the Celery task (to avoid a race where the next `get_task` would see a terminal state and reject the cancellation).
|
|
280
|
+
- `GetTaskPayloadRequest` — returns a pointer `{"resourceUri": "mcp://results/<tool>/<task_id>"}` instead of the payload itself; the client is expected to read the resource.
|
|
281
|
+
|
|
282
|
+
Plus a resource template per tool whose reader calls `backend.get_result(task_id)` and returns JSON.
|
|
283
|
+
|
|
284
|
+
`CeleryTaskStore` is an `InMemoryTaskStore` that overrides `get_task` to query Celery on every read and reconcile the MCP task status with what the worker actually reports. Terminal MCP states are never overwritten.
|
|
285
|
+
|
|
286
|
+
When `notifications=True`, `_watch_and_notify` runs per task, polling the store every `poll_interval_ms` and pushing `TaskStatusNotification` on any status change until a terminal state is reached.
|
|
287
|
+
|
|
288
|
+
### State mapping
|
|
289
|
+
|
|
290
|
+
All backend-specific state strings are translated once, in [`lifecycle.py`](src/mcp_celery/lifecycle.py):
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
"PENDING" → TaskStatus.PENDING
|
|
294
|
+
"STARTED" → TaskStatus.RUNNING
|
|
295
|
+
"SUCCESS" → TaskStatus.COMPLETED
|
|
296
|
+
"FAILURE" → TaskStatus.FAILED
|
|
297
|
+
"REVOKED" → TaskStatus.CANCELLED
|
|
298
|
+
# unknown custom states → RUNNING (typically set via update_state)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Everything else in the code uses the `TaskStatus` enum.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Public API
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
from mcp_celery import (
|
|
309
|
+
AsyncToolServer, # main entry point
|
|
310
|
+
TaskStatus, # canonical status enum
|
|
311
|
+
TaskInfo, AsyncToolDef, # dataclasses
|
|
312
|
+
format_response, # response builder
|
|
313
|
+
|
|
314
|
+
AbstractBackend, CeleryBackend,
|
|
315
|
+
AbstractExposureStrategy, PollingExposureStrategy, OperationResourceStrategy,
|
|
316
|
+
GeneratedTool, ToolRegistry,
|
|
317
|
+
CeleryTaskStore,
|
|
318
|
+
map_celery_state,
|
|
319
|
+
|
|
320
|
+
McpCeleryError, TaskNotReady, TaskNotFound, BackendError,
|
|
321
|
+
)
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`AsyncToolServer` is the only class most users touch:
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
server = AsyncToolServer(
|
|
328
|
+
name="my-server",
|
|
329
|
+
celery_app=celery_app,
|
|
330
|
+
strategy=PollingExposureStrategy(), # or OperationResourceStrategy(...)
|
|
331
|
+
default_cancel=True,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Decorator form: register-as-you-define
|
|
335
|
+
@server.async_tool
|
|
336
|
+
def generate_report(user_id: str) -> dict:
|
|
337
|
+
...
|
|
338
|
+
|
|
339
|
+
# Imperative form: register an existing Celery task
|
|
340
|
+
server.register_async_tool(
|
|
341
|
+
existing_celery_task,
|
|
342
|
+
name="generate_report",
|
|
343
|
+
description="Generate a report for a user",
|
|
344
|
+
cancel=True,
|
|
345
|
+
result_ttl=300,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
server.run() # delegates to FastMCP.run()
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## Project layout
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
mcp-celery/
|
|
357
|
+
├── pyproject.toml
|
|
358
|
+
├── LICENSE
|
|
359
|
+
├── CHANGELOG.md
|
|
360
|
+
├── README.md
|
|
361
|
+
├── src/
|
|
362
|
+
│ └── mcp_celery/ # the importable package
|
|
363
|
+
│ ├── __init__.py # public API (lazy-loads runtime deps)
|
|
364
|
+
│ ├── py.typed # advertises inline type hints
|
|
365
|
+
│ ├── schema.py # TaskStatus, AsyncToolDef, TaskInfo, format_response
|
|
366
|
+
│ ├── errors.py # McpCeleryError, TaskNotReady, TaskNotFound, BackendError
|
|
367
|
+
│ ├── lifecycle.py # Celery state ↔ TaskStatus ↔ MCP status
|
|
368
|
+
│ ├── registry.py # ToolRegistry (name → AsyncToolDef)
|
|
369
|
+
│ ├── server.py # AsyncToolServer + JSON-schema-from-signature helper
|
|
370
|
+
│ ├── backend/
|
|
371
|
+
│ │ ├── base.py # AbstractBackend (dispatch / get_status / get_result / cancel)
|
|
372
|
+
│ │ └── celery.py # CeleryBackend
|
|
373
|
+
│ ├── exposure/
|
|
374
|
+
│ │ ├── base.py # AbstractExposureStrategy, GeneratedTool
|
|
375
|
+
│ │ ├── polling.py # PollingExposureStrategy (Approach 1)
|
|
376
|
+
│ │ └── operation_resource.py # OperationResourceStrategy (Approach 3)
|
|
377
|
+
│ └── stores/
|
|
378
|
+
│ └── celery_task_store.py # Celery-synced MCP TaskStore (Approach 3)
|
|
379
|
+
├── tests/
|
|
380
|
+
│ ├── test_tools.py # unit tests — no Redis or Celery worker required
|
|
381
|
+
│ └── test_operation_resource.py
|
|
382
|
+
├── examples/
|
|
383
|
+
│ ├── setup.sh # one-time venv setup
|
|
384
|
+
│ ├── run.sh # one-command runner — see Quickstart
|
|
385
|
+
│ ├── baseline/ # manual MCP tools (before)
|
|
386
|
+
│ ├── with_package/ # mcp-celery polling strategy (after)
|
|
387
|
+
│ └── approach3/ # OperationResource strategy + optional notifications
|
|
388
|
+
└── docs/
|
|
389
|
+
├── approach3-design.md # Approach 3 design notes
|
|
390
|
+
├── transport-resumable.md # transport-agnostic resumable requests notes
|
|
391
|
+
└── quickstart-example.py # stand-alone usage snippet
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
The `src/` layout is deliberate — it prevents `pytest` from silently picking up the in-repo sources instead of the installed wheel, so every test run exercises what users will actually `pip install`.
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Running the unit tests
|
|
399
|
+
|
|
400
|
+
No Redis, no Celery worker needed:
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
pip install -e ".[dev]"
|
|
404
|
+
pytest tests/test_tools.py -v
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
The tests use a mock backend ([tests/mock_backend.py](tests/mock_backend.py)) and a manual `run(coro)` helper that creates a fresh event loop per call — there are no `pytest-asyncio` markers.
|