airflow-dev-mcp 0.2.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.
- airflow_dev_mcp-0.2.0/.gitignore +72 -0
- airflow_dev_mcp-0.2.0/LICENSE +21 -0
- airflow_dev_mcp-0.2.0/PKG-INFO +147 -0
- airflow_dev_mcp-0.2.0/README.md +122 -0
- airflow_dev_mcp-0.2.0/pyproject.toml +46 -0
- airflow_dev_mcp-0.2.0/src/airflow_dev_mcp/__init__.py +3 -0
- airflow_dev_mcp-0.2.0/src/airflow_dev_mcp/models.py +150 -0
- airflow_dev_mcp-0.2.0/src/airflow_dev_mcp/server.py +532 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# Unit test / coverage reports
|
|
30
|
+
htmlcov/
|
|
31
|
+
.tox/
|
|
32
|
+
.nox/
|
|
33
|
+
.coverage
|
|
34
|
+
.coverage.*
|
|
35
|
+
.cache
|
|
36
|
+
nosetests.xml
|
|
37
|
+
coverage.xml
|
|
38
|
+
*.cover
|
|
39
|
+
*.py,cover
|
|
40
|
+
.hypothesis/
|
|
41
|
+
.pytest_cache/
|
|
42
|
+
cover/
|
|
43
|
+
|
|
44
|
+
# Environments
|
|
45
|
+
.env
|
|
46
|
+
.venv
|
|
47
|
+
env/
|
|
48
|
+
venv/
|
|
49
|
+
ENV/
|
|
50
|
+
env.bak/
|
|
51
|
+
venv.bak/
|
|
52
|
+
|
|
53
|
+
# mypy / type checkers
|
|
54
|
+
.mypy_cache/
|
|
55
|
+
.dmypy.json
|
|
56
|
+
dmypy.json
|
|
57
|
+
.pyre/
|
|
58
|
+
.pytype/
|
|
59
|
+
|
|
60
|
+
# Ruff
|
|
61
|
+
.ruff_cache/
|
|
62
|
+
|
|
63
|
+
# uv
|
|
64
|
+
.uv/
|
|
65
|
+
|
|
66
|
+
# IDEs / editors
|
|
67
|
+
.idea/
|
|
68
|
+
.vscode/
|
|
69
|
+
*.swp
|
|
70
|
+
|
|
71
|
+
# OS
|
|
72
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brian London
|
|
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,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: airflow-dev-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server for driving a dev/local Airflow cluster over its REST API — trigger DAGs, check run/task status, fetch logs, and diagnose import errors from inside your MCP client.
|
|
5
|
+
Project-URL: Homepage, https://github.com/BrianLondon/airflow-dev-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/BrianLondon/airflow-dev-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/BrianLondon/airflow-dev-mcp/issues
|
|
8
|
+
Author-email: Brian London <brian@seatgeek.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: airflow,claude,dags,llm,mcp,model-context-protocol
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: mcp[cli]>=1.9.0
|
|
23
|
+
Requires-Dist: pydantic>=2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# airflow-dev-mcp
|
|
27
|
+
|
|
28
|
+
An [MCP](https://modelcontextprotocol.io) server that lets an AI coding assistant
|
|
29
|
+
(Claude Code, Cursor, and other MCP clients) drive a development or local Airflow
|
|
30
|
+
cluster through Airflow's REST API. It can trigger DAG runs, watch their status, read task logs,
|
|
31
|
+
and diagnose parse errors.
|
|
32
|
+
|
|
33
|
+
It talks to Airflow over HTTP only. There's no dependency on your Airflow source tree,
|
|
34
|
+
no filesystem or database access, and no local config files. All configuration is set through environment
|
|
35
|
+
variables. It support both Airflow 3 (via `/api/v2`, the default) and
|
|
36
|
+
Airflow 2 (`/api/v1`).
|
|
37
|
+
|
|
38
|
+
> Airflow-dev-mcp is designed for the write-a-DAG / run-it / read-the-logs
|
|
39
|
+
> loop against a development and/or local environment. Pointing it at a production cluster is not recommended.
|
|
40
|
+
|
|
41
|
+
## Install & run
|
|
42
|
+
|
|
43
|
+
The package ships a single console command, `airflow-dev-mcp`, which starts the MCP
|
|
44
|
+
server on stdio. Installation requires [uv](https://docs.astral.sh/uv/).
|
|
45
|
+
|
|
46
|
+
_Note: most users will skip this and just add it to their coding environment (See: below)
|
|
47
|
+
|
|
48
|
+
To download and validate the package, run:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uvx airflow-dev-mcp --check # fetch + run a one-shot connectivity check
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It can be installed as a persistent tool but typical installation is to
|
|
55
|
+
just have your coding agent call it through `uvx` (See: Configure your
|
|
56
|
+
MCP client below). If you do want to install it system wide, use one of the
|
|
57
|
+
two following commands
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv tool install airflow-dev-mcp
|
|
61
|
+
# or
|
|
62
|
+
pipx install airflow-dev-mcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Configure your MCP client
|
|
66
|
+
|
|
67
|
+
### Claude Code
|
|
68
|
+
|
|
69
|
+
For most users all you should need to do is add the server to `~/.claude.json`
|
|
70
|
+
(applies everywhere) or a project's `.claude/settings.json` (just that project):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"airflow-dev": {
|
|
76
|
+
"command": "uvx",
|
|
77
|
+
"args": ["airflow-dev-mcp"],
|
|
78
|
+
"env": {
|
|
79
|
+
"AIRFLOW_URL": "http://localhost:8080",
|
|
80
|
+
"AIRFLOW_USERNAME": "admin",
|
|
81
|
+
"AIRFLOW_PASSWORD": "admin"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Using `uvx` means you don't have to manage a virtualenv — it fetches and caches the
|
|
89
|
+
package on first launch. If you'd rather pin an installed copy, replace the command with
|
|
90
|
+
`"command": "airflow-dev-mcp", "args": []` after `uv tool install`.
|
|
91
|
+
|
|
92
|
+
Restart Claude Code. The tools show up namespaced as `mcp__airflow-dev__trigger_dag`,
|
|
93
|
+
and so on.
|
|
94
|
+
|
|
95
|
+
### Other MCP clients
|
|
96
|
+
|
|
97
|
+
Any client that launches stdio MCP servers works the same way: run the command
|
|
98
|
+
`airflow-dev-mcp` (or `uvx airflow-dev-mcp`) with the environment variables below.
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
All configuration is via environment variables:
|
|
103
|
+
|
|
104
|
+
| Variable | Default | Description |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| `AIRFLOW_URL` | `http://localhost:8080` | Base URL of the cluster, no path. |
|
|
107
|
+
| `AIRFLOW_API_PREFIX` | `/api/v2` | API path prefix. Use `/api/v1` for Airflow 2.x. |
|
|
108
|
+
| `AIRFLOW_USERNAME` | — | Username. Used together with `AIRFLOW_PASSWORD`. |
|
|
109
|
+
| `AIRFLOW_PASSWORD` | — | Password. |
|
|
110
|
+
| `AIRFLOW_TOKEN` | — | Explicit bearer token; skips username/password entirely. |
|
|
111
|
+
| `AIRFLOW_AUTH_MODE` | `auto` | `auto`, `jwt`, or `basic` (see below). |
|
|
112
|
+
| `AIRFLOW_TOKEN_ENDPOINT` | `/auth/token` | Path used to exchange credentials for a JWT. |
|
|
113
|
+
| `AIRFLOW_TIMEOUT` | `30` | HTTP timeout, in seconds. |
|
|
114
|
+
| `AIRFLOW_VERIFY_SSL` | `true` | Set `false` to skip TLS verification (self-signed dev certs). |
|
|
115
|
+
|
|
116
|
+
### Authentication
|
|
117
|
+
|
|
118
|
+
- **Airflow 3.x** (the default local/MWAA-style image): leave `AIRFLOW_AUTH_MODE=auto`.
|
|
119
|
+
The server posts your username/password to `/auth/token`, caches the returned JWT, and
|
|
120
|
+
sends it as a bearer token on every request.
|
|
121
|
+
- **Airflow 2.x**: set `AIRFLOW_API_PREFIX=/api/v1` and `AIRFLOW_AUTH_MODE=basic` (2.x
|
|
122
|
+
uses HTTP basic auth against the REST API).
|
|
123
|
+
- **Pre-issued token**: set `AIRFLOW_TOKEN` and omit the username/password.
|
|
124
|
+
|
|
125
|
+
## Tools
|
|
126
|
+
|
|
127
|
+
| Tool | What it does |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `trigger_dag` | Start a manual DAG run, optionally with a `conf` payload. Returns the `dag_run_id`. |
|
|
130
|
+
| `get_run_status` | State of a run plus per-task states (task, state, try number, operator, timing). |
|
|
131
|
+
| `get_task_logs` | Logs for one task attempt, tailed to the last N lines by default. |
|
|
132
|
+
| `list_dag_runs` | Recent runs of a DAG — find a run when you don't already have its id. |
|
|
133
|
+
| `clear_task_instances` | Clear tasks so they re-run. Defaults to a dry-run preview. |
|
|
134
|
+
| `list_dags` | Registered DAGs with their paused / import-error / active flags. |
|
|
135
|
+
| `get_import_errors` | Parse failures with filename and traceback — *why a new DAG isn't showing up.* |
|
|
136
|
+
| `set_dag_paused` | Pause or unpause a DAG (new local DAGs start paused). |
|
|
137
|
+
| `list_variables` | Read Airflow Variables (read-only). |
|
|
138
|
+
| `list_connections` | Read Airflow Connections, minus passwords (read-only). |
|
|
139
|
+
|
|
140
|
+
The four `list_*` tools, `get_run_status`, `get_task_logs`, and `get_import_errors` are
|
|
141
|
+
strictly read-only. `trigger_dag`, `set_dag_paused`, and `clear_task_instances` change
|
|
142
|
+
cluster state. There are deliberately **no** tools that create or modify Variables or
|
|
143
|
+
Connections.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# airflow-dev-mcp
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that lets an AI coding assistant
|
|
4
|
+
(Claude Code, Cursor, and other MCP clients) drive a development or local Airflow
|
|
5
|
+
cluster through Airflow's REST API. It can trigger DAG runs, watch their status, read task logs,
|
|
6
|
+
and diagnose parse errors.
|
|
7
|
+
|
|
8
|
+
It talks to Airflow over HTTP only. There's no dependency on your Airflow source tree,
|
|
9
|
+
no filesystem or database access, and no local config files. All configuration is set through environment
|
|
10
|
+
variables. It support both Airflow 3 (via `/api/v2`, the default) and
|
|
11
|
+
Airflow 2 (`/api/v1`).
|
|
12
|
+
|
|
13
|
+
> Airflow-dev-mcp is designed for the write-a-DAG / run-it / read-the-logs
|
|
14
|
+
> loop against a development and/or local environment. Pointing it at a production cluster is not recommended.
|
|
15
|
+
|
|
16
|
+
## Install & run
|
|
17
|
+
|
|
18
|
+
The package ships a single console command, `airflow-dev-mcp`, which starts the MCP
|
|
19
|
+
server on stdio. Installation requires [uv](https://docs.astral.sh/uv/).
|
|
20
|
+
|
|
21
|
+
_Note: most users will skip this and just add it to their coding environment (See: below)
|
|
22
|
+
|
|
23
|
+
To download and validate the package, run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uvx airflow-dev-mcp --check # fetch + run a one-shot connectivity check
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
It can be installed as a persistent tool but typical installation is to
|
|
30
|
+
just have your coding agent call it through `uvx` (See: Configure your
|
|
31
|
+
MCP client below). If you do want to install it system wide, use one of the
|
|
32
|
+
two following commands
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv tool install airflow-dev-mcp
|
|
36
|
+
# or
|
|
37
|
+
pipx install airflow-dev-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configure your MCP client
|
|
41
|
+
|
|
42
|
+
### Claude Code
|
|
43
|
+
|
|
44
|
+
For most users all you should need to do is add the server to `~/.claude.json`
|
|
45
|
+
(applies everywhere) or a project's `.claude/settings.json` (just that project):
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"airflow-dev": {
|
|
51
|
+
"command": "uvx",
|
|
52
|
+
"args": ["airflow-dev-mcp"],
|
|
53
|
+
"env": {
|
|
54
|
+
"AIRFLOW_URL": "http://localhost:8080",
|
|
55
|
+
"AIRFLOW_USERNAME": "admin",
|
|
56
|
+
"AIRFLOW_PASSWORD": "admin"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Using `uvx` means you don't have to manage a virtualenv — it fetches and caches the
|
|
64
|
+
package on first launch. If you'd rather pin an installed copy, replace the command with
|
|
65
|
+
`"command": "airflow-dev-mcp", "args": []` after `uv tool install`.
|
|
66
|
+
|
|
67
|
+
Restart Claude Code. The tools show up namespaced as `mcp__airflow-dev__trigger_dag`,
|
|
68
|
+
and so on.
|
|
69
|
+
|
|
70
|
+
### Other MCP clients
|
|
71
|
+
|
|
72
|
+
Any client that launches stdio MCP servers works the same way: run the command
|
|
73
|
+
`airflow-dev-mcp` (or `uvx airflow-dev-mcp`) with the environment variables below.
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
All configuration is via environment variables:
|
|
78
|
+
|
|
79
|
+
| Variable | Default | Description |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `AIRFLOW_URL` | `http://localhost:8080` | Base URL of the cluster, no path. |
|
|
82
|
+
| `AIRFLOW_API_PREFIX` | `/api/v2` | API path prefix. Use `/api/v1` for Airflow 2.x. |
|
|
83
|
+
| `AIRFLOW_USERNAME` | — | Username. Used together with `AIRFLOW_PASSWORD`. |
|
|
84
|
+
| `AIRFLOW_PASSWORD` | — | Password. |
|
|
85
|
+
| `AIRFLOW_TOKEN` | — | Explicit bearer token; skips username/password entirely. |
|
|
86
|
+
| `AIRFLOW_AUTH_MODE` | `auto` | `auto`, `jwt`, or `basic` (see below). |
|
|
87
|
+
| `AIRFLOW_TOKEN_ENDPOINT` | `/auth/token` | Path used to exchange credentials for a JWT. |
|
|
88
|
+
| `AIRFLOW_TIMEOUT` | `30` | HTTP timeout, in seconds. |
|
|
89
|
+
| `AIRFLOW_VERIFY_SSL` | `true` | Set `false` to skip TLS verification (self-signed dev certs). |
|
|
90
|
+
|
|
91
|
+
### Authentication
|
|
92
|
+
|
|
93
|
+
- **Airflow 3.x** (the default local/MWAA-style image): leave `AIRFLOW_AUTH_MODE=auto`.
|
|
94
|
+
The server posts your username/password to `/auth/token`, caches the returned JWT, and
|
|
95
|
+
sends it as a bearer token on every request.
|
|
96
|
+
- **Airflow 2.x**: set `AIRFLOW_API_PREFIX=/api/v1` and `AIRFLOW_AUTH_MODE=basic` (2.x
|
|
97
|
+
uses HTTP basic auth against the REST API).
|
|
98
|
+
- **Pre-issued token**: set `AIRFLOW_TOKEN` and omit the username/password.
|
|
99
|
+
|
|
100
|
+
## Tools
|
|
101
|
+
|
|
102
|
+
| Tool | What it does |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| `trigger_dag` | Start a manual DAG run, optionally with a `conf` payload. Returns the `dag_run_id`. |
|
|
105
|
+
| `get_run_status` | State of a run plus per-task states (task, state, try number, operator, timing). |
|
|
106
|
+
| `get_task_logs` | Logs for one task attempt, tailed to the last N lines by default. |
|
|
107
|
+
| `list_dag_runs` | Recent runs of a DAG — find a run when you don't already have its id. |
|
|
108
|
+
| `clear_task_instances` | Clear tasks so they re-run. Defaults to a dry-run preview. |
|
|
109
|
+
| `list_dags` | Registered DAGs with their paused / import-error / active flags. |
|
|
110
|
+
| `get_import_errors` | Parse failures with filename and traceback — *why a new DAG isn't showing up.* |
|
|
111
|
+
| `set_dag_paused` | Pause or unpause a DAG (new local DAGs start paused). |
|
|
112
|
+
| `list_variables` | Read Airflow Variables (read-only). |
|
|
113
|
+
| `list_connections` | Read Airflow Connections, minus passwords (read-only). |
|
|
114
|
+
|
|
115
|
+
The four `list_*` tools, `get_run_status`, `get_task_logs`, and `get_import_errors` are
|
|
116
|
+
strictly read-only. `trigger_dag`, `set_dag_paused`, and `clear_task_instances` change
|
|
117
|
+
cluster state. There are deliberately **no** tools that create or modify Variables or
|
|
118
|
+
Connections.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "airflow-dev-mcp"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "MCP server for driving a dev/local Airflow cluster over its REST API — trigger DAGs, check run/task status, fetch logs, and diagnose import errors from inside your MCP client."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Brian London", email = "brian@seatgeek.com" }]
|
|
14
|
+
keywords = ["airflow", "mcp", "model-context-protocol", "claude", "dags", "llm"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Build Tools",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"mcp[cli]>=1.9.0",
|
|
27
|
+
"httpx>=0.27",
|
|
28
|
+
"pydantic>=2",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
airflow-dev-mcp = "airflow_dev_mcp.server:main"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/BrianLondon/airflow-dev-mcp"
|
|
36
|
+
Repository = "https://github.com/BrianLondon/airflow-dev-mcp"
|
|
37
|
+
Issues = "https://github.com/BrianLondon/airflow-dev-mcp/issues"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.version]
|
|
40
|
+
path = "src/airflow_dev_mcp/__init__.py"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/airflow_dev_mcp"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = ["src/airflow_dev_mcp", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Pydantic models returned by the MCP tools.
|
|
2
|
+
|
|
3
|
+
Each model is populated directly from an Airflow REST API response with
|
|
4
|
+
``model_validate``; unknown fields are ignored. Validation aliases absorb the
|
|
5
|
+
naming differences between the Airflow 2.x (``/api/v1``) and Airflow 3.x
|
|
6
|
+
(``/api/v2``) APIs so the same models work against both.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DagRunSummary(BaseModel):
|
|
15
|
+
"""Condensed view of an Airflow DAG run.
|
|
16
|
+
|
|
17
|
+
The aliases absorb the AF2/AF3 naming differences (``run_id``/``execution_date``
|
|
18
|
+
vs. ``dag_run_id``/``logical_date``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
dag_id: str | None = None
|
|
22
|
+
dag_run_id: str | None = Field(
|
|
23
|
+
default=None, validation_alias=AliasChoices("dag_run_id", "run_id")
|
|
24
|
+
)
|
|
25
|
+
state: str | None = None
|
|
26
|
+
run_type: str | None = None
|
|
27
|
+
logical_date: str | None = Field(
|
|
28
|
+
default=None, validation_alias=AliasChoices("logical_date", "execution_date")
|
|
29
|
+
)
|
|
30
|
+
start_date: str | None = None
|
|
31
|
+
end_date: str | None = None
|
|
32
|
+
note: str | None = None
|
|
33
|
+
conf: dict[str, Any] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TaskInstanceSummary(BaseModel):
|
|
37
|
+
"""Condensed view of a single task instance within a run."""
|
|
38
|
+
|
|
39
|
+
task_id: str | None = None
|
|
40
|
+
state: str | None = None
|
|
41
|
+
try_number: int | None = None
|
|
42
|
+
map_index: int | None = None
|
|
43
|
+
operator: str | None = None
|
|
44
|
+
start_date: str | None = None
|
|
45
|
+
end_date: str | None = None
|
|
46
|
+
duration: float | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RunStatus(BaseModel):
|
|
50
|
+
"""A DAG run plus, optionally, its task instances."""
|
|
51
|
+
|
|
52
|
+
run: DagRunSummary
|
|
53
|
+
tasks: list[TaskInstanceSummary] | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TaskLogResult(BaseModel):
|
|
57
|
+
"""Logs for one task instance attempt."""
|
|
58
|
+
|
|
59
|
+
content: str
|
|
60
|
+
truncated: bool
|
|
61
|
+
line_count: int
|
|
62
|
+
try_number: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DagInfo(BaseModel):
|
|
66
|
+
"""Registration-level view of a DAG (not a specific run)."""
|
|
67
|
+
|
|
68
|
+
dag_id: str | None = None
|
|
69
|
+
is_paused: bool | None = None
|
|
70
|
+
is_active: bool | None = None
|
|
71
|
+
has_import_errors: bool | None = None
|
|
72
|
+
fileloc: str | None = None
|
|
73
|
+
description: str | None = None
|
|
74
|
+
tags: list[str] | None = None
|
|
75
|
+
next_dagrun: str | None = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
validation_alias=AliasChoices("next_dagrun", "next_dagrun_logical_date"),
|
|
78
|
+
)
|
|
79
|
+
last_parsed_time: str | None = None
|
|
80
|
+
|
|
81
|
+
@field_validator("tags", mode="before")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _flatten_tags(cls, v: Any) -> Any:
|
|
84
|
+
# Airflow returns tags as [{"name": "x"}, ...]; flatten to ["x", ...].
|
|
85
|
+
if isinstance(v, list):
|
|
86
|
+
return [t.get("name") if isinstance(t, dict) else t for t in v]
|
|
87
|
+
return v
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DagList(BaseModel):
|
|
91
|
+
dags: list[DagInfo]
|
|
92
|
+
total_entries: int | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ImportErrorInfo(BaseModel):
|
|
96
|
+
"""A DAG parse failure recorded by the scheduler."""
|
|
97
|
+
|
|
98
|
+
import_error_id: int | None = None
|
|
99
|
+
timestamp: str | None = None
|
|
100
|
+
filename: str | None = None
|
|
101
|
+
stack_trace: str | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ImportErrorList(BaseModel):
|
|
105
|
+
import_errors: list[ImportErrorInfo]
|
|
106
|
+
total_entries: int | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DagRunList(BaseModel):
|
|
110
|
+
dag_runs: list[DagRunSummary]
|
|
111
|
+
total_entries: int | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ClearResult(BaseModel):
|
|
115
|
+
"""Result of a clearTaskInstances call."""
|
|
116
|
+
|
|
117
|
+
dry_run: bool
|
|
118
|
+
task_instances: list[TaskInstanceSummary]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class VariableInfo(BaseModel):
|
|
122
|
+
key: str | None = None
|
|
123
|
+
value: str | None = None
|
|
124
|
+
description: str | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class VariableList(BaseModel):
|
|
128
|
+
variables: list[VariableInfo]
|
|
129
|
+
total_entries: int | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ConnectionInfo(BaseModel):
|
|
133
|
+
"""Connection metadata. The API never returns the password."""
|
|
134
|
+
|
|
135
|
+
connection_id: str | None = Field(
|
|
136
|
+
default=None, validation_alias=AliasChoices("connection_id", "conn_id")
|
|
137
|
+
)
|
|
138
|
+
conn_type: str | None = None
|
|
139
|
+
host: str | None = None
|
|
140
|
+
db_schema: str | None = Field(
|
|
141
|
+
default=None, validation_alias=AliasChoices("schema", "db_schema")
|
|
142
|
+
)
|
|
143
|
+
login: str | None = None
|
|
144
|
+
port: int | None = None
|
|
145
|
+
description: str | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ConnectionList(BaseModel):
|
|
149
|
+
connections: list[ConnectionInfo]
|
|
150
|
+
total_entries: int | None = None
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""MCP server for driving a dev/local Airflow cluster over its REST API.
|
|
2
|
+
|
|
3
|
+
Configure entirely via environment variables:
|
|
4
|
+
|
|
5
|
+
AIRFLOW_URL Base URL, e.g. http://localhost:8081. Default: http://localhost:8080.
|
|
6
|
+
AIRFLOW_API_PREFIX REST API path prefix. Default: /api/v2 (Airflow 3.x). Use /api/v1 for AF2.
|
|
7
|
+
AIRFLOW_USERNAME Username (used with AIRFLOW_PASSWORD).
|
|
8
|
+
AIRFLOW_PASSWORD Password.
|
|
9
|
+
AIRFLOW_TOKEN Explicit bearer token; skips creds/JWT exchange.
|
|
10
|
+
AIRFLOW_AUTH_MODE 'auto' (default), 'jwt', or 'basic'.
|
|
11
|
+
AIRFLOW_TOKEN_ENDPOINT Path to exchange creds for a JWT. Default: /auth/token.
|
|
12
|
+
AIRFLOW_TIMEOUT HTTP timeout in seconds. Default: 30.
|
|
13
|
+
AIRFLOW_VERIFY_SSL 'false' to skip TLS verification. Default: true.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from mcp.server.fastmcp import FastMCP
|
|
23
|
+
|
|
24
|
+
from airflow_dev_mcp.models import (
|
|
25
|
+
ClearResult,
|
|
26
|
+
ConnectionList,
|
|
27
|
+
DagInfo,
|
|
28
|
+
DagList,
|
|
29
|
+
DagRunList,
|
|
30
|
+
DagRunSummary,
|
|
31
|
+
ImportErrorList,
|
|
32
|
+
RunStatus,
|
|
33
|
+
TaskInstanceSummary,
|
|
34
|
+
TaskLogResult,
|
|
35
|
+
VariableList,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_INSTRUCTIONS = """\
|
|
39
|
+
Tools for driving a development or local Airflow cluster over its REST API while \
|
|
40
|
+
developing DAGs: trigger runs, inspect run/task status, read task logs, and diagnose \
|
|
41
|
+
DAG parse errors.
|
|
42
|
+
|
|
43
|
+
Typical development loop:
|
|
44
|
+
1. After writing or editing a DAG file, call `list_dags` to confirm it registered. If \
|
|
45
|
+
it's missing, call `get_import_errors` to see the parse traceback.
|
|
46
|
+
2. New DAGs start paused, so call `set_dag_paused(dag_id, paused=false)` before triggering.
|
|
47
|
+
3. Call `trigger_dag(dag_id, conf=...)` and keep the returned `dag_run_id`.
|
|
48
|
+
4. Poll `get_run_status(dag_id, run_id)` until the run finishes.
|
|
49
|
+
5. On failure, read `get_task_logs(...)`. After fixing the code, call \
|
|
50
|
+
`clear_task_instances(dag_id, dag_run_id, dry_run=false)` to re-run just the affected \
|
|
51
|
+
tasks instead of triggering a whole new run.
|
|
52
|
+
|
|
53
|
+
`list_variables` and `list_connections` are read-only helpers for troubleshooting a \
|
|
54
|
+
task's configuration."""
|
|
55
|
+
|
|
56
|
+
mcp = FastMCP("airflow-dev", instructions=_INSTRUCTIONS)
|
|
57
|
+
|
|
58
|
+
_token_cache: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _base_url() -> str:
|
|
62
|
+
return os.environ.get("AIRFLOW_URL", "http://localhost:8080").rstrip("/")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _api_prefix() -> str:
|
|
66
|
+
prefix = os.environ.get("AIRFLOW_API_PREFIX", "/api/v2").rstrip("/")
|
|
67
|
+
if not prefix.startswith("/"):
|
|
68
|
+
prefix = "/" + prefix
|
|
69
|
+
return prefix
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _timeout() -> float:
|
|
73
|
+
return float(os.environ.get("AIRFLOW_TIMEOUT", "30"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _verify_ssl() -> bool:
|
|
77
|
+
return os.environ.get("AIRFLOW_VERIFY_SSL", "true").lower() != "false"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _exchange_jwt(user: str, pw: str) -> str | None:
|
|
81
|
+
endpoint = os.environ.get("AIRFLOW_TOKEN_ENDPOINT", "/auth/token")
|
|
82
|
+
if not endpoint.startswith("/"):
|
|
83
|
+
endpoint = "/" + endpoint
|
|
84
|
+
try:
|
|
85
|
+
r = httpx.post(
|
|
86
|
+
f"{_base_url()}{endpoint}",
|
|
87
|
+
json={"username": user, "password": pw},
|
|
88
|
+
timeout=_timeout(),
|
|
89
|
+
verify=_verify_ssl(),
|
|
90
|
+
)
|
|
91
|
+
except httpx.HTTPError:
|
|
92
|
+
return None
|
|
93
|
+
if not r.is_success:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
j = r.json()
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None
|
|
99
|
+
if not isinstance(j, dict):
|
|
100
|
+
return None
|
|
101
|
+
return j.get("access_token") or j.get("token") or j.get("jwt")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_auth() -> tuple[dict[str, str], httpx.BasicAuth | None]:
|
|
105
|
+
"""Return (extra_headers, basic_auth) based on env vars.
|
|
106
|
+
|
|
107
|
+
Precedence: AIRFLOW_TOKEN > (username+password with AIRFLOW_AUTH_MODE) > no auth.
|
|
108
|
+
"""
|
|
109
|
+
global _token_cache
|
|
110
|
+
|
|
111
|
+
if token := os.environ.get("AIRFLOW_TOKEN"):
|
|
112
|
+
return {"Authorization": f"Bearer {token}"}, None
|
|
113
|
+
|
|
114
|
+
user = os.environ.get("AIRFLOW_USERNAME")
|
|
115
|
+
pw = os.environ.get("AIRFLOW_PASSWORD")
|
|
116
|
+
if not (user and pw):
|
|
117
|
+
return {}, None
|
|
118
|
+
|
|
119
|
+
mode = os.environ.get("AIRFLOW_AUTH_MODE", "auto").lower()
|
|
120
|
+
if mode not in ("auto", "jwt", "basic"):
|
|
121
|
+
raise RuntimeError(f"Unknown AIRFLOW_AUTH_MODE: {mode!r} (want auto|jwt|basic)")
|
|
122
|
+
|
|
123
|
+
if mode == "basic":
|
|
124
|
+
return {}, httpx.BasicAuth(user, pw)
|
|
125
|
+
|
|
126
|
+
if _token_cache is None:
|
|
127
|
+
_token_cache = _exchange_jwt(user, pw)
|
|
128
|
+
|
|
129
|
+
if _token_cache:
|
|
130
|
+
return {"Authorization": f"Bearer {_token_cache}"}, None
|
|
131
|
+
|
|
132
|
+
if mode == "jwt":
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
"JWT token exchange failed. Verify AIRFLOW_URL, credentials, and "
|
|
135
|
+
"AIRFLOW_TOKEN_ENDPOINT, or set AIRFLOW_AUTH_MODE=basic for AF2."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return {}, httpx.BasicAuth(user, pw)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _client() -> httpx.Client:
|
|
142
|
+
extra_headers, basic = _resolve_auth()
|
|
143
|
+
headers = {"Accept": "application/json"}
|
|
144
|
+
headers.update(extra_headers)
|
|
145
|
+
return httpx.Client(
|
|
146
|
+
base_url=_base_url(),
|
|
147
|
+
headers=headers,
|
|
148
|
+
auth=basic,
|
|
149
|
+
timeout=_timeout(),
|
|
150
|
+
verify=_verify_ssl(),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _raise(resp: httpx.Response) -> None:
|
|
155
|
+
if resp.is_success:
|
|
156
|
+
return
|
|
157
|
+
detail: Any = resp.text
|
|
158
|
+
try:
|
|
159
|
+
j = resp.json()
|
|
160
|
+
if isinstance(j, dict):
|
|
161
|
+
detail = j.get("detail") or j.get("message") or j
|
|
162
|
+
except ValueError:
|
|
163
|
+
pass
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
f"Airflow API {resp.request.method} {resp.request.url} "
|
|
166
|
+
f"→ HTTP {resp.status_code}: {detail}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@mcp.tool()
|
|
171
|
+
def trigger_dag(
|
|
172
|
+
dag_id: str,
|
|
173
|
+
conf: dict[str, Any] | None = None,
|
|
174
|
+
logical_date: str | None = None,
|
|
175
|
+
note: str | None = None,
|
|
176
|
+
) -> DagRunSummary:
|
|
177
|
+
"""Trigger a manual run of a DAG in the dev Airflow cluster.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
dag_id: DAG identifier as it appears in Airflow.
|
|
181
|
+
conf: Optional dict passed to the run (accessible as `dag_run.conf` inside tasks).
|
|
182
|
+
logical_date: Optional ISO-8601 timestamp for the run's logical date. Defaults to now.
|
|
183
|
+
note: Optional human-readable note attached to the run.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
DagRunSummary for the created run, including `dag_run_id` needed for status/log lookups.
|
|
187
|
+
Note: if the DAG is paused, the run is created in `queued` state but will not execute
|
|
188
|
+
until the DAG is unpaused (see `set_dag_paused`).
|
|
189
|
+
"""
|
|
190
|
+
body: dict[str, Any] = {}
|
|
191
|
+
if conf is not None:
|
|
192
|
+
body["conf"] = conf
|
|
193
|
+
if logical_date:
|
|
194
|
+
body["logical_date"] = logical_date
|
|
195
|
+
if note:
|
|
196
|
+
body["note"] = note
|
|
197
|
+
|
|
198
|
+
with _client() as c:
|
|
199
|
+
resp = c.post(f"{_api_prefix()}/dags/{quote(dag_id, safe='')}/dagRuns", json=body)
|
|
200
|
+
_raise(resp)
|
|
201
|
+
return DagRunSummary.model_validate(resp.json())
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@mcp.tool()
|
|
205
|
+
def get_run_status(
|
|
206
|
+
dag_id: str,
|
|
207
|
+
run_id: str,
|
|
208
|
+
include_tasks: bool = True,
|
|
209
|
+
) -> RunStatus:
|
|
210
|
+
"""Get the state of a DAG run and (optionally) its task instances.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
dag_id: DAG identifier.
|
|
214
|
+
run_id: DAG run identifier returned by `trigger_dag`
|
|
215
|
+
(e.g. `manual__2026-07-02T14:23:11+00:00`).
|
|
216
|
+
include_tasks: When True (default), also fetch per-task states.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
RunStatus with `run` (a DagRunSummary) and, if requested, `tasks` (a list of
|
|
220
|
+
TaskInstanceSummary: task_id, state, try_number, operator, start/end dates,
|
|
221
|
+
duration, map_index). `tasks` is null when include_tasks is False.
|
|
222
|
+
"""
|
|
223
|
+
prefix = _api_prefix()
|
|
224
|
+
dag = quote(dag_id, safe="")
|
|
225
|
+
run = quote(run_id, safe="")
|
|
226
|
+
|
|
227
|
+
with _client() as c:
|
|
228
|
+
r = c.get(f"{prefix}/dags/{dag}/dagRuns/{run}")
|
|
229
|
+
_raise(r)
|
|
230
|
+
status = RunStatus(run=DagRunSummary.model_validate(r.json()))
|
|
231
|
+
if include_tasks:
|
|
232
|
+
r2 = c.get(f"{prefix}/dags/{dag}/dagRuns/{run}/taskInstances")
|
|
233
|
+
_raise(r2)
|
|
234
|
+
status.tasks = [
|
|
235
|
+
TaskInstanceSummary.model_validate(ti)
|
|
236
|
+
for ti in r2.json().get("task_instances", [])
|
|
237
|
+
]
|
|
238
|
+
return status
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@mcp.tool()
|
|
242
|
+
def get_task_logs(
|
|
243
|
+
dag_id: str,
|
|
244
|
+
run_id: str,
|
|
245
|
+
task_id: str,
|
|
246
|
+
try_number: int = 1,
|
|
247
|
+
map_index: int = -1,
|
|
248
|
+
tail_lines: int | None = 500,
|
|
249
|
+
) -> TaskLogResult:
|
|
250
|
+
"""Fetch logs for a single task instance attempt.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
dag_id: DAG identifier.
|
|
254
|
+
run_id: DAG run identifier.
|
|
255
|
+
task_id: Task identifier within the DAG.
|
|
256
|
+
try_number: Attempt number (1-indexed). Retried tasks have multiple attempts —
|
|
257
|
+
call `get_run_status` to see the latest `try_number` per task.
|
|
258
|
+
map_index: Mapped task index for dynamic task mapping. Use -1 for a normal task.
|
|
259
|
+
tail_lines: Return only the last N lines (default 500). Pass null for the full log —
|
|
260
|
+
beware, large tasks can produce many MB of output that will blow up context.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
TaskLogResult with `content` (log text), `truncated` (True if tailing dropped earlier
|
|
264
|
+
lines), `line_count` (lines returned), and `try_number` (echoed back).
|
|
265
|
+
"""
|
|
266
|
+
prefix = _api_prefix()
|
|
267
|
+
dag = quote(dag_id, safe="")
|
|
268
|
+
run = quote(run_id, safe="")
|
|
269
|
+
task = quote(task_id, safe="")
|
|
270
|
+
|
|
271
|
+
params: dict[str, Any] = {"full_content": "true"}
|
|
272
|
+
if map_index >= 0:
|
|
273
|
+
params["map_index"] = map_index
|
|
274
|
+
|
|
275
|
+
with _client() as c:
|
|
276
|
+
resp = c.get(
|
|
277
|
+
f"{prefix}/dags/{dag}/dagRuns/{run}/taskInstances/{task}/logs/{try_number}",
|
|
278
|
+
params=params,
|
|
279
|
+
headers={"Accept": "text/plain"},
|
|
280
|
+
)
|
|
281
|
+
_raise(resp)
|
|
282
|
+
|
|
283
|
+
text = resp.text
|
|
284
|
+
ctype = resp.headers.get("content-type", "")
|
|
285
|
+
if "application/json" in ctype:
|
|
286
|
+
try:
|
|
287
|
+
j = resp.json()
|
|
288
|
+
if isinstance(j, dict) and "content" in j:
|
|
289
|
+
content = j["content"]
|
|
290
|
+
text = content if isinstance(content, str) else str(content)
|
|
291
|
+
except ValueError:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
lines = text.splitlines()
|
|
295
|
+
truncated = False
|
|
296
|
+
if tail_lines is not None and len(lines) > tail_lines:
|
|
297
|
+
lines = lines[-tail_lines:]
|
|
298
|
+
truncated = True
|
|
299
|
+
|
|
300
|
+
return TaskLogResult(
|
|
301
|
+
content="\n".join(lines),
|
|
302
|
+
truncated=truncated,
|
|
303
|
+
line_count=len(lines),
|
|
304
|
+
try_number=try_number,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@mcp.tool()
|
|
309
|
+
def list_dags(
|
|
310
|
+
limit: int = 100,
|
|
311
|
+
offset: int = 0,
|
|
312
|
+
dag_id_pattern: str | None = None,
|
|
313
|
+
tags: list[str] | None = None,
|
|
314
|
+
) -> DagList:
|
|
315
|
+
"""List DAGs registered in the cluster with their paused / import-error flags.
|
|
316
|
+
|
|
317
|
+
Use this to confirm a DAG parsed and registered. Important: a DAG whose file fails
|
|
318
|
+
to import at module load does NOT appear here at all — call `get_import_errors` for that.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
limit: Max DAGs to return (default 100).
|
|
322
|
+
offset: Pagination offset.
|
|
323
|
+
dag_id_pattern: Optional case-insensitive substring filter on dag_id.
|
|
324
|
+
tags: Optional list of tags; only DAGs carrying one of them are returned.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
DagList with `dags` (dag_id, is_paused, is_active, has_import_errors, fileloc,
|
|
328
|
+
description, tags, next_dagrun, last_parsed_time) and `total_entries`.
|
|
329
|
+
"""
|
|
330
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
331
|
+
if dag_id_pattern:
|
|
332
|
+
params["dag_id_pattern"] = dag_id_pattern
|
|
333
|
+
if tags:
|
|
334
|
+
params["tags"] = tags
|
|
335
|
+
|
|
336
|
+
with _client() as c:
|
|
337
|
+
r = c.get(f"{_api_prefix()}/dags", params=params)
|
|
338
|
+
_raise(r)
|
|
339
|
+
return DagList.model_validate(r.json())
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@mcp.tool()
|
|
343
|
+
def get_import_errors(limit: int = 100, offset: int = 0) -> ImportErrorList:
|
|
344
|
+
"""List DAG import errors (parse failures) recorded by the scheduler.
|
|
345
|
+
|
|
346
|
+
The primary debugging tool when a DAG you just wrote isn't showing up: a file that
|
|
347
|
+
raises at import time is recorded here with its filename and full traceback.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
limit: Max errors to return (default 100).
|
|
351
|
+
offset: Pagination offset.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
ImportErrorList with `import_errors` (filename, stack_trace, timestamp,
|
|
355
|
+
import_error_id) and `total_entries`.
|
|
356
|
+
"""
|
|
357
|
+
with _client() as c:
|
|
358
|
+
r = c.get(f"{_api_prefix()}/importErrors", params={"limit": limit, "offset": offset})
|
|
359
|
+
_raise(r)
|
|
360
|
+
return ImportErrorList.model_validate(r.json())
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@mcp.tool()
|
|
364
|
+
def set_dag_paused(dag_id: str, paused: bool) -> DagInfo:
|
|
365
|
+
"""Pause or unpause a DAG.
|
|
366
|
+
|
|
367
|
+
Locally, newly added DAGs are paused by default, so `trigger_dag` will queue a run
|
|
368
|
+
that never executes until the DAG is unpaused. Call this with paused=False to enable it.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
dag_id: DAG identifier.
|
|
372
|
+
paused: True to pause, False to unpause.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
DagInfo reflecting the updated state.
|
|
376
|
+
"""
|
|
377
|
+
with _client() as c:
|
|
378
|
+
r = c.patch(
|
|
379
|
+
f"{_api_prefix()}/dags/{quote(dag_id, safe='')}",
|
|
380
|
+
json={"is_paused": paused},
|
|
381
|
+
)
|
|
382
|
+
_raise(r)
|
|
383
|
+
return DagInfo.model_validate(r.json())
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@mcp.tool()
|
|
387
|
+
def list_dag_runs(
|
|
388
|
+
dag_id: str,
|
|
389
|
+
limit: int = 25,
|
|
390
|
+
offset: int = 0,
|
|
391
|
+
state: list[str] | None = None,
|
|
392
|
+
) -> DagRunList:
|
|
393
|
+
"""List recent runs of a DAG — useful when you don't already hold a run_id.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
dag_id: DAG identifier. Pass "~" to list runs across all DAGs.
|
|
397
|
+
limit: Max runs to return (default 25).
|
|
398
|
+
offset: Pagination offset.
|
|
399
|
+
state: Optional filter, e.g. ["running"], ["failed"], ["success", "queued"].
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
DagRunList with `dag_runs` (each a DagRunSummary) and `total_entries`.
|
|
403
|
+
"""
|
|
404
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
405
|
+
if state:
|
|
406
|
+
params["state"] = state
|
|
407
|
+
|
|
408
|
+
with _client() as c:
|
|
409
|
+
r = c.get(f"{_api_prefix()}/dags/{quote(dag_id, safe='')}/dagRuns", params=params)
|
|
410
|
+
_raise(r)
|
|
411
|
+
return DagRunList.model_validate(r.json())
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@mcp.tool()
|
|
415
|
+
def clear_task_instances(
|
|
416
|
+
dag_id: str,
|
|
417
|
+
dag_run_id: str | None = None,
|
|
418
|
+
task_ids: list[str] | None = None,
|
|
419
|
+
only_failed: bool = False,
|
|
420
|
+
reset_dag_runs: bool = True,
|
|
421
|
+
dry_run: bool = True,
|
|
422
|
+
) -> ClearResult:
|
|
423
|
+
"""Clear task instances so they re-run — the fast way to re-test a task after a fix.
|
|
424
|
+
|
|
425
|
+
Defaults to a DRY RUN: it reports which task instances *would* be cleared without
|
|
426
|
+
touching them. Pass dry_run=False to actually clear; with reset_dag_runs=True the
|
|
427
|
+
affected run is put back into a running state so cleared tasks re-execute.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
dag_id: DAG identifier.
|
|
431
|
+
dag_run_id: Restrict to a single run (recommended). If omitted, the API's other
|
|
432
|
+
filters apply across runs.
|
|
433
|
+
task_ids: Restrict to specific task_ids. If omitted, all matching tasks are cleared.
|
|
434
|
+
only_failed: When True, only clear failed task instances.
|
|
435
|
+
reset_dag_runs: When True (default), set affected runs back to running so cleared
|
|
436
|
+
tasks are re-scheduled.
|
|
437
|
+
dry_run: When True (default), preview only. Set False to actually clear.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
ClearResult with `dry_run` (echoed) and `task_instances` (the affected TIs).
|
|
441
|
+
"""
|
|
442
|
+
body: dict[str, Any] = {
|
|
443
|
+
"dry_run": dry_run,
|
|
444
|
+
"only_failed": only_failed,
|
|
445
|
+
"reset_dag_runs": reset_dag_runs,
|
|
446
|
+
}
|
|
447
|
+
if dag_run_id:
|
|
448
|
+
body["dag_run_id"] = dag_run_id
|
|
449
|
+
if task_ids:
|
|
450
|
+
body["task_ids"] = task_ids
|
|
451
|
+
|
|
452
|
+
with _client() as c:
|
|
453
|
+
r = c.post(
|
|
454
|
+
f"{_api_prefix()}/dags/{quote(dag_id, safe='')}/clearTaskInstances",
|
|
455
|
+
json=body,
|
|
456
|
+
)
|
|
457
|
+
_raise(r)
|
|
458
|
+
return ClearResult(
|
|
459
|
+
dry_run=dry_run,
|
|
460
|
+
task_instances=[
|
|
461
|
+
TaskInstanceSummary.model_validate(ti)
|
|
462
|
+
for ti in r.json().get("task_instances", [])
|
|
463
|
+
],
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@mcp.tool()
|
|
468
|
+
def list_variables(limit: int = 100, offset: int = 0) -> VariableList:
|
|
469
|
+
"""List Airflow Variables (read-only) — handy when troubleshooting why a task can't
|
|
470
|
+
find config it expects.
|
|
471
|
+
|
|
472
|
+
Values flagged sensitive by Airflow's secrets masker come back masked. Read-only by
|
|
473
|
+
design: this tool cannot create or modify variables.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
limit: Max variables to return (default 100).
|
|
477
|
+
offset: Pagination offset.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
VariableList with `variables` (key, value, description) and `total_entries`.
|
|
481
|
+
"""
|
|
482
|
+
with _client() as c:
|
|
483
|
+
r = c.get(f"{_api_prefix()}/variables", params={"limit": limit, "offset": offset})
|
|
484
|
+
_raise(r)
|
|
485
|
+
return VariableList.model_validate(r.json())
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@mcp.tool()
|
|
489
|
+
def list_connections(limit: int = 100, offset: int = 0) -> ConnectionList:
|
|
490
|
+
"""List Airflow Connections (read-only) — passwords are never returned by the API.
|
|
491
|
+
|
|
492
|
+
Read-only by design: use it to confirm a connection exists with the expected
|
|
493
|
+
conn_type / host / schema when a task fails to connect.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
limit: Max connections to return (default 100).
|
|
497
|
+
offset: Pagination offset.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
ConnectionList with `connections` (connection_id, conn_type, host, db_schema,
|
|
501
|
+
login, port, description) and `total_entries`.
|
|
502
|
+
"""
|
|
503
|
+
with _client() as c:
|
|
504
|
+
r = c.get(f"{_api_prefix()}/connections", params={"limit": limit, "offset": offset})
|
|
505
|
+
_raise(r)
|
|
506
|
+
return ConnectionList.model_validate(r.json())
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _check() -> int:
|
|
510
|
+
"""One-shot connectivity check for debugging outside an MCP client."""
|
|
511
|
+
try:
|
|
512
|
+
with _client() as c:
|
|
513
|
+
r = c.get(f"{_api_prefix()}/dags", params={"limit": 1})
|
|
514
|
+
_raise(r)
|
|
515
|
+
data = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
|
516
|
+
print(f"OK — {_base_url()}{_api_prefix()} reachable, "
|
|
517
|
+
f"total DAGs: {data.get('total_entries', '?')}")
|
|
518
|
+
return 0
|
|
519
|
+
except Exception as e:
|
|
520
|
+
print(f"FAIL — {e}", file=sys.stderr)
|
|
521
|
+
return 1
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def main() -> None:
|
|
525
|
+
"""Console entry point. Runs the stdio MCP server, or `--check` connectivity test."""
|
|
526
|
+
if "--check" in sys.argv:
|
|
527
|
+
raise SystemExit(_check())
|
|
528
|
+
mcp.run()
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
if __name__ == "__main__":
|
|
532
|
+
main()
|