django-mcp-sql 0.1.0a1__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.
- django_mcp_sql-0.1.0a1/CHANGELOG.md +53 -0
- django_mcp_sql-0.1.0a1/LICENSE +21 -0
- django_mcp_sql-0.1.0a1/MANIFEST.in +6 -0
- django_mcp_sql-0.1.0a1/PKG-INFO +278 -0
- django_mcp_sql-0.1.0a1/README.md +237 -0
- django_mcp_sql-0.1.0a1/__init__.py +6 -0
- django_mcp_sql-0.1.0a1/admin.py +240 -0
- django_mcp_sql-0.1.0a1/apps.py +45 -0
- django_mcp_sql-0.1.0a1/auth.py +395 -0
- django_mcp_sql-0.1.0a1/conf.py +328 -0
- django_mcp_sql-0.1.0a1/consts.py +52 -0
- django_mcp_sql-0.1.0a1/db_router.py +34 -0
- django_mcp_sql-0.1.0a1/decorators.py +72 -0
- django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/PKG-INFO +278 -0
- django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/SOURCES.txt +114 -0
- django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/dependency_links.txt +1 -0
- django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/requires.txt +15 -0
- django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/top_level.txt +1 -0
- django_mcp_sql-0.1.0a1/docs/architecture.md +711 -0
- django_mcp_sql-0.1.0a1/docs/oauth.md +560 -0
- django_mcp_sql-0.1.0a1/docs/role-setup.md +443 -0
- django_mcp_sql-0.1.0a1/executor.py +533 -0
- django_mcp_sql-0.1.0a1/fencing.py +76 -0
- django_mcp_sql-0.1.0a1/grants.py +339 -0
- django_mcp_sql-0.1.0a1/management/__init__.py +0 -0
- django_mcp_sql-0.1.0a1/management/commands/__init__.py +0 -0
- django_mcp_sql-0.1.0a1/management/commands/mcp_sql_grants.py +78 -0
- django_mcp_sql-0.1.0a1/management/commands/mcp_sql_lint.py +100 -0
- django_mcp_sql-0.1.0a1/management/commands/mcp_sql_role_setup.py +120 -0
- django_mcp_sql-0.1.0a1/management/commands/mcp_sql_smoke.py +287 -0
- django_mcp_sql-0.1.0a1/migrations/0001_initial.py +45 -0
- django_mcp_sql-0.1.0a1/migrations/0002_revoke_audit_grants.py +26 -0
- django_mcp_sql-0.1.0a1/migrations/0003_alter_mcpquerylog_result_sample.py +18 -0
- django_mcp_sql-0.1.0a1/migrations/0004_create_mcp_sql_users_group.py +28 -0
- django_mcp_sql-0.1.0a1/migrations/0005_create_mcp_sql_application.py +72 -0
- django_mcp_sql-0.1.0a1/migrations/0006_remove_mcpquerylog_result_sample.py +17 -0
- django_mcp_sql-0.1.0a1/migrations/0007_mcpauthrejectionlog.py +35 -0
- django_mcp_sql-0.1.0a1/migrations/0008_revoke_mcpauthrejectionlog_grants.py +49 -0
- django_mcp_sql-0.1.0a1/migrations/0009_alter_mcpauthrejectionlog_reason.py +18 -0
- django_mcp_sql-0.1.0a1/migrations/0010_mcpquerylog_tool.py +18 -0
- django_mcp_sql-0.1.0a1/migrations/0011_alter_mcpquerylog_options_mcpquerylog_profile_and_more.py +27 -0
- django_mcp_sql-0.1.0a1/migrations/__init__.py +0 -0
- django_mcp_sql-0.1.0a1/models.py +186 -0
- django_mcp_sql-0.1.0a1/oauth.py +56 -0
- django_mcp_sql-0.1.0a1/observability.py +89 -0
- django_mcp_sql-0.1.0a1/parser.py +660 -0
- django_mcp_sql-0.1.0a1/pyproject.toml +187 -0
- django_mcp_sql-0.1.0a1/schemas.py +176 -0
- django_mcp_sql-0.1.0a1/session.py +104 -0
- django_mcp_sql-0.1.0a1/setup.cfg +4 -0
- django_mcp_sql-0.1.0a1/signals.py +309 -0
- django_mcp_sql-0.1.0a1/sql/10_mcp_role.sh +38 -0
- django_mcp_sql-0.1.0a1/sql/role_setup.sql +74 -0
- django_mcp_sql-0.1.0a1/templates/admin/mcp_sql/mcpquerylog_change_list.html +8 -0
- django_mcp_sql-0.1.0a1/templates/admin/mcp_sql/usage_summary.html +53 -0
- django_mcp_sql-0.1.0a1/templates/mcp_sql/authorize.html +42 -0
- django_mcp_sql-0.1.0a1/throttle.py +102 -0
- django_mcp_sql-0.1.0a1/urls.py +76 -0
- django_mcp_sql-0.1.0a1/validation.py +263 -0
- django_mcp_sql-0.1.0a1/views/__init__.py +0 -0
- django_mcp_sql-0.1.0a1/views/discovery.py +110 -0
- django_mcp_sql-0.1.0a1/views/mcp_endpoint.py +511 -0
- django_mcp_sql-0.1.0a1/views/oauth_authorize.py +84 -0
- django_mcp_sql-0.1.0a1/views/registration.py +201 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `django-mcp-sql` are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
## 0.1.0a1 - unreleased
|
|
11
|
+
|
|
12
|
+
First alpha. The feature set below has been exercised in production as part
|
|
13
|
+
of a larger Django CRM; the standalone distribution itself is pre-release.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Three MCP tools over Streamable HTTP at `/mcp/sql/`: `list_tables`,
|
|
18
|
+
`describe_table`, `run_query` (single validated SELECT).
|
|
19
|
+
- sqlglot-backed AST parser gate: single SELECT-shaped statement, scope-aware
|
|
20
|
+
table whitelist, system-schema and function deny-lists, no
|
|
21
|
+
`SELECT *` (configurable), no writeable CTEs, no OFFSET/FETCH/locking
|
|
22
|
+
reads, no set-returning functions in the projection.
|
|
23
|
+
- Read-only executor: dedicated `mcp_readonly` Django DB alias,
|
|
24
|
+
`SET LOCAL ROLE` into a Postgres NOLOGIN role with statement-level guard
|
|
25
|
+
GUCs, most-restrictive-wins row caps with LIMIT N+1 truncation detection,
|
|
26
|
+
per-cell and total byte caps.
|
|
27
|
+
- Append-only audit: one `MCPQueryLog` row per `run_query` call (every code
|
|
28
|
+
path) and one `MCPAuthRejectionLog` row per resolved-user auth rejection;
|
|
29
|
+
read-only Django admin browsers plus a per-user usage-summary view.
|
|
30
|
+
- OAuth 2.1 surface via django-oauth-toolkit: authorization-code + PKCE
|
|
31
|
+
(S256 only), public client, 6h tokens, no refresh tokens; RFC 7591
|
|
32
|
+
dynamic client registration (loopback-only redirect URIs), RFC 8414 +
|
|
33
|
+
RFC 9728 discovery documents; issuance gate and per-request re-validation
|
|
34
|
+
(active staff + MFA + unambiguous profile + optional session-existence
|
|
35
|
+
check); logout revokes tokens.
|
|
36
|
+
- Multi-profile access tiers: N profiles in `MCP_SQL["PROFILES"]`, each its
|
|
37
|
+
own Postgres role, whitelist, Django permission and group;
|
|
38
|
+
explicit-assignment binding (superuser confers nothing); config-derived
|
|
39
|
+
group/permission provisioning via post_migrate; dormant per-profile
|
|
40
|
+
`SESSION_CONTEXT` hook for per-user row scoping recipes.
|
|
41
|
+
- Prompt-injection fencing: `run_query` rows/error wrapped in a per-response
|
|
42
|
+
random-UUID `<untrusted-data-…>` fence with a `data_handling` instruction;
|
|
43
|
+
standing security posture delivered via MCP `initialize` instructions.
|
|
44
|
+
- Grants tooling: `mcp_sql_grants` (drift check / `--apply`),
|
|
45
|
+
`mcp_sql_role_setup --emit-sql` (N-role bootstrap SQL),
|
|
46
|
+
`mcp_sql_smoke` (session-contract + end-to-end executor smoke),
|
|
47
|
+
`mcp_sql_lint` (column-add review gate); idempotent
|
|
48
|
+
`sql/role_setup.sql` + Docker init wrapper.
|
|
49
|
+
- Observability: per-user query-volume tripwires (alert, never block),
|
|
50
|
+
group-add alerts, silent per-IP throttle on bad-token probing and
|
|
51
|
+
anonymous registration.
|
|
52
|
+
- Standalone test suite (`tests/settings.py`, stock Django + Postgres) and
|
|
53
|
+
GitHub Actions CI (Python 3.11–3.13 × PostgreSQL 14).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Kharlamov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Without this, distutils' legacy default sweeps `tests/test_*.py` (and ONLY
|
|
2
|
+
# those — no conftest.py / settings.py / __init__.py / testapp/) into the
|
|
3
|
+
# sdist: an unimportable fragment. The suite is repo-only by design (see the
|
|
4
|
+
# package-data comment in pyproject.toml); keep the sdist clean instead.
|
|
5
|
+
prune tests
|
|
6
|
+
include CHANGELOG.md
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-mcp-sql
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Read-only PostgreSQL surface for an LLM agent over the MCP protocol, with defense-in-depth at parser, executor, DB-role, and transport layers.
|
|
5
|
+
Author: Ivan Kharlamov, Claude (Anthropic)
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/thepapermen/django-mcp-sql
|
|
8
|
+
Project-URL: Repository, https://github.com/thepapermen/django-mcp-sql
|
|
9
|
+
Project-URL: Issues, https://github.com/thepapermen/django-mcp-sql/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/thepapermen/django-mcp-sql/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: django,mcp,postgresql,llm-agent,oauth,read-only-sql
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Database
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: django<6.0,>=5.2
|
|
28
|
+
Requires-Dist: djangorestframework<4,>=3.15.2
|
|
29
|
+
Requires-Dist: django-oauth-toolkit<4,>=3.2
|
|
30
|
+
Requires-Dist: sqlglot<31,>=30.7
|
|
31
|
+
Requires-Dist: mcp<2,>=1.27
|
|
32
|
+
Requires-Dist: a2wsgi<2,>=1.10
|
|
33
|
+
Requires-Dist: pydantic<3,>=2.11
|
|
34
|
+
Provides-Extra: allauth
|
|
35
|
+
Requires-Dist: django-allauth[mfa]<66,>=65.14; extra == "allauth"
|
|
36
|
+
Provides-Extra: test
|
|
37
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
38
|
+
Requires-Dist: pytest-django>=4.8; extra == "test"
|
|
39
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == "test"
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# django-mcp-sql
|
|
43
|
+
|
|
44
|
+
[](https://pypi.org/project/django-mcp-sql/)
|
|
45
|
+
[](https://github.com/thepapermen/django-mcp-sql/actions/workflows/ci.yml)
|
|
46
|
+
|
|
47
|
+
A tightly scoped, read-only PostgreSQL surface for an LLM agent (e.g.
|
|
48
|
+
Claude Code) over the [Model Context Protocol](https://modelcontextprotocol.io/).
|
|
49
|
+
Defense-in-depth at four layers: parser (sqlglot AST validators), executor
|
|
50
|
+
(PG NOLOGIN role + GUCs), DB-role (`mcp_readonly_role` SELECT grants), and
|
|
51
|
+
transport (DRF + django-oauth-toolkit OAuth 2.1 with PKCE + RFC 7591/8414/9728
|
|
52
|
+
discovery).
|
|
53
|
+
|
|
54
|
+
> **Status**: pre-release alpha (`0.1.0a1`). The package is used in
|
|
55
|
+
> production as part of a larger Django project; expect the public API and
|
|
56
|
+
> settings shape to move between alpha releases.
|
|
57
|
+
|
|
58
|
+
## What you get
|
|
59
|
+
|
|
60
|
+
Three MCP tools mounted at `/mcp/sql/`:
|
|
61
|
+
|
|
62
|
+
| Tool | Purpose |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `list_tables()` | Returns the whitelisted db_tables for the surface (sorted). |
|
|
65
|
+
| `describe_table(name)` | Returns column types / null / pk for a whitelisted table. |
|
|
66
|
+
| `run_query(sql, limit=None)` | Validates + executes a single SELECT. Returns `{columns, rows, row_count, truncated, duration_ms, hint, rejection_reason, error, data_handling}`. `rows` (and `error`, when set) come back wrapped in a per-response random-UUID `<untrusted-data-…>` fence so DB content carrying a prompt-injection payload can't be read as agent instructions; `data_handling` explains the boundary. |
|
|
67
|
+
|
|
68
|
+
Every call writes one append-only `MCPQueryLog` audit row. Every auth
|
|
69
|
+
rejection writes one `MCPAuthRejectionLog` row (six resolved-user gates;
|
|
70
|
+
anonymous / bad-token probing goes through Django-cache counters with a
|
|
71
|
+
silent per-IP block, not the audit table — use a shared cache backend
|
|
72
|
+
(Redis, Memcached) in production: with a per-process backend like LocMem
|
|
73
|
+
the counters, and therefore the block, are per-worker).
|
|
74
|
+
|
|
75
|
+
**Observability** — per-user query-volume tripwires (one `ERROR` per
|
|
76
|
+
`(user, decision, window)` crossing of `VOLUME_ALERT_THRESHOLDS`; alerts,
|
|
77
|
+
never blocks), an `ERROR` when a user is added to the MCP permission group,
|
|
78
|
+
and read-only Django admin browsers for both audit tables plus a per-user
|
|
79
|
+
usage-summary view (allowed / rejected / auth-rejection counts per rolling
|
|
80
|
+
window). The package emits `logger.error` only — wire a Sentry
|
|
81
|
+
`LoggingIntegration(event_level=logging.ERROR)` to receive these as events;
|
|
82
|
+
the package itself never imports `sentry_sdk`.
|
|
83
|
+
|
|
84
|
+
## Postgres-only by design
|
|
85
|
+
|
|
86
|
+
The package depends on Postgres features that don't port: `SET LOCAL ROLE`
|
|
87
|
+
into a NOLOGIN role, `statement_timeout` / `lock_timeout` /
|
|
88
|
+
`idle_in_transaction_session_timeout` / `default_transaction_read_only`
|
|
89
|
+
GUCs, PG-only error codes (`57014`, `42501`), `CREATE OR REPLACE VIEW`
|
|
90
|
+
semantics, sqlglot's `dialect='postgres'`. There is no design path to
|
|
91
|
+
MySQL / SQLite without a parallel implementation — hence `django-mcp-sql`
|
|
92
|
+
not `django-mcp-mysql` etc.
|
|
93
|
+
|
|
94
|
+
## Installation
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
pip install django-mcp-sql
|
|
98
|
+
# Optional extras
|
|
99
|
+
pip install "django-mcp-sql[allauth]" # wire MFA gate to allauth.mfa.utils.is_mfa_enabled
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Then in your Django settings:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
INSTALLED_APPS = [
|
|
106
|
+
# ... your apps ...
|
|
107
|
+
"rest_framework",
|
|
108
|
+
"oauth2_provider",
|
|
109
|
+
"mcp_sql",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
DATABASES = {
|
|
113
|
+
"default": { ... },
|
|
114
|
+
# Required: dedicated read-only alias. The executor asserts
|
|
115
|
+
# connection.alias == MCP_SQL["DB_ALIAS"] before issuing any SELECT.
|
|
116
|
+
"mcp_readonly": {
|
|
117
|
+
# ... pointed at the same database as default but as a non-superuser ...
|
|
118
|
+
"OPTIONS": {"application_name": "mcp-readonly"},
|
|
119
|
+
"ATOMIC_REQUESTS": False,
|
|
120
|
+
"CONN_MAX_AGE": 0,
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
DATABASE_ROUTERS = ["mcp_sql.db_router.McpSqlRouter"]
|
|
125
|
+
|
|
126
|
+
MCP_SQL = {
|
|
127
|
+
"ALLOWED_MODELS": [
|
|
128
|
+
"auth.Permission", # your real whitelist goes here
|
|
129
|
+
],
|
|
130
|
+
"BAN_SELECT_STAR": True,
|
|
131
|
+
"LIMITS": {"DEFAULT_LIMIT": 10, "HARD_LIMIT": 100, "BYTES_LIMIT": 256 * 1024},
|
|
132
|
+
# Per-user volume tripwires: {decision: {window_seconds: threshold}}.
|
|
133
|
+
# Crossing emits one Sentry ERROR per (user, decision, window) bucket;
|
|
134
|
+
# it alerts, it never blocks.
|
|
135
|
+
"VOLUME_ALERT_THRESHOLDS": {
|
|
136
|
+
"allowed": {3600: 50, 86400: 150},
|
|
137
|
+
"rejected": {3600: 50, 86400: 150},
|
|
138
|
+
},
|
|
139
|
+
"BAD_TOKEN_IP_THRESHOLD": 100,
|
|
140
|
+
"BAD_TOKEN_IP_WINDOW_SECONDS": 21600,
|
|
141
|
+
# Optional overrides — see `mcp_sql/conf.py` DEFAULTS for the full list:
|
|
142
|
+
# "RESOURCE_NAME": "My App",
|
|
143
|
+
# "MFA_CHECKER": "allauth.mfa.utils.is_mfa_enabled",
|
|
144
|
+
# "SESSION_MODEL": "your_app.Session", # opt-in runtime session-existence gate;
|
|
145
|
+
# must be a session model with a `user` FK
|
|
146
|
+
# (stock `django.contrib.sessions.Session`
|
|
147
|
+
# does NOT qualify — its absence of a `user`
|
|
148
|
+
# column is why the default is `None`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
OAUTH2_PROVIDER = {
|
|
152
|
+
"OAUTH2_VALIDATOR_CLASS": "mcp_sql.oauth.MCPOAuth2Validator",
|
|
153
|
+
"SCOPES": {"mcp:sql": "Read-only SQL surface for MCP agents"},
|
|
154
|
+
"DEFAULT_SCOPES": ["mcp:sql"],
|
|
155
|
+
"ACCESS_TOKEN_EXPIRE_SECONDS": 6 * 3600,
|
|
156
|
+
"REFRESH_TOKEN_EXPIRE_SECONDS": 0,
|
|
157
|
+
"AUTHORIZATION_CODE_EXPIRE_SECONDS": 60,
|
|
158
|
+
"PKCE_REQUIRED": True,
|
|
159
|
+
"ALLOWED_REDIRECT_URI_SCHEMES": ["http"], # RFC 8252 loopback
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Wire the URLs in your project's `urls.py`:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
urlpatterns = [
|
|
167
|
+
# ... your routes ...
|
|
168
|
+
path("", include("mcp_sql.urls")),
|
|
169
|
+
]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Then run the DBA setup once per environment (creates the
|
|
173
|
+
`mcp_readonly_role` Postgres role + role-level guard GUCs):
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
psql -U <superuser> -d <database> \
|
|
177
|
+
-v app_role=<your_app_role> \
|
|
178
|
+
-f $(python -c "import mcp_sql, os; print(os.path.join(os.path.dirname(mcp_sql.__file__), 'sql/role_setup.sql'))")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Then apply migrations and the SELECT grants:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
python manage.py migrate
|
|
185
|
+
python manage.py mcp_sql_grants --apply
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Documentation
|
|
189
|
+
|
|
190
|
+
The architecture / design doc and the full operational runbooks ship inside
|
|
191
|
+
the package (importable consumers find them under `mcp_sql/docs/`):
|
|
192
|
+
|
|
193
|
+
- `docs/architecture.md` — design, file map, settings shape, OAuth surface,
|
|
194
|
+
curated-view pattern, the complete "Watch out" list.
|
|
195
|
+
- `docs/role-setup.md` — DBA setup, grants reconciliation, sanity checks.
|
|
196
|
+
- `docs/oauth.md` — OAuth issuance gate, MCP client registration, incident response.
|
|
197
|
+
|
|
198
|
+
## Compatibility
|
|
199
|
+
|
|
200
|
+
- **Python**: 3.11+
|
|
201
|
+
- **Django**: 5.2+ (LTS line; 6.0 untested).
|
|
202
|
+
- **Postgres**: 14+ recommended (uses `pg_has_role`, `information_schema.role_table_grants`, `SET LOCAL ROLE`, `CREATE OR REPLACE VIEW` — all of which work on earlier versions, but the test matrix runs on 14+).
|
|
203
|
+
|
|
204
|
+
The package has been exercised against a 2000+ test suite in a real
|
|
205
|
+
Django CRM. Its own standalone suite (`make test`, settings in
|
|
206
|
+
`tests/settings.py`) runs in CI across Python 3.11–3.13 against
|
|
207
|
+
PostgreSQL 14 (`.github/workflows/ci.yml`).
|
|
208
|
+
|
|
209
|
+
## Postgres role setup
|
|
210
|
+
|
|
211
|
+
Once per environment, a DBA with PG superuser rights applies
|
|
212
|
+
`sql/role_setup.sql` to create the `mcp_readonly_role` role + the
|
|
213
|
+
role-level guard GUCs (`statement_timeout`, `lock_timeout`,
|
|
214
|
+
`idle_in_transaction_session_timeout`, `default_transaction_read_only`)
|
|
215
|
+
and grant the role membership to the consuming app's PG user. The script
|
|
216
|
+
is idempotent and is parameterised by a `-v app_role=<role>` psql
|
|
217
|
+
variable so a single SQL file works across deployments whose app role
|
|
218
|
+
differs.
|
|
219
|
+
|
|
220
|
+
```sh
|
|
221
|
+
psql -h <pg_host> -U <pg_superuser> -d <database> \
|
|
222
|
+
-v app_role=<app_pg_role> \
|
|
223
|
+
-f sql/role_setup.sql
|
|
224
|
+
|
|
225
|
+
# Verify:
|
|
226
|
+
psql -h <pg_host> -U <pg_superuser> -d <database> -c "\du mcp_readonly_role"
|
|
227
|
+
# Expected: row present, "Cannot login".
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
After the role exists, apply the package's migrations and reconcile the
|
|
231
|
+
table-level SELECT grants:
|
|
232
|
+
|
|
233
|
+
```sh
|
|
234
|
+
python manage.py migrate
|
|
235
|
+
python manage.py mcp_sql_grants --apply
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
See `docs/role-setup.md` for the full DBA-facing runbook (drift
|
|
239
|
+
detection, CI gates, troubleshooting).
|
|
240
|
+
|
|
241
|
+
## Local example
|
|
242
|
+
|
|
243
|
+
A standalone, stock-Django consumer of the package lives in the
|
|
244
|
+
[`example/`](https://github.com/thepapermen/django-mcp-sql/tree/main/example)
|
|
245
|
+
directory of the repository (not shipped in the wheel). It demonstrates the
|
|
246
|
+
package against a vanilla Django setup — `auth.User`, stock sessions, no
|
|
247
|
+
allauth — including a two-profile (multi-tier) configuration with a
|
|
248
|
+
row-and-column-limited curated view. Its own README carries the full
|
|
249
|
+
end-to-end runbook: bootstrap, OAuth dance, and registering the server with
|
|
250
|
+
`claude mcp add`.
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
Run the package's own test suite (needs `uv` and a reachable PostgreSQL —
|
|
255
|
+
see `tests/settings.py` for the `MCP_SQL_TEST_PG_*` connection env vars.
|
|
256
|
+
Bootstrap `mcp_readonly_role` via `sql/role_setup.sql` first — several
|
|
257
|
+
tests enter it with `SET LOCAL ROLE` — and connect as a superuser so the
|
|
258
|
+
role-isolation tests run instead of skipping):
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
make test
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Build the distribution and verify the wheel installs cleanly into a fresh
|
|
265
|
+
venv (Django-independent imports + package-data presence):
|
|
266
|
+
|
|
267
|
+
```sh
|
|
268
|
+
make build # produces ./dist/django_mcp_sql-<version>-py3-none-any.whl + .tar.gz
|
|
269
|
+
make test-install # ephemeral build + venv install + import & package-data smoke
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
All targets require `uv` on PATH (install once: `curl -LsSf https://astral.sh/uv/install.sh | sh`).
|
|
273
|
+
Release/extraction mechanics live in `RELEASING.md`; contribution
|
|
274
|
+
expectations in `CONTRIBUTING.md`.
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
MIT.
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# django-mcp-sql
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/django-mcp-sql/)
|
|
4
|
+
[](https://github.com/thepapermen/django-mcp-sql/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
A tightly scoped, read-only PostgreSQL surface for an LLM agent (e.g.
|
|
7
|
+
Claude Code) over the [Model Context Protocol](https://modelcontextprotocol.io/).
|
|
8
|
+
Defense-in-depth at four layers: parser (sqlglot AST validators), executor
|
|
9
|
+
(PG NOLOGIN role + GUCs), DB-role (`mcp_readonly_role` SELECT grants), and
|
|
10
|
+
transport (DRF + django-oauth-toolkit OAuth 2.1 with PKCE + RFC 7591/8414/9728
|
|
11
|
+
discovery).
|
|
12
|
+
|
|
13
|
+
> **Status**: pre-release alpha (`0.1.0a1`). The package is used in
|
|
14
|
+
> production as part of a larger Django project; expect the public API and
|
|
15
|
+
> settings shape to move between alpha releases.
|
|
16
|
+
|
|
17
|
+
## What you get
|
|
18
|
+
|
|
19
|
+
Three MCP tools mounted at `/mcp/sql/`:
|
|
20
|
+
|
|
21
|
+
| Tool | Purpose |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `list_tables()` | Returns the whitelisted db_tables for the surface (sorted). |
|
|
24
|
+
| `describe_table(name)` | Returns column types / null / pk for a whitelisted table. |
|
|
25
|
+
| `run_query(sql, limit=None)` | Validates + executes a single SELECT. Returns `{columns, rows, row_count, truncated, duration_ms, hint, rejection_reason, error, data_handling}`. `rows` (and `error`, when set) come back wrapped in a per-response random-UUID `<untrusted-data-…>` fence so DB content carrying a prompt-injection payload can't be read as agent instructions; `data_handling` explains the boundary. |
|
|
26
|
+
|
|
27
|
+
Every call writes one append-only `MCPQueryLog` audit row. Every auth
|
|
28
|
+
rejection writes one `MCPAuthRejectionLog` row (six resolved-user gates;
|
|
29
|
+
anonymous / bad-token probing goes through Django-cache counters with a
|
|
30
|
+
silent per-IP block, not the audit table — use a shared cache backend
|
|
31
|
+
(Redis, Memcached) in production: with a per-process backend like LocMem
|
|
32
|
+
the counters, and therefore the block, are per-worker).
|
|
33
|
+
|
|
34
|
+
**Observability** — per-user query-volume tripwires (one `ERROR` per
|
|
35
|
+
`(user, decision, window)` crossing of `VOLUME_ALERT_THRESHOLDS`; alerts,
|
|
36
|
+
never blocks), an `ERROR` when a user is added to the MCP permission group,
|
|
37
|
+
and read-only Django admin browsers for both audit tables plus a per-user
|
|
38
|
+
usage-summary view (allowed / rejected / auth-rejection counts per rolling
|
|
39
|
+
window). The package emits `logger.error` only — wire a Sentry
|
|
40
|
+
`LoggingIntegration(event_level=logging.ERROR)` to receive these as events;
|
|
41
|
+
the package itself never imports `sentry_sdk`.
|
|
42
|
+
|
|
43
|
+
## Postgres-only by design
|
|
44
|
+
|
|
45
|
+
The package depends on Postgres features that don't port: `SET LOCAL ROLE`
|
|
46
|
+
into a NOLOGIN role, `statement_timeout` / `lock_timeout` /
|
|
47
|
+
`idle_in_transaction_session_timeout` / `default_transaction_read_only`
|
|
48
|
+
GUCs, PG-only error codes (`57014`, `42501`), `CREATE OR REPLACE VIEW`
|
|
49
|
+
semantics, sqlglot's `dialect='postgres'`. There is no design path to
|
|
50
|
+
MySQL / SQLite without a parallel implementation — hence `django-mcp-sql`
|
|
51
|
+
not `django-mcp-mysql` etc.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
pip install django-mcp-sql
|
|
57
|
+
# Optional extras
|
|
58
|
+
pip install "django-mcp-sql[allauth]" # wire MFA gate to allauth.mfa.utils.is_mfa_enabled
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then in your Django settings:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
INSTALLED_APPS = [
|
|
65
|
+
# ... your apps ...
|
|
66
|
+
"rest_framework",
|
|
67
|
+
"oauth2_provider",
|
|
68
|
+
"mcp_sql",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
DATABASES = {
|
|
72
|
+
"default": { ... },
|
|
73
|
+
# Required: dedicated read-only alias. The executor asserts
|
|
74
|
+
# connection.alias == MCP_SQL["DB_ALIAS"] before issuing any SELECT.
|
|
75
|
+
"mcp_readonly": {
|
|
76
|
+
# ... pointed at the same database as default but as a non-superuser ...
|
|
77
|
+
"OPTIONS": {"application_name": "mcp-readonly"},
|
|
78
|
+
"ATOMIC_REQUESTS": False,
|
|
79
|
+
"CONN_MAX_AGE": 0,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
DATABASE_ROUTERS = ["mcp_sql.db_router.McpSqlRouter"]
|
|
84
|
+
|
|
85
|
+
MCP_SQL = {
|
|
86
|
+
"ALLOWED_MODELS": [
|
|
87
|
+
"auth.Permission", # your real whitelist goes here
|
|
88
|
+
],
|
|
89
|
+
"BAN_SELECT_STAR": True,
|
|
90
|
+
"LIMITS": {"DEFAULT_LIMIT": 10, "HARD_LIMIT": 100, "BYTES_LIMIT": 256 * 1024},
|
|
91
|
+
# Per-user volume tripwires: {decision: {window_seconds: threshold}}.
|
|
92
|
+
# Crossing emits one Sentry ERROR per (user, decision, window) bucket;
|
|
93
|
+
# it alerts, it never blocks.
|
|
94
|
+
"VOLUME_ALERT_THRESHOLDS": {
|
|
95
|
+
"allowed": {3600: 50, 86400: 150},
|
|
96
|
+
"rejected": {3600: 50, 86400: 150},
|
|
97
|
+
},
|
|
98
|
+
"BAD_TOKEN_IP_THRESHOLD": 100,
|
|
99
|
+
"BAD_TOKEN_IP_WINDOW_SECONDS": 21600,
|
|
100
|
+
# Optional overrides — see `mcp_sql/conf.py` DEFAULTS for the full list:
|
|
101
|
+
# "RESOURCE_NAME": "My App",
|
|
102
|
+
# "MFA_CHECKER": "allauth.mfa.utils.is_mfa_enabled",
|
|
103
|
+
# "SESSION_MODEL": "your_app.Session", # opt-in runtime session-existence gate;
|
|
104
|
+
# must be a session model with a `user` FK
|
|
105
|
+
# (stock `django.contrib.sessions.Session`
|
|
106
|
+
# does NOT qualify — its absence of a `user`
|
|
107
|
+
# column is why the default is `None`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
OAUTH2_PROVIDER = {
|
|
111
|
+
"OAUTH2_VALIDATOR_CLASS": "mcp_sql.oauth.MCPOAuth2Validator",
|
|
112
|
+
"SCOPES": {"mcp:sql": "Read-only SQL surface for MCP agents"},
|
|
113
|
+
"DEFAULT_SCOPES": ["mcp:sql"],
|
|
114
|
+
"ACCESS_TOKEN_EXPIRE_SECONDS": 6 * 3600,
|
|
115
|
+
"REFRESH_TOKEN_EXPIRE_SECONDS": 0,
|
|
116
|
+
"AUTHORIZATION_CODE_EXPIRE_SECONDS": 60,
|
|
117
|
+
"PKCE_REQUIRED": True,
|
|
118
|
+
"ALLOWED_REDIRECT_URI_SCHEMES": ["http"], # RFC 8252 loopback
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Wire the URLs in your project's `urls.py`:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
urlpatterns = [
|
|
126
|
+
# ... your routes ...
|
|
127
|
+
path("", include("mcp_sql.urls")),
|
|
128
|
+
]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Then run the DBA setup once per environment (creates the
|
|
132
|
+
`mcp_readonly_role` Postgres role + role-level guard GUCs):
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
psql -U <superuser> -d <database> \
|
|
136
|
+
-v app_role=<your_app_role> \
|
|
137
|
+
-f $(python -c "import mcp_sql, os; print(os.path.join(os.path.dirname(mcp_sql.__file__), 'sql/role_setup.sql'))")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Then apply migrations and the SELECT grants:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
python manage.py migrate
|
|
144
|
+
python manage.py mcp_sql_grants --apply
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Documentation
|
|
148
|
+
|
|
149
|
+
The architecture / design doc and the full operational runbooks ship inside
|
|
150
|
+
the package (importable consumers find them under `mcp_sql/docs/`):
|
|
151
|
+
|
|
152
|
+
- `docs/architecture.md` — design, file map, settings shape, OAuth surface,
|
|
153
|
+
curated-view pattern, the complete "Watch out" list.
|
|
154
|
+
- `docs/role-setup.md` — DBA setup, grants reconciliation, sanity checks.
|
|
155
|
+
- `docs/oauth.md` — OAuth issuance gate, MCP client registration, incident response.
|
|
156
|
+
|
|
157
|
+
## Compatibility
|
|
158
|
+
|
|
159
|
+
- **Python**: 3.11+
|
|
160
|
+
- **Django**: 5.2+ (LTS line; 6.0 untested).
|
|
161
|
+
- **Postgres**: 14+ recommended (uses `pg_has_role`, `information_schema.role_table_grants`, `SET LOCAL ROLE`, `CREATE OR REPLACE VIEW` — all of which work on earlier versions, but the test matrix runs on 14+).
|
|
162
|
+
|
|
163
|
+
The package has been exercised against a 2000+ test suite in a real
|
|
164
|
+
Django CRM. Its own standalone suite (`make test`, settings in
|
|
165
|
+
`tests/settings.py`) runs in CI across Python 3.11–3.13 against
|
|
166
|
+
PostgreSQL 14 (`.github/workflows/ci.yml`).
|
|
167
|
+
|
|
168
|
+
## Postgres role setup
|
|
169
|
+
|
|
170
|
+
Once per environment, a DBA with PG superuser rights applies
|
|
171
|
+
`sql/role_setup.sql` to create the `mcp_readonly_role` role + the
|
|
172
|
+
role-level guard GUCs (`statement_timeout`, `lock_timeout`,
|
|
173
|
+
`idle_in_transaction_session_timeout`, `default_transaction_read_only`)
|
|
174
|
+
and grant the role membership to the consuming app's PG user. The script
|
|
175
|
+
is idempotent and is parameterised by a `-v app_role=<role>` psql
|
|
176
|
+
variable so a single SQL file works across deployments whose app role
|
|
177
|
+
differs.
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
psql -h <pg_host> -U <pg_superuser> -d <database> \
|
|
181
|
+
-v app_role=<app_pg_role> \
|
|
182
|
+
-f sql/role_setup.sql
|
|
183
|
+
|
|
184
|
+
# Verify:
|
|
185
|
+
psql -h <pg_host> -U <pg_superuser> -d <database> -c "\du mcp_readonly_role"
|
|
186
|
+
# Expected: row present, "Cannot login".
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
After the role exists, apply the package's migrations and reconcile the
|
|
190
|
+
table-level SELECT grants:
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
python manage.py migrate
|
|
194
|
+
python manage.py mcp_sql_grants --apply
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
See `docs/role-setup.md` for the full DBA-facing runbook (drift
|
|
198
|
+
detection, CI gates, troubleshooting).
|
|
199
|
+
|
|
200
|
+
## Local example
|
|
201
|
+
|
|
202
|
+
A standalone, stock-Django consumer of the package lives in the
|
|
203
|
+
[`example/`](https://github.com/thepapermen/django-mcp-sql/tree/main/example)
|
|
204
|
+
directory of the repository (not shipped in the wheel). It demonstrates the
|
|
205
|
+
package against a vanilla Django setup — `auth.User`, stock sessions, no
|
|
206
|
+
allauth — including a two-profile (multi-tier) configuration with a
|
|
207
|
+
row-and-column-limited curated view. Its own README carries the full
|
|
208
|
+
end-to-end runbook: bootstrap, OAuth dance, and registering the server with
|
|
209
|
+
`claude mcp add`.
|
|
210
|
+
|
|
211
|
+
## Development
|
|
212
|
+
|
|
213
|
+
Run the package's own test suite (needs `uv` and a reachable PostgreSQL —
|
|
214
|
+
see `tests/settings.py` for the `MCP_SQL_TEST_PG_*` connection env vars.
|
|
215
|
+
Bootstrap `mcp_readonly_role` via `sql/role_setup.sql` first — several
|
|
216
|
+
tests enter it with `SET LOCAL ROLE` — and connect as a superuser so the
|
|
217
|
+
role-isolation tests run instead of skipping):
|
|
218
|
+
|
|
219
|
+
```sh
|
|
220
|
+
make test
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Build the distribution and verify the wheel installs cleanly into a fresh
|
|
224
|
+
venv (Django-independent imports + package-data presence):
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
make build # produces ./dist/django_mcp_sql-<version>-py3-none-any.whl + .tar.gz
|
|
228
|
+
make test-install # ephemeral build + venv install + import & package-data smoke
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
All targets require `uv` on PATH (install once: `curl -LsSf https://astral.sh/uv/install.sh | sh`).
|
|
232
|
+
Release/extraction mechanics live in `RELEASING.md`; contribution
|
|
233
|
+
expectations in `CONTRIBUTING.md`.
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
MIT.
|