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.
Files changed (64) hide show
  1. django_mcp_sql-0.1.0a1/CHANGELOG.md +53 -0
  2. django_mcp_sql-0.1.0a1/LICENSE +21 -0
  3. django_mcp_sql-0.1.0a1/MANIFEST.in +6 -0
  4. django_mcp_sql-0.1.0a1/PKG-INFO +278 -0
  5. django_mcp_sql-0.1.0a1/README.md +237 -0
  6. django_mcp_sql-0.1.0a1/__init__.py +6 -0
  7. django_mcp_sql-0.1.0a1/admin.py +240 -0
  8. django_mcp_sql-0.1.0a1/apps.py +45 -0
  9. django_mcp_sql-0.1.0a1/auth.py +395 -0
  10. django_mcp_sql-0.1.0a1/conf.py +328 -0
  11. django_mcp_sql-0.1.0a1/consts.py +52 -0
  12. django_mcp_sql-0.1.0a1/db_router.py +34 -0
  13. django_mcp_sql-0.1.0a1/decorators.py +72 -0
  14. django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/PKG-INFO +278 -0
  15. django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/SOURCES.txt +114 -0
  16. django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/dependency_links.txt +1 -0
  17. django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/requires.txt +15 -0
  18. django_mcp_sql-0.1.0a1/django_mcp_sql.egg-info/top_level.txt +1 -0
  19. django_mcp_sql-0.1.0a1/docs/architecture.md +711 -0
  20. django_mcp_sql-0.1.0a1/docs/oauth.md +560 -0
  21. django_mcp_sql-0.1.0a1/docs/role-setup.md +443 -0
  22. django_mcp_sql-0.1.0a1/executor.py +533 -0
  23. django_mcp_sql-0.1.0a1/fencing.py +76 -0
  24. django_mcp_sql-0.1.0a1/grants.py +339 -0
  25. django_mcp_sql-0.1.0a1/management/__init__.py +0 -0
  26. django_mcp_sql-0.1.0a1/management/commands/__init__.py +0 -0
  27. django_mcp_sql-0.1.0a1/management/commands/mcp_sql_grants.py +78 -0
  28. django_mcp_sql-0.1.0a1/management/commands/mcp_sql_lint.py +100 -0
  29. django_mcp_sql-0.1.0a1/management/commands/mcp_sql_role_setup.py +120 -0
  30. django_mcp_sql-0.1.0a1/management/commands/mcp_sql_smoke.py +287 -0
  31. django_mcp_sql-0.1.0a1/migrations/0001_initial.py +45 -0
  32. django_mcp_sql-0.1.0a1/migrations/0002_revoke_audit_grants.py +26 -0
  33. django_mcp_sql-0.1.0a1/migrations/0003_alter_mcpquerylog_result_sample.py +18 -0
  34. django_mcp_sql-0.1.0a1/migrations/0004_create_mcp_sql_users_group.py +28 -0
  35. django_mcp_sql-0.1.0a1/migrations/0005_create_mcp_sql_application.py +72 -0
  36. django_mcp_sql-0.1.0a1/migrations/0006_remove_mcpquerylog_result_sample.py +17 -0
  37. django_mcp_sql-0.1.0a1/migrations/0007_mcpauthrejectionlog.py +35 -0
  38. django_mcp_sql-0.1.0a1/migrations/0008_revoke_mcpauthrejectionlog_grants.py +49 -0
  39. django_mcp_sql-0.1.0a1/migrations/0009_alter_mcpauthrejectionlog_reason.py +18 -0
  40. django_mcp_sql-0.1.0a1/migrations/0010_mcpquerylog_tool.py +18 -0
  41. django_mcp_sql-0.1.0a1/migrations/0011_alter_mcpquerylog_options_mcpquerylog_profile_and_more.py +27 -0
  42. django_mcp_sql-0.1.0a1/migrations/__init__.py +0 -0
  43. django_mcp_sql-0.1.0a1/models.py +186 -0
  44. django_mcp_sql-0.1.0a1/oauth.py +56 -0
  45. django_mcp_sql-0.1.0a1/observability.py +89 -0
  46. django_mcp_sql-0.1.0a1/parser.py +660 -0
  47. django_mcp_sql-0.1.0a1/pyproject.toml +187 -0
  48. django_mcp_sql-0.1.0a1/schemas.py +176 -0
  49. django_mcp_sql-0.1.0a1/session.py +104 -0
  50. django_mcp_sql-0.1.0a1/setup.cfg +4 -0
  51. django_mcp_sql-0.1.0a1/signals.py +309 -0
  52. django_mcp_sql-0.1.0a1/sql/10_mcp_role.sh +38 -0
  53. django_mcp_sql-0.1.0a1/sql/role_setup.sql +74 -0
  54. django_mcp_sql-0.1.0a1/templates/admin/mcp_sql/mcpquerylog_change_list.html +8 -0
  55. django_mcp_sql-0.1.0a1/templates/admin/mcp_sql/usage_summary.html +53 -0
  56. django_mcp_sql-0.1.0a1/templates/mcp_sql/authorize.html +42 -0
  57. django_mcp_sql-0.1.0a1/throttle.py +102 -0
  58. django_mcp_sql-0.1.0a1/urls.py +76 -0
  59. django_mcp_sql-0.1.0a1/validation.py +263 -0
  60. django_mcp_sql-0.1.0a1/views/__init__.py +0 -0
  61. django_mcp_sql-0.1.0a1/views/discovery.py +110 -0
  62. django_mcp_sql-0.1.0a1/views/mcp_endpoint.py +511 -0
  63. django_mcp_sql-0.1.0a1/views/oauth_authorize.py +84 -0
  64. 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
+ [![PyPI](https://img.shields.io/pypi/v/django-mcp-sql)](https://pypi.org/project/django-mcp-sql/)
45
+ [![CI](https://github.com/thepapermen/django-mcp-sql/actions/workflows/ci.yml/badge.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/django-mcp-sql)](https://pypi.org/project/django-mcp-sql/)
4
+ [![CI](https://github.com/thepapermen/django-mcp-sql/actions/workflows/ci.yml/badge.svg)](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.
@@ -0,0 +1,6 @@
1
+ """django-mcp-sql.
2
+
3
+ Read-only PostgreSQL surface for an LLM agent over the MCP protocol.
4
+ """
5
+
6
+ __version__ = "0.1.0a1"