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.
@@ -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,3 @@
1
+ """airflow-dev-mcp: an MCP server for driving a dev/local Airflow cluster."""
2
+
3
+ __version__ = "0.2.0"
@@ -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()