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,150 @@
1
+ """Drush command client."""
2
+
3
+ import asyncio
4
+ import json
5
+ import shlex
6
+ from typing import Any
7
+
8
+
9
+ class DrushClient:
10
+ """Runs drush commands as subprocesses.
11
+
12
+ ``DRUSH_PATH`` may contain arguments, so container-based setups work:
13
+ set it to ``ddev drush`` (or ``lander drush``) and start the MCP server
14
+ from the project directory.
15
+ """
16
+
17
+ def __init__(self, settings: Any):
18
+ self._drush = shlex.split(str(getattr(settings, "DRUSH_PATH", "drush") or "drush"))
19
+ if not self._drush:
20
+ self._drush = ["drush"]
21
+ self._alias = str(getattr(settings, "DRUSH_ALIAS", "") or "")
22
+ self._timeout = 120 # seconds
23
+
24
+ def _command(self, command: str, args: list[str], with_format: bool = True) -> list[str]:
25
+ argv = list(self._drush)
26
+ if self._alias:
27
+ argv.append(self._alias)
28
+ argv.append(command)
29
+ # Many tools already pass --format=json themselves; adding it twice is
30
+ # sloppy, and commands like `drush cron` reject the option outright.
31
+ if with_format and not any(a.startswith("--format") for a in args):
32
+ argv.append("--format=json")
33
+ argv += args
34
+ return argv
35
+
36
+ @staticmethod
37
+ def _flatten(parts: tuple[Any, ...]) -> list[str]:
38
+ """Turn whatever a tool passes into a list of argv tokens.
39
+
40
+ Call sites spell a command line every way there is: `run(*cmd)` with
41
+ eight separate tokens, `run("config:set", [a, b, c])`, and
42
+ `run("config:set", "a b c")`. Treating a string as a sequence turned
43
+ the last form into one argument per character, so it never worked.
44
+ """
45
+ tokens: list[str] = []
46
+ for part in parts:
47
+ if part is None:
48
+ continue
49
+ if isinstance(part, str):
50
+ tokens.extend(shlex.split(part) if part.split() else ([part] if part else []))
51
+ elif isinstance(part, (list, tuple)):
52
+ # Items in a list are already argv tokens and must be passed
53
+ # through whole. Splitting each one destroyed anything holding
54
+ # a space: `sql:query ["SHOW TABLES"]` became two arguments, so
55
+ # drush answered "Too many arguments", and PHP handed to
56
+ # php:eval was shredded before it ever reached Drupal.
57
+ for item in part:
58
+ if item is None:
59
+ continue
60
+ tokens.append(str(item))
61
+ else:
62
+ tokens.append(str(part))
63
+ return tokens
64
+
65
+ async def run(self, *parts: Any, cwd: str | None = None) -> dict[str, Any]:
66
+ """Run a drush command line.
67
+
68
+ Accepts the command plus any number of argument tokens, in any
69
+ combination of strings and lists::
70
+
71
+ run("cron")
72
+ run("status", ["--format=json"])
73
+ run("config:set", "twig.config", "debug", "true")
74
+ run(*("user:role:add", "authenticated", "1"))
75
+ """
76
+ if not parts:
77
+ return {
78
+ "returncode": 1,
79
+ "data": {},
80
+ "stderr": "No drush command was given.",
81
+ "command": "",
82
+ }
83
+ command, rest = str(parts[0]), parts[1:]
84
+ argv = self._flatten(rest)
85
+ cmd = self._command(command, argv)
86
+ result = await self._exec(cmd, cwd)
87
+ # `drush cron` and friends have no --format option. Retry once without
88
+ # it rather than failing a command that would otherwise succeed.
89
+ if result["returncode"] != 0 and "--format" in result.get("stderr", ""):
90
+ retried = await self._exec(self._command(command, argv, with_format=False), cwd)
91
+ # Report the run that actually executed. Returning the original
92
+ # made every failure read as "The --format option does not exist",
93
+ # which buries the real reason.
94
+ retried["format_flag_dropped"] = True
95
+ return retried
96
+ return result
97
+
98
+ async def _exec(self, cmd: list[str], cwd: str | None) -> dict[str, Any]:
99
+ try:
100
+ proc = await asyncio.create_subprocess_exec(
101
+ *cmd,
102
+ stdout=asyncio.subprocess.PIPE,
103
+ stderr=asyncio.subprocess.PIPE,
104
+ cwd=cwd,
105
+ )
106
+ except FileNotFoundError:
107
+ # Without a distinct message every drush tool just reports
108
+ # "No such file or directory", which reads like a product bug.
109
+ return {
110
+ "returncode": 127,
111
+ "data": {},
112
+ "stderr": (
113
+ f"Drush executable {cmd[0]!r} not found on PATH. "
114
+ "Set DRUSH_PATH to a working command (for example "
115
+ "'ddev drush') and start the MCP server from the project "
116
+ "directory."
117
+ ),
118
+ "command": " ".join(cmd),
119
+ }
120
+ except PermissionError:
121
+ return {
122
+ "returncode": 126,
123
+ "data": {},
124
+ "stderr": f"Drush executable {cmd[0]!r} is not executable.",
125
+ "command": " ".join(cmd),
126
+ }
127
+
128
+ try:
129
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=self._timeout)
130
+ except TimeoutError: # asyncio.TimeoutError is an alias on 3.11+
131
+ proc.kill()
132
+ return {
133
+ "returncode": -1,
134
+ "data": {},
135
+ "stderr": f"Command timed out after {self._timeout}s",
136
+ "command": " ".join(cmd),
137
+ }
138
+ try:
139
+ data = json.loads(stdout.decode()) if stdout else {}
140
+ except json.JSONDecodeError:
141
+ data = {"output": stdout.decode()}
142
+ return {
143
+ "returncode": proc.returncode,
144
+ "data": data,
145
+ "stderr": stderr.decode(),
146
+ "command": " ".join(cmd),
147
+ }
148
+
149
+ async def status(self) -> dict[str, Any]:
150
+ return await self.run("status")
@@ -0,0 +1,182 @@
1
+ """GraphQL client for Drupal."""
2
+
3
+ from typing import Any
4
+
5
+ from .base import DrupalBaseClient
6
+
7
+ # Standard GraphQL introspection query; enough to render both JSON and SDL.
8
+ _INTROSPECTION_QUERY = """
9
+ query IntrospectionQuery {
10
+ __schema {
11
+ queryType { name }
12
+ mutationType { name }
13
+ subscriptionType { name }
14
+ types { ...FullType }
15
+ directives {
16
+ name
17
+ description
18
+ locations
19
+ args { ...InputValue }
20
+ }
21
+ }
22
+ }
23
+ fragment FullType on __Type {
24
+ kind
25
+ name
26
+ description
27
+ fields(includeDeprecated: true) {
28
+ name
29
+ description
30
+ args { ...InputValue }
31
+ type { ...TypeRef }
32
+ isDeprecated
33
+ deprecationReason
34
+ }
35
+ inputFields { ...InputValue }
36
+ interfaces { ...TypeRef }
37
+ enumValues(includeDeprecated: true) {
38
+ name
39
+ description
40
+ isDeprecated
41
+ deprecationReason
42
+ }
43
+ possibleTypes { ...TypeRef }
44
+ }
45
+ fragment InputValue on __InputValue {
46
+ name
47
+ description
48
+ type { ...TypeRef }
49
+ defaultValue
50
+ }
51
+ fragment TypeRef on __Type {
52
+ kind
53
+ name
54
+ ofType {
55
+ kind
56
+ name
57
+ ofType {
58
+ kind
59
+ name
60
+ ofType {
61
+ kind
62
+ name
63
+ }
64
+ }
65
+ }
66
+ }
67
+ """
68
+
69
+
70
+ def _type_ref(ref: dict[str, Any] | None) -> str:
71
+ """Render a nested __Type reference as GraphQL SDL syntax."""
72
+ if not ref:
73
+ return "unknown"
74
+ inner = ref.get("ofType")
75
+ if inner:
76
+ rendered = _type_ref(inner)
77
+ else:
78
+ rendered = ref.get("name") or "unknown"
79
+ kind = ref.get("kind")
80
+ if kind == "LIST":
81
+ return f"[{rendered}]"
82
+ if kind == "NON_NULL":
83
+ return f"{rendered}!"
84
+ return rendered
85
+
86
+
87
+ def _args(args: list[dict[str, Any]] | None) -> str:
88
+ if not args:
89
+ return ""
90
+ return "(" + ", ".join(f"{a['name']}: {_type_ref(a.get('type'))}" for a in args) + ")"
91
+
92
+
93
+ def _render_type(t: dict[str, Any]) -> str | None:
94
+ name = t.get("name")
95
+ if not name or name.startswith("__"):
96
+ return None
97
+ kind = t.get("kind")
98
+
99
+ if kind == "SCALAR":
100
+ return f"scalar {name}"
101
+ if kind == "UNION":
102
+ members = [p["name"] for p in (t.get("possibleTypes") or [])]
103
+ return f"union {name} = {' | '.join(members)}" if members else f"union {name}"
104
+ if kind == "ENUM":
105
+ values = [v["name"] for v in (t.get("enumValues") or [])]
106
+ body = "\n".join(f" {v}" for v in values)
107
+ return f"enum {name} {{\n{body}\n}}"
108
+ if kind in ("OBJECT", "INTERFACE"):
109
+ keyword = "type" if kind == "OBJECT" else "interface"
110
+ ifaces = [i["name"] for i in (t.get("interfaces") or [])]
111
+ implements = f" implements {', '.join(ifaces)}" if ifaces else ""
112
+ lines = []
113
+ for f in t.get("fields") or []:
114
+ lines.append(f" {f['name']}{_args(f.get('args'))}: {_type_ref(f.get('type'))}")
115
+ body = "\n".join(lines)
116
+ return f"{keyword} {name}{implements} {{\n{body}\n}}"
117
+ if kind == "INPUT_OBJECT":
118
+ lines = [
119
+ f" {f['name']}: {_type_ref(f.get('type'))}"
120
+ for f in (t.get("inputFields") or [])
121
+ ]
122
+ body = "\n".join(lines)
123
+ return f"input {name} {{\n{body}\n}}"
124
+ return None
125
+
126
+
127
+ class GraphQLClient(DrupalBaseClient):
128
+ """Client for Drupal GraphQL endpoints."""
129
+
130
+ def __init__(self, settings: Any, auth_manager: Any = None):
131
+ super().__init__(settings, auth_manager)
132
+ self._gql_path = settings.DRUPAL_GRAPHQL_PATH.strip("/")
133
+
134
+ async def execute(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any] | list[Any]:
135
+ payload: dict[str, Any] = {"query": query}
136
+ if variables:
137
+ payload["variables"] = variables
138
+ return await self.post(self._gql_path, json_body=payload)
139
+
140
+ async def get_schema(self) -> dict[str, Any] | list[Any]:
141
+ """Fetch the schema via GraphQL introspection, as JSON.
142
+
143
+ Drupal's GraphQL module exposes no schema endpoint, so introspection is
144
+ the only portable way to read it.
145
+ """
146
+ return await self.execute(_INTROSPECTION_QUERY)
147
+
148
+ async def get_schema_sdl(self) -> str:
149
+ """Fetch the schema and render it as SDL text."""
150
+ document = await self.get_schema()
151
+ schema = (
152
+ document.get("data", {}).get("__schema")
153
+ if isinstance(document, dict)
154
+ else None
155
+ )
156
+ if not schema:
157
+ raise ValueError(
158
+ "Introspection did not return a __schema; "
159
+ "is the GraphQL module enabled on this site?"
160
+ )
161
+
162
+ blocks = []
163
+ for t in schema.get("types") or []:
164
+ rendered = _render_type(t)
165
+ if rendered:
166
+ blocks.append(rendered)
167
+
168
+ entry: list[tuple[str, str]] = []
169
+ for role, label in (
170
+ ("queryType", "query"),
171
+ ("mutationType", "mutation"),
172
+ ("subscriptionType", "subscription"),
173
+ ):
174
+ node = schema.get(role)
175
+ if node and node.get("name"):
176
+ entry.append((label, node["name"]))
177
+
178
+ header = ""
179
+ if entry:
180
+ lines = "\n".join(f" {label}: {name}" for label, name in entry)
181
+ header = f"schema {{\n{lines}\n}}\n\n"
182
+ return header + "\n\n".join(blocks) + "\n"
@@ -0,0 +1,248 @@
1
+ """JSON:API client for Drupal."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from .base import DrupalBaseClient
7
+
8
+ # JSON:API addresses single resources by UUID. Drupal's internal IDs (nid, uid,
9
+ # tid, ...) are not usable in the path and must be resolved with a filter.
10
+ _UUID_RE = re.compile(
11
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
12
+ )
13
+
14
+ # Internal ID field names across core entity types, probed in order.
15
+ _ID_FIELDS = (
16
+ "drupal_internal__nid",
17
+ "drupal_internal__uid",
18
+ "drupal_internal__tid",
19
+ "drupal_internal__vid",
20
+ "drupal_internal__mid",
21
+ "drupal_internal__cid",
22
+ "drupal_internal__fid",
23
+ "drupal_internal__lid",
24
+ "drupal_internal__id",
25
+ )
26
+
27
+
28
+ class JSONAPIClient(DrupalBaseClient):
29
+ """Client for Drupal JSON:API endpoints."""
30
+
31
+ # Drupal answers any other Content-Type with 415 Unsupported Media Type.
32
+ _media_type = "application/vnd.api+json"
33
+
34
+ def __init__(self, settings: Any, auth_manager: Any = None):
35
+ super().__init__(settings, auth_manager)
36
+ self._api_path = settings.DRUPAL_JSONAPI_PATH.strip("/")
37
+ self._index_paths: dict[str, str] | None = None
38
+
39
+ def _jsonapi_url(self, resource: str) -> str:
40
+ return f"{self._api_path}/{resource.lstrip('/')}"
41
+
42
+ async def get_index(self) -> dict[str, Any] | list[Any]:
43
+ """Fetch the JSON:API root, which lists available resources.
44
+
45
+ Hits ``/jsonapi`` directly. Do not route this through
46
+ :meth:`get_resource`, which would produce ``/jsonapi/jsonapi``.
47
+ """
48
+ return await self.get(self._api_path)
49
+
50
+ async def _path_map(self) -> dict[str, str]:
51
+ """Map JSON:API resource types (``node--article``) to URL paths.
52
+
53
+ Drupal serves resources at ``/jsonapi/node/article`` while the
54
+ *type* string used in payloads stays ``node--article``. The index is
55
+ the only reliable source for this mapping, and it also lets us expand
56
+ a bare entity type (``node``) into its bundles.
57
+ """
58
+ if self._index_paths is not None:
59
+ return self._index_paths
60
+ try:
61
+ index = await self.get_index()
62
+ except Exception:
63
+ self._index_paths = {}
64
+ return self._index_paths
65
+ mapping: dict[str, str] = {}
66
+ links = index.get("links", {}) if isinstance(index, dict) else {}
67
+ prefix = f"/{self._api_path}/"
68
+ for key, value in links.items():
69
+ if key == "self" or not isinstance(value, dict):
70
+ continue
71
+ href = str(value.get("href", ""))
72
+ marker = href.find(prefix)
73
+ if marker == -1:
74
+ continue
75
+ mapping[key] = href[marker + len(prefix):].split("?")[0]
76
+ self._index_paths = mapping
77
+ return mapping
78
+
79
+ async def _candidates(self, resource_type: str) -> list[str]:
80
+ """URL path candidates for a resource type, in priority order."""
81
+ resource_type = resource_type.strip("/")
82
+ if "--" in resource_type:
83
+ return [resource_type.replace("--", "/")]
84
+ mapping = await self._path_map()
85
+ if resource_type in mapping:
86
+ return [mapping[resource_type]]
87
+ prefix = f"{resource_type}--"
88
+ found = [path for key, path in mapping.items() if key.startswith(prefix)]
89
+ return found or [resource_type]
90
+
91
+ async def get_resource(self, resource_type: str, params: dict[str, Any] | None = None) -> dict[str, Any] | list[Any]:
92
+ candidates = await self._candidates(resource_type)
93
+ if len(candidates) == 1:
94
+ return await self.get(self._jsonapi_url(candidates[0]), params=params)
95
+ return await self._merge(candidates, params)
96
+
97
+ async def _merge(self, candidates: list[str], params: dict[str, Any] | None) -> dict[str, Any]:
98
+ """Fetch several bundle collections and merge their data."""
99
+ merged: dict[str, Any] = {"data": [], "included": []}
100
+ for path in candidates:
101
+ try:
102
+ chunk = await self.get(self._jsonapi_url(path), params=params)
103
+ except Exception:
104
+ continue
105
+ if not isinstance(chunk, dict):
106
+ continue
107
+ merged["data"].extend(chunk.get("data") or [])
108
+ merged["included"].extend(chunk.get("included") or [])
109
+ for key in ("meta", "links"):
110
+ if key in chunk and key not in merged:
111
+ merged[key] = chunk[key]
112
+ seen: set[str] = set()
113
+ unique: list[Any] = []
114
+ for item in merged["included"]:
115
+ item_id = item.get("id")
116
+ if item_id not in seen:
117
+ seen.add(item_id)
118
+ unique.append(item)
119
+ merged["included"] = unique
120
+ return merged
121
+
122
+ async def _resolve_uuid(self, resource_type: str, resource_id: str) -> tuple[str, str]:
123
+ """Turn an internal ID into ``(path, uuid)`` for a single resource."""
124
+ resource_id = str(resource_id)
125
+ candidates = await self._candidates(resource_type)
126
+
127
+ if _UUID_RE.match(resource_id):
128
+ if len(candidates) == 1:
129
+ return candidates[0], resource_id
130
+ # Bundle unknown: probe each candidate collection for the UUID.
131
+ for path in candidates:
132
+ try:
133
+ await self.get(self._jsonapi_url(f"{path}/{resource_id}"))
134
+ except Exception:
135
+ continue
136
+ return path, resource_id
137
+ return candidates[0], resource_id
138
+
139
+ matched_field: str | None = None
140
+ for path in candidates:
141
+ for field in _ID_FIELDS:
142
+ # Field validity is per entity type, not per bundle, so a hit
143
+ # (or a valid-but-empty result) is remembered across paths.
144
+ candidate_field = matched_field or field
145
+ response = await self.request(
146
+ "GET",
147
+ self._jsonapi_url(path),
148
+ params={f"filter[{candidate_field}]": resource_id},
149
+ )
150
+ if response.status_code == 400:
151
+ # Unknown filter key: try the next field name.
152
+ continue
153
+ matched_field = candidate_field
154
+ if response.status_code >= 400:
155
+ break
156
+ rows = response.json().get("data") if response.content else None
157
+ if rows:
158
+ return path, rows[0]["id"]
159
+ # Valid filter, empty result: next bundle (or done).
160
+ break
161
+ raise LookupError(
162
+ f"No resource of type {resource_type!r} with id {resource_id!r}"
163
+ )
164
+
165
+ async def get_by_id(
166
+ self,
167
+ resource_type: str,
168
+ uuid: str,
169
+ params: dict[str, Any] | None = None,
170
+ ) -> dict[str, Any] | list[Any]:
171
+ """Fetch a single resource by ID, optionally with relationship includes.
172
+
173
+ Args:
174
+ resource_type: JSON:API resource type (e.g. ``node``, ``user--user``).
175
+ uuid: Resource UUID or Drupal internal ID (nid, uid, tid, ...).
176
+ params: Optional query params, typically ``{"include": "..."}``.
177
+
178
+ Returns:
179
+ Parsed JSON:API response.
180
+ """
181
+ path, resource_id = await self._resolve_uuid(resource_type, uuid)
182
+ return await self.get(self._jsonapi_url(f"{path}/{resource_id}"), params=params)
183
+
184
+ # Sibling keys of ``attributes`` in a JSON:API resource object. Including
185
+ # ``attributes`` matters: a caller that passes an already-structured
186
+ # document must not have it nested as attributes.attributes.
187
+ _RESOURCE_KEYS = frozenset(
188
+ {"type", "id", "lid", "attributes", "relationships", "links", "meta"}
189
+ )
190
+
191
+ @classmethod
192
+ def _payload(cls, body: Any) -> Any:
193
+ """Normalise a payload into JSON:API document form.
194
+
195
+ Most write tools assemble a flat dict (``{"title": ..., "type": ...}``),
196
+ but Drupal requires ``{"data": {"type": ..., "attributes": {...}}}`` and
197
+ rejects anything else with 400 "Resource object must include a type".
198
+ Documents that are already wrapped are passed through untouched.
199
+ """
200
+ if not isinstance(body, dict) or "data" in body or not body:
201
+ return body
202
+ resource: dict[str, Any] = {}
203
+ attributes: dict[str, Any] = {}
204
+ for key, value in body.items():
205
+ if key in cls._RESOURCE_KEYS:
206
+ resource[key] = value
207
+ else:
208
+ attributes[key] = value
209
+ if attributes:
210
+ resource["attributes"] = attributes
211
+ return {"data": resource}
212
+
213
+ async def get_revision(
214
+ self,
215
+ resource_type: str,
216
+ resource_id: str,
217
+ revision_id: str,
218
+ params: dict[str, Any] | None = None,
219
+ ) -> dict[str, Any] | list[Any]:
220
+ """Fetch one specific revision of a resource.
221
+
222
+ JSON:API reads historical revisions through the ``resourceVersion``
223
+ relationship (``id:<vid>``). It has no revision *collection*
224
+ endpoint -- ``.../revisions`` answers 405 -- so this is the only
225
+ revision access the module offers.
226
+ """
227
+ path, uuid = await self._resolve_uuid(resource_type, resource_id)
228
+ query = dict(params or {})
229
+ query["resourceVersion"] = f"id:{revision_id}"
230
+ return await self.get(self._jsonapi_url(f"{path}/{uuid}"), params=query)
231
+
232
+ async def create_resource(self, resource_type: str, data: dict[str, Any]) -> dict[str, Any] | list[Any]:
233
+ # The payload keeps the ``node--article`` type; only the URL is a path.
234
+ candidates = await self._candidates(resource_type)
235
+ return await self.post(self._jsonapi_url(candidates[0]), json_body=self._payload(data))
236
+
237
+ async def update_resource(self, resource_type: str, uuid: str, data: dict[str, Any]) -> dict[str, Any] | list[Any]:
238
+ path, resource_id = await self._resolve_uuid(resource_type, uuid)
239
+ body = self._payload(data)
240
+ # Drupal addresses the resource by UUID, not by the internal ID the
241
+ # caller may have passed in.
242
+ if isinstance(body, dict) and isinstance(body.get("data"), dict):
243
+ body["data"]["id"] = resource_id
244
+ return await self.patch(self._jsonapi_url(f"{path}/{resource_id}"), json_body=body)
245
+
246
+ async def delete_resource(self, resource_type: str, uuid: str) -> dict[str, Any] | list[Any]:
247
+ path, resource_id = await self._resolve_uuid(resource_type, uuid)
248
+ return await self.delete(self._jsonapi_url(f"{path}/{resource_id}"))
@@ -0,0 +1,84 @@
1
+ """Configuration settings for Drupal MCP Server."""
2
+
3
+ from functools import lru_cache
4
+
5
+ from pydantic import Field
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ """Application settings loaded from environment variables."""
11
+
12
+ model_config = SettingsConfigDict(
13
+ env_file=".env",
14
+ env_file_encoding="utf-8",
15
+ case_sensitive=False,
16
+ extra="ignore",
17
+ )
18
+
19
+ # Drupal Connection
20
+ DRUPAL_BASE_URL: str = Field(default="http://localhost", description="Base URL of Drupal site")
21
+ DRUPAL_JSONAPI_PATH: str = Field(default="/jsonapi", description="JSON:API endpoint path")
22
+ DRUPAL_GRAPHQL_PATH: str = Field(default="/graphql", description="GraphQL endpoint path")
23
+ DRUPAL_USERNAME: str | None = Field(default=None, description="Basic auth username")
24
+ DRUPAL_PASSWORD: str | None = Field(default=None, description="Basic auth password")
25
+ DRUPAL_OAUTH_CLIENT_ID: str | None = Field(default=None, description="OAuth client ID")
26
+ DRUPAL_OAUTH_CLIENT_SECRET: str | None = Field(default=None, description="OAuth client secret")
27
+ DRUPAL_OAUTH_TOKEN_URL: str | None = Field(default=None, description="OAuth token endpoint URL")
28
+ DRUPAL_API_TOKEN: str | None = Field(
29
+ default=None,
30
+ description="Pre-obtained bearer token, sent as Authorization: Bearer",
31
+ )
32
+ DRUPAL_VERIFY_SSL: bool = Field(
33
+ default=True,
34
+ description="Verify TLS certificates. Set false for self-signed dev certs (DDEV, Lando).",
35
+ )
36
+
37
+ # Drush
38
+ DRUSH_PATH: str = Field(
39
+ default="drush",
40
+ description=(
41
+ "Drush command. May include arguments, e.g. 'ddev drush' "
42
+ "(start the MCP server from the project directory)."
43
+ ),
44
+ )
45
+ DRUSH_ALIAS: str = Field(default="@self", description="Drush site alias")
46
+
47
+ # DDEV
48
+ DDEV_BINARY: str = Field(default="ddev", description="Path to ddev executable")
49
+
50
+ # AI Providers
51
+ DEFAULT_AI_PROVIDER: str = Field(default="ollama", description="Default AI provider")
52
+ AI_ENABLED: bool = Field(default=True, description="Enable AI-enhanced tools")
53
+ AI_FALLBACK_TO_CORE: bool = Field(default=True, description="Fallback to core tools if AI fails")
54
+ AI_TIMEOUT: int = Field(default=60, description="AI request timeout in seconds")
55
+
56
+ # Ollama
57
+ OLLAMA_BASE_URL: str = Field(default="http://localhost:11434", description="Ollama API base URL")
58
+ OLLAMA_MODEL: str = Field(default="llama3.2:3b", description="Ollama model name")
59
+
60
+ # OpenAI
61
+ OPENAI_API_KEY: str | None = Field(default=None, description="OpenAI API key")
62
+ OPENAI_MODEL: str = Field(default="gpt-4o-mini", description="OpenAI model name")
63
+
64
+ # Anthropic
65
+ ANTHROPIC_API_KEY: str | None = Field(default=None, description="Anthropic API key")
66
+ ANTHROPIC_MODEL: str = Field(default="claude-3-5-haiku-20241022", description="Anthropic model name")
67
+
68
+ # NVIDIA NIM
69
+ NVIDIA_NIM_API_KEY: str | None = Field(default=None, description="NVIDIA NIM API key")
70
+ NVIDIA_NIM_BASE_URL: str = Field(default="https://integrate.api.nvidia.com/v1", description="NVIDIA NIM base URL")
71
+ NVIDIA_NIM_MODEL: str = Field(default="nvidia/nemotron-3-ultra", description="NVIDIA NIM model name")
72
+
73
+ # MCP Server
74
+ MCP_SERVER_NAME: str = Field(default="drupal-mcp-server", description="MCP server name")
75
+ MCP_TRANSPORT: str = Field(default="stdio", description="MCP transport type")
76
+ MCP_HOST: str = Field(default="0.0.0.0", description="MCP HTTP host")
77
+ MCP_PORT: int = Field(default=8000, description="MCP HTTP port")
78
+ MCP_LOG_LEVEL: str = Field(default="INFO", description="Logging level")
79
+
80
+
81
+ @lru_cache
82
+ def get_settings() -> Settings:
83
+ """Get cached settings instance."""
84
+ return Settings()