mcp-cloudflare-dns 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.
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+ .env
11
+
12
+ # uv
13
+ uv.lock
14
+
15
+ # IDE
16
+ .vscode/
17
+ .idea/
18
+ *.swp
19
+
20
+ # OS
21
+ .DS_Store
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-cloudflare-dns
3
+ Version: 0.1.0
4
+ Summary: Cloudflare DNS MCP server — manage zones, records, cache, and page rules from any MCP-compatible AI assistant
5
+ License: MIT
6
+ Keywords: ai,cloudflare,devops,dns,mcp
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: cloudflare>=4.0.0
13
+ Requires-Dist: fastmcp>=2.0.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # mcp-cloudflare-dns
17
+
18
+ Cloudflare DNS MCP server. Manage zones, DNS records, cache, and page rules from Claude, Cursor, Codex, or any MCP-compatible AI assistant.
19
+
20
+ ```
21
+ # Install: uvx mcp-cloudflare-dns
22
+
23
+ # Ask your AI:
24
+ "List all DNS records for example.com"
25
+ "Add a CNAME record pointing api.example.com to my-app.vercel.app"
26
+ "Purge the cache for https://example.com/products"
27
+ "What page rules are active on example.com?"
28
+ ```
29
+
30
+ ---
31
+
32
+ ## Why this one?
33
+
34
+ The official Cloudflare MCP covers Workers, KV, D1, and R2 — but has **zero DNS tools**. This server fills that gap.
35
+
36
+ | Feature | This server | Official CF MCP |
37
+ |---|---|---|
38
+ | DNS record CRUD | Yes | No |
39
+ | Zone listing | Yes | No |
40
+ | Cache purge | Yes | No |
41
+ | Page rules | Yes | No |
42
+ | Zone settings | Yes | No |
43
+ | Workers/KV/D1/R2 | No | Yes |
44
+
45
+ They complement each other — use both.
46
+
47
+ ---
48
+
49
+ ## Quickstart
50
+
51
+ **1. Get a Cloudflare API token** — [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) → Create Token → use "Edit zone DNS" template
52
+
53
+ **2. Add to your MCP client config:**
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "cloudflare-dns": {
59
+ "command": "uvx",
60
+ "args": ["mcp-cloudflare-dns"],
61
+ "env": {
62
+ "CF_API_TOKEN": "your-cloudflare-api-token"
63
+ }
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ **3. Restart your AI client. Done.**
70
+
71
+ ---
72
+
73
+ ## Available tools
74
+
75
+ | Tool | What it does |
76
+ |---|---|
77
+ | `list_zones` | All zones on your account with status and nameservers |
78
+ | `get_zone` | Details for a specific zone |
79
+ | `get_zone_settings` | SSL mode, security level, minification, HTTPS redirect, etc. |
80
+ | `list_dns_records` | All DNS records, filterable by type or name |
81
+ | `get_dns_record` | Single record by ID |
82
+ | `create_dns_record` | Add A, AAAA, CNAME, MX, TXT, NS, etc. |
83
+ | `update_dns_record` | Edit content, TTL, proxy status, or comment |
84
+ | `delete_dns_record` | Remove a record *(requires `CF_ALLOW_DESTRUCTIVE=true`)* |
85
+ | `purge_cache` | Purge specific URLs or entire zone cache |
86
+ | `list_page_rules` | All page rules with targets and actions |
87
+
88
+ ---
89
+
90
+ ## Environment variables
91
+
92
+ | Variable | Required | Description |
93
+ |---|---|---|
94
+ | `CF_API_TOKEN` | Yes | Cloudflare API token (also accepts `CLOUDFLARE_API_TOKEN`) |
95
+ | `CF_ALLOW_DESTRUCTIVE` | No | Set to `true` to enable record deletion and full cache purge |
96
+ | `MCP_TRANSPORT` | No | Set to `sse` for remote/VPS deployment (default: `stdio`) |
97
+ | `MCP_HOST` | No | SSE bind host (default: `127.0.0.1`) |
98
+ | `MCP_PORT` | No | SSE bind port (default: `3001`) |
99
+
100
+ ---
101
+
102
+ ## API token permissions
103
+
104
+ Minimum required scopes for your token:
105
+
106
+ | Resource | Permission |
107
+ |---|---|
108
+ | Zone — DNS | Edit |
109
+ | Zone — Zone | Read |
110
+ | Zone — Cache Purge | Purge |
111
+ | Zone — Page Rules | Edit *(if using page rules tools)* |
112
+
113
+ ---
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,102 @@
1
+ # mcp-cloudflare-dns
2
+
3
+ Cloudflare DNS MCP server. Manage zones, DNS records, cache, and page rules from Claude, Cursor, Codex, or any MCP-compatible AI assistant.
4
+
5
+ ```
6
+ # Install: uvx mcp-cloudflare-dns
7
+
8
+ # Ask your AI:
9
+ "List all DNS records for example.com"
10
+ "Add a CNAME record pointing api.example.com to my-app.vercel.app"
11
+ "Purge the cache for https://example.com/products"
12
+ "What page rules are active on example.com?"
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Why this one?
18
+
19
+ The official Cloudflare MCP covers Workers, KV, D1, and R2 — but has **zero DNS tools**. This server fills that gap.
20
+
21
+ | Feature | This server | Official CF MCP |
22
+ |---|---|---|
23
+ | DNS record CRUD | Yes | No |
24
+ | Zone listing | Yes | No |
25
+ | Cache purge | Yes | No |
26
+ | Page rules | Yes | No |
27
+ | Zone settings | Yes | No |
28
+ | Workers/KV/D1/R2 | No | Yes |
29
+
30
+ They complement each other — use both.
31
+
32
+ ---
33
+
34
+ ## Quickstart
35
+
36
+ **1. Get a Cloudflare API token** — [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) → Create Token → use "Edit zone DNS" template
37
+
38
+ **2. Add to your MCP client config:**
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "cloudflare-dns": {
44
+ "command": "uvx",
45
+ "args": ["mcp-cloudflare-dns"],
46
+ "env": {
47
+ "CF_API_TOKEN": "your-cloudflare-api-token"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ **3. Restart your AI client. Done.**
55
+
56
+ ---
57
+
58
+ ## Available tools
59
+
60
+ | Tool | What it does |
61
+ |---|---|
62
+ | `list_zones` | All zones on your account with status and nameservers |
63
+ | `get_zone` | Details for a specific zone |
64
+ | `get_zone_settings` | SSL mode, security level, minification, HTTPS redirect, etc. |
65
+ | `list_dns_records` | All DNS records, filterable by type or name |
66
+ | `get_dns_record` | Single record by ID |
67
+ | `create_dns_record` | Add A, AAAA, CNAME, MX, TXT, NS, etc. |
68
+ | `update_dns_record` | Edit content, TTL, proxy status, or comment |
69
+ | `delete_dns_record` | Remove a record *(requires `CF_ALLOW_DESTRUCTIVE=true`)* |
70
+ | `purge_cache` | Purge specific URLs or entire zone cache |
71
+ | `list_page_rules` | All page rules with targets and actions |
72
+
73
+ ---
74
+
75
+ ## Environment variables
76
+
77
+ | Variable | Required | Description |
78
+ |---|---|---|
79
+ | `CF_API_TOKEN` | Yes | Cloudflare API token (also accepts `CLOUDFLARE_API_TOKEN`) |
80
+ | `CF_ALLOW_DESTRUCTIVE` | No | Set to `true` to enable record deletion and full cache purge |
81
+ | `MCP_TRANSPORT` | No | Set to `sse` for remote/VPS deployment (default: `stdio`) |
82
+ | `MCP_HOST` | No | SSE bind host (default: `127.0.0.1`) |
83
+ | `MCP_PORT` | No | SSE bind port (default: `3001`) |
84
+
85
+ ---
86
+
87
+ ## API token permissions
88
+
89
+ Minimum required scopes for your token:
90
+
91
+ | Resource | Permission |
92
+ |---|---|
93
+ | Zone — DNS | Edit |
94
+ | Zone — Zone | Read |
95
+ | Zone — Cache Purge | Purge |
96
+ | Zone — Page Rules | Edit *(if using page rules tools)* |
97
+
98
+ ---
99
+
100
+ ## License
101
+
102
+ MIT
File without changes
@@ -0,0 +1,377 @@
1
+ import os
2
+ import time
3
+ from typing import Optional
4
+
5
+ import cloudflare
6
+ from cloudflare import APIError, APIStatusError
7
+ from fastmcp import FastMCP
8
+
9
+ mcp = FastMCP("mcp-cloudflare-dns")
10
+
11
+ _client: Optional[cloudflare.Cloudflare] = None
12
+
13
+ _RETRYABLE = {429, 500, 502, 503, 504}
14
+ _MAX_RETRIES = 5
15
+
16
+
17
+ def _get_client() -> cloudflare.Cloudflare:
18
+ global _client
19
+ if _client is None:
20
+ token = os.environ.get("CF_API_TOKEN") or os.environ.get("CLOUDFLARE_API_TOKEN")
21
+ if not token:
22
+ raise RuntimeError(
23
+ "CF_API_TOKEN not set. Export your Cloudflare API token before starting the server."
24
+ )
25
+ _client = cloudflare.Cloudflare(api_token=token)
26
+ return _client
27
+
28
+
29
+ def _call(fn, *args, **kwargs):
30
+ delay = 1.0
31
+ for attempt in range(_MAX_RETRIES):
32
+ try:
33
+ return fn(*args, **kwargs)
34
+ except APIStatusError as e:
35
+ if e.status_code not in _RETRYABLE or attempt == _MAX_RETRIES - 1:
36
+ raise RuntimeError(f"Cloudflare API error {e.status_code}: {e.message}") from None
37
+ time.sleep(delay)
38
+ delay = min(delay * 2, 60)
39
+ except APIError as e:
40
+ raise RuntimeError(f"Cloudflare error: {e}") from None
41
+ return None
42
+
43
+
44
+ def _err(e: Exception) -> dict:
45
+ return {"error": str(e)}
46
+
47
+
48
+ def _record_to_dict(r) -> dict:
49
+ return {
50
+ "id": r.id,
51
+ "name": r.name,
52
+ "type": r.type,
53
+ "content": r.content,
54
+ "ttl": r.ttl,
55
+ "proxied": getattr(r, "proxied", None),
56
+ "comment": getattr(r, "comment", None),
57
+ "created_on": str(r.created_on) if getattr(r, "created_on", None) else None,
58
+ "modified_on": str(r.modified_on) if getattr(r, "modified_on", None) else None,
59
+ }
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Zone tools
64
+ # ---------------------------------------------------------------------------
65
+
66
+ @mcp.tool()
67
+ def list_zones(name_filter: Optional[str] = None) -> list[dict]:
68
+ """
69
+ List all Cloudflare zones on this account.
70
+
71
+ Args:
72
+ name_filter: Optional domain name substring to filter results (e.g. 'example.com')
73
+ """
74
+ try:
75
+ cf = _get_client()
76
+ kwargs = {}
77
+ if name_filter:
78
+ kwargs["name"] = name_filter
79
+ zones = _call(cf.zones.list, **kwargs)
80
+ return [
81
+ {
82
+ "id": z.id,
83
+ "name": z.name,
84
+ "status": z.status,
85
+ "plan": z.plan.name if z.plan else None,
86
+ "nameservers": list(z.name_servers) if z.name_servers else [],
87
+ "paused": z.paused,
88
+ "account": z.account.name if z.account else None,
89
+ }
90
+ for z in zones
91
+ ]
92
+ except Exception as e:
93
+ return _err(e)
94
+
95
+
96
+ @mcp.tool()
97
+ def get_zone(zone_id: str) -> dict:
98
+ """Get details for a specific Cloudflare zone by ID."""
99
+ try:
100
+ cf = _get_client()
101
+ z = _call(cf.zones.get, zone_id=zone_id)
102
+ return {
103
+ "id": z.id,
104
+ "name": z.name,
105
+ "status": z.status,
106
+ "plan": z.plan.name if z.plan else None,
107
+ "nameservers": list(z.name_servers) if z.name_servers else [],
108
+ "original_nameservers": list(z.original_name_servers) if z.original_name_servers else [],
109
+ "paused": z.paused,
110
+ "type": z.type,
111
+ "created_on": str(z.created_on) if z.created_on else None,
112
+ "modified_on": str(z.modified_on) if z.modified_on else None,
113
+ "activated_on": str(z.activated_on) if z.activated_on else None,
114
+ }
115
+ except Exception as e:
116
+ return _err(e)
117
+
118
+
119
+ @mcp.tool()
120
+ def get_zone_settings(zone_id: str) -> dict:
121
+ """
122
+ Get key security and performance settings for a zone.
123
+ Returns SSL mode, security level, minification, always-https, brotli, etc.
124
+ """
125
+ try:
126
+ cf = _get_client()
127
+ settings = _call(cf.zones.settings.get, zone_id=zone_id)
128
+ result = {}
129
+ for item in settings.result if hasattr(settings, "result") else []:
130
+ result[item.id] = item.value
131
+ return result
132
+ except Exception as e:
133
+ return _err(e)
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # DNS record tools
138
+ # ---------------------------------------------------------------------------
139
+
140
+ @mcp.tool()
141
+ def list_dns_records(
142
+ zone_id: str,
143
+ record_type: Optional[str] = None,
144
+ name: Optional[str] = None,
145
+ ) -> list[dict]:
146
+ """
147
+ List DNS records for a zone.
148
+
149
+ Args:
150
+ zone_id: Cloudflare zone ID
151
+ record_type: Filter by type — A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, etc.
152
+ name: Filter by record name (e.g. 'api.example.com')
153
+ """
154
+ try:
155
+ cf = _get_client()
156
+ kwargs = {"zone_id": zone_id}
157
+ if record_type:
158
+ kwargs["type"] = record_type
159
+ if name:
160
+ kwargs["name"] = name
161
+ records = _call(cf.dns.records.list, **kwargs)
162
+ return [_record_to_dict(r) for r in records]
163
+ except Exception as e:
164
+ return _err(e)
165
+
166
+
167
+ @mcp.tool()
168
+ def get_dns_record(zone_id: str, record_id: str) -> dict:
169
+ """Get a specific DNS record by zone ID and record ID."""
170
+ try:
171
+ cf = _get_client()
172
+ r = _call(cf.dns.records.get, dns_record_id=record_id, zone_id=zone_id)
173
+ return _record_to_dict(r)
174
+ except Exception as e:
175
+ return _err(e)
176
+
177
+
178
+ @mcp.tool()
179
+ def create_dns_record(
180
+ zone_id: str,
181
+ record_type: str,
182
+ name: str,
183
+ content: str,
184
+ ttl: int = 1,
185
+ proxied: bool = False,
186
+ priority: Optional[int] = None,
187
+ comment: Optional[str] = None,
188
+ ) -> dict:
189
+ """
190
+ Create a new DNS record.
191
+
192
+ Args:
193
+ zone_id: Cloudflare zone ID
194
+ record_type: A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, etc.
195
+ name: Record name (e.g. 'api' or 'api.example.com')
196
+ content: Record value (IP address, hostname, text, etc.)
197
+ ttl: TTL in seconds. 1 = auto (only valid when proxied=True)
198
+ proxied: Whether to proxy through Cloudflare (orange cloud). Only valid for A, AAAA, CNAME.
199
+ priority: MX/SRV priority (required for MX records)
200
+ comment: Optional note about this record
201
+ """
202
+ try:
203
+ cf = _get_client()
204
+ kwargs = {
205
+ "zone_id": zone_id,
206
+ "type": record_type.upper(),
207
+ "name": name,
208
+ "content": content,
209
+ "ttl": ttl,
210
+ }
211
+ if proxied:
212
+ kwargs["proxied"] = proxied
213
+ if priority is not None:
214
+ kwargs["priority"] = priority
215
+ if comment:
216
+ kwargs["comment"] = comment
217
+ r = _call(cf.dns.records.create, **kwargs)
218
+ return _record_to_dict(r)
219
+ except Exception as e:
220
+ return _err(e)
221
+
222
+
223
+ @mcp.tool()
224
+ def update_dns_record(
225
+ zone_id: str,
226
+ record_id: str,
227
+ content: Optional[str] = None,
228
+ ttl: Optional[int] = None,
229
+ proxied: Optional[bool] = None,
230
+ comment: Optional[str] = None,
231
+ ) -> dict:
232
+ """
233
+ Update an existing DNS record. Only the fields you provide will be changed.
234
+
235
+ Args:
236
+ zone_id: Cloudflare zone ID
237
+ record_id: DNS record ID to update
238
+ content: New record value
239
+ ttl: New TTL in seconds (1 = auto)
240
+ proxied: Toggle Cloudflare proxy on/off
241
+ comment: Update the record comment
242
+ """
243
+ try:
244
+ cf = _get_client()
245
+ # Fetch current record first to fill required fields
246
+ current = _call(cf.dns.records.get, dns_record_id=record_id, zone_id=zone_id)
247
+ kwargs = {
248
+ "zone_id": zone_id,
249
+ "dns_record_id": record_id,
250
+ "type": current.type,
251
+ "name": current.name,
252
+ "content": content if content is not None else current.content,
253
+ "ttl": ttl if ttl is not None else current.ttl,
254
+ }
255
+ if proxied is not None:
256
+ kwargs["proxied"] = proxied
257
+ elif getattr(current, "proxied", None) is not None:
258
+ kwargs["proxied"] = current.proxied
259
+ if comment is not None:
260
+ kwargs["comment"] = comment
261
+ r = _call(cf.dns.records.update, **kwargs)
262
+ return _record_to_dict(r)
263
+ except Exception as e:
264
+ return _err(e)
265
+
266
+
267
+ @mcp.tool()
268
+ def delete_dns_record(zone_id: str, record_id: str) -> dict:
269
+ """
270
+ Delete a DNS record. Requires CF_ALLOW_DESTRUCTIVE=true.
271
+
272
+ Args:
273
+ zone_id: Cloudflare zone ID
274
+ record_id: DNS record ID to delete
275
+ """
276
+ if not os.environ.get("CF_ALLOW_DESTRUCTIVE"):
277
+ return {"error": "DNS record deletion is disabled. Set CF_ALLOW_DESTRUCTIVE=true to enable."}
278
+ try:
279
+ cf = _get_client()
280
+ _call(cf.dns.records.delete, dns_record_id=record_id, zone_id=zone_id)
281
+ return {"success": True, "deleted": record_id}
282
+ except Exception as e:
283
+ return _err(e)
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Cache tools
288
+ # ---------------------------------------------------------------------------
289
+
290
+ @mcp.tool()
291
+ def purge_cache(
292
+ zone_id: str,
293
+ urls: Optional[list[str]] = None,
294
+ purge_everything: bool = False,
295
+ ) -> dict:
296
+ """
297
+ Purge Cloudflare cache for a zone.
298
+
299
+ Args:
300
+ zone_id: Cloudflare zone ID
301
+ urls: Specific URLs to purge (up to 30 per call)
302
+ purge_everything: Purge entire zone cache (overrides urls). Requires CF_ALLOW_DESTRUCTIVE=true.
303
+ """
304
+ if purge_everything and not os.environ.get("CF_ALLOW_DESTRUCTIVE"):
305
+ return {"error": "purge_everything requires CF_ALLOW_DESTRUCTIVE=true."}
306
+ if not urls and not purge_everything:
307
+ return {"error": "Provide urls to purge or set purge_everything=true."}
308
+ try:
309
+ cf = _get_client()
310
+ if purge_everything:
311
+ _call(cf.cache.purge, zone_id=zone_id, purge_everything=True)
312
+ return {"success": True, "purged": "everything"}
313
+ else:
314
+ if len(urls) > 30:
315
+ return {"error": "Maximum 30 URLs per purge call. Split into batches."}
316
+ _call(cf.cache.purge, zone_id=zone_id, files=urls)
317
+ return {"success": True, "purged": urls}
318
+ except Exception as e:
319
+ return _err(e)
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Page rules
324
+ # ---------------------------------------------------------------------------
325
+
326
+ @mcp.tool()
327
+ def list_page_rules(zone_id: str, status: Optional[str] = None) -> list[dict]:
328
+ """
329
+ List page rules for a zone.
330
+
331
+ Args:
332
+ zone_id: Cloudflare zone ID
333
+ status: Filter by 'active' or 'disabled' (default: all)
334
+ """
335
+ try:
336
+ cf = _get_client()
337
+ kwargs = {"zone_id": zone_id}
338
+ if status:
339
+ kwargs["status"] = status
340
+ rules = _call(cf.page_rules.list, **kwargs)
341
+ return [
342
+ {
343
+ "id": r.id,
344
+ "status": r.status,
345
+ "priority": r.priority,
346
+ "targets": [
347
+ {"target": t.target, "constraint": {"operator": t.constraint.operator, "value": t.constraint.value}}
348
+ for t in (r.targets or [])
349
+ ],
350
+ "actions": [
351
+ {"id": a.id, "value": a.value}
352
+ for a in (r.actions or [])
353
+ ],
354
+ "modified_on": str(r.modified_on) if r.modified_on else None,
355
+ }
356
+ for r in (rules or [])
357
+ ]
358
+ except Exception as e:
359
+ return _err(e)
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Entry point
364
+ # ---------------------------------------------------------------------------
365
+
366
+ def main():
367
+ transport = os.environ.get("MCP_TRANSPORT", "stdio")
368
+ if transport == "sse":
369
+ host = os.environ.get("MCP_HOST", "127.0.0.1")
370
+ port = int(os.environ.get("MCP_PORT", "3001"))
371
+ mcp.run(transport="sse", host=host, port=port)
372
+ else:
373
+ mcp.run(transport="stdio")
374
+
375
+
376
+ if __name__ == "__main__":
377
+ main()
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-cloudflare-dns"
7
+ version = "0.1.0"
8
+ description = "Cloudflare DNS MCP server — manage zones, records, cache, and page rules from any MCP-compatible AI assistant"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ keywords = ["mcp", "cloudflare", "dns", "devops", "ai"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3.11",
18
+ ]
19
+ dependencies = [
20
+ "fastmcp>=2.0.0",
21
+ "cloudflare>=4.0.0",
22
+ ]
23
+
24
+ [project.scripts]
25
+ mcp-cloudflare-dns = "cf.server:main"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["cf"]
29
+
30
+ [tool.ruff]
31
+ line-length = 100
32
+ target-version = "py311"