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.
- django_mcp_sql-0.1.0a1.dist-info/METADATA +278 -0
- django_mcp_sql-0.1.0a1.dist-info/RECORD +57 -0
- django_mcp_sql-0.1.0a1.dist-info/WHEEL +5 -0
- django_mcp_sql-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- django_mcp_sql-0.1.0a1.dist-info/top_level.txt +1 -0
- mcp_sql/__init__.py +6 -0
- mcp_sql/admin.py +240 -0
- mcp_sql/apps.py +45 -0
- mcp_sql/auth.py +395 -0
- mcp_sql/conf.py +328 -0
- mcp_sql/consts.py +52 -0
- mcp_sql/db_router.py +34 -0
- mcp_sql/decorators.py +72 -0
- mcp_sql/docs/architecture.md +711 -0
- mcp_sql/docs/oauth.md +560 -0
- mcp_sql/docs/role-setup.md +443 -0
- mcp_sql/executor.py +533 -0
- mcp_sql/fencing.py +76 -0
- mcp_sql/grants.py +339 -0
- mcp_sql/management/__init__.py +0 -0
- mcp_sql/management/commands/__init__.py +0 -0
- mcp_sql/management/commands/mcp_sql_grants.py +78 -0
- mcp_sql/management/commands/mcp_sql_lint.py +100 -0
- mcp_sql/management/commands/mcp_sql_role_setup.py +120 -0
- mcp_sql/management/commands/mcp_sql_smoke.py +287 -0
- mcp_sql/migrations/0001_initial.py +45 -0
- mcp_sql/migrations/0002_revoke_audit_grants.py +26 -0
- mcp_sql/migrations/0003_alter_mcpquerylog_result_sample.py +18 -0
- mcp_sql/migrations/0004_create_mcp_sql_users_group.py +28 -0
- mcp_sql/migrations/0005_create_mcp_sql_application.py +72 -0
- mcp_sql/migrations/0006_remove_mcpquerylog_result_sample.py +17 -0
- mcp_sql/migrations/0007_mcpauthrejectionlog.py +35 -0
- mcp_sql/migrations/0008_revoke_mcpauthrejectionlog_grants.py +49 -0
- mcp_sql/migrations/0009_alter_mcpauthrejectionlog_reason.py +18 -0
- mcp_sql/migrations/0010_mcpquerylog_tool.py +18 -0
- mcp_sql/migrations/0011_alter_mcpquerylog_options_mcpquerylog_profile_and_more.py +27 -0
- mcp_sql/migrations/__init__.py +0 -0
- mcp_sql/models.py +186 -0
- mcp_sql/oauth.py +56 -0
- mcp_sql/observability.py +89 -0
- mcp_sql/parser.py +660 -0
- mcp_sql/schemas.py +176 -0
- mcp_sql/session.py +104 -0
- mcp_sql/signals.py +309 -0
- mcp_sql/sql/10_mcp_role.sh +38 -0
- mcp_sql/sql/role_setup.sql +74 -0
- mcp_sql/templates/admin/mcp_sql/mcpquerylog_change_list.html +8 -0
- mcp_sql/templates/admin/mcp_sql/usage_summary.html +53 -0
- mcp_sql/templates/mcp_sql/authorize.html +42 -0
- mcp_sql/throttle.py +102 -0
- mcp_sql/urls.py +76 -0
- mcp_sql/validation.py +263 -0
- mcp_sql/views/__init__.py +0 -0
- mcp_sql/views/discovery.py +110 -0
- mcp_sql/views/mcp_endpoint.py +511 -0
- mcp_sql/views/oauth_authorize.py +84 -0
- 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
|
+
[](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,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,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
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
|
+
)
|