django-mcp-sql 0.1.0a1__py3-none-any.whl

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 (57) hide show
  1. django_mcp_sql-0.1.0a1.dist-info/METADATA +278 -0
  2. django_mcp_sql-0.1.0a1.dist-info/RECORD +57 -0
  3. django_mcp_sql-0.1.0a1.dist-info/WHEEL +5 -0
  4. django_mcp_sql-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  5. django_mcp_sql-0.1.0a1.dist-info/top_level.txt +1 -0
  6. mcp_sql/__init__.py +6 -0
  7. mcp_sql/admin.py +240 -0
  8. mcp_sql/apps.py +45 -0
  9. mcp_sql/auth.py +395 -0
  10. mcp_sql/conf.py +328 -0
  11. mcp_sql/consts.py +52 -0
  12. mcp_sql/db_router.py +34 -0
  13. mcp_sql/decorators.py +72 -0
  14. mcp_sql/docs/architecture.md +711 -0
  15. mcp_sql/docs/oauth.md +560 -0
  16. mcp_sql/docs/role-setup.md +443 -0
  17. mcp_sql/executor.py +533 -0
  18. mcp_sql/fencing.py +76 -0
  19. mcp_sql/grants.py +339 -0
  20. mcp_sql/management/__init__.py +0 -0
  21. mcp_sql/management/commands/__init__.py +0 -0
  22. mcp_sql/management/commands/mcp_sql_grants.py +78 -0
  23. mcp_sql/management/commands/mcp_sql_lint.py +100 -0
  24. mcp_sql/management/commands/mcp_sql_role_setup.py +120 -0
  25. mcp_sql/management/commands/mcp_sql_smoke.py +287 -0
  26. mcp_sql/migrations/0001_initial.py +45 -0
  27. mcp_sql/migrations/0002_revoke_audit_grants.py +26 -0
  28. mcp_sql/migrations/0003_alter_mcpquerylog_result_sample.py +18 -0
  29. mcp_sql/migrations/0004_create_mcp_sql_users_group.py +28 -0
  30. mcp_sql/migrations/0005_create_mcp_sql_application.py +72 -0
  31. mcp_sql/migrations/0006_remove_mcpquerylog_result_sample.py +17 -0
  32. mcp_sql/migrations/0007_mcpauthrejectionlog.py +35 -0
  33. mcp_sql/migrations/0008_revoke_mcpauthrejectionlog_grants.py +49 -0
  34. mcp_sql/migrations/0009_alter_mcpauthrejectionlog_reason.py +18 -0
  35. mcp_sql/migrations/0010_mcpquerylog_tool.py +18 -0
  36. mcp_sql/migrations/0011_alter_mcpquerylog_options_mcpquerylog_profile_and_more.py +27 -0
  37. mcp_sql/migrations/__init__.py +0 -0
  38. mcp_sql/models.py +186 -0
  39. mcp_sql/oauth.py +56 -0
  40. mcp_sql/observability.py +89 -0
  41. mcp_sql/parser.py +660 -0
  42. mcp_sql/schemas.py +176 -0
  43. mcp_sql/session.py +104 -0
  44. mcp_sql/signals.py +309 -0
  45. mcp_sql/sql/10_mcp_role.sh +38 -0
  46. mcp_sql/sql/role_setup.sql +74 -0
  47. mcp_sql/templates/admin/mcp_sql/mcpquerylog_change_list.html +8 -0
  48. mcp_sql/templates/admin/mcp_sql/usage_summary.html +53 -0
  49. mcp_sql/templates/mcp_sql/authorize.html +42 -0
  50. mcp_sql/throttle.py +102 -0
  51. mcp_sql/urls.py +76 -0
  52. mcp_sql/validation.py +263 -0
  53. mcp_sql/views/__init__.py +0 -0
  54. mcp_sql/views/discovery.py +110 -0
  55. mcp_sql/views/mcp_endpoint.py +511 -0
  56. mcp_sql/views/oauth_authorize.py +84 -0
  57. mcp_sql/views/registration.py +201 -0
@@ -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,57 @@
1
+ django_mcp_sql-0.1.0a1.dist-info/licenses/LICENSE,sha256=J9wxXiLlVvjXpiSKCRoBT3IvcQp3WwK0R0HK4U50ZI0,1071
2
+ mcp_sql/__init__.py,sha256=3vrbyJfW4C0ufPH0MF8CthVo0a97OjEoob40ZpRTH-0,118
3
+ mcp_sql/admin.py,sha256=iJtW1ORLJNZRGyX794QMvWm-fu_8iLn7GQK7fsEq8F0,8871
4
+ mcp_sql/apps.py,sha256=Z2y5ABH5KoFXS-zrERZX60K5SZ6vSqfxK2sCdRqQV0U,1674
5
+ mcp_sql/auth.py,sha256=wb8DVPQbD9lhlG-fFSEElZcdqgl3CaufIwubsC-g_tg,19752
6
+ mcp_sql/conf.py,sha256=Nw6XTmiu9CdCaBY3RW1MDGSO0oAZ-PHCeS7kCQv6VcQ,15793
7
+ mcp_sql/consts.py,sha256=nff2OPifscDhJF8UWj_hka5T6Wk9ro3ZXVTrJplkrnw,2423
8
+ mcp_sql/db_router.py,sha256=nd6iuSng7gijYv7KYQelqKnKXvD6XVVdnKFr7NRcqBU,1796
9
+ mcp_sql/decorators.py,sha256=fVexC_ty5pbpYdeKAea2fMX2QJ2U6ZGbMK6cdp9hkss,3121
10
+ mcp_sql/executor.py,sha256=rVZLSSQUna8ZcdklwTxV7urySJEg6xHnooFM2vKHKBE,20912
11
+ mcp_sql/fencing.py,sha256=xQmmOPiWrV9ClAeFSNU4sQr5EiaWhC2enr0UOY8_ZfM,3352
12
+ mcp_sql/grants.py,sha256=3HRAV3xEZLrtTL1MNn8730zyiiZZ3K1iCYHynKR8PwY,13687
13
+ mcp_sql/models.py,sha256=dWNdMmHzFNnMteF82ToUhaEXV0p1zCjJBwTa-IoyMes,8830
14
+ mcp_sql/oauth.py,sha256=haeiXPApsOsU4wcRvU58L_sEXerP5dC0C6sGfr3AzfI,2656
15
+ mcp_sql/observability.py,sha256=Rwru3HQ919NnesPCpd_zITcxYXfeSj6w6q7lMbNZmZs,4038
16
+ mcp_sql/parser.py,sha256=LoSirb6HV7sHVYXHWpO6vpRXPIEmHsLBzSsk4IgWDkQ,30753
17
+ mcp_sql/schemas.py,sha256=v9XmBqrv9jgcWEdKgfGHjzgQ6azkstFkq0Tdjf-RgxQ,7556
18
+ mcp_sql/session.py,sha256=cgkQdsnxv1a_qcCF81FFT4iprTTYvfu7bxXgRH33D14,4606
19
+ mcp_sql/signals.py,sha256=Bq4KbAbgA4jMVtIbUGkrkl3lYJrBjp1E2595Vv9RBlE,13426
20
+ mcp_sql/throttle.py,sha256=ykwCumoUcJ7w8hbQAQdqVnNsnNMRQCqPXf3cWgCgkIo,4559
21
+ mcp_sql/urls.py,sha256=tAr4HFDqxFWcoMlfBAYQwm9wdHqP7o7KtLd9eOmtaR0,3347
22
+ mcp_sql/validation.py,sha256=qOd1jRv6sTb0rLpehENb3xZ0v8bZXDcd6fdXynV8S08,11337
23
+ mcp_sql/docs/architecture.md,sha256=R7L1PozXw8YuhcJIFr5yQJ_diQNESh2Fi_tyQ4qQ7pg,67734
24
+ mcp_sql/docs/oauth.md,sha256=Z5PtI2lte6pb9nOl173Kxls2GSIRPJEune4arq6lLyI,27049
25
+ mcp_sql/docs/role-setup.md,sha256=TDypv2TBJYF-TtIuZ9bXrkKOu5GqGI-tsnXqYn19Zh8,17700
26
+ mcp_sql/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
+ mcp_sql/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
+ mcp_sql/management/commands/mcp_sql_grants.py,sha256=fBQkLp2OpZ3x9z7GHmxYMED-83CsWWXqDNJ8HLeL-Z0,3141
29
+ mcp_sql/management/commands/mcp_sql_lint.py,sha256=7KHcskEU32uvqbmJ7l2QPBKxss2N2_1riU80Hbhf9zA,3257
30
+ mcp_sql/management/commands/mcp_sql_role_setup.py,sha256=sc_QExVKOGpezxryLTpVpOIM1K0hN5qVq7oXC8_FeA4,4612
31
+ mcp_sql/management/commands/mcp_sql_smoke.py,sha256=JF56kT2hpP1PEneyTfG8CrYR6vP6wZHWlS3AceejVvc,12405
32
+ mcp_sql/migrations/0001_initial.py,sha256=UvYtd1LMfPokp_6WSD0DeKy4GwtKx-C7uobXmumoa8M,2307
33
+ mcp_sql/migrations/0002_revoke_audit_grants.py,sha256=jeWE0F5pqJLdxtzNDKSMIDW9TpxCrcNG5zXqJoqwdwc,485
34
+ mcp_sql/migrations/0003_alter_mcpquerylog_result_sample.py,sha256=qYuXrf0hRe5h9u_UBSoAuSxzsD2a-gvY-YmazgJifPo,411
35
+ mcp_sql/migrations/0004_create_mcp_sql_users_group.py,sha256=ZTKmY587EO-SMOF6s89IpGvHha1VDYnD1QfaKiz8izs,1286
36
+ mcp_sql/migrations/0005_create_mcp_sql_application.py,sha256=kp1LK6ohZcoikjUX171eFfig5vfkC_NIhS5PICINyCY,3361
37
+ mcp_sql/migrations/0006_remove_mcpquerylog_result_sample.py,sha256=gUB-2jiG0SAMymixen4cVlQIX_4BR849hH-UQrYoJYw,349
38
+ mcp_sql/migrations/0007_mcpauthrejectionlog.py,sha256=Ixyf7l0CSIk3w3LiQd-KdMuZYpS4DNUZSnuEXiBG-ww,1977
39
+ mcp_sql/migrations/0008_revoke_mcpauthrejectionlog_grants.py,sha256=blQYJcd283daIlOyU7GENc6fyDHgi3EJUTwxF8hoDPA,1708
40
+ mcp_sql/migrations/0009_alter_mcpauthrejectionlog_reason.py,sha256=FKL4wbM8PqZQEZYIMSUtdaeRUlzdQZfncpi_NhRlWdI,836
41
+ mcp_sql/migrations/0010_mcpquerylog_tool.py,sha256=JExavu-q59DyDorp6JL_ifmTqsCkKqTGcZMfKAui5vk,423
42
+ mcp_sql/migrations/0011_alter_mcpquerylog_options_mcpquerylog_profile_and_more.py,sha256=7xYsNQf-rd-oFjkHO7Dgdv5gnPaqCasBeSAI-ldvFP4,1271
43
+ mcp_sql/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
+ mcp_sql/sql/10_mcp_role.sh,sha256=jdL6hzZkky9JU-1uLSHtaS_T26PFTAmNT09bmi00spA,1936
45
+ mcp_sql/sql/role_setup.sql,sha256=MeWtc1Z4lATpbSUDt6fqnOJVAQCrcplsuYdOjj6h9A4,3566
46
+ mcp_sql/templates/admin/mcp_sql/mcpquerylog_change_list.html,sha256=VnZBpqOx204ipH_NIKbiNUdbXGEKcjiR4-BU80E07QI,229
47
+ mcp_sql/templates/admin/mcp_sql/usage_summary.html,sha256=K3xWBelNfwcFoOY186AL7xXcshYkjkITnSpsqiZSbKg,1637
48
+ mcp_sql/templates/mcp_sql/authorize.html,sha256=FNVMRhjt8UUaQt3rJZkfZEJnCgtNFNQhj1IHQwVwazM,1572
49
+ mcp_sql/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
+ mcp_sql/views/discovery.py,sha256=xg0DtF9YJ1CYZts1HRs-WMDMe7q504GGnCQEyJ0aHqU,4814
51
+ mcp_sql/views/mcp_endpoint.py,sha256=OPLF5svBZZUaCF8uAQrDN-YrbcIOtflRljbcTTfeSuQ,23893
52
+ mcp_sql/views/oauth_authorize.py,sha256=eegFLe5bSFm86BxQm6S3lnCEHtFGZdoVm6QoFHFOjyc,3982
53
+ mcp_sql/views/registration.py,sha256=G6lQH4pqSLSDtv2qBY-d8zEFZilFosyKOSLWhgetBuU,9506
54
+ django_mcp_sql-0.1.0a1.dist-info/METADATA,sha256=0b8mespTjHicBg4bcdHQtsPj3RbadaS_2ggCCZR-OOY,11274
55
+ django_mcp_sql-0.1.0a1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
56
+ django_mcp_sql-0.1.0a1.dist-info/top_level.txt,sha256=vgGs1OTAVjBuHkT8ANto9EOtce_kwc3fvcu4TWtEF18,8
57
+ django_mcp_sql-0.1.0a1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ mcp_sql
mcp_sql/__init__.py ADDED
@@ -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"
mcp_sql/admin.py ADDED
@@ -0,0 +1,240 @@
1
+ """Unregister django-oauth-toolkit ModelAdmin classes.
2
+
3
+ DOT auto-registers ModelAdmin for `Application`, `AccessToken`, `Grant`,
4
+ `RefreshToken`, and `IDToken` when `oauth2_provider` is in
5
+ `INSTALLED_APPS`. Those admins are write-enabled to superusers, which
6
+ would bypass two design invariants of the MCP SQL surface:
7
+
8
+ 1. The single-Application contract. A superuser could create a new
9
+ Application, attach a redirect URI under their control, and mint
10
+ tokens with `mcp:sql` scope. `MCPOAuth2Authentication` rejects tokens
11
+ not bound to an `mcp-sql` Application, but keeping a parallel write
12
+ path open invites confusion and a future regression.
13
+ 2. The locked-down redirect URI. Migration 0005 sets the canonical
14
+ `mcp-sql` Application's `redirect_uris` to the single loopback entry
15
+ `http://127.0.0.1` (RFC 8252 §7.3). Editing it via the admin would
16
+ reroute the OAuth flow to an attacker-controlled URI in a single edit.
17
+
18
+ Unregistering does not remove the underlying tables (DOT still uses them
19
+ at runtime). It only removes the operator-facing admin surface. Token
20
+ lifecycle operations (revoke a user's tokens, audit usage) are documented
21
+ in `docs/oauth.md` and use the Django shell.
22
+
23
+ This module also registers READ-ONLY admins for the two audit tables
24
+ (`MCPQueryLog`, `MCPAuthRejectionLog`) plus a per-user usage-summary view
25
+ (allowed/rejected query counts + auth-rejection counts per rolling window) —
26
+ the instrument for tuning `MCP_SQL["VOLUME_ALERT_THRESHOLDS"]`.
27
+ """
28
+
29
+ import contextlib
30
+ from datetime import timedelta
31
+
32
+ from django.contrib import admin
33
+ from django.contrib.admin.sites import NotRegistered
34
+ from django.contrib.auth import get_user_model
35
+ from django.core.exceptions import PermissionDenied
36
+ from django.db.models import Count
37
+ from django.template.response import TemplateResponse
38
+ from django.urls import path
39
+ from django.utils import timezone
40
+ from oauth2_provider.models import AccessToken
41
+ from oauth2_provider.models import Application
42
+ from oauth2_provider.models import Grant
43
+ from oauth2_provider.models import IDToken
44
+ from oauth2_provider.models import RefreshToken
45
+
46
+ from mcp_sql.conf import mcp_sql_settings
47
+ from mcp_sql.models import MCPAuthRejectionLog
48
+ from mcp_sql.models import MCPQueryLog
49
+
50
+ for model_cls in (Application, AccessToken, Grant, RefreshToken, IDToken):
51
+ with contextlib.suppress(NotRegistered):
52
+ admin.site.unregister(model_cls)
53
+
54
+
55
+ # Rolling windows for the usage summary: label → seconds.
56
+ _USAGE_WINDOWS = (("1h", 3600), ("24h", 86400), ("7d", 604800))
57
+
58
+ # Search / ordering / summary labels traverse the consumer user model's
59
+ # USERNAME_FIELD — the same model-agnostic contract as `get_username()`.
60
+ _USER_LOOKUP = f"user__{get_user_model().USERNAME_FIELD}"
61
+
62
+
63
+ class _ProfileListFilter(admin.SimpleListFilter):
64
+ """Filter by configured tier names from `MCP_SQL["PROFILES"]`.
65
+
66
+ NOT the stock `AllValuesFieldListFilter` a bare `"profile"` entry would
67
+ give: `profile` is an unindexed, choice-less CharField, so the stock
68
+ filter runs `SELECT DISTINCT profile` over the unbounded append-only
69
+ audit table on every changelist render. Configured tiers are also the
70
+ more correct lookup set — operators filter by live tiers, not by
71
+ whatever historical strings accumulated.
72
+ """
73
+
74
+ title = "profile"
75
+ parameter_name = "profile"
76
+
77
+ def lookups(self, request, model_admin):
78
+ return [(name, name) for name in sorted(mcp_sql_settings.profiles())]
79
+
80
+ def queryset(self, request, queryset):
81
+ if self.value():
82
+ return queryset.filter(profile=self.value())
83
+ return queryset
84
+
85
+
86
+ class _ReadOnlyModelAdmin(admin.ModelAdmin):
87
+ """View-only admin: browse rows, never add/change/delete.
88
+
89
+ The audit tables are append-only — the executor, signal receivers, and
90
+ auth class are the only writers — so the admin must not open a write
91
+ path. A LOCAL mixin (not some consumer-provided read-only mixin) keeps
92
+ the package free of project imports.
93
+ """
94
+
95
+ def has_add_permission(self, request):
96
+ return False
97
+
98
+ def has_change_permission(self, request, obj=None):
99
+ return False
100
+
101
+ def has_delete_permission(self, request, obj=None):
102
+ return False
103
+
104
+
105
+ @admin.register(MCPQueryLog)
106
+ class MCPQueryLogAdmin(_ReadOnlyModelAdmin):
107
+ """Read-only browser for query audit rows + the per-user usage summary.
108
+
109
+ Search / ordering / the `user_email` display all key on the consumer
110
+ user model's `USERNAME_FIELD` (via `_USER_LOOKUP` / `get_username()`),
111
+ so the admin works against any user model.
112
+ """
113
+
114
+ date_hierarchy = "started_at"
115
+ list_display = (
116
+ "id",
117
+ "started_at",
118
+ "user_email",
119
+ "profile",
120
+ "decision",
121
+ "tool",
122
+ "rejection_reason",
123
+ "row_count",
124
+ "truncated",
125
+ "duration_ms",
126
+ "client_ip",
127
+ )
128
+ list_filter = ("decision", "tool", "truncated", _ProfileListFilter)
129
+ search_fields = (_USER_LOOKUP, "rejection_reason", "client_ip")
130
+ list_select_related = ("user",)
131
+ change_list_template = "admin/mcp_sql/mcpquerylog_change_list.html"
132
+
133
+ @admin.display(description="user", ordering=_USER_LOOKUP)
134
+ def user_email(self, obj):
135
+ return obj.user.get_username()
136
+
137
+ def get_urls(self):
138
+ return [
139
+ path(
140
+ "usage-summary/",
141
+ self.admin_site.admin_view(self.usage_summary_view),
142
+ name="mcp_sql_usage_summary",
143
+ ),
144
+ *super().get_urls(),
145
+ ]
146
+
147
+ def usage_summary_view(self, request):
148
+ # `admin_view` enforces staff login but NOT the model view
149
+ # permission — re-add it so only operators allowed to read the audit
150
+ # log see the per-user volume breakdown.
151
+ if not self.has_view_permission(request):
152
+ raise PermissionDenied
153
+ return TemplateResponse(
154
+ request,
155
+ "admin/mcp_sql/usage_summary.html",
156
+ {
157
+ **self.admin_site.each_context(request),
158
+ "title": "MCP usage summary",
159
+ "opts": self.model._meta,
160
+ "window_labels": [label for label, _ in _USAGE_WINDOWS],
161
+ "col_count": len(_USAGE_WINDOWS) * 3 + 1,
162
+ "rows": _usage_rows(),
163
+ },
164
+ )
165
+
166
+
167
+ @admin.register(MCPAuthRejectionLog)
168
+ class MCPAuthRejectionLogAdmin(_ReadOnlyModelAdmin):
169
+ """Read-only browser for resolved-user access-ending events."""
170
+
171
+ date_hierarchy = "started_at"
172
+ list_display = (
173
+ "id",
174
+ "started_at",
175
+ "user_email",
176
+ "reason",
177
+ "application_name",
178
+ "client_ip",
179
+ )
180
+ list_filter = ("reason",)
181
+ search_fields = (_USER_LOOKUP, "application_name", "client_ip")
182
+ list_select_related = ("user",)
183
+
184
+ @admin.display(description="user", ordering=_USER_LOOKUP)
185
+ def user_email(self, obj):
186
+ return obj.user.get_username()
187
+
188
+
189
+ def _usage_rows():
190
+ """Per-user counts of allowed/rejected queries + auth-rejections per window.
191
+
192
+ Six grouped queries (3 windows x {query log, auth-rejection log}), each
193
+ backed by the models' `(decision|reason, started_at)` / `(user,
194
+ started_at)` indexes. Each row is pre-shaped into a `cells` list aligned
195
+ to `_USAGE_WINDOWS` because Django templates cannot index a dict by a
196
+ computed key. Row labels traverse the consumer user model's
197
+ `USERNAME_FIELD` so the summary stays model-agnostic (mirrors
198
+ `get_username()` without a per-row instance fetch).
199
+ """
200
+ now = timezone.now()
201
+ table: dict = {}
202
+
203
+ def _row(user_id, user_label):
204
+ return table.setdefault(
205
+ user_id,
206
+ {
207
+ "label": user_label,
208
+ "cells": {
209
+ label: {"allowed": 0, "rejected": 0, "auth": 0}
210
+ for label, _ in _USAGE_WINDOWS
211
+ },
212
+ },
213
+ )
214
+
215
+ for label, seconds in _USAGE_WINDOWS:
216
+ since = now - timedelta(seconds=seconds)
217
+ for r in (
218
+ MCPQueryLog.objects.filter(started_at__gte=since)
219
+ .values("user_id", _USER_LOOKUP, "decision")
220
+ .annotate(n=Count("id"))
221
+ ):
222
+ cell = _row(r["user_id"], r[_USER_LOOKUP])["cells"][label]
223
+ if r["decision"] in cell: # "allowed" / "rejected"
224
+ cell[r["decision"]] += r["n"]
225
+ for r in (
226
+ MCPAuthRejectionLog.objects.filter(started_at__gte=since)
227
+ .values("user_id", _USER_LOOKUP)
228
+ .annotate(n=Count("id"))
229
+ ):
230
+ _row(r["user_id"], r[_USER_LOOKUP])["cells"][label]["auth"] += r["n"]
231
+
232
+ rows = [
233
+ {
234
+ "label": row["label"],
235
+ "cells": [row["cells"][label] for label, _ in _USAGE_WINDOWS],
236
+ }
237
+ for row in table.values()
238
+ ]
239
+ rows.sort(key=lambda r: r["label"] or "") # label is nullable on exotic user models
240
+ return rows
mcp_sql/apps.py ADDED
@@ -0,0 +1,45 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class McpSqlConfig(AppConfig):
5
+ name = "mcp_sql"
6
+ verbose_name = "MCP SQL"
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+
9
+ def ready(self):
10
+ self.validate_settings()
11
+ self.warn_if_mfa_unconfigured()
12
+ # Importing the module wires its @receiver-decorated handlers.
13
+ from mcp_sql import signals # noqa: F401
14
+
15
+ @staticmethod
16
+ def validate_settings():
17
+ from django.conf import settings
18
+
19
+ from mcp_sql.validation import validate_mcp_sql_settings
20
+
21
+ validate_mcp_sql_settings(settings.MCP_SQL)
22
+
23
+ @staticmethod
24
+ def warn_if_mfa_unconfigured():
25
+ """Loudly flag the fail-closed default MFA checker at startup.
26
+
27
+ The in-package default (`deny_unconfigured_mfa`) denies every
28
+ MFA-gated decision, so a consumer who never set
29
+ `MCP_SQL["MFA_CHECKER"]` would find the whole MCP surface
30
+ inaccessible. That's the safe failure (fail-closed), but a silent
31
+ one is mystifying — emit one WARNING per process so the cause is
32
+ obvious in logs.
33
+ """
34
+ import logging
35
+
36
+ from mcp_sql.conf import deny_unconfigured_mfa
37
+ from mcp_sql.conf import mcp_sql_settings
38
+
39
+ if mcp_sql_settings.MFA_CHECKER is deny_unconfigured_mfa:
40
+ logging.getLogger("mcp_sql").warning(
41
+ "MCP SQL is using the fail-closed default MFA checker "
42
+ "(deny_unconfigured_mfa): every MFA-gated decision will be "
43
+ "DENIED until MCP_SQL['MFA_CHECKER'] is set to a real check "
44
+ "(django-allauth projects use 'allauth.mfa.utils.is_mfa_enabled')."
45
+ )