Django Chainsaw
PYPI · DJANGO-CHAINSAW-MCP · SCANNED SEP 21
Finds what will hurt in a Django project: cascades, N+1, unsafe migrations, tenant leaks.
Available components
How this component scores in each security and reliability category. Every signal is checked automatically from public evidence about the published package, including repeated runs of it in an isolated sandbox, and we only credit what we can confirm. How we score → Why this is hard to score →
Supply Chain Security94
- No malware found by supply-chain analysis.Pass
- No known CVEs affecting this package version or its production dependencies.Pass
- Runs a script at install time (build_backend) that we could not recognise. It may be perfectly ordinary, but we do not read the published tarball, so we cannot say what it does. View diagnostics → Partial
- 1 of 29 dependencies flagged as unhealthy. View diagnostics → Partial
Provenance & Transparency35
- Source repository is publicly reachable at the declared URL. View diagnostics → Pass
- Provenance check failed: no build-provenance attestation is published. See how to fix → View diagnostics → Fail
- License check failed: no license is declared. See how to fix → Fail
- Actively maintained (last published 10 days ago).Pass
- Publishes a security disclosure policy (SECURITY.md).Pass
Schema Quality & AI Usability78
- 100% of prompts and resources have a non-trivial description (not blank, and not just the item's name).Pass
- AI-judged instruction clarity (excellent).Pass
- Context-footprint check failed: tool/resource definitions use about 7527 tokens (~198/item across 38 items; 37 tools + 1 resources), over budget; trim descriptions and params. See how to fix → Fail
- Usage-examples check failed: none of the tools include examples. See how to fix → Fail
Stability & Change Management40
- Stability observed for 12 of 30 days with no destabilising changes; credit accrues until the full window elapses.Partial
Tool Coverage71
- 100% of tools have a non-trivial description (not blank, and not just the tool's name).Pass
- 0% of tool parameters carry a description.Fail
- Structured output schemas are declared (100% of tools); any adoption earns full credit.Pass
Tool Safety75
- No prompt-injection markers were found in the server instructions, tool names or descriptions we captured.Pass
- 0 of 2 tool(s) whose name or description implies an irreversible operation declare an MCP destructiveHint annotation; "delete_impact" implies "delete" and declares readOnlyHint instead, contradicting what its own name says it does. See how to fix → Fail
- An AI judge read all 39 captured unit(s) of tool text and found none that tries to manipulate the model reading it.Pass
Capabilities100
- Implements a current MCP spec version (2026-07-28).Pass
How do I install the Django Chainsaw MCP server?
Django Chainsaw runs locally as a PyPI package, launched with uvx django-chainsaw-mcp. Ready-made configuration for Claude, Cursor, VS Code, Codex and 5 more is on this page, copied from each client's own documentation.
pypi · django-chainsaw-mcp
claude mcp add syrian963-django-chainsaw-mcp -- uvx django-chainsaw-mcp
{
"mcpServers": {
"syrian963-django-chainsaw-mcp": {
"command": "uvx",
"args": [
"django-chainsaw-mcp"
]
}
}
} {
"servers": {
"syrian963-django-chainsaw-mcp": {
"command": "uvx",
"args": [
"django-chainsaw-mcp"
]
}
}
} codex mcp add syrian963-django-chainsaw-mcp -- uvx django-chainsaw-mcp
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"syrian963-django-chainsaw-mcp": {
"type": "local",
"command": [
"uvx",
"django-chainsaw-mcp"
],
"enabled": true
}
}
} openclaw mcp add syrian963-django-chainsaw-mcp --command uvx --arg django-chainsaw-mcp
mcp_servers:
syrian963-django-chainsaw-mcp:
command: "uvx"
args: ["django-chainsaw-mcp"] {
"McpServers": {
"syrian963-django-chainsaw-mcp": {
"Transport": "stdio",
"Command": "uvx",
"Arguments": [
"django-chainsaw-mcp"
]
}
}
} assistant mcp add syrian963-django-chainsaw-mcp -t stdio -c uvx -a django-chainsaw-mcp
{
"mcpServers": {
"syrian963-django-chainsaw-mcp": {
"command": "uvx",
"args": [
"django-chainsaw-mcp"
]
}
}
} Every change we have recorded for this component, newest first. Security-relevant changes are always shown. ▲ marks a change for the better, ▼ a change for the worse; unmarked changes are neutral.
- 20 Sept 26 +1
No change was recorded against any check on this day. Stability & Change Management went from 33 to 37. That category is still filling its 30-day observation window: 10 days of observed history at the previous scan, 11 at this one. The score rises as the window fills, whether or not the server changes.
- 18 Sept 26 +1
No change was recorded against any check on this day. Stability & Change Management went from 27 to 30. That category is still filling its 30-day observation window: 8 days of observed history at the previous scan, 9 at this one. The score rises as the window fills, whether or not the server changes.
- 16 Sept 26 +1
No change was recorded against any check on this day. Stability & Change Management went from 20 to 23. That category is still filling its 30-day observation window: 6 days of observed history at the previous scan, 7 at this one. The score rises as the window fills, whether or not the server changes.
- 14 Sept 26 +1
No change was recorded against any check on this day. Stability & Change Management went from 13 to 17. That category is still filling its 30-day observation window: 4 days of observed history at the previous scan, 5 at this one. The score rises as the window fills, whether or not the server changes.
- 12 Sept 26 +1
No change was recorded against any check on this day. Stability & Change Management went from 7 to 10. That category is still filling its 30-day observation window: 2 days of observed history at the previous scan, 3 at this one. The score rises as the window fills, whether or not the server changes.
- 10 Sept 26 +1
- Tool safety: pass → unverified ▼ security
- Stability: Stability not yet verified: we do not have a sandbox capture of the MCP schema this version of the package serves yet. security
- Capabilities: pass → unverified ▼ functional
- Tool coverage: 100 → unverified ▼ functional
- Schema quality: 100 → unverified ▼ functional
- Stability: unverified → 0.03 ▲ functional
- Package version: 0.1.4 → 0.1.5 functional
- 9 Sept 26 63
First indexed and scored.
Diagnostic detail from the automated scan of this channel: what the scanner observed at each step, so you can see exactly where a check passed or failed. It is informational only and never changes the trust score.
Captured 21 Sept 2026 · Analysed pypi/django-chainsaw-mcp@0.1.5
Provenance No attestation
The registry publishes no build provenance for this version, so there is nothing to verify.
| Result | No attestation |
|---|---|
| Ecosystem | pypi |
Background: How many MCP packages publish verified provenance →
Install scripts 1 script
| Hook | Tier | Command |
|---|---|---|
| build_backend | unreviewed | uv_build |
Background: Why install scripts are a supply-chain risk →
Dependencies 29 packages
| Packages resolved | 29 |
|---|---|
| No linked repository | 1 |
| Tree resolution | Complete |
Background: SBOMs and build attestations, explained →
The tools this component advertises to a client, with an estimated token cost for each. Expand a tool to see its parameters and schema. The per-tool counts are indicative and are not scored directly; the schema's total context footprint is one signal in Schema Quality & AI Usability. A tool's description is untrusted text the model reads on every call, which is what makes this list a security surface and not just an inventory: how tool poisoning works →
amplification ~229
Endpoints anyone can call that cost a great deal to answer. Two facts, each of which is somebody else's finding and neither of which is wrong alone: GET /orders has no authentication. GET /orders issues about 2852 queries per request. The first is right on a public catalogue. The second, behind a login, is a backlog item. Together they are one request, from anyone, that costs the database three thousand queries - and every one of them returns 200, so nothing in the logs looks like an attack. The other half is the unbounded list: public, unpaginated, and therefore the whole table in one request. That is not load, it is exfiltration. The tools that look for this are DAST scanners; they need the service running, reachable and holding enough rows for the cost to show. All of it is in the source. Works on Django (DRF views) and FastAPI. Args: search_path: directory to scan. Defaults to the configured project.
| Name | Type | Req | Description |
|---|---|---|---|
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
api_contract ~110
The shape every serializer currently promises its clients. Field names, types, whether each is read only, required, nullable, and what the nested ones expand to. Resolved from the class definitions, so nothing needs to run and no request needs to be sent. Capture this on the branch you already shipped, commit the result, and api_contract_check will tell a later branch what it broke. Args: max_depth: how far to expand nested serializers.
| Name | Type | Req | Description |
|---|---|---|---|
| max_depth | integer | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
api_contract_check ~219
What this branch changes about the API, and who it breaks. Removing a serializer field is a one line diff that reads as tidying up. The client on a version nobody updated still reads that field. Every tool that catches this needs the app running: drf-api-checker records real responses during a test run, OpenAPI diffing needs the schema generated. Changes come back sorted by who they hurt. Breaking means an existing client stops working: a field it reads disappears, a field it omits becomes required, a type or a constraint narrows. Risky means it still parses but the values may surprise it. Additive means nobody notices. Args: snapshot_path: the committed contract to compare against. update: overwrite the snapshot with the current shape instead of comparing. Do this once you have decided a change is intended. max_depth: how far to expand nested serializers.
| Name | Type | Req | Description |
|---|---|---|---|
| max_depth | integer | – | – |
| snapshot_path | string | – | – |
| update | boolean | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
blocking_in_async ~245
Synchronous calls that run on the event loop. FastAPI runs an `async def` endpoint on the loop itself and a `def` endpoint in a threadpool. So a blocking call inside `async def` does not slow one request, it stops every request in the process - invisible at one request a second, an outage at two hundred. ruff's ASYNC rules cover open, time.sleep and subprocess inside an async function. This adds the two that matter more: a synchronous database call, which is the common one, and a blocking call reached through another project function, where nothing at the call site looks blocking. The second comes back with the path that reaches it. Needs no Django. A `def` endpoint is never reported, because blocking in a threadpool is fine and telling somebody to make it async causes the outage. Args: search_path: directory to scan. Defaults to the configured project. follow_calls: also report blocking reached through a project function. max_depth: how many calls deep to follow.
| Name | Type | Req | Description |
|---|---|---|---|
| follow_calls | boolean | – | – |
| max_depth | integer | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
bypassed_effects ~232
Bulk writes that skip everything the model's save() chain promised. what_happens_on says saving an Order creates an Invoice. That is true for order.save() and false for Order.objects.bulk_create(), .bulk_update() and .update(): they go straight to SQL, so no save() override runs and no pre_save/post_save receiver fires. Django documents this in one sentence per method; nothing at the call site says it. The finding is not "bulk_create bypasses signals" but this call, on this model, skips these named effects - the receivers, the overridden save(), the models that would have been written, transitively. A cache that never gets invalidated and a search index that quietly drifts are both this. Only models whose chain does something are reported. QuerySet.delete() is not listed: Django sends delete signals per object, so that chain fires. Args: search_path: directory to scan. Defaults to the project root. model: restrict to one "app_label.ModelName".
| Name | Type | Req | Description |
|---|---|---|---|
| model | – | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
celery_arguments ~253
Model instances handed to Celery tasks, and calls whose arity is wrong. order = Order.objects.get(pk=pk) send_confirmation.delay(order) The worker does not get that order. It gets whatever the serialiser made of it, rehydrated later on another machine: the row may have changed in between, the whole object crosses the broker, and under the JSON serialiser - the default since Celery 4 - it may not encode at all. Pass the primary key and let the task load it. Also reports a dispatch whose argument count cannot match the task. Celery's strict_typing catches that at call time, which for a nightly job or an error branch means in production, months later. flake8-pie has Celery lints for names, crontab arguments and expirations. None of them look at what is passed. A dispatch is only checked when the name resolves to a task this project defines, through the file's own imports - matching on the bare name reported unrelated objects of the same name against the task's signature. Args: search_path: directory to scan. Defaults to the configured project.
| Name | Type | Req | Description |
|---|---|---|---|
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
check ~179
Run every analysis and return one severity-sorted list. The single call to reach for on an unfamiliar project. It runs the checks whose findings are defects, merges them, and sorts by severity, instead of making you know which of a dozen tools to ask for. A check that fails to run is listed in `checks_failed` rather than counted as clean. This is the one slow call here - a minute or more on a large project - and it reports progress as each check starts, so a client can name the check that is running instead of showing nothing for a minute. Args: tenant_root: the model that owns data, for the ownership check. only: run just these checks. skip: run everything except these.
| Name | Type | Req | Description |
|---|---|---|---|
| only | – | – | – |
| skip | – | – | – |
| tenant_root | string | – | – |
| Name | Type | Req | Description |
|---|---|---|---|
| by_severity | – | – | – |
| checks_failed | – | – | checks that raised. These are not clean results - a project with a failed check has fewer findings than it has problems. |
| checks_not_applicable | – | – | check name to the reason it does not apply here |
| checks_run | – | – | per check: whether it ran, and how many it produced |
| error | – | – | – |
| finding_count | – | – | – |
| findings | – | – | – |
| frameworks | – | – | what was detected in the project, and its version |
| note | – | – | – |
| ok | – | – | present and false only when the run could not happen at all |
No examples provided.
choice_typos ~273
Literals compared against a field whose `choices` will never match them. STATUS = [("canceled", "Canceled"), ...] Order.objects.filter(status="cancelled") Two Ls. Valid Python, valid SQL, zero rows, no exception, wrong forever. Nothing in Django objects: `choices` is checked by `full_clean()`, which a queryset never calls and `create()` never calls either - so the write side is worse, and puts a value in the column the application does not believe exists. No existing tool finds this: django-stubs types the field as `str` rather than a Literal union of its choices, so mypy is satisfied, and the DJ rules do not read the model registry. Only literals are checked - an enum member is the spelling that cannot go wrong - and only equality and `in`, because `iexact` can legitimately match a differently spelled value. `order.status == "..."` names no model, so it is reported only when the literal is wrong for every model with a field of that name. Args: search_path: directory to scan. Defaults to the configured project. include_tests: also scan test files.
| Name | Type | Req | Description |
|---|---|---|---|
| include_tests | boolean | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
dangling_references ~311
URL names and template names that nothing will resolve. return redirect("order-detial") return render(request, "shop/order_detial.html", context) {% url 'shop:order-detial' order.pk %} Each of these is resolved while serving a request and checked by nothing before then. Rename a URL pattern or move a template and they keep importing, keep passing every test that does not walk that branch, and raise NoReverseMatch or TemplateDoesNotExist the first time a real person opens the page - on the path nobody was watching. Both sides use the project's own machinery: names come from every URLconf in the project walked through each include(), so namespaces are real and a second URLconf served by host is not mistaken for a missing one; templates go through get_template(), so the project's loaders decide. `by_name` groups the findings, because one missing name used in thirty-five places is one problem. Args: search_path: directory to scan. Defaults to the configured project. include_templates: also read `{% url %}`, `{% include %}` and `{% extends %}` out of the templates. include_tests: also scan test modules. Off by default: tests run under their own settings, so a name missing from these ones may be registered under theirs.
| Name | Type | Req | Description |
|---|---|---|---|
| include_templates | boolean | – | – |
| include_tests | boolean | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
datetime_audit ~142
Naive datetimes in code, and ambiguous defaults on model fields. With USE_TZ on, code that builds its own datetimes with datetime.now() or from parts produces naive values, and mixing them with the aware ones the ORM returns either raises or compares against the wrong instant. It is invisible for ten months and shows up on the two nights the clock moves. Also reports model fields whose default is naive, whose default was evaluated once at import time, or that set auto_now and auto_now_add together. Args: search_path: directory to scan. Defaults to the project path.
| Name | Type | Req | Description |
|---|---|---|---|
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
defeated_prefetches ~239
Relations that were prefetched and then re-queried anyway. A prefetched related manager answers `.count()`, `.exists()`, `.all()` and a slice from its cache. Anything else goes back to the database, once per parent object, with the prefetch query already paid for on top: orders = Order.objects.prefetch_related("lines") for order in orders: for line in order.lines.filter(active=True): # one query per order That costs more than never prefetching at all, and it reads like an optimisation, which is why it survives review. Reported only where the prefetch and the accessor are provably the same object - bound in the same scope, or the loop variable iterating it. `nplusone` finds the neighbouring problem, an eager load nothing touches, at runtime; `unused_eager_loading` answers that one statically for DRF. Args: search_path: directory to scan. Defaults to the configured project. include_tests: also report inside test files.
| Name | Type | Req | Description |
|---|---|---|---|
| include_tests | boolean | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
delete_impact ~115
Show what deleting one row of a model would take with it. Follows on_delete across the whole model graph: which models lose rows through CASCADE, which PROTECT relations would block the delete, and which fields get set to NULL. Reads the graph only, never the database. Args: model_label: "app_label.ModelName", e.g. "shop.Customer". max_depth: how far to follow chained cascades.
| Name | Type | Req | Description |
|---|---|---|---|
| max_depth | integer | – | – |
| model_label | string | yes | – |
Structured output declared, but exposes no named fields.
No examples provided.
deploy_safety ~149
Is a pending destructive migration safe to deploy yet? A migration linter says RemoveField is backward incompatible, always. This answers the question that actually decides the deploy: has the code caught up? For every unapplied migration that removes or renames a field, model, index or constraint, the source tree is searched for code that still refers to it, and each one comes back either "blocking" with file and line numbers, or "clear". Args: search_path: directory to scan. Defaults to the configured project path. max_hits_per_symbol: stop after this many references per symbol.
| Name | Type | Req | Description |
|---|---|---|---|
| max_hits_per_symbol | integer | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
endpoint_cost ~216
How many queries one request to each endpoint will cost. Every tool that answers this runs the application and tells you afterwards: the debug toolbar, silk, assertNumQueries. The number is derivable before anything runs. One query for the page, plus one per object for every serializer field crossing a relation the view did not prefetch, plus that again per level of nesting. On the demo project the same serializer measured 2852 queries behind an unoptimised queryset and 2 behind an optimised one. The ratio is the reliable part; the absolute number is only as good as nested_fan_out. Args: page_size: objects a list response returns. nested_fan_out: assumed children per parent one level down. A property of your data that reading the code cannot reveal. list_only: skip views that only ever return a single object.
| Name | Type | Req | Description |
|---|---|---|---|
| list_only | boolean | – | – |
| nested_fan_out | integer | – | – |
| page_size | integer | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
escaping_side_effects ~266
Calls inside a transaction whose effect cannot be rolled back. A transaction can be rolled back. An email cannot, and neither can a webhook or a task a worker has already picked up. with transaction.atomic(): order = Order.objects.create(...) send_confirmation.delay(order.pk) Two defects, and only one is famous. The race: the broker has the task immediately, a worker can start before the commit, and it queries for a row that is not there. It passes every test, because tests run in a transaction that never commits with a worker that runs eagerly, and it fails under load. The quieter one: if anything after that line raises, the order is gone and the customer has the email. The fix is transaction.on_commit, and calls already deferred that way are not reported. The ecosystem's answer to this is runtime wrappers; ruff and flake8-django do not look at it. Args: search_path: directory to scan. Defaults to the project root. include_low_confidence: also report calls like `.send()` that are guessed from the name, since it is also Signal.send and socket.send.
| Name | Type | Req | Description |
|---|---|---|---|
| include_low_confidence | boolean | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
explain_model ~200
Everything known about one model, and the risks only visible combined. Start here when meeting a model for the first time. It runs the structural, ownership, deletion, signal, exposure, index and datetime checks and returns one picture instead of seven reports. The part worth reading is `correlated_risks`. Some defects exist only in the overlap and no single analyser can see them: a model that is owned, read without scoping, and serialised with fields = "__all__" is a complete path from a URL to another customer's row, while each of those three alone is just a warning. Args: model_label: "app_label.ModelName". tenant_root: the model that owns data, for the ownership half. include_raw: attach the full report from each analyser as well.
| Name | Type | Req | Description |
|---|---|---|---|
| include_raw | boolean | – | – |
| model_label | string | yes | – |
| tenant_root | string | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
fastapi_exposure ~242
FastAPI endpoints that serialise more than they declare. @app.get("/users/{pk}") async def get_user(pk: int): return session.get(User, pk) No response_model and no return annotation, so FastAPI serialises whatever it is handed - the whole ORM object, every column, including the ones added to the model next month. The absence of one line is the entire bug, so there is nothing in the file to read or review. Every FastAPI guide says to set response_model and several say a CI rule should enforce it. No linter ships one. An endpoint returning a dict or a literal is not reported: the author decided what goes in it. Unauthenticated is critical, behind a dependency is high - it still leaks to everyone who can log in. Nothing here imports the project, because a FastAPI app usually wants a database URL and a secret before it will import at all, and none of that is needed to read a decorator. Needs no Django. Args: search_path: directory to scan. Defaults to the configured project.
| Name | Type | Req | Description |
|---|---|---|---|
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
find_n_plus_one ~137
Find relation traversals in a template that each cost a query. Resolves attribute chains against the real model graph and flags the ones that cross a relation inside a loop, which is where N+1 queries come from. Reports candidates: whether a crossing really costs a query depends on the queryset in the view, which this does not read. Args: template_path: path to the template file. root_models: context variable to model label, e.g. {"orders": "shop.Order"}. Loop variables inherit from these.
| Name | Type | Req | Description |
|---|---|---|---|
| root_models | object | yes | – |
| template_path | string | yes | – |
Structured output declared, but exposes no named fields.
No examples provided.
find_unscoped_queries ~234
Find querysets that read tenant-scoped data without scoping the query. This is the shape behind most IDOR reports: a view loads an object by primary key and never checks who owns it. Generic analysers struggle because the defect is the absence of a filter, and absence has no syntax. The model graph makes it tractable: it knows Order reaches the tenant root through 'customer', so it can tell that filtering on pk alone is not enough. Candidates, not verdicts. A filter in a base class, a mixin, a custom manager or a get_queryset() override is invisible from here. Args: tenant_root: the model that owns data, e.g. "auth.User" or "shop.Customer". search_path: directory to scan. Defaults to the project path. max_depth: how many relation hops still count as owned. include_exempt: also scan admin, management commands and tests.
| Name | Type | Req | Description |
|---|---|---|---|
| include_exempt | boolean | – | – |
| max_depth | integer | – | – |
| search_path | – | – | – |
| tenant_root | string | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
list_models ~70
List the project's models with their fields and relations. Args: app_label: Restrict to one app, e.g. "shop". Omit for all apps. include_fields: Set False for a short overview without field details.
| Name | Type | Req | Description |
|---|---|---|---|
| app_label | – | – | – |
| include_fields | boolean | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
migration_risk ~67
Rate migrations by what they do to a live database. Flags operations that block writes, rewrite a table, or break the code that is still running during a rolling deploy. Args: include_applied: also classify migrations that already ran.
| Name | Type | Req | Description |
|---|---|---|---|
| include_applied | boolean | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
missing_indexes ~174
Fields the code filters or sorts on that carry no index. Runtime tools answer this by watching real traffic, which only ever covers the paths traffic reached. Reading the source covers every path in the repository, works with no database and no traffic, and can run on a branch before it ships. The trade is that it cannot weigh anything: a filter on a forty row table looks like one on a forty million row table. It reports where an index is missing and how often the code asks for it, and leaves the decision to somebody who knows the row counts. Args: search_path: directory to scan. Defaults to the project path. min_occurrences: only report a field asked for at least this often.
| Name | Type | Req | Description |
|---|---|---|---|
| min_occurrences | integer | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
money_precision ~253
Places where a decimal amount stops being exact. A DecimalField exists so that money is exact. Four things give that up, and they are not equally bad: Decimal(0.1) wrong from birth - 0.1 has no exact binary form, so this is 0.1000000000000000055... float(invoice.amount) a one-way door; everything after is approximate round(amount, 2) exact, but banker's rounding: 0.125 becomes 0.12 where an invoice expects 0.13 FloatField("price") the column itself cannot hold money Decimal(0.5) is NOT reported as a defect: that float is exactly representable and nothing is lost. The check computes the round trip, so on a real project 41 of 50 Decimal(<float>) calls came back harmless and 11 were genuinely wrong. Nothing in the linter ecosystem looks at this; the usual advice stops at the model definition and every one of these happens somewhere else. Args: search_path: directory to scan. Defaults to the project root.
| Name | Type | Req | Description |
|---|---|---|---|
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
multiplied_aggregates ~245
Aggregates whose numbers are wrong because a join multiplied the rows. Order.objects.annotate(lines=Count("lines"), shipments=Count("shipments")) Joining two multi-valued relations gives the cartesian product of them: an order with 3 lines and 2 shipments produces 6 rows, and both counts come back as 6. Nothing raises. Two plausible numbers, both the product of the two, usually on a dashboard nobody can check by hand. Only Count and Sum are reported. A join repeats rows uniformly within each group, so Min and Max return the value they would anyway and Avg divides a multiplied total by a multiplied count - including them reported a correct query as a defect on the first real project this saw. Count(distinct=True) is treated as correct. Sum has no equivalent and needs a Subquery, so a query is still reported when every Count in it is distinct but a Sum crosses a second relation. Django's own documentation warns about this and no linter checks it. Args: search_path: directory to scan. Defaults to the configured project.
| Name | Type | Req | Description |
|---|---|---|---|
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
open_endpoints ~196
Endpoints anyone can call, crossed with what their serializer exposes. serializer_exposure knows CustomerExportSerializer leaks a password reset token; a Semgrep rule knows a view has AllowAny. Each alone is a judgement call - maybe the serializer only feeds an admin export, maybe the view serves a catalogue. Together there is nothing left to judge, and neither check can make the connection alone. DRF's own default permission is AllowAny. A project that never configured DEFAULT_PERMISSION_CLASSES has every view without explicit permission_classes open, and none of them say so; that is volunteered first. Views overriding get_permissions() are listed, not judged. Args: include_unbounded: also report open endpoints whose serializer uses fields="__all__" or exclude, even with nothing sensitive on the model today. The next migration decides what leaks.
| Name | Type | Req | Description |
|---|---|---|---|
| include_unbounded | boolean | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
project_info ~54
Check that the target Django project loads, and report what it is. Run this first when something is not working. It is the smallest call that proves both halves of the setup: the MCP transport and the Django boot.
Input schema present but exposes no named parameters.
Structured output declared, but exposes no named fields.
No examples provided.
project_profile ~115
What this project is built on, without needing Django to boot. Frameworks are counted by how many of the project's own files import them, not by what is installed: a package sitting in the virtualenv that nothing imports is a fact about the environment, not the code. A project can be Django and FastAPI at once and both are reported. Use it to find out which checks can say anything here. Args: search_path: directory to scan. Defaults to the configured project.
| Name | Type | Req | Description |
|---|---|---|---|
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
queries_in_loops ~248
Database work written inside a loop, split by what the fix is. The template and serializer checks here find the N+1 a framework causes. This finds the one somebody wrote by hand, which is where it lives in a codebase whose views build their responses themselves. Three shapes share one appearance and need three different fixes: Customer.objects.get(pk=order.customer_id) uses the loop variable: once per row, needs a bulk fetch or a prefetch Config.objects.get(key="vat") does not: the same question N times for the same answer, move it above the loop order.save() N round trips; bulk_update fixes it and skips signals django-check does static N+1 for relation access in a loop and is the closest existing tool; nplusone and the debug toolbar find it at runtime. The separation is what is added here. Args: search_path: directory to scan. Defaults to the configured project. include_writes: also report save()/delete() inside a loop.
| Name | Type | Req | Description |
|---|---|---|---|
| include_writes | boolean | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
race_conditions ~304
Read-modify-save races, and row locks taken outside any transaction. product = Product.objects.get(pk=pk) product.stock -= quantity product.save() Two requests read 10, both subtract 3, both write 7; one sale is gone. A transaction does not help, since neither sees the other's uncommitted write. The fix is F("stock") - quantity so the database does the maths, or select_for_update() inside atomic() to hold the row - and both of those are silent here. Counters, balances, stock, retry counts: the fields where off-by-one costs money. Also: get_or_create() on a lookup no unique field, unique_together or UniqueConstraint covers - two requests miss the get together, both create, and the next call raises MultipleObjectsReturned. And select_for_update() with no atomic() around it, which is not a race but a TransactionManagementError the first time the line is reached. Whether a transaction is open is judged with the call graph, so a caller's atomic(), a decorator and ATOMIC_REQUESTS on a view all count. Args: search_path: directory to scan. Defaults to the project root. include_parameters: also report mutations of an instance passed in as a parameter, at medium confidence (the caller may hold a lock).
| Name | Type | Req | Description |
|---|---|---|---|
| include_parameters | boolean | – | – |
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
request_impact ~257
Every finding, grouped by the entry points that actually reach it. The other checks answer "where is this defect". A few hundred correct entries sorted by severity still does not say where to start, because risk is severity times how often the code runs and nothing in the list says whether a line is on the path of an endpoint served ten thousand times an hour or of a command last run in 2023. This runs the checks, maps each finding to the function containing it, and walks the call graph backwards to the HTTP routes, Celery tasks, signal receivers and management commands that reach it. Each finding carries the path taken to it. `unattributed` means no entry point this can see reaches the finding. It does not mean unreachable and it does not mean safe - a plain Django function view carries no decorator and belongs to no view class. Args: search_path: directory to scan. Defaults to the configured project. max_depth: how many callers to walk back through. tenant_root: passed to the ownership check when this runs the checks.
| Name | Type | Req | Description |
|---|---|---|---|
| max_depth | integer | – | – |
| search_path | – | – | – |
| tenant_root | string | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
scan_templates ~129
Run the N+1 analysis over every template in a directory. find_n_plus_one needs a context map per template. This resolves it instead from class-based views that declare template_name together with model or queryset, so a whole project can be scanned without typing anything. Args: template_root: template directory. Defaults to the project path. project_root: where to look for views. Defaults to the project path. root_models: context applied to every template, for names no view supplies.
| Name | Type | Req | Description |
|---|---|---|---|
| project_root | – | – | – |
| root_models | – | – | – |
| template_root | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
serializer_exposure ~98
What each DRF ModelSerializer exposes, and what looks unintended. `fields = "__all__"` is a decision made once and then re-made silently by every migration after it. Add a token column to the model and the API starts returning it, with no diff on the serializer for anyone to review. Args: include_safe: also list serializers with an explicit, clean field list.
| Name | Type | Req | Description |
|---|---|---|---|
| include_safe | boolean | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
serializer_nplusone ~108
N+1 queries in DRF serializers, with the queryset fix for each. find_n_plus_one answers this for templates. Most Django written today renders JSON, and there the N+1 comes from a nested serializer field: a list of a hundred orders runs a hundred extra queries per nested relation. Follows nested serializers, so the lookup it suggests is the full path. Args: max_depth: how far to follow nested serializers.
| Name | Type | Req | Description |
|---|---|---|---|
| max_depth | integer | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
sqlalchemy_nplusone ~231
Relationships SQLAlchemy will load one row at a time. orders = db.query(Order).all() for order in orders: print(order.customer.name) # one query per order And the quieter FastAPI shape, where there is no loop to see: @app.get("/orders", response_model=list[OrderOut]) def list_orders(db=Depends(get_db)): return db.query(Order).all() OrderOut declares `items`, so serialisation walks the relationship once per row - after the endpoint has returned, which is why nothing in the function body mentions it. nplusone finds this at runtime by watching lazy loads happen, and lazy="raise" turns it into an exception; both need the code path to run. A relationship declared lazy="selectin", "joined" or "raise" is never reported here, since the first two are already eager and the third is the recommended fix. Nothing is imported. Needs no Django. Args: search_path: directory to scan. Defaults to the configured project.
| Name | Type | Req | Description |
|---|---|---|---|
| search_path | – | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
suggest_fixes ~195
Findings turned into code, grouped by how safe each one is to apply. Three classes, and the distinction is the point: - **mechanical**: one correct answer derivable from the code alone, such as datetime.now() becoming timezone.now(). No judgement in it. - **generated**: a machine can write the artefact, a human decides whether it should exist. An index migration is exactly right as text and entirely wrong if that table is write-heavy. - **advisory**: real code with the right names resolved, but the decision belongs to somebody who knows the domain. Which user owns a row is not a question the AST can answer. Nothing is applied. The CLI `fix --write` applies the mechanical class only. Args: tenant_root: the model that owns data, for the ownership suggestions.
| Name | Type | Req | Description |
|---|---|---|---|
| tenant_root | string | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
unused_eager_loading ~238
select_related and prefetch_related the serializer never reads. Every tool in this space looks the other way: a relation the serializer touches that the queryset did not prefetch, which is the N+1. This is the opposite, and it costs on every request. An unused select_related is a JOIN on every row; an unused prefetch_related is a whole extra query plus the objects it returns. It is invisible because it looks like an optimisation, and it usually was one - the field it was added for was removed and nobody takes the line out, because removing one feels riskier than leaving it in. nplusone finds this at runtime by watching which loaded objects go untouched, so it covers the paths the tests exercise. Both halves are in the source: the queryset says what it loads, the serializer what it reads. Args: include_low_confidence: also report views whose serializer has a SerializerMethodField or which override list/retrieve/ to_representation, where the relation may be read out of sight.
| Name | Type | Req | Description |
|---|---|---|---|
| include_low_confidence | boolean | – | – |
Structured output declared, but exposes no named fields.
No examples provided.
what_happens_on ~165
Follow the signal chain a save or delete actually triggers. Nothing at an `order.save()` call site hints that it also writes an Invoice, clears a cache and queues a task, because the receivers live elsewhere and were connected in AppConfig.ready(). Tools that list registered receivers exist; this follows the chain, because the second hop is where the surprise lives. It is also the other half of delete_impact, which deliberately ignores signals and says so. Args: model_label: "app_label.ModelName". event: "save" or "delete". max_depth: how far to follow writes into further signals.
| Name | Type | Req | Description |
|---|---|---|---|
| event | string | – | – |
| max_depth | integer | – | – |
| model_label | string | yes | – |
Structured output declared, but exposes no named fields.
No examples provided.
What is the Django Chainsaw MCP server?
Django Chainsaw is an MCP server listed in the public MCP registry as io.github.syrian963/django-chainsaw-mcp. Finds what will hurt in a Django project: cascades, N+1, unsafe migrations, tenant leaks. This page covers its PyPI package (django-chainsaw-mcp).
Is the Django Chainsaw MCP server safe to use?
Django Chainsaw scores 69 out of 100 on VerifyMCP. We found no known CVEs affecting it as of 21 September 2026. That is a record of what we were able to check automatically, not an endorsement. The category breakdown on this page shows every signal behind the number, including the ones we could not confirm.
What tools does the Django Chainsaw MCP server expose?
Django Chainsaw exposes 37 tools: project_info, endpoint_cost, api_contract, api_contract_check, escaping_side_effects, and 32 more. Their descriptions and schemas cost roughly 7,138 tokens of context every time the server is loaded.
Is the Django Chainsaw MCP server still maintained?
Django Chainsaw is still listed as active in the MCP registry. We last reached this channel on 21 September 2026. Those dates come from our own scans of the registry and the channel itself, not from anything the publisher announced.