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.
Files changed (66) hide show
  1. drupal_mcp_server/__init__.py +3 -0
  2. drupal_mcp_server/auth/__init__.py +94 -0
  3. drupal_mcp_server/auth/basic.py +10 -0
  4. drupal_mcp_server/auth/mcp_oauth.py +256 -0
  5. drupal_mcp_server/auth/session.py +108 -0
  6. drupal_mcp_server/auth/token.py +9 -0
  7. drupal_mcp_server/clients/__init__.py +7 -0
  8. drupal_mcp_server/clients/base.py +163 -0
  9. drupal_mcp_server/clients/drush.py +150 -0
  10. drupal_mcp_server/clients/graphql.py +182 -0
  11. drupal_mcp_server/clients/jsonapi.py +248 -0
  12. drupal_mcp_server/config.py +84 -0
  13. drupal_mcp_server/errors.py +45 -0
  14. drupal_mcp_server/exceptions.py +77 -0
  15. drupal_mcp_server/logging_config.py +37 -0
  16. drupal_mcp_server/schemas.py +55 -0
  17. drupal_mcp_server/server.py +233 -0
  18. drupal_mcp_server/services/__init__.py +6 -0
  19. drupal_mcp_server/services/ai_service.py +89 -0
  20. drupal_mcp_server/services/migration_service.py +20 -0
  21. drupal_mcp_server/services/site_manager.py +43 -0
  22. drupal_mcp_server/tools/__init__.py +1 -0
  23. drupal_mcp_server/tools/_utils.py +239 -0
  24. drupal_mcp_server/tools/accessibility.py +264 -0
  25. drupal_mcp_server/tools/ai.py +57 -0
  26. drupal_mcp_server/tools/analysis.py +460 -0
  27. drupal_mcp_server/tools/code_audit.py +227 -0
  28. drupal_mcp_server/tools/comments.py +224 -0
  29. drupal_mcp_server/tools/commerce.py +443 -0
  30. drupal_mcp_server/tools/config.py +248 -0
  31. drupal_mcp_server/tools/contact.py +189 -0
  32. drupal_mcp_server/tools/content.py +561 -0
  33. drupal_mcp_server/tools/database.py +79 -0
  34. drupal_mcp_server/tools/devops.py +501 -0
  35. drupal_mcp_server/tools/drupal_org.py +525 -0
  36. drupal_mcp_server/tools/feeds.py +224 -0
  37. drupal_mcp_server/tools/fields.py +227 -0
  38. drupal_mcp_server/tools/headless.py +231 -0
  39. drupal_mcp_server/tools/layout_builder.py +569 -0
  40. drupal_mcp_server/tools/media_enhanced.py +193 -0
  41. drupal_mcp_server/tools/menus.py +233 -0
  42. drupal_mcp_server/tools/metatag.py +229 -0
  43. drupal_mcp_server/tools/migration.py +326 -0
  44. drupal_mcp_server/tools/moderation.py +547 -0
  45. drupal_mcp_server/tools/module_dev.py +1510 -0
  46. drupal_mcp_server/tools/multilingual.py +620 -0
  47. drupal_mcp_server/tools/paragraphs.py +235 -0
  48. drupal_mcp_server/tools/pathauto.py +186 -0
  49. drupal_mcp_server/tools/performance.py +153 -0
  50. drupal_mcp_server/tools/redirects.py +197 -0
  51. drupal_mcp_server/tools/revisions.py +196 -0
  52. drupal_mcp_server/tools/scheduler.py +309 -0
  53. drupal_mcp_server/tools/search_api.py +280 -0
  54. drupal_mcp_server/tools/security_audit.py +265 -0
  55. drupal_mcp_server/tools/taxonomy.py +170 -0
  56. drupal_mcp_server/tools/testing.py +249 -0
  57. drupal_mcp_server/tools/theme_dev.py +755 -0
  58. drupal_mcp_server/tools/users.py +209 -0
  59. drupal_mcp_server/tools/views.py +336 -0
  60. drupal_mcp_server/tools/webform.py +564 -0
  61. drupal_mcp_server/validation.py +173 -0
  62. drupal_mcp_server-0.1.0.dist-info/METADATA +503 -0
  63. drupal_mcp_server-0.1.0.dist-info/RECORD +66 -0
  64. drupal_mcp_server-0.1.0.dist-info/WHEEL +4 -0
  65. drupal_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
  66. drupal_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Drupal MCP Server - Model Context Protocol server for Drupal."""
2
+
3
+ __version__ = "0.1.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,9 @@
1
+ """Bearer token authentication for Drupal."""
2
+
3
+
4
+ class TokenAuth:
5
+ def __init__(self, token: str):
6
+ self._token = token
7
+
8
+ def get_headers(self) -> dict[str, str]:
9
+ return {"Authorization": f"Bearer {self._token}"}
@@ -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}