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,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()
|