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.
Files changed (32) hide show
  1. mcp_celery-0.1.0/.gitignore +85 -0
  2. mcp_celery-0.1.0/CHANGELOG.md +25 -0
  3. mcp_celery-0.1.0/LICENSE +21 -0
  4. mcp_celery-0.1.0/PKG-INFO +407 -0
  5. mcp_celery-0.1.0/README.md +347 -0
  6. mcp_celery-0.1.0/docs/approach3-design.md +161 -0
  7. mcp_celery-0.1.0/docs/quickstart-example.py +87 -0
  8. mcp_celery-0.1.0/docs/transport-resumable.md +656 -0
  9. mcp_celery-0.1.0/pyproject.toml +98 -0
  10. mcp_celery-0.1.0/src/mcp_celery/__init__.py +80 -0
  11. mcp_celery-0.1.0/src/mcp_celery/backend/__init__.py +21 -0
  12. mcp_celery-0.1.0/src/mcp_celery/backend/base.py +52 -0
  13. mcp_celery-0.1.0/src/mcp_celery/backend/celery.py +68 -0
  14. mcp_celery-0.1.0/src/mcp_celery/errors.py +32 -0
  15. mcp_celery-0.1.0/src/mcp_celery/exposure/__init__.py +17 -0
  16. mcp_celery-0.1.0/src/mcp_celery/exposure/base.py +49 -0
  17. mcp_celery-0.1.0/src/mcp_celery/exposure/operation_resource.py +378 -0
  18. mcp_celery-0.1.0/src/mcp_celery/exposure/polling.py +169 -0
  19. mcp_celery-0.1.0/src/mcp_celery/lifecycle.py +50 -0
  20. mcp_celery-0.1.0/src/mcp_celery/py.typed +0 -0
  21. mcp_celery-0.1.0/src/mcp_celery/registry.py +31 -0
  22. mcp_celery-0.1.0/src/mcp_celery/schema.py +73 -0
  23. mcp_celery-0.1.0/src/mcp_celery/server.py +202 -0
  24. mcp_celery-0.1.0/src/mcp_celery/stores/__init__.py +3 -0
  25. mcp_celery-0.1.0/src/mcp_celery/stores/celery_task_store.py +58 -0
  26. mcp_celery-0.1.0/tests/__init__.py +0 -0
  27. mcp_celery-0.1.0/tests/mock_backend.py +53 -0
  28. mcp_celery-0.1.0/tests/run_client.py +114 -0
  29. mcp_celery-0.1.0/tests/server_for_test.py +34 -0
  30. mcp_celery-0.1.0/tests/test_operation_resource.py +782 -0
  31. mcp_celery-0.1.0/tests/test_tools.py +178 -0
  32. 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/`.
@@ -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.