drupal-mcp-server 0.1.0__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.
- drupal_mcp_server/__init__.py +3 -0
- drupal_mcp_server/auth/__init__.py +94 -0
- drupal_mcp_server/auth/basic.py +10 -0
- drupal_mcp_server/auth/mcp_oauth.py +256 -0
- drupal_mcp_server/auth/session.py +108 -0
- drupal_mcp_server/auth/token.py +9 -0
- drupal_mcp_server/clients/__init__.py +7 -0
- drupal_mcp_server/clients/base.py +163 -0
- drupal_mcp_server/clients/drush.py +150 -0
- drupal_mcp_server/clients/graphql.py +182 -0
- drupal_mcp_server/clients/jsonapi.py +248 -0
- drupal_mcp_server/config.py +84 -0
- drupal_mcp_server/errors.py +45 -0
- drupal_mcp_server/exceptions.py +77 -0
- drupal_mcp_server/logging_config.py +37 -0
- drupal_mcp_server/schemas.py +55 -0
- drupal_mcp_server/server.py +233 -0
- drupal_mcp_server/services/__init__.py +6 -0
- drupal_mcp_server/services/ai_service.py +89 -0
- drupal_mcp_server/services/migration_service.py +20 -0
- drupal_mcp_server/services/site_manager.py +43 -0
- drupal_mcp_server/tools/__init__.py +1 -0
- drupal_mcp_server/tools/_utils.py +239 -0
- drupal_mcp_server/tools/accessibility.py +264 -0
- drupal_mcp_server/tools/ai.py +57 -0
- drupal_mcp_server/tools/analysis.py +460 -0
- drupal_mcp_server/tools/code_audit.py +227 -0
- drupal_mcp_server/tools/comments.py +224 -0
- drupal_mcp_server/tools/commerce.py +443 -0
- drupal_mcp_server/tools/config.py +248 -0
- drupal_mcp_server/tools/contact.py +189 -0
- drupal_mcp_server/tools/content.py +561 -0
- drupal_mcp_server/tools/database.py +79 -0
- drupal_mcp_server/tools/devops.py +501 -0
- drupal_mcp_server/tools/drupal_org.py +525 -0
- drupal_mcp_server/tools/feeds.py +224 -0
- drupal_mcp_server/tools/fields.py +227 -0
- drupal_mcp_server/tools/headless.py +231 -0
- drupal_mcp_server/tools/layout_builder.py +569 -0
- drupal_mcp_server/tools/media_enhanced.py +193 -0
- drupal_mcp_server/tools/menus.py +233 -0
- drupal_mcp_server/tools/metatag.py +229 -0
- drupal_mcp_server/tools/migration.py +326 -0
- drupal_mcp_server/tools/moderation.py +547 -0
- drupal_mcp_server/tools/module_dev.py +1510 -0
- drupal_mcp_server/tools/multilingual.py +620 -0
- drupal_mcp_server/tools/paragraphs.py +235 -0
- drupal_mcp_server/tools/pathauto.py +186 -0
- drupal_mcp_server/tools/performance.py +153 -0
- drupal_mcp_server/tools/redirects.py +197 -0
- drupal_mcp_server/tools/revisions.py +196 -0
- drupal_mcp_server/tools/scheduler.py +309 -0
- drupal_mcp_server/tools/search_api.py +280 -0
- drupal_mcp_server/tools/security_audit.py +265 -0
- drupal_mcp_server/tools/taxonomy.py +170 -0
- drupal_mcp_server/tools/testing.py +249 -0
- drupal_mcp_server/tools/theme_dev.py +755 -0
- drupal_mcp_server/tools/users.py +209 -0
- drupal_mcp_server/tools/views.py +336 -0
- drupal_mcp_server/tools/webform.py +564 -0
- drupal_mcp_server/validation.py +173 -0
- drupal_mcp_server-0.1.0.dist-info/METADATA +503 -0
- drupal_mcp_server-0.1.0.dist-info/RECORD +66 -0
- drupal_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- drupal_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- drupal_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Authentication managers for Drupal MCP Server."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from .basic import BasicAuth
|
|
6
|
+
from .mcp_oauth import DrupalOAuthProvider
|
|
7
|
+
from .session import SessionAuth
|
|
8
|
+
from .token import TokenAuth
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthManager:
|
|
12
|
+
"""Selects and wraps auth strategy from settings."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, settings: Any):
|
|
15
|
+
self._auth: Any | None = None
|
|
16
|
+
self._session: Any | None = None
|
|
17
|
+
self._basic: BasicAuth | None = None
|
|
18
|
+
|
|
19
|
+
if settings.DRUPAL_OAUTH_TOKEN_URL:
|
|
20
|
+
self._auth = TokenAuth(self._fetch_oauth_token(settings))
|
|
21
|
+
elif settings.DRUPAL_USERNAME and settings.DRUPAL_PASSWORD:
|
|
22
|
+
# Session auth covers both reads and writes; basic auth alone is
|
|
23
|
+
# rejected with 403 on Drupal's mutating endpoints. Basic remains
|
|
24
|
+
# as a fallback for sites without /user/login so reads still work.
|
|
25
|
+
self._basic = BasicAuth(settings.DRUPAL_USERNAME, settings.DRUPAL_PASSWORD)
|
|
26
|
+
self._session = SessionAuth(
|
|
27
|
+
base_url=settings.DRUPAL_BASE_URL,
|
|
28
|
+
username=settings.DRUPAL_USERNAME,
|
|
29
|
+
password=settings.DRUPAL_PASSWORD,
|
|
30
|
+
verify_ssl=bool(getattr(settings, "DRUPAL_VERIFY_SSL", True)),
|
|
31
|
+
)
|
|
32
|
+
self._auth = self._session
|
|
33
|
+
elif getattr(settings, "DRUPAL_API_TOKEN", None):
|
|
34
|
+
# Documented as an auth method since v0.1 but never wired up, so
|
|
35
|
+
# setting it silently produced unauthenticated requests.
|
|
36
|
+
self._auth = TokenAuth(settings.DRUPAL_API_TOKEN)
|
|
37
|
+
|
|
38
|
+
async def ensure_session(self) -> None:
|
|
39
|
+
"""Log in and cache a CSRF token. Safe to call more than once."""
|
|
40
|
+
if self._session is not None and not self._session.is_logged_in():
|
|
41
|
+
await self._session.login()
|
|
42
|
+
|
|
43
|
+
def get_session_cookies(self) -> dict[str, str]:
|
|
44
|
+
"""Session cookies for mutating requests, or {} if not using session auth."""
|
|
45
|
+
if self._session is None:
|
|
46
|
+
return {}
|
|
47
|
+
return cast("dict[str, str]", self._session.get_cookies())
|
|
48
|
+
|
|
49
|
+
def _fetch_oauth_token(self, settings: Any) -> str:
|
|
50
|
+
"""Fetch OAuth token from the configured token URL."""
|
|
51
|
+
import httpx
|
|
52
|
+
try:
|
|
53
|
+
resp = httpx.post(
|
|
54
|
+
settings.DRUPAL_OAUTH_TOKEN_URL,
|
|
55
|
+
data={
|
|
56
|
+
"grant_type": "client_credentials",
|
|
57
|
+
"client_id": settings.DRUPAL_OAUTH_CLIENT_ID or "",
|
|
58
|
+
"client_secret": settings.DRUPAL_OAUTH_CLIENT_SECRET or "",
|
|
59
|
+
},
|
|
60
|
+
timeout=30.0,
|
|
61
|
+
)
|
|
62
|
+
resp.raise_for_status()
|
|
63
|
+
return cast("str", resp.json().get("access_token", ""))
|
|
64
|
+
except Exception:
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
def get_headers(self) -> dict[str, str]:
|
|
68
|
+
"""Return request headers for the active auth strategy.
|
|
69
|
+
|
|
70
|
+
Synchronous to match the underlying strategies and the sync call site in
|
|
71
|
+
``clients/base.py::_headers``.
|
|
72
|
+
|
|
73
|
+
When session auth is configured but not (yet) logged in, fall back to
|
|
74
|
+
basic auth rather than sending an unauthenticated request.
|
|
75
|
+
"""
|
|
76
|
+
if self._session is not None and not self._session.is_logged_in():
|
|
77
|
+
if self._basic is not None:
|
|
78
|
+
return self._basic.get_headers()
|
|
79
|
+
return {}
|
|
80
|
+
if self._auth:
|
|
81
|
+
return cast("dict[str, str]", self._auth.get_headers())
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
def get_mcp_oauth_provider(self, settings: Any) -> DrupalOAuthProvider | None:
|
|
85
|
+
"""Get OAuth provider for MCP HTTP transport."""
|
|
86
|
+
if settings.MCP_TRANSPORT in ("http", "sse", "streamable-http"):
|
|
87
|
+
base_url = f"http://{settings.MCP_HOST}:{settings.MCP_PORT}"
|
|
88
|
+
return DrupalOAuthProvider(
|
|
89
|
+
base_url=base_url,
|
|
90
|
+
client_id=settings.DRUPAL_OAUTH_CLIENT_ID,
|
|
91
|
+
client_secret=settings.DRUPAL_OAUTH_CLIENT_SECRET,
|
|
92
|
+
token_url=settings.DRUPAL_OAUTH_TOKEN_URL,
|
|
93
|
+
)
|
|
94
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Basic authentication for Drupal."""
|
|
2
|
+
import base64
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BasicAuth:
|
|
6
|
+
def __init__(self, username: str, password: str):
|
|
7
|
+
self._token = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
8
|
+
|
|
9
|
+
def get_headers(self) -> dict[str, str]:
|
|
10
|
+
return {"Authorization": f"Basic {self._token}"}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""OAuth 2.1 authentication provider for FastMCP HTTP transport with per-session scopes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastmcp.server.auth import AccessToken as FastMCPAccessToken
|
|
6
|
+
from fastmcp.server.auth import OAuthProvider
|
|
7
|
+
from mcp.server.auth.provider import (
|
|
8
|
+
AccessToken,
|
|
9
|
+
AuthorizationCodeT,
|
|
10
|
+
AuthorizationParams,
|
|
11
|
+
RefreshTokenT,
|
|
12
|
+
)
|
|
13
|
+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DrupalOAuthProvider(OAuthProvider):
|
|
17
|
+
"""OAuth 2.1 provider with Drupal integration and per-session scopes."""
|
|
18
|
+
|
|
19
|
+
# Define available scopes
|
|
20
|
+
SCOPES = {
|
|
21
|
+
"drupal:read": "Read Drupal content and configuration",
|
|
22
|
+
"drupal:write": "Create and update Drupal content",
|
|
23
|
+
"drupal:admin": "Administrative access (config, modules, users)",
|
|
24
|
+
"drupal:migrate": "Run migrations and analysis tools",
|
|
25
|
+
"drupal:devops": "Execute Drush commands and manage infrastructure",
|
|
26
|
+
"drupal:ai": "Use AI-powered tools",
|
|
27
|
+
"drupal:security": "Run security audits and scans",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Default scopes for different roles
|
|
31
|
+
ROLE_SCOPES = {
|
|
32
|
+
"anonymous": ["drupal:read"],
|
|
33
|
+
"authenticated": ["drupal:read", "drupal:write"],
|
|
34
|
+
"editor": ["drupal:read", "drupal:write"],
|
|
35
|
+
"administrator": ["drupal:read", "drupal:write", "drupal:admin"],
|
|
36
|
+
"migrator": ["drupal:read", "drupal:write", "drupal:migrate"],
|
|
37
|
+
"devops": ["drupal:read", "drupal:write", "drupal:devops"],
|
|
38
|
+
"security": ["drupal:read", "drupal:admin", "drupal:security"],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
base_url: str,
|
|
44
|
+
client_id: str | None = None,
|
|
45
|
+
client_secret: str | None = None,
|
|
46
|
+
token_url: str | None = None,
|
|
47
|
+
required_scopes: list[str] | None = None,
|
|
48
|
+
):
|
|
49
|
+
super().__init__(
|
|
50
|
+
base_url=base_url,
|
|
51
|
+
required_scopes=required_scopes or ["drupal:read", "drupal:write"],
|
|
52
|
+
)
|
|
53
|
+
self._drupal_client_id = client_id
|
|
54
|
+
self._drupal_client_secret = client_secret
|
|
55
|
+
self._drupal_token_url = token_url
|
|
56
|
+
self._access_tokens: dict[str, dict[str, Any]] = {}
|
|
57
|
+
self._auth_codes: dict[str, dict[str, Any]] = {}
|
|
58
|
+
self._refresh_tokens: dict[str, dict[str, Any]] = {}
|
|
59
|
+
self._clients: dict[str, OAuthClientInformationFull] = {}
|
|
60
|
+
# Per-session scopes: session_id -> list of scopes
|
|
61
|
+
self._session_scopes: dict[str, list[str]] = {}
|
|
62
|
+
# Session -> client mapping
|
|
63
|
+
self._session_clients: dict[str, str] = {}
|
|
64
|
+
|
|
65
|
+
def get_available_scopes(self) -> dict[str, str]:
|
|
66
|
+
"""Get all available scopes with descriptions."""
|
|
67
|
+
return self.SCOPES.copy()
|
|
68
|
+
|
|
69
|
+
def get_role_scopes(self, role: str) -> list[str]:
|
|
70
|
+
"""Get scopes for a role."""
|
|
71
|
+
return self.ROLE_SCOPES.get(role, self.ROLE_SCOPES["authenticated"])
|
|
72
|
+
|
|
73
|
+
def set_session_scopes(self, session_id: str, scopes: list[str]) -> None:
|
|
74
|
+
"""Set scopes for a session."""
|
|
75
|
+
# Validate scopes
|
|
76
|
+
valid_scopes = [s for s in scopes if s in self.SCOPES]
|
|
77
|
+
self._session_scopes[session_id] = valid_scopes
|
|
78
|
+
|
|
79
|
+
def get_session_scopes(self, session_id: str) -> list[str]:
|
|
80
|
+
"""Get scopes for a session."""
|
|
81
|
+
return self._session_scopes.get(session_id, self.required_scopes)
|
|
82
|
+
|
|
83
|
+
def set_session_client(self, session_id: str, client_id: str) -> None:
|
|
84
|
+
"""Associate a client with a session."""
|
|
85
|
+
self._session_clients[session_id] = client_id
|
|
86
|
+
|
|
87
|
+
def get_session_client(self, session_id: str) -> str | None:
|
|
88
|
+
"""Get client ID for a session."""
|
|
89
|
+
return self._session_clients.get(session_id)
|
|
90
|
+
|
|
91
|
+
def clear_session(self, session_id: str) -> None:
|
|
92
|
+
"""Clear session data."""
|
|
93
|
+
self._session_scopes.pop(session_id, None)
|
|
94
|
+
self._session_clients.pop(session_id, None)
|
|
95
|
+
|
|
96
|
+
def get_challenge_scopes(self, required_scopes: list[str] | None = None) -> list[str]:
|
|
97
|
+
"""Get scopes for WWW-Authenticate challenge.
|
|
98
|
+
|
|
99
|
+
This can be overridden to provide session-specific scopes.
|
|
100
|
+
"""
|
|
101
|
+
# If we have session-specific scopes, use those
|
|
102
|
+
# For now, use the default behavior
|
|
103
|
+
return super().get_challenge_scopes(required_scopes)
|
|
104
|
+
|
|
105
|
+
async def load_access_token(self, token: str) -> FastMCPAccessToken | None:
|
|
106
|
+
"""Load and validate an access token.
|
|
107
|
+
|
|
108
|
+
The token has to be returned as an `AccessToken`, not as the dict it
|
|
109
|
+
was stored in. FastMCP reads `.scopes` and `.client_id` off the result
|
|
110
|
+
to decide whether a request is allowed, and a dict answers every one of
|
|
111
|
+
those with `AttributeError` -- so a perfectly good token was rejected as
|
|
112
|
+
harshly as a forged one, and the only OAuth behaviour ever observed was
|
|
113
|
+
the fail-closed side. That also makes this the constructor of last
|
|
114
|
+
resort for the stored fields, which is why `expires_at` is stored as an
|
|
115
|
+
int: `AccessToken.expires_at` is one, and a float fails its validation.
|
|
116
|
+
"""
|
|
117
|
+
token_data = self._access_tokens.get(token)
|
|
118
|
+
if token_data is None:
|
|
119
|
+
return None
|
|
120
|
+
import time
|
|
121
|
+
|
|
122
|
+
if token_data.get("expires_at", 0) <= time.time():
|
|
123
|
+
return None
|
|
124
|
+
return FastMCPAccessToken(
|
|
125
|
+
token=str(token_data.get("token", token)),
|
|
126
|
+
client_id=str(token_data.get("client_id") or ""),
|
|
127
|
+
scopes=list(token_data.get("scopes") or []),
|
|
128
|
+
expires_at=int(token_data.get("expires_at", 0)),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def load_authorization_code(
|
|
132
|
+
self,
|
|
133
|
+
client: OAuthClientInformationFull,
|
|
134
|
+
authorization_code: str,
|
|
135
|
+
) -> AuthorizationCodeT | None:
|
|
136
|
+
"""Load an authorization code."""
|
|
137
|
+
auth_code_str = str(authorization_code)
|
|
138
|
+
return self._auth_codes.get(auth_code_str) # type: ignore[return-value]
|
|
139
|
+
|
|
140
|
+
async def load_refresh_token(
|
|
141
|
+
self,
|
|
142
|
+
client: OAuthClientInformationFull,
|
|
143
|
+
refresh_token: str,
|
|
144
|
+
) -> RefreshTokenT | None:
|
|
145
|
+
"""Load a refresh token."""
|
|
146
|
+
refresh_token_str = str(refresh_token)
|
|
147
|
+
return self._refresh_tokens.get(refresh_token_str) # type: ignore[return-value]
|
|
148
|
+
|
|
149
|
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
150
|
+
"""Get client information."""
|
|
151
|
+
return self._clients.get(client_id)
|
|
152
|
+
|
|
153
|
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
154
|
+
"""Register a new OAuth client."""
|
|
155
|
+
self._clients[client_info.client_id] = client_info
|
|
156
|
+
|
|
157
|
+
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
|
158
|
+
"""Handle authorization request - redirect to Drupal OAuth."""
|
|
159
|
+
# Generate auth code
|
|
160
|
+
import secrets
|
|
161
|
+
auth_code = secrets.token_urlsafe(32)
|
|
162
|
+
self._auth_codes[auth_code] = {
|
|
163
|
+
"client_id": client.client_id,
|
|
164
|
+
"redirect_uri": params.redirect_uri,
|
|
165
|
+
"scopes": params.scopes,
|
|
166
|
+
"code_challenge": params.code_challenge,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Redirect back to client with auth code
|
|
170
|
+
return f"{params.redirect_uri}?code={auth_code}"
|
|
171
|
+
|
|
172
|
+
async def exchange_authorization_code(
|
|
173
|
+
self,
|
|
174
|
+
client: OAuthClientInformationFull,
|
|
175
|
+
authorization_code: AuthorizationCodeT,
|
|
176
|
+
) -> OAuthToken:
|
|
177
|
+
"""Exchange authorization code for access token."""
|
|
178
|
+
auth_code_str = str(authorization_code)
|
|
179
|
+
if auth_code_str not in self._auth_codes:
|
|
180
|
+
raise ValueError("Invalid authorization code")
|
|
181
|
+
|
|
182
|
+
code_data = self._auth_codes.pop(auth_code_str)
|
|
183
|
+
|
|
184
|
+
# Generate tokens
|
|
185
|
+
import secrets
|
|
186
|
+
import time
|
|
187
|
+
access_token = secrets.token_urlsafe(32)
|
|
188
|
+
refresh_token = secrets.token_urlsafe(32)
|
|
189
|
+
expires_at = int(time.time()) + 3600 # 1 hour
|
|
190
|
+
|
|
191
|
+
token_data = {
|
|
192
|
+
"token": access_token,
|
|
193
|
+
"client_id": client.client_id,
|
|
194
|
+
"scopes": code_data["scopes"],
|
|
195
|
+
"expires_at": expires_at,
|
|
196
|
+
}
|
|
197
|
+
self._access_tokens[access_token] = token_data
|
|
198
|
+
self._refresh_tokens[refresh_token] = {
|
|
199
|
+
"client_id": client.client_id,
|
|
200
|
+
"scopes": code_data["scopes"],
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return OAuthToken(
|
|
204
|
+
access_token=access_token,
|
|
205
|
+
refresh_token=refresh_token,
|
|
206
|
+
token_type="Bearer",
|
|
207
|
+
expires_in=3600,
|
|
208
|
+
scope=" ".join(code_data["scopes"]),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def exchange_refresh_token(
|
|
212
|
+
self,
|
|
213
|
+
client: OAuthClientInformationFull,
|
|
214
|
+
refresh_token: RefreshTokenT,
|
|
215
|
+
scopes: list[str],
|
|
216
|
+
) -> OAuthToken:
|
|
217
|
+
"""Exchange refresh token for new access token."""
|
|
218
|
+
refresh_token_str = str(refresh_token)
|
|
219
|
+
if refresh_token_str not in self._refresh_tokens:
|
|
220
|
+
raise ValueError("Invalid refresh token")
|
|
221
|
+
|
|
222
|
+
# Indexed for its KeyError side effect: it rejects unknown tokens.
|
|
223
|
+
_token_data = self._refresh_tokens[refresh_token_str]
|
|
224
|
+
|
|
225
|
+
import secrets
|
|
226
|
+
import time
|
|
227
|
+
new_access_token = secrets.token_urlsafe(32)
|
|
228
|
+
new_refresh_token = secrets.token_urlsafe(32)
|
|
229
|
+
expires_at = int(time.time()) + 3600
|
|
230
|
+
|
|
231
|
+
new_token_data = {
|
|
232
|
+
"token": new_access_token,
|
|
233
|
+
"client_id": client.client_id,
|
|
234
|
+
"scopes": scopes,
|
|
235
|
+
"expires_at": expires_at,
|
|
236
|
+
}
|
|
237
|
+
self._access_tokens[new_access_token] = new_token_data
|
|
238
|
+
self._refresh_tokens[new_refresh_token] = {
|
|
239
|
+
"client_id": client.client_id,
|
|
240
|
+
"scopes": scopes,
|
|
241
|
+
}
|
|
242
|
+
del self._refresh_tokens[refresh_token_str]
|
|
243
|
+
|
|
244
|
+
return OAuthToken(
|
|
245
|
+
access_token=new_access_token,
|
|
246
|
+
refresh_token=new_refresh_token,
|
|
247
|
+
token_type="Bearer",
|
|
248
|
+
expires_in=3600,
|
|
249
|
+
scope=" ".join(scopes),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def revoke_token(self, token: AccessToken | RefreshTokenT) -> None:
|
|
253
|
+
"""Revoke an access or refresh token."""
|
|
254
|
+
token_str = str(token)
|
|
255
|
+
self._access_tokens.pop(token_str, None)
|
|
256
|
+
self._refresh_tokens.pop(token_str, None)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Session (cookie + CSRF) authentication for Drupal.
|
|
2
|
+
|
|
3
|
+
Drupal protects every mutating endpoint behind a per-session CSRF token.
|
|
4
|
+
Basic auth alone is sufficient for reads but is rejected with 403 on writes,
|
|
5
|
+
so tools that create/update/delete content must hold a real session.
|
|
6
|
+
|
|
7
|
+
Flow:
|
|
8
|
+
1. POST /user/login with the credentials to obtain a session cookie.
|
|
9
|
+
2. GET /session/token on that session to obtain the CSRF token.
|
|
10
|
+
3. Send both the cookie and X-CSRF-Token on every mutating request.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SessionAuth:
|
|
17
|
+
"""Cookie + CSRF token auth for Drupal write operations."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, base_url: str, username: str, password: str,
|
|
20
|
+
verify_ssl: bool = True, timeout: float = 30.0):
|
|
21
|
+
self._base_url = base_url.rstrip("/")
|
|
22
|
+
self._username = username
|
|
23
|
+
self._password = password
|
|
24
|
+
self._verify_ssl = verify_ssl
|
|
25
|
+
self._timeout = timeout
|
|
26
|
+
self._cookies: dict[str, str] = {}
|
|
27
|
+
self._csrf: str | None = None
|
|
28
|
+
self._logged_in = False
|
|
29
|
+
|
|
30
|
+
async def login(self) -> None:
|
|
31
|
+
"""Establish a session and fetch a CSRF token.
|
|
32
|
+
|
|
33
|
+
The login POST is deliberately not followed past its redirect. Drupal
|
|
34
|
+
answers a good login with 303 to the account page, built from the
|
|
35
|
+
scheme and host it saw the request arrive on -- its own idea of its
|
|
36
|
+
base URL, not the client's. Following that turns a successful login
|
|
37
|
+
into a failure whenever the two disagree: reached on a port-mapped
|
|
38
|
+
HTTPS endpoint the site reported itself as plain `http`, so the
|
|
39
|
+
redirect was fetched as HTTP against a TLS port, nginx answered 400,
|
|
40
|
+
and the session cookie the same response had just issued was thrown
|
|
41
|
+
away. Every write then silently fell back to basic auth, which
|
|
42
|
+
Drupal's mutating endpoints reject.
|
|
43
|
+
|
|
44
|
+
Only the redirect's status and the cookie it sets are wanted here;
|
|
45
|
+
the page it points at is the site's business.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
RuntimeError: If login fails or no token is issued.
|
|
49
|
+
"""
|
|
50
|
+
async with httpx.AsyncClient(
|
|
51
|
+
verify=self._verify_ssl,
|
|
52
|
+
timeout=self._timeout,
|
|
53
|
+
follow_redirects=False,
|
|
54
|
+
) as client:
|
|
55
|
+
resp = await client.post(
|
|
56
|
+
f"{self._base_url}/user/login",
|
|
57
|
+
data={
|
|
58
|
+
"name": self._username,
|
|
59
|
+
"pass": self._password,
|
|
60
|
+
"form_id": "user_login_form",
|
|
61
|
+
"op": "Log in",
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
if resp.status_code >= 400:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"Drupal login failed (HTTP {resp.status_code}); "
|
|
67
|
+
"check DRUPAL_USERNAME and DRUPAL_PASSWORD"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# A 3xx here is the success path, and a refused login is a 200
|
|
71
|
+
# re-rendering the form without setting a cookie -- so this check,
|
|
72
|
+
# not the status above, is what actually rejects bad credentials.
|
|
73
|
+
self._cookies = {k: v for k, v in client.cookies.items()}
|
|
74
|
+
if not self._cookies:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
"Drupal login returned no session cookie; "
|
|
77
|
+
"the site may not expose /user/login, or the credentials "
|
|
78
|
+
"were refused."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
token_resp = await client.get(
|
|
82
|
+
f"{self._base_url}/session/token",
|
|
83
|
+
cookies=self._cookies,
|
|
84
|
+
)
|
|
85
|
+
if token_resp.status_code >= 400:
|
|
86
|
+
raise RuntimeError("Failed to fetch Drupal CSRF token")
|
|
87
|
+
self._csrf = token_resp.text.strip() or None
|
|
88
|
+
if not self._csrf:
|
|
89
|
+
raise RuntimeError("Drupal issued an empty CSRF token")
|
|
90
|
+
|
|
91
|
+
self._logged_in = True
|
|
92
|
+
|
|
93
|
+
def get_headers(self) -> dict[str, str]:
|
|
94
|
+
"""Headers for a mutating request, including the CSRF token."""
|
|
95
|
+
if not self._logged_in:
|
|
96
|
+
return {}
|
|
97
|
+
headers = {}
|
|
98
|
+
if self._csrf:
|
|
99
|
+
headers["X-CSRF-Token"] = self._csrf
|
|
100
|
+
return headers
|
|
101
|
+
|
|
102
|
+
def get_cookies(self) -> dict[str, str]:
|
|
103
|
+
"""Session cookies to attach to a request."""
|
|
104
|
+
return dict(self._cookies) if self._logged_in else {}
|
|
105
|
+
|
|
106
|
+
def is_logged_in(self) -> bool:
|
|
107
|
+
"""Whether a session has been established."""
|
|
108
|
+
return self._logged_in
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""HTTP clients for Drupal MCP Server."""
|
|
2
|
+
from .base import DrupalBaseClient
|
|
3
|
+
from .drush import DrushClient
|
|
4
|
+
from .graphql import GraphQLClient
|
|
5
|
+
from .jsonapi import JSONAPIClient
|
|
6
|
+
|
|
7
|
+
__all__ = ["DrupalBaseClient", "JSONAPIClient", "GraphQLClient", "DrushClient"]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Base HTTP client for Drupal REST endpoints."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DrupalBaseClient:
|
|
9
|
+
"""Base client for Drupal REST endpoints (JSON:API, GraphQL)."""
|
|
10
|
+
|
|
11
|
+
# Media type for requests. JSON:API rejects application/json with 415.
|
|
12
|
+
_media_type = "application/json"
|
|
13
|
+
|
|
14
|
+
def __init__(self, settings: Any, auth_manager: Any = None):
|
|
15
|
+
self.settings = settings
|
|
16
|
+
self.auth_manager = auth_manager
|
|
17
|
+
self._base_url = settings.DRUPAL_BASE_URL.rstrip("/")
|
|
18
|
+
self._timeout = 60
|
|
19
|
+
self._verify_ssl = bool(getattr(settings, "DRUPAL_VERIFY_SSL", True))
|
|
20
|
+
|
|
21
|
+
def _cookies(self) -> dict[str, str]:
|
|
22
|
+
"""Session cookies from SessionAuth, if one is configured."""
|
|
23
|
+
mgr = self.auth_manager
|
|
24
|
+
if mgr is None:
|
|
25
|
+
return {}
|
|
26
|
+
getter = getattr(mgr, "get_session_cookies", None)
|
|
27
|
+
if getter is None:
|
|
28
|
+
return {}
|
|
29
|
+
return cast("dict[str, str]", getter())
|
|
30
|
+
|
|
31
|
+
def _client(self) -> httpx.AsyncClient:
|
|
32
|
+
"""Build an AsyncClient honouring the DRUPAL_VERIFY_SSL setting.
|
|
33
|
+
|
|
34
|
+
Local dev environments (DDEV, Lando) serve self-signed certs, so this
|
|
35
|
+
must be configurable or every request fails with CERTIFICATE_VERIFY_FAILED.
|
|
36
|
+
|
|
37
|
+
Session cookies are attached here so reads see the same authenticated
|
|
38
|
+
session as writes.
|
|
39
|
+
"""
|
|
40
|
+
return httpx.AsyncClient(
|
|
41
|
+
timeout=self._timeout,
|
|
42
|
+
verify=self._verify_ssl,
|
|
43
|
+
cookies=self._cookies(),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _build_url(self, endpoint: str) -> str:
|
|
47
|
+
return f"{self._base_url}/{endpoint.lstrip('/')}"
|
|
48
|
+
|
|
49
|
+
def _headers(self) -> dict[str, str]:
|
|
50
|
+
headers = {
|
|
51
|
+
"Accept": self._media_type,
|
|
52
|
+
"Content-Type": self._media_type,
|
|
53
|
+
}
|
|
54
|
+
if self.auth_manager:
|
|
55
|
+
headers.update(self.auth_manager.get_headers())
|
|
56
|
+
return headers
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _decode(response: httpx.Response, method: str) -> Any:
|
|
60
|
+
"""Parse a JSON body, and say what happened when there isn't one.
|
|
61
|
+
|
|
62
|
+
An empty body used to surface as ``Expecting value: line 1 column 1
|
|
63
|
+
(char 0)``, which reads like a client bug and gives no clue that
|
|
64
|
+
Drupal's own PHP died mid-request.
|
|
65
|
+
"""
|
|
66
|
+
body = response.content
|
|
67
|
+
if not body.strip():
|
|
68
|
+
if response.status_code == 204 or method == "DELETE":
|
|
69
|
+
return {}
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
f"{method} {response.request.url} returned HTTP "
|
|
72
|
+
f"{response.status_code} with an empty body, so there is "
|
|
73
|
+
"nothing to parse. Drupal does this when PHP fails "
|
|
74
|
+
"mid-request - a vendor directory being rewritten by a "
|
|
75
|
+
"concurrent `composer install`, or a fatal error with output "
|
|
76
|
+
"buffering enabled. Check the site's watchdog or web server "
|
|
77
|
+
"log and retry."
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
return response.json()
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
f"{method} {response.request.url} returned HTTP "
|
|
84
|
+
f"{response.status_code} with a body that is not JSON "
|
|
85
|
+
f"({exc}): {body[:160]!r}"
|
|
86
|
+
) from None
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _raise(response: httpx.Response) -> None:
|
|
90
|
+
"""Raise for status, preserving Drupal's JSON:API error body.
|
|
91
|
+
|
|
92
|
+
``raise_for_status()`` alone reports only the status code, which hides
|
|
93
|
+
the actionable ``errors[].detail`` Drupal returns on validation failures.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
except httpx.HTTPStatusError as exc:
|
|
98
|
+
detail = response.text.strip()
|
|
99
|
+
if detail:
|
|
100
|
+
# Drupal returns HTML for some 40x pages; keep it short.
|
|
101
|
+
detail = detail[:400]
|
|
102
|
+
raise httpx.HTTPStatusError(
|
|
103
|
+
f"{exc} — {detail}", request=exc.request, response=exc.response
|
|
104
|
+
) from None
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
async def request(
|
|
108
|
+
self,
|
|
109
|
+
method: str,
|
|
110
|
+
endpoint: str,
|
|
111
|
+
*,
|
|
112
|
+
params: dict[str, Any] | None = None,
|
|
113
|
+
json_body: Any = None,
|
|
114
|
+
data: dict[str, Any] | None = None,
|
|
115
|
+
) -> httpx.Response:
|
|
116
|
+
"""Perform an HTTP request and return the raw response."""
|
|
117
|
+
url = self._build_url(endpoint)
|
|
118
|
+
async with self._client() as client:
|
|
119
|
+
return await client.request(
|
|
120
|
+
method,
|
|
121
|
+
url,
|
|
122
|
+
headers=self._headers(),
|
|
123
|
+
params=params,
|
|
124
|
+
json=json_body,
|
|
125
|
+
data=data,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def get(
|
|
129
|
+
self,
|
|
130
|
+
endpoint: str,
|
|
131
|
+
params: dict[str, Any] | None = None,
|
|
132
|
+
) -> dict[str, Any] | list[Any]:
|
|
133
|
+
"""Perform a GET request to the Drupal endpoint."""
|
|
134
|
+
response = await self.request("GET", endpoint, params=params)
|
|
135
|
+
self._raise(response)
|
|
136
|
+
return self._decode(response, "GET") # type: ignore[no-any-return]
|
|
137
|
+
|
|
138
|
+
async def post(
|
|
139
|
+
self,
|
|
140
|
+
endpoint: str,
|
|
141
|
+
data: dict[str, Any] | None = None,
|
|
142
|
+
json_body: dict[str, Any] | None = None,
|
|
143
|
+
) -> dict[str, Any] | list[Any]:
|
|
144
|
+
"""Perform a POST request to the Drupal endpoint."""
|
|
145
|
+
response = await self.request("POST", endpoint, json_body=json_body or data)
|
|
146
|
+
self._raise(response)
|
|
147
|
+
return self._decode(response, "POST") # type: ignore[no-any-return]
|
|
148
|
+
|
|
149
|
+
async def patch(self, endpoint: str, json_body: dict[str, Any]) -> dict[str, Any] | list[Any]:
|
|
150
|
+
"""Perform a PATCH request to the Drupal endpoint."""
|
|
151
|
+
response = await self.request("PATCH", endpoint, json_body=json_body)
|
|
152
|
+
self._raise(response)
|
|
153
|
+
return self._decode(response, "PATCH") # type: ignore[no-any-return]
|
|
154
|
+
|
|
155
|
+
async def delete(self, endpoint: str) -> dict[str, Any] | list[Any]:
|
|
156
|
+
"""Perform a DELETE request to the Drupal endpoint.
|
|
157
|
+
|
|
158
|
+
Drupal answers DELETE with 204 No Content, so an empty body is normal
|
|
159
|
+
and must not be parsed as JSON.
|
|
160
|
+
"""
|
|
161
|
+
response = await self.request("DELETE", endpoint)
|
|
162
|
+
self._raise(response)
|
|
163
|
+
return {"deleted": True, "status_code": response.status_code}
|