mcp-server-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_server_celery-0.1.0/LICENSE +21 -0
- mcp_server_celery-0.1.0/PKG-INFO +156 -0
- mcp_server_celery-0.1.0/README.md +134 -0
- mcp_server_celery-0.1.0/pyproject.toml +73 -0
- mcp_server_celery-0.1.0/pyproject.toml.orig +53 -0
- mcp_server_celery-0.1.0/src/mcp_server_celery/__init__.py +3 -0
- mcp_server_celery-0.1.0/src/mcp_server_celery/__main__.py +3 -0
- mcp_server_celery-0.1.0/src/mcp_server_celery/client.py +80 -0
- mcp_server_celery-0.1.0/src/mcp_server_celery/config.py +22 -0
- mcp_server_celery-0.1.0/src/mcp_server_celery/py.typed +0 -0
- mcp_server_celery-0.1.0/src/mcp_server_celery/redact.py +22 -0
- mcp_server_celery-0.1.0/src/mcp_server_celery/server.py +72 -0
- mcp_server_celery-0.1.0/src/mcp_server_celery/tools.py +44 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ariel Even
|
|
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,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-server-celery
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read-only MCP server for debugging Celery: workers, queue depths, and stuck tasks.
|
|
5
|
+
Keywords: mcp,celery,model-context-protocol,debugging,rabbitmq,redis
|
|
6
|
+
Author: Ariel Even
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: Celery
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: System :: Monitoring
|
|
14
|
+
Requires-Dist: celery>=5.3
|
|
15
|
+
Requires-Dist: fastmcp>=4.1
|
|
16
|
+
Requires-Dist: mcp>=2.3
|
|
17
|
+
Requires-Dist: redis>=5.0
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Project-URL: Repository, https://github.com/taltal-beep/mcp-server-celery
|
|
20
|
+
Project-URL: Issues, https://github.com/taltal-beep/mcp-server-celery/issues
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# mcp-server-celery
|
|
24
|
+
|
|
25
|
+
<!-- mcp-name: io.github.taltal-beep/mcp-server-celery -->
|
|
26
|
+
|
|
27
|
+
[](https://github.com/taltal-beep/mcp-server-celery/actions/workflows/ci.yml)
|
|
28
|
+
|
|
29
|
+
An MCP server that lets AI agents debug a running Celery deployment. It checks which workers are alive, what each one consumes, and where messages are piling up — the checks you'd otherwise run by hand with `celery inspect` and `rabbitmqctl` every time something gets stuck.
|
|
30
|
+
|
|
31
|
+
It is **read-only by default**. Broker passwords are masked in every response, including the broker info that Celery's own `inspect stats` sends back in plain text.
|
|
32
|
+
|
|
33
|
+
Works with RabbitMQ (`amqp://`) and Redis (`redis://`, `rediss://`).
|
|
34
|
+
|
|
35
|
+
## Tools
|
|
36
|
+
|
|
37
|
+
| Tool | What it answers |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `list_workers` | Which workers respond, their pool and concurrency, uptime, prefetch count, tasks processed, and the queues each one listens to. |
|
|
40
|
+
| `queue_depths` | How many messages are waiting in each queue and which workers consume it. With no arguments it checks your configured queues plus every queue a live worker consumes. |
|
|
41
|
+
|
|
42
|
+
Coming next: `active_tasks`, `reserved_tasks`, `scheduled_tasks`, `task_status`, and `diagnose_stuck`, which cross-references the above to flag queues no worker listens to, tasks routed to queues with no consumer, and workers that stopped responding. See the [roadmap](#roadmap).
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uvx mcp-server-celery
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Until the first PyPI release, run it from GitHub:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uvx --from git+https://github.com/taltal-beep/mcp-server-celery mcp-server-celery
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
All configuration is through environment variables.
|
|
61
|
+
|
|
62
|
+
| Variable | Default | Purpose |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `CELERY_BROKER_URL` | `amqp://guest:guest@localhost:5672//` | Broker to inspect. |
|
|
65
|
+
| `CELERY_RESULT_BACKEND` | — | Result backend (used by `task_status`, coming in the next release). |
|
|
66
|
+
| `CELERY_APP` | — | Import path of your app, e.g. `proj.celery:app`. Lets the server see your `task_queues` and routes. `CELERY_BROKER_URL` still overrides the app's broker if both are set. |
|
|
67
|
+
| `CELERY_INSPECT_TIMEOUT` | `1.0` | Seconds to wait for worker replies. Raise it for large or slow clusters. |
|
|
68
|
+
|
|
69
|
+
`CELERY_APP` has to be importable, so run the server from your project's directory and environment:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv run --with mcp-server-celery mcp-server-celery
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Connect it
|
|
76
|
+
|
|
77
|
+
### Claude Code
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
claude mcp add celery -e CELERY_BROKER_URL=redis://localhost:6379/0 -- uvx mcp-server-celery
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Cursor
|
|
84
|
+
|
|
85
|
+
In `.cursor/mcp.json`:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"mcpServers": {
|
|
90
|
+
"celery": {
|
|
91
|
+
"command": "uvx",
|
|
92
|
+
"args": ["mcp-server-celery"],
|
|
93
|
+
"env": { "CELERY_BROKER_URL": "redis://localhost:6379/0" }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Claude Desktop
|
|
100
|
+
|
|
101
|
+
In `claude_desktop_config.json`, use the same `command`, `args` and `env` block as Cursor, under `mcpServers`.
|
|
102
|
+
|
|
103
|
+
## Example
|
|
104
|
+
|
|
105
|
+
Ask the agent "why are my email tasks not running?" and it can call `queue_depths`:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"broker": "amqp://app:****@rabbit:5672//",
|
|
110
|
+
"queues": [
|
|
111
|
+
{ "queue": "celery", "exists": true, "messages": 0, "consumers": 2, "workers_consuming": ["w1@web", "w2@web"] },
|
|
112
|
+
{ "queue": "emails", "exists": true, "messages": 1843, "consumers": 0, "workers_consuming": [] }
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`emails` has a backlog and no worker consumes it: the workers were started without `-Q emails`.
|
|
118
|
+
|
|
119
|
+
## Notes on the data
|
|
120
|
+
|
|
121
|
+
- **Redis** has no notion of consumers, so `consumers` is `null`; use `workers_consuming`, which comes from the workers themselves. Redis also deletes a queue's key once it is empty, so a drained queue shows `"messages": 0, "exists": null`.
|
|
122
|
+
- **RabbitMQ** checks use a passive declare, so the server never creates a queue. A queue that doesn't exist shows `"exists": false`.
|
|
123
|
+
- Workers that are too busy to answer within `CELERY_INSPECT_TIMEOUT` don't appear in `list_workers`. If a worker you expect is missing, try a larger `timeout` argument before assuming it's dead.
|
|
124
|
+
|
|
125
|
+
## Safety
|
|
126
|
+
|
|
127
|
+
- Every tool is annotated `readOnlyHint: true`. Write actions (revoke, retry, purge) will only be available behind an explicit opt-in flag.
|
|
128
|
+
- URL credentials and `password`/`secret`/`token` fields are masked in all output and error messages.
|
|
129
|
+
|
|
130
|
+
## Roadmap
|
|
131
|
+
|
|
132
|
+
**v0.1** — `list_workers`, `queue_depths` ✅
|
|
133
|
+
|
|
134
|
+
**v1** — `active_tasks`, `reserved_tasks`, `scheduled_tasks`, `task_status(task_id)`, `diagnose_stuck`
|
|
135
|
+
|
|
136
|
+
**v2** — `summarize_failures`: groups recent failures by task name and exception type. Celery keeps no failure history by default, so this needs an event listener.
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
uv sync
|
|
142
|
+
uv run pytest tests/unit
|
|
143
|
+
uv run ruff check . && uv run ruff format --check .
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Integration tests start a real worker and need a broker:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
CELERY_BROKER_URL=redis://localhost:6379/0 uv run pytest tests/integration -m integration
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
CI runs them against Redis and RabbitMQ on every push.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# mcp-server-celery
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.taltal-beep/mcp-server-celery -->
|
|
4
|
+
|
|
5
|
+
[](https://github.com/taltal-beep/mcp-server-celery/actions/workflows/ci.yml)
|
|
6
|
+
|
|
7
|
+
An MCP server that lets AI agents debug a running Celery deployment. It checks which workers are alive, what each one consumes, and where messages are piling up — the checks you'd otherwise run by hand with `celery inspect` and `rabbitmqctl` every time something gets stuck.
|
|
8
|
+
|
|
9
|
+
It is **read-only by default**. Broker passwords are masked in every response, including the broker info that Celery's own `inspect stats` sends back in plain text.
|
|
10
|
+
|
|
11
|
+
Works with RabbitMQ (`amqp://`) and Redis (`redis://`, `rediss://`).
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
| Tool | What it answers |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `list_workers` | Which workers respond, their pool and concurrency, uptime, prefetch count, tasks processed, and the queues each one listens to. |
|
|
18
|
+
| `queue_depths` | How many messages are waiting in each queue and which workers consume it. With no arguments it checks your configured queues plus every queue a live worker consumes. |
|
|
19
|
+
|
|
20
|
+
Coming next: `active_tasks`, `reserved_tasks`, `scheduled_tasks`, `task_status`, and `diagnose_stuck`, which cross-references the above to flag queues no worker listens to, tasks routed to queues with no consumer, and workers that stopped responding. See the [roadmap](#roadmap).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uvx mcp-server-celery
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Until the first PyPI release, run it from GitHub:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uvx --from git+https://github.com/taltal-beep/mcp-server-celery mcp-server-celery
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
All configuration is through environment variables.
|
|
39
|
+
|
|
40
|
+
| Variable | Default | Purpose |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `CELERY_BROKER_URL` | `amqp://guest:guest@localhost:5672//` | Broker to inspect. |
|
|
43
|
+
| `CELERY_RESULT_BACKEND` | — | Result backend (used by `task_status`, coming in the next release). |
|
|
44
|
+
| `CELERY_APP` | — | Import path of your app, e.g. `proj.celery:app`. Lets the server see your `task_queues` and routes. `CELERY_BROKER_URL` still overrides the app's broker if both are set. |
|
|
45
|
+
| `CELERY_INSPECT_TIMEOUT` | `1.0` | Seconds to wait for worker replies. Raise it for large or slow clusters. |
|
|
46
|
+
|
|
47
|
+
`CELERY_APP` has to be importable, so run the server from your project's directory and environment:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv run --with mcp-server-celery mcp-server-celery
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Connect it
|
|
54
|
+
|
|
55
|
+
### Claude Code
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
claude mcp add celery -e CELERY_BROKER_URL=redis://localhost:6379/0 -- uvx mcp-server-celery
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Cursor
|
|
62
|
+
|
|
63
|
+
In `.cursor/mcp.json`:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"celery": {
|
|
69
|
+
"command": "uvx",
|
|
70
|
+
"args": ["mcp-server-celery"],
|
|
71
|
+
"env": { "CELERY_BROKER_URL": "redis://localhost:6379/0" }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Claude Desktop
|
|
78
|
+
|
|
79
|
+
In `claude_desktop_config.json`, use the same `command`, `args` and `env` block as Cursor, under `mcpServers`.
|
|
80
|
+
|
|
81
|
+
## Example
|
|
82
|
+
|
|
83
|
+
Ask the agent "why are my email tasks not running?" and it can call `queue_depths`:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"broker": "amqp://app:****@rabbit:5672//",
|
|
88
|
+
"queues": [
|
|
89
|
+
{ "queue": "celery", "exists": true, "messages": 0, "consumers": 2, "workers_consuming": ["w1@web", "w2@web"] },
|
|
90
|
+
{ "queue": "emails", "exists": true, "messages": 1843, "consumers": 0, "workers_consuming": [] }
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`emails` has a backlog and no worker consumes it: the workers were started without `-Q emails`.
|
|
96
|
+
|
|
97
|
+
## Notes on the data
|
|
98
|
+
|
|
99
|
+
- **Redis** has no notion of consumers, so `consumers` is `null`; use `workers_consuming`, which comes from the workers themselves. Redis also deletes a queue's key once it is empty, so a drained queue shows `"messages": 0, "exists": null`.
|
|
100
|
+
- **RabbitMQ** checks use a passive declare, so the server never creates a queue. A queue that doesn't exist shows `"exists": false`.
|
|
101
|
+
- Workers that are too busy to answer within `CELERY_INSPECT_TIMEOUT` don't appear in `list_workers`. If a worker you expect is missing, try a larger `timeout` argument before assuming it's dead.
|
|
102
|
+
|
|
103
|
+
## Safety
|
|
104
|
+
|
|
105
|
+
- Every tool is annotated `readOnlyHint: true`. Write actions (revoke, retry, purge) will only be available behind an explicit opt-in flag.
|
|
106
|
+
- URL credentials and `password`/`secret`/`token` fields are masked in all output and error messages.
|
|
107
|
+
|
|
108
|
+
## Roadmap
|
|
109
|
+
|
|
110
|
+
**v0.1** — `list_workers`, `queue_depths` ✅
|
|
111
|
+
|
|
112
|
+
**v1** — `active_tasks`, `reserved_tasks`, `scheduled_tasks`, `task_status(task_id)`, `diagnose_stuck`
|
|
113
|
+
|
|
114
|
+
**v2** — `summarize_failures`: groups recent failures by task name and exception type. Celery keeps no failure history by default, so this needs an event listener.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
uv sync
|
|
120
|
+
uv run pytest tests/unit
|
|
121
|
+
uv run ruff check . && uv run ruff format --check .
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Integration tests start a real worker and need a broker:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
CELERY_BROKER_URL=redis://localhost:6379/0 uv run pytest tests/integration -m integration
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
CI runs them against Redis and RabbitMQ on every push.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-server-celery"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Read-only MCP server for debugging Celery: workers, queue depths, and stuck tasks."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
keywords = [
|
|
10
|
+
"mcp",
|
|
11
|
+
"celery",
|
|
12
|
+
"model-context-protocol",
|
|
13
|
+
"debugging",
|
|
14
|
+
"rabbitmq",
|
|
15
|
+
"redis",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Framework :: Celery",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: System :: Monitoring",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"celery>=5.3",
|
|
26
|
+
"fastmcp>=4.1",
|
|
27
|
+
"mcp>=2.3",
|
|
28
|
+
"redis>=5.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[[project.authors]]
|
|
32
|
+
name = "Ariel Even"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/taltal-beep/mcp-server-celery"
|
|
36
|
+
Issues = "https://github.com/taltal-beep/mcp-server-celery/issues"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
mcp-server-celery = "mcp_server_celery.server:main"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["uv_build>=0.8,<0.14"]
|
|
43
|
+
build-backend = "uv_build"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8",
|
|
48
|
+
"ruff>=0.15",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
markers = ["integration: needs a live broker (set CELERY_BROKER_URL)"]
|
|
53
|
+
filterwarnings = ["error"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 120
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = [
|
|
60
|
+
"E",
|
|
61
|
+
"F",
|
|
62
|
+
"I",
|
|
63
|
+
"B",
|
|
64
|
+
"UP",
|
|
65
|
+
"S",
|
|
66
|
+
"BLE",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.per-file-ignores]
|
|
70
|
+
"tests/**" = [
|
|
71
|
+
"S101",
|
|
72
|
+
"S603",
|
|
73
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-server-celery"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Read-only MCP server for debugging Celery: workers, queue depths, and stuck tasks."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Ariel Even" }]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
keywords = ["mcp", "celery", "model-context-protocol", "debugging", "rabbitmq", "redis"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: Celery",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Topic :: System :: Monitoring",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"celery>=5.3",
|
|
20
|
+
"fastmcp>=4.1",
|
|
21
|
+
"mcp>=2.3",
|
|
22
|
+
"redis>=5.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/taltal-beep/mcp-server-celery"
|
|
27
|
+
Issues = "https://github.com/taltal-beep/mcp-server-celery/issues"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
mcp-server-celery = "mcp_server_celery.server:main"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["uv_build>=0.8,<0.14"]
|
|
34
|
+
build-backend = "uv_build"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"ruff>=0.15",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
markers = ["integration: needs a live broker (set CELERY_BROKER_URL)"]
|
|
44
|
+
filterwarnings = ["error"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 120
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "F", "I", "B", "UP", "S", "BLE"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.per-file-ignores]
|
|
53
|
+
"tests/**" = ["S101", "S603"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
from celery import Celery
|
|
7
|
+
from celery.utils.imports import symbol_by_name
|
|
8
|
+
from kombu.exceptions import ChannelError
|
|
9
|
+
|
|
10
|
+
from mcp_server_celery.config import DEFAULT_BROKER_URL, Settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Inspector(Protocol):
|
|
14
|
+
def ping(self) -> dict[str, Any] | None: ...
|
|
15
|
+
def stats(self) -> dict[str, Any] | None: ...
|
|
16
|
+
def active_queues(self) -> dict[str, list[dict[str, Any]]] | None: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CeleryClient:
|
|
20
|
+
def __init__(self, app: Celery, inspect_timeout: float = 1.0):
|
|
21
|
+
self.app = app
|
|
22
|
+
self.inspect_timeout = inspect_timeout
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_settings(cls, settings: Settings) -> "CeleryClient":
|
|
26
|
+
if settings.app_path:
|
|
27
|
+
if os.getcwd() not in sys.path:
|
|
28
|
+
sys.path.insert(0, os.getcwd()) # match `celery -A`, which imports from cwd
|
|
29
|
+
app = symbol_by_name(settings.app_path)
|
|
30
|
+
if settings.broker_url:
|
|
31
|
+
app.conf.broker_url = settings.broker_url
|
|
32
|
+
if settings.result_backend:
|
|
33
|
+
app.conf.result_backend = settings.result_backend
|
|
34
|
+
else:
|
|
35
|
+
app = Celery(
|
|
36
|
+
"mcp-server-celery",
|
|
37
|
+
broker=settings.broker_url or DEFAULT_BROKER_URL,
|
|
38
|
+
backend=settings.result_backend,
|
|
39
|
+
)
|
|
40
|
+
return cls(app, settings.inspect_timeout)
|
|
41
|
+
|
|
42
|
+
def inspect(self, timeout: float | None = None, destination: list[str] | None = None) -> Inspector:
|
|
43
|
+
return self.app.control.inspect(timeout=timeout or self.inspect_timeout, destination=destination)
|
|
44
|
+
|
|
45
|
+
def configured_queues(self) -> list[str]:
|
|
46
|
+
queues = self.app.conf.task_queues or []
|
|
47
|
+
names = [q.name for q in queues]
|
|
48
|
+
default = self.app.conf.task_default_queue
|
|
49
|
+
return names if default in names else [default, *names]
|
|
50
|
+
|
|
51
|
+
def queue_depths(self, names: list[str]) -> list[dict[str, Any]]:
|
|
52
|
+
with self.app.connection_for_read() as conn:
|
|
53
|
+
conn.ensure_connection(max_retries=1)
|
|
54
|
+
is_amqp = conn.transport.driver_type == "amqp"
|
|
55
|
+
return [self._probe(conn, name, is_amqp) for name in names]
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _probe(conn, name: str, is_amqp: bool) -> dict[str, Any]:
|
|
59
|
+
channel = conn.channel()
|
|
60
|
+
try:
|
|
61
|
+
_, messages, consumers = channel.queue_declare(queue=name, passive=True)
|
|
62
|
+
except ChannelError:
|
|
63
|
+
if is_amqp:
|
|
64
|
+
return {
|
|
65
|
+
"queue": name,
|
|
66
|
+
"exists": False,
|
|
67
|
+
"messages": None,
|
|
68
|
+
"consumers": None,
|
|
69
|
+
}
|
|
70
|
+
# Virtual transports drop the key once a queue is drained.
|
|
71
|
+
return {"queue": name, "exists": None, "messages": 0, "consumers": None}
|
|
72
|
+
finally:
|
|
73
|
+
with suppress(ChannelError, OSError):
|
|
74
|
+
channel.close()
|
|
75
|
+
return {
|
|
76
|
+
"queue": name,
|
|
77
|
+
"exists": True,
|
|
78
|
+
"messages": messages,
|
|
79
|
+
"consumers": consumers if is_amqp else None,
|
|
80
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
DEFAULT_BROKER_URL = "amqp://guest:guest@localhost:5672//"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Settings:
|
|
9
|
+
broker_url: str | None = None
|
|
10
|
+
result_backend: str | None = None
|
|
11
|
+
app_path: str | None = None
|
|
12
|
+
inspect_timeout: float = 1.0
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_env(cls, env: dict[str, str] | None = None) -> "Settings":
|
|
16
|
+
env = os.environ if env is None else env
|
|
17
|
+
return cls(
|
|
18
|
+
broker_url=env.get("CELERY_BROKER_URL") or None,
|
|
19
|
+
result_backend=env.get("CELERY_RESULT_BACKEND") or None,
|
|
20
|
+
app_path=env.get("CELERY_APP") or None,
|
|
21
|
+
inspect_timeout=float(env.get("CELERY_INSPECT_TIMEOUT", "1.0")),
|
|
22
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
SECRET_KEYS = frozenset({"password", "passwd", "secret", "token", "api_key", "apikey"})
|
|
5
|
+
MASK = "****"
|
|
6
|
+
_URL_CREDENTIALS = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?P<user>[^:@/\s]*):(?P<password>[^@/\s]+)@")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def mask_url(url: str) -> str:
|
|
10
|
+
return _URL_CREDENTIALS.sub(lambda m: f"{m['scheme']}{m['user']}:{MASK}@", url)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def redact(value: Any) -> Any:
|
|
14
|
+
if isinstance(value, dict):
|
|
15
|
+
return {
|
|
16
|
+
k: (MASK if isinstance(k, str) and k.lower() in SECRET_KEYS and v else redact(v)) for k, v in value.items()
|
|
17
|
+
}
|
|
18
|
+
if isinstance(value, (list, tuple)):
|
|
19
|
+
return [redact(v) for v in value]
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
return mask_url(value)
|
|
22
|
+
return value
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from fastmcp import FastMCP
|
|
6
|
+
from fastmcp.exceptions import ToolError
|
|
7
|
+
from kombu.exceptions import OperationalError
|
|
8
|
+
from mcp.types import ToolAnnotations
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from mcp_server_celery import tools
|
|
12
|
+
from mcp_server_celery.client import CeleryClient
|
|
13
|
+
from mcp_server_celery.config import Settings
|
|
14
|
+
from mcp_server_celery.redact import mask_url
|
|
15
|
+
|
|
16
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, open_world_hint=True)
|
|
17
|
+
|
|
18
|
+
Timeout = Annotated[
|
|
19
|
+
float | None,
|
|
20
|
+
Field(description="Seconds to wait for worker replies. Defaults to CELERY_INSPECT_TIMEOUT."),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _broker_errors(client: CeleryClient, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
25
|
+
@wraps(fn)
|
|
26
|
+
def wrapper(*args, **kwargs):
|
|
27
|
+
try:
|
|
28
|
+
return fn(*args, **kwargs)
|
|
29
|
+
except (OperationalError, ConnectionError, OSError) as exc:
|
|
30
|
+
broker = mask_url(client.app.conf.broker_url or "")
|
|
31
|
+
raise ToolError(f"Cannot reach broker {broker}: {mask_url(str(exc))}") from exc
|
|
32
|
+
|
|
33
|
+
return wrapper
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_server(client: CeleryClient) -> FastMCP:
|
|
37
|
+
mcp = FastMCP(
|
|
38
|
+
"celery",
|
|
39
|
+
instructions=(
|
|
40
|
+
"Read-only diagnostics for a Celery deployment: workers, queues and tasks. "
|
|
41
|
+
"Broker credentials are always masked."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def _wrap(fn):
|
|
46
|
+
return _broker_errors(client, fn)
|
|
47
|
+
|
|
48
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
49
|
+
@_wrap
|
|
50
|
+
def list_workers(timeout: Timeout = None) -> dict:
|
|
51
|
+
"""List workers that reply to broadcast, with pool, concurrency, uptime and the queues each consumes."""
|
|
52
|
+
return tools.list_workers(client, timeout=timeout)
|
|
53
|
+
|
|
54
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
55
|
+
@_wrap
|
|
56
|
+
def queue_depths(
|
|
57
|
+
queues: Annotated[
|
|
58
|
+
list[str] | None,
|
|
59
|
+
Field(
|
|
60
|
+
description="Queue names to check. Defaults to configured queues plus every queue a worker consumes."
|
|
61
|
+
),
|
|
62
|
+
] = None,
|
|
63
|
+
timeout: Timeout = None,
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Count waiting messages per queue and show which workers consume each one. Works on RabbitMQ and Redis."""
|
|
66
|
+
return tools.queue_depths(client, queues=queues, timeout=timeout)
|
|
67
|
+
|
|
68
|
+
return mcp
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> None:
|
|
72
|
+
build_server(CeleryClient.from_settings(Settings.from_env())).run()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from mcp_server_celery.client import CeleryClient
|
|
4
|
+
from mcp_server_celery.redact import redact
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def list_workers(client: CeleryClient, timeout: float | None = None) -> dict[str, Any]:
|
|
8
|
+
inspector = client.inspect(timeout=timeout)
|
|
9
|
+
pings = inspector.ping() or {}
|
|
10
|
+
stats = inspector.stats() or {}
|
|
11
|
+
queues = inspector.active_queues() or {}
|
|
12
|
+
|
|
13
|
+
workers = []
|
|
14
|
+
for hostname in sorted(set(pings) | set(stats) | set(queues)):
|
|
15
|
+
worker_stats = stats.get(hostname, {})
|
|
16
|
+
pool = worker_stats.get("pool") or {}
|
|
17
|
+
workers.append(
|
|
18
|
+
{
|
|
19
|
+
"hostname": hostname,
|
|
20
|
+
"responded_to_ping": hostname in pings,
|
|
21
|
+
"pid": worker_stats.get("pid"),
|
|
22
|
+
"uptime_seconds": worker_stats.get("uptime"),
|
|
23
|
+
"pool": pool.get("implementation"),
|
|
24
|
+
"concurrency": pool.get("max-concurrency"),
|
|
25
|
+
"prefetch_count": worker_stats.get("prefetch_count"),
|
|
26
|
+
"tasks_processed": worker_stats.get("total", {}),
|
|
27
|
+
"queues": sorted(q["name"] for q in queues.get(hostname, [])),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
return redact({"worker_count": len(workers), "workers": workers})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def queue_depths(client: CeleryClient, queues: list[str] | None = None, timeout: float | None = None) -> dict[str, Any]:
|
|
34
|
+
consumed_by: dict[str, list[str]] = {}
|
|
35
|
+
for hostname, worker_queues in (client.inspect(timeout=timeout).active_queues() or {}).items():
|
|
36
|
+
for q in worker_queues:
|
|
37
|
+
consumed_by.setdefault(q["name"], []).append(hostname)
|
|
38
|
+
|
|
39
|
+
names = queues or sorted(set(client.configured_queues()) | set(consumed_by))
|
|
40
|
+
results = []
|
|
41
|
+
for depth in client.queue_depths(names):
|
|
42
|
+
listeners = sorted(consumed_by.get(depth["queue"], []))
|
|
43
|
+
results.append({**depth, "workers_consuming": listeners})
|
|
44
|
+
return redact({"broker": client.app.conf.broker_url, "queues": results})
|