django-chainsaw-mcp
This MCP server statically analyzes a Django (plus some FastAPI/SQLAlchemy/Python) project to surface bugs, risks, and performance issues before runtime.
Run a combined
checkthat executes all applicable analyses and returns severity-sorted findings.Inspect project and model structure:
project_info,list_models,explain_model,delete_impact, andwhat_happens_onsignal chains.Detect query performance problems: N+1 in templates and DRF serializers, queries in loops, unused eager loading, missing indexes, and endpoint query costs.
Assess migrations and deploy safety:
migration_risk,deploy_safety, and whether code still references removed fields.Find security and tenant-isolation issues: unscoped queries/IDOR, open endpoints, FastAPI exposure, and amplification attacks.
Audit correctness and consistency: race conditions, bypassed bulk-write effects, escaping side effects, money precision, datetime naivety, choice typos, dangling references, multiplied aggregates, and Celery arguments.
Compare API contracts to catch breaking serializer changes via
api_contractandapi_contract_check.Generate fix suggestions grouped by safety (mechanical/generated/advisory) and produce request-impact or HTML report views.
Profile non-Django Python projects for async blocking, SQLAlchemy N+1, FastAPI exposure, and framework composition.
Analyses Django projects through the app registry: inspects models and relations, calculates delete impact, finds N+1 queries in templates and DRF serializers, rates migration and deploy safety, detects unscoped tenant queries, signal side effects, race conditions, and more.
Reads FastAPI routes to extend analysis beyond Django, helping determine which findings are reachable through API endpoints.
Reads SQLAlchemy models or mappings to provide cross-framework analysis beyond Django's app registry.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@django-chainsaw-mcp Find risky patterns like N+1 queries and unsafe migrations in my Django project"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
django-chainsaw-mcp
An MCP server and CLI that analyses a Django project rather than describing
it. Not what is in here — what will hurt: what a delete takes with it,
which migration breaks the pods still running, which query returns another
tenant's row, what a save() sets off three hops away.
There is no model in the loop. Every answer comes from the AST and Django's own app registry, so the same input gives the same output, and nothing leaves the machine. It is an MCP server so an assistant can ask it questions, and a CLI so CI can gate on the answers.
22 checks: 19 need the app registry (16 Django, three of those DRF as well), one needs only Python, one reads FastAPI, one reads SQLAlchemy. Why this exists →
Install
It has to run in an interpreter that can import your project. Everything
here reads the app registry, which means django.setup(), your settings and
your apps:
/path/to/project/.venv/bin/python -m pip install django-chainsaw-mcpA plain uvx django-chainsaw-mcp starts and then fails every check, because
uvx gives it an isolated environment with no trace of your project. To avoid
installing, hand uv the dependencies instead:
uvx --with-requirements requirements.txt --from django-chainsaw-mcp django-chainsaw checkTwo environment variables point it at the project:
Variable | Example |
|
|
|
|
django-chainsaw project-info proves the setup before anything else, and says
which half is missing. Five minutes end to end →
mcp-name: io.github.syrian963/django-chainsaw-mcp
Related MCP server: mcp-tenant-isolation
One command
django-chainsaw check --tenant-root myapp.Organisation44 finding(s): 1 critical, 14 high, 29 medium
CRITICAL
--------
[deploy-safety] RemoveField drops 'legacy_code' while code still uses it
shop/0002_remove_product_legacy_code
During a rolling deploy the old pods keep running against the new
schema and will fail.
fix: Ship a release that stops using it, deploy that everywhere,
then ship this migration.Every analysis, merged, worst first, one exit code.
On a pull request
This is the part that decides whether a tool like this survives. Point tenancy
at a five year old project and it returns two hundred candidates; nobody reads
two hundred candidates, somebody adds continue-on-error, and it runs forever
with nobody looking.
django-chainsaw tenancy --since main # only what this branch changed
django-chainsaw tenancy --baseline # everything old, ratcheted
django-chainsaw check --sarif out.json # annotate the diff, on the line--since compares at the merge base, so a branch that is behind main is not
blamed for other people's work. --baseline keeps existing findings in the
report and stops them blocking; anything new fails the build, and fixing an old
one is reported so the number only ever goes down. Findings are fingerprinted on
file plus identity, never the line, so adding an import does not resurrect
twenty findings nobody touched.
--sarif writes the format GitHub and GitLab annotate a pull request with, so
findings land on the line instead of in a log nobody opens.
As an MCP server
claude mcp add django-chainsaw --scope local \
--env DJANGO_CHAINSAW_PROJECT_PATH=/srv/app \
--env DJANGO_CHAINSAW_SETTINGS_MODULE=myproject.settings \
-- /srv/app/.venv/bin/python -m django_chainsaw_mcp.serverAsk it project_info first: the smallest call that proves both the transport
and the Django boot. Five prompts carry the ordering the tools do not —
before_deploy, why_is_this_slow, what_breaks_if_i_delete, triage,
review_this_branch.
Claude Desktop, Cursor, VS Code, Windsurf, Zed, Docker →
Or as one HTML file
django-chainsaw report --out findings.html --title myproject
Grouped by endpoint is the view that matters: which pages carry this, and through what call path. No server, no network, no build step — the CSS, the script and the data are all in the file, so it works from a CI artifact or an email attachment. report.md
The checks
Tool | Answers |
| Does the target project load at all? Run this first when something is broken. |
| Every model with fields, relation kind, direction and |
| Delete one row: what cascades, what blocks, what gets nulled. Transitive. |
| Relation traversals in a template that each cost a query, and the fix. |
| The same across a directory, resolving context from views. |
| Migrations rated: blocks writes, rewrites the table, breaks running code. |
| Is this destructive migration safe to ship yet? |
| Which queries read data the caller may not own? The IDOR shape. |
| What does this save actually trigger? Follows the signal chain. |
| Fields the code filters or sorts on that carry no index. |
| Naive datetimes and field defaults that break when the clock moves. |
| What DRF serializers expose, including what the next migration will add. |
| N+1 in DRF serializers, which is where it lives in an API project. |
| Everything about one model, plus the risks only visible combined. |
| How many queries one request costs, before anybody sends one. |
| What this branch changes about the API, and who it breaks. |
| Mail and tasks fired inside a transaction that can still roll back. |
| Bulk writes that skip everything the |
| Counters read into Python, changed, and saved. Also unsafe upserts. |
| Where a decimal amount stops being exact. |
| What the worker actually receives, and whether it can even be called. |
| Queries written inside a loop, split by which of three fixes applies. |
| Prefetches paid for and then re-queried by the accessor that reads them. |
| Every finding grouped by the entry points that reach it, so the question becomes which endpoint to fix. |
| Literals a field's |
| Counts and sums multiplied by a join across two multi-valued relations. |
| URL names, templates, signal senders and Celery tasks nothing will resolve. |
| Sensitive fields on endpoints anybody can call. |
| Joins and prefetches nothing in the response reads. |
| Run everything that applies, one severity-sorted list, one exit code. |
| Findings turned into code, grouped by how safe each one is to apply. |
Tool | Answers |
| What is this built on? Counted from the project's own imports. |
| Which synchronous call stops the event loop for every request? |
| Endpoints that serialise more than they declare. |
| Relationships loaded one row at a time, including during serialisation. |
| Which endpoint can a stranger use to exhaust the database? |
Plus the resource django://models. check profiles the project first and runs
what applies, and says "does not apply, and here is why" for the rest —
silence would read exactly like a clean result. Nothing about the FastAPI
support imports the project, so those checks run on a checkout with no
dependencies installed at all.
36 of the 37 tools declare readOnlyHint, so a client can stop asking
permission for each call; the exception is api_contract_check with
update=True, which writes the snapshot and says so.
Every tool, argument and output shape →
What it will not tell you
Nothing here executes the target project or reads its data, which buys safety and speed and costs certainty. Every tool states its own blind spots in its own output:
delete_impactdoes not run signals or customdelete()overrides.find_n_plus_onereports candidates; it reads the template and the model graph, not the queryset in the view.migration_riskdoes not know row counts, PostgreSQL version, or deploy strategy.deploy_safetycannot seegetattr(obj, name), runtime SQL, or another repository.CLEARmeans nothing was found here.
A confident wrong answer is worse than an incomplete one. In this kind of tooling the failure mode is not a crash, it is a plausible sentence that sends someone in the wrong direction. limitations.md
What it does to your code
It imports the target project. django.setup() imports your settings and
every app in INSTALLED_APPS, and the checks additionally import the modules
that declare serializers, views and URLs — so anything those do at import time
happens. Do not point this at code you would not run.
It does not run your application: no view, no task, no management command.
One check reads the database, read-only — MigrationLoader reads
django_migrations, and nothing is written.
It writes files only when you ask: fix --write applies the mechanical
class of fix only, and a baseline, a contract snapshot or --sarif write where
you tell them to. Nothing leaves the machine — no network calls, no
telemetry, no uploads.
Documentation
docs/ is the index.
why this exists, three checks worth reading about, and the bar a new one clears | |
five minutes from clone to first finding | |
installing against a real project, Docker, troubleshooting | |
Claude Code, Cursor, VS Code, Windsurf, Zed | |
commands, exit codes, CI | |
every tool, argument and output shape | |
eighteen public projects, what they found in this tool, and the checks that never fired | |
what the analysis cannot see | |
ratcheting, so this survives a legacy codebase | |
suggestions as real code, and which can be applied | |
how it is put together, and why the bootstrap drives the design | |
where the time goes on a large project |
Contributing
workflow, house style, how to run the suites | |
every release, and the reasoning behind the changes | |
what this does to the code you point it at | |
be straight with people and be kind about it |
A proposal for a new check answers four questions, which the issue template asks directly: what the defect looks like as code, how it fails in production, what already finds it, and what it must stay silent on. Two finished features were deleted from this repository after measurement showed they could not tell a real finding from a correct one.
MIT. One process stays bound to the first project it loads, because
django.setup() cannot be undone — run a second instance for a second project.
Available Tools
37 toolsamplificationBRead-onlyIdempotent
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.
Inputs: search_path. Returns: object
api_contractARead-onlyIdempotent
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.
Inputs: max_depth. Returns: object
api_contract_checkAIdempotent
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.
Inputs: update, max_depth, snapshot_path. Returns: object
blocking_in_asyncARead-onlyIdempotent
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.
Inputs: max_depth, search_path, follow_calls. Returns: object
bypassed_effectsARead-onlyIdempotent
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".
Inputs: model, search_path. Returns: object
celery_argumentsARead-onlyIdempotent
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.
Inputs: search_path. Returns: object
checkARead-onlyIdempotent
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.
Inputs: only, skip, tenant_root. Returns: What `check` returns. Nothing is required. The error envelope (`ok: false` with…
choice_typosARead-onlyIdempotent
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.
Inputs: search_path, include_tests. Returns: object
dangling_referencesARead-onlyIdempotent
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.
Inputs: search_path, include_tests, include_templates. Returns: object
datetime_auditARead-onlyIdempotent
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.
Inputs: search_path. Returns: object
defeated_prefetchesARead-onlyIdempotent
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.
Inputs: search_path, include_tests. Returns: object
delete_impactARead-onlyIdempotent
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.
Inputs: model_label (required), max_depth. Returns: object
deploy_safetyARead-onlyIdempotent
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.
Inputs: search_path, max_hits_per_symbol. Returns: object
endpoint_costARead-onlyIdempotent
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.
Inputs: list_only, page_size, nested_fan_out. Returns: object
escaping_side_effectsARead-onlyIdempotent
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.
Inputs: search_path, include_low_confidence. Returns: object
explain_modelARead-onlyIdempotent
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.
Inputs: model_label (required), include_raw, tenant_root. Returns: object
fastapi_exposureARead-onlyIdempotent
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.
Inputs: search_path. Returns: object
find_n_plus_oneARead-onlyIdempotent
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.
Inputs: root_models (required), template_path (required). Returns: object
find_unscoped_queriesARead-onlyIdempotent
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.
Inputs: max_depth, search_path, tenant_root, include_exempt. Returns: object
list_modelsARead-onlyIdempotent
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.
Inputs: app_label, include_fields. Returns: object
migration_riskARead-onlyIdempotent
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.
Inputs: include_applied. Returns: object
missing_indexesARead-onlyIdempotent
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.
Inputs: search_path, min_occurrences. Returns: object
money_precisionARead-onlyIdempotent
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.
Inputs: search_path. Returns: object
multiplied_aggregatesARead-onlyIdempotent
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.
Inputs: search_path. Returns: object
open_endpointsARead-onlyIdempotent
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.
Inputs: include_unbounded. Returns: object
project_infoARead-onlyIdempotent
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.
Inputs: no named parameters. Returns: object
project_profileARead-onlyIdempotent
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.
Inputs: search_path. Returns: object
queries_in_loopsARead-onlyIdempotent
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.
Inputs: search_path, include_writes. Returns: object
race_conditionsARead-onlyIdempotent
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).
Inputs: search_path, include_parameters. Returns: object
request_impactARead-onlyIdempotent
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.
Inputs: max_depth, search_path, tenant_root. Returns: object
scan_templatesARead-onlyIdempotent
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.
Inputs: root_models, project_root, template_root. Returns: object
serializer_exposureARead-onlyIdempotent
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.
Inputs: include_safe. Returns: object
serializer_nplusoneARead-onlyIdempotent
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.
Inputs: max_depth. Returns: object
sqlalchemy_nplusoneARead-onlyIdempotent
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.
Inputs: search_path. Returns: object
suggest_fixesARead-onlyIdempotent
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.
Inputs: tenant_root. Returns: object
unused_eager_loadingARead-onlyIdempotent
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.
Inputs: include_low_confidence. Returns: object
what_happens_onARead-onlyIdempotent
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.
Inputs: model_label (required), event, max_depth. Returns: object
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 37 tools
Several tools cluster around related concerns—N+1 queries (defeated_prefetches, unused_eager_loading, serializer_nplusone, sqlalchemy_nplusone, find_n_plus_one, scan_templates, queries_in_loops) and API exposure (api_contract, serializer_exposure, open_endpoints, amplification, fastapi_exposure). The individual descriptions do differentiate them, but an agent assembling a workflow would need to read carefully to avoid selecting a near-miss tool.
All names are snake_case and largely domain-specific, but they mix grammatical forms: noun phrases (project_info, endpoint_cost, missing_indexes), past participles (bypassed_effects, multiplied_aggregates), and bare verbs (check, explain_model). Related concepts are phrased inconsistently (find_n_plus_one vs serializer_nplusone vs defeated_prefetches), so the convention is readable but not predictable.
37 tools is well beyond the 'heavy' range and several could be consolidated (notably the N+1 cluster and the exposure/serializer cluster). The broad framework coverage (Django, DRF, FastAPI, SQLAlchemy, Celery) explains part of the size, but it makes the surface feel bloated rather than focused.
For a static analysis/linter server, the surface is remarkably broad: migrations, serializers, security, tenant-scoping, deletion cascades, signals, datetime, indexes, aggregates, and async/Celery pitfalls are all covered. Minor gaps exist—there is no direct MCP tool to apply fixes or manage snapshots beyond check and suggest_fixes—but no core workflow feels like a dead end.
Maintenance
Related MCP Connectors
Zero-config MCP security scanner for AI-generated apps. 25K+ vulnerability patterns.
Detects database migration table locks, terraform cost leaks, and OWASP API flaws.
Security, SEO and AI-visibility scanner for web apps · free scans and focused checks via MCP.
Code review for diffs: rule-based checks for leaked secrets, unsafe migrations and more.
Related MCP Servers
- FlicenseBqualityCmaintenanceEnables comprehensive security vulnerability scanning and code quality analysis for Python applications. Provides detailed reports with scoring, actionable suggestions, and comparison tracking specifically designed for backend developers working with frameworks like Django, Flask, and FastAPI.51-
- AlicenseNot gradedqualityBmaintenanceMCP server for static analysis of multi-tenant SaaS and MCP server code, catching cross-tenant data leakage. Provides tools to scan code, list/explain rules, and manage suppressions.1,216 npm5MIT
- AlicenseNot gradedqualityCmaintenanceEnables security scanning of source code and Apple configuration files, providing evidence-based findings, explanations, remediation plans, threat modeling, and knowledge base access through MCP tools.MIT
- AlicenseNot gradedqualityDmaintenanceStatic analysis scanner for multi-tenant SaaS and MCP server code that catches cross-tenant data leakage with 57 deterministic rules, including an MCP server for Claude Desktop and Cursor integration.1,216 npmMIT