drupal-rag-mcp 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_rag_mcp/__init__.py +0 -0
- drupal_rag_mcp/api_server.py +279 -0
- drupal_rag_mcp/config.py +56 -0
- drupal_rag_mcp/index.py +131 -0
- drupal_rag_mcp/mcp_server.py +82 -0
- drupal_rag_mcp-0.1.0.dist-info/METADATA +371 -0
- drupal_rag_mcp-0.1.0.dist-info/RECORD +11 -0
- drupal_rag_mcp-0.1.0.dist-info/WHEEL +5 -0
- drupal_rag_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- drupal_rag_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- drupal_rag_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import subprocess
|
|
3
|
+
import yaml
|
|
4
|
+
from fastapi import FastAPI, HTTPException
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
import uvicorn
|
|
7
|
+
from langchain_chroma import Chroma
|
|
8
|
+
from langchain_huggingface import HuggingFaceEmbeddings
|
|
9
|
+
|
|
10
|
+
from drupal_rag_mcp.config import DB_DIR, DRUPAL_ROOT, ALLOWED_COMMANDS
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
uvicorn.run(
|
|
14
|
+
"drupal_rag_mcp.api_server:app",
|
|
15
|
+
host="127.0.0.1",
|
|
16
|
+
port=8000,
|
|
17
|
+
reload=False,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
app = FastAPI()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
embedding = HuggingFaceEmbeddings(
|
|
26
|
+
model_name="sentence-transformers/all-MiniLM-L6-v2"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
vectorstore = Chroma(
|
|
30
|
+
persist_directory=DB_DIR,
|
|
31
|
+
embedding_function=embedding,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
retriever = vectorstore.as_retriever(search_kwargs={"k": 8})
|
|
35
|
+
|
|
36
|
+
from drupal_rag_mcp.config import (
|
|
37
|
+
ALLOWED_COMMANDS,
|
|
38
|
+
CODE_EXTS,
|
|
39
|
+
DRUPAL_ROOT,
|
|
40
|
+
IGNORE_EXTS,
|
|
41
|
+
IGNORE_DIRS
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# -------------------------
|
|
45
|
+
# Helpers
|
|
46
|
+
# -------------------------
|
|
47
|
+
|
|
48
|
+
def safe_path(relative_path: str) -> Path:
|
|
49
|
+
"""
|
|
50
|
+
Prevents the API from reading/writing outside your Drupal project.
|
|
51
|
+
"""
|
|
52
|
+
path = (DRUPAL_ROOT / relative_path).resolve()
|
|
53
|
+
|
|
54
|
+
if not str(path).startswith(str(DRUPAL_ROOT.resolve())):
|
|
55
|
+
raise HTTPException(status_code=400, detail="Unsafe path")
|
|
56
|
+
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
def should_skip(path: Path) -> bool:
|
|
60
|
+
parts = set(path.relative_to(DRUPAL_ROOT).parts)
|
|
61
|
+
if parts & IGNORE_DIRS:
|
|
62
|
+
return True
|
|
63
|
+
return path.suffix.lower() in IGNORE_EXTS
|
|
64
|
+
# -------------------------
|
|
65
|
+
# Request models
|
|
66
|
+
# -------------------------
|
|
67
|
+
|
|
68
|
+
class SearchRequest(BaseModel):
|
|
69
|
+
query: str
|
|
70
|
+
|
|
71
|
+
class FindRoutesRequest(BaseModel):
|
|
72
|
+
query: str = ""
|
|
73
|
+
max_results: int = 50
|
|
74
|
+
|
|
75
|
+
class FileRequest(BaseModel):
|
|
76
|
+
path: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class WriteFileRequest(BaseModel):
|
|
80
|
+
path: str
|
|
81
|
+
content: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CommandRequest(BaseModel):
|
|
85
|
+
command: list[str]
|
|
86
|
+
|
|
87
|
+
class ListFilesRequest(BaseModel):
|
|
88
|
+
directory: str = "."
|
|
89
|
+
max_files: int = 300
|
|
90
|
+
# -------------------------
|
|
91
|
+
# Tool 1: Search RAG
|
|
92
|
+
# -------------------------
|
|
93
|
+
|
|
94
|
+
@app.post("/search")
|
|
95
|
+
def search_docs(request: SearchRequest):
|
|
96
|
+
docs = retriever.invoke(request.query)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"results": [
|
|
100
|
+
{
|
|
101
|
+
"source": doc.metadata.get("source"),
|
|
102
|
+
"type": doc.metadata.get("type"),
|
|
103
|
+
"content": doc.page_content,
|
|
104
|
+
}
|
|
105
|
+
for doc in docs
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# -------------------------
|
|
111
|
+
# Tool 2: Read file
|
|
112
|
+
# -------------------------
|
|
113
|
+
|
|
114
|
+
@app.post("/read-file")
|
|
115
|
+
def read_file(request: FileRequest):
|
|
116
|
+
path = safe_path(request.path)
|
|
117
|
+
|
|
118
|
+
if not path.exists():
|
|
119
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
120
|
+
|
|
121
|
+
if not path.is_file():
|
|
122
|
+
raise HTTPException(status_code=400, detail="Path is not a file")
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"path": request.path,
|
|
126
|
+
"content": path.read_text(encoding="utf-8"),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# -------------------------
|
|
131
|
+
# Tool 3: Write file
|
|
132
|
+
# -------------------------
|
|
133
|
+
|
|
134
|
+
@app.post("/write-file")
|
|
135
|
+
def write_file(request: WriteFileRequest):
|
|
136
|
+
path = safe_path(request.path)
|
|
137
|
+
|
|
138
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
path.write_text(request.content, encoding="utf-8")
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"status": "written",
|
|
143
|
+
"path": request.path,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# -------------------------
|
|
148
|
+
# Tool 4: Run safe command
|
|
149
|
+
# -------------------------
|
|
150
|
+
|
|
151
|
+
@app.post("/run-command")
|
|
152
|
+
def run_command(request: CommandRequest):
|
|
153
|
+
if not request.command:
|
|
154
|
+
raise HTTPException(status_code=400, detail="Empty command")
|
|
155
|
+
|
|
156
|
+
executable = request.command[0]
|
|
157
|
+
|
|
158
|
+
if executable not in ALLOWED_COMMANDS:
|
|
159
|
+
raise HTTPException(
|
|
160
|
+
status_code=403,
|
|
161
|
+
detail=f"Command not allowed: {executable}",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
result = subprocess.run(
|
|
165
|
+
request.command,
|
|
166
|
+
cwd=DRUPAL_ROOT,
|
|
167
|
+
text=True,
|
|
168
|
+
capture_output=True,
|
|
169
|
+
timeout=60,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"command": request.command,
|
|
174
|
+
"returncode": result.returncode,
|
|
175
|
+
"stdout": result.stdout,
|
|
176
|
+
"stderr": result.stderr,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# -------------------------
|
|
180
|
+
# Tool 5: List Project Files
|
|
181
|
+
# -------------------------
|
|
182
|
+
|
|
183
|
+
@app.post("/list-files")
|
|
184
|
+
def list_files(request: ListFilesRequest):
|
|
185
|
+
directory = safe_path(request.directory)
|
|
186
|
+
|
|
187
|
+
if not directory.exists():
|
|
188
|
+
raise HTTPException(status_code=404, detail="Directory not found")
|
|
189
|
+
|
|
190
|
+
if not directory.is_dir():
|
|
191
|
+
raise HTTPException(status_code=400, detail="Path is not a directory")
|
|
192
|
+
|
|
193
|
+
files: list[str] = []
|
|
194
|
+
|
|
195
|
+
for path in directory.rglob("*"):
|
|
196
|
+
if len(files) >= request.max_files:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
if not path.is_file():
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
if should_skip(path):
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if path.suffix.lower() not in CODE_EXTS:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
files.append(str(path.relative_to(DRUPAL_ROOT)))
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"root": str(DRUPAL_ROOT),
|
|
212
|
+
"directory": request.directory,
|
|
213
|
+
"count": len(files),
|
|
214
|
+
"files": files,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# -------------------------
|
|
218
|
+
# Tool 6: Find - Routes
|
|
219
|
+
# -------------------------
|
|
220
|
+
@app.post("/find-routes")
|
|
221
|
+
def find_routes(request: FindRoutesRequest):
|
|
222
|
+
routes = []
|
|
223
|
+
|
|
224
|
+
for path in DRUPAL_ROOT.rglob("*.routing.yml"):
|
|
225
|
+
if should_skip(path):
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
230
|
+
except Exception:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
if not isinstance(data, dict):
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
for route_name, route_info in data.items():
|
|
237
|
+
if not isinstance(route_info, dict):
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
route_path = route_info.get("path", "")
|
|
241
|
+
defaults = route_info.get("defaults", {})
|
|
242
|
+
requirements = route_info.get("requirements", {})
|
|
243
|
+
|
|
244
|
+
controller = defaults.get("_controller", "")
|
|
245
|
+
form = defaults.get("_form", "")
|
|
246
|
+
title = defaults.get("_title", "")
|
|
247
|
+
permission = requirements.get("_permission", "")
|
|
248
|
+
|
|
249
|
+
searchable = " ".join([
|
|
250
|
+
str(route_name),
|
|
251
|
+
str(route_path),
|
|
252
|
+
str(controller),
|
|
253
|
+
str(form),
|
|
254
|
+
str(title),
|
|
255
|
+
str(permission),
|
|
256
|
+
str(path.relative_to(DRUPAL_ROOT)),
|
|
257
|
+
]).lower()
|
|
258
|
+
|
|
259
|
+
if request.query and request.query.lower() not in searchable:
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
routes.append({
|
|
263
|
+
"route_name": route_name,
|
|
264
|
+
"path": route_path,
|
|
265
|
+
"controller": controller,
|
|
266
|
+
"form": form,
|
|
267
|
+
"title": title,
|
|
268
|
+
"permission": permission,
|
|
269
|
+
"source": str(path.relative_to(DRUPAL_ROOT)),
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
if len(routes) >= request.max_results:
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
"query": request.query,
|
|
277
|
+
"count": len(routes),
|
|
278
|
+
"routes": routes,
|
|
279
|
+
}
|
drupal_rag_mcp/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
import tomllib
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
load_dotenv()
|
|
7
|
+
|
|
8
|
+
CONFIG_FILE = Path.home() / ".drupal-rag" / "config.toml"
|
|
9
|
+
|
|
10
|
+
DOC_EXTS = {".md", ".txt", ".rst"}
|
|
11
|
+
|
|
12
|
+
CODE_EXTS = {
|
|
13
|
+
".php", ".module", ".inc", ".install",
|
|
14
|
+
".theme", ".yml", ".twig", ".js", ".css"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
IGNORE_EXTS = {
|
|
18
|
+
".sqlite", ".gz", ".zip", ".tar",
|
|
19
|
+
".jpg", ".jpeg", ".png", ".gif",
|
|
20
|
+
".pdf", ".mp4", ".mov", ".sql"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
IGNORE_DIRS = {
|
|
24
|
+
"vendor",
|
|
25
|
+
"node_modules",
|
|
26
|
+
".git",
|
|
27
|
+
"web/sites/default/files",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ALLOWED_COMMANDS = {
|
|
31
|
+
"phpunit",
|
|
32
|
+
"composer",
|
|
33
|
+
"drush",
|
|
34
|
+
"vendor/bin/phpunit",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
config = {}
|
|
38
|
+
if CONFIG_FILE.exists():
|
|
39
|
+
config = tomllib.loads(CONFIG_FILE.read_text())
|
|
40
|
+
|
|
41
|
+
drupal_root_value = os.getenv("DRUPAL_ROOT") or config.get("drupal_root")
|
|
42
|
+
|
|
43
|
+
if not drupal_root_value:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"DRUPAL_ROOT is not set. Set it in your MCP env config or in ~/.drupal-rag/config.toml"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
DRUPAL_ROOT = Path(drupal_root_value).expanduser().resolve()
|
|
49
|
+
|
|
50
|
+
DB_DIR = Path(
|
|
51
|
+
os.getenv("DB_DIR")
|
|
52
|
+
or config.get("db_dir")
|
|
53
|
+
or "~/.drupal-rag/chroma"
|
|
54
|
+
).expanduser().resolve()
|
|
55
|
+
|
|
56
|
+
RAG_API = os.getenv("RAG_API") or config.get("api_url") or "http://127.0.0.1:8000"
|
drupal_rag_mcp/index.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# --------------------------------------------------
|
|
2
|
+
# Import Libraries
|
|
3
|
+
# Document: Every file we load will be converted into a Document so LangChain can process it consistently
|
|
4
|
+
# RecursiveCharacterTextSplitter: Splits large files into smaller chunks before embedding.
|
|
5
|
+
# Chroma is our vector database
|
|
6
|
+
# HuggingFaceEmbeddings: Converts text into vectors without requiring OpenAI
|
|
7
|
+
# --------------------------------------------------
|
|
8
|
+
from langchain_core.documents import Document
|
|
9
|
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
|
10
|
+
from langchain_chroma import Chroma
|
|
11
|
+
from langchain_community.embeddings import HuggingFaceEmbeddings
|
|
12
|
+
|
|
13
|
+
# --------------------------------------------------
|
|
14
|
+
# Import and use configurations
|
|
15
|
+
# --------------------------------------------------
|
|
16
|
+
from drupal_rag_mcp.config import (
|
|
17
|
+
DRUPAL_ROOT,
|
|
18
|
+
DB_DIR,
|
|
19
|
+
DOC_EXTS,
|
|
20
|
+
CODE_EXTS,
|
|
21
|
+
IGNORE_DIRS,
|
|
22
|
+
IGNORE_EXTS
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# --------------------------------------------------
|
|
26
|
+
# Create embedding model: This model converts text into numerical vectors
|
|
27
|
+
# --------------------------------------------------
|
|
28
|
+
embedding = HuggingFaceEmbeddings(
|
|
29
|
+
model_name="sentence-transformers/all-MiniLM-L6-v2"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# --------------------------------------------------
|
|
33
|
+
# Determine if IGNORE_DIRS and IGNORE_EXTS should be skipped
|
|
34
|
+
# --------------------------------------------------
|
|
35
|
+
def should_skip(path):
|
|
36
|
+
return any(part in IGNORE_DIRS for part in path.parts)
|
|
37
|
+
|
|
38
|
+
def should_skip_file(path):
|
|
39
|
+
return path.suffix.lower() in IGNORE_EXTS
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------
|
|
42
|
+
# Load Drupal files into LangChain Documents: Converts files into Document objects
|
|
43
|
+
# --------------------------------------------------
|
|
44
|
+
def load_files():
|
|
45
|
+
docs = []
|
|
46
|
+
# Recursively search every file and directory
|
|
47
|
+
for path in DRUPAL_ROOT.rglob("*"):
|
|
48
|
+
if not path.is_file():
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
if should_skip(path):
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if should_skip_file(path):
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if path.suffix in DOC_EXTS or path.suffix in CODE_EXTS:
|
|
58
|
+
try:
|
|
59
|
+
text = path.read_text(encoding="utf-8")
|
|
60
|
+
except UnicodeDecodeError:
|
|
61
|
+
continue
|
|
62
|
+
# Create a LangChain Document.
|
|
63
|
+
docs.append(
|
|
64
|
+
Document(
|
|
65
|
+
page_content=text,
|
|
66
|
+
metadata={
|
|
67
|
+
"source": str(path.relative_to(DRUPAL_ROOT)),
|
|
68
|
+
"extension": path.suffix,
|
|
69
|
+
"type": "doc" if path.suffix in DOC_EXTS else "code",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return docs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------
|
|
79
|
+
# Split documents into chunks for easy retrieval later
|
|
80
|
+
# --------------------------------------------------
|
|
81
|
+
splitter = RecursiveCharacterTextSplitter(
|
|
82
|
+
chunk_size=1200,
|
|
83
|
+
chunk_overlap=200,
|
|
84
|
+
separators=[
|
|
85
|
+
"\n## ",
|
|
86
|
+
"\n### ",
|
|
87
|
+
"\nfunction ",
|
|
88
|
+
"\nclass ",
|
|
89
|
+
"\npublic function ",
|
|
90
|
+
"\n\n",
|
|
91
|
+
"\n",
|
|
92
|
+
" ",
|
|
93
|
+
"",
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
# Split all documents into chunks.
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main():
|
|
101
|
+
documents = load_files()
|
|
102
|
+
|
|
103
|
+
splitter = RecursiveCharacterTextSplitter(
|
|
104
|
+
chunk_size=1200,
|
|
105
|
+
chunk_overlap=200,
|
|
106
|
+
separators=[
|
|
107
|
+
"\n## ",
|
|
108
|
+
"\n### ",
|
|
109
|
+
"\nfunction ",
|
|
110
|
+
"\nclass ",
|
|
111
|
+
"\npublic function ",
|
|
112
|
+
"\n\n",
|
|
113
|
+
"\n",
|
|
114
|
+
" ",
|
|
115
|
+
"",
|
|
116
|
+
],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
chunks = splitter.split_documents(documents)
|
|
120
|
+
|
|
121
|
+
Chroma.from_documents(
|
|
122
|
+
documents=chunks,
|
|
123
|
+
embedding=embedding,
|
|
124
|
+
persist_directory=str(DB_DIR),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
print(f"Indexed {len(chunks)} chunks into {DB_DIR}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from mcp.server.fastmcp import FastMCP
|
|
3
|
+
|
|
4
|
+
mcp = FastMCP("drupal-rag")
|
|
5
|
+
|
|
6
|
+
RAG_API = "http://127.0.0.1:8000"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@mcp.tool()
|
|
10
|
+
def search_drupal_docs(query: str) -> dict:
|
|
11
|
+
"""Search the local Drupal RAG knowledge base."""
|
|
12
|
+
response = requests.post(
|
|
13
|
+
f"{RAG_API}/search",
|
|
14
|
+
json={"query": query},
|
|
15
|
+
timeout=30,
|
|
16
|
+
)
|
|
17
|
+
response.raise_for_status()
|
|
18
|
+
return response.json()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@mcp.tool()
|
|
22
|
+
def read_drupal_file(path: str) -> dict:
|
|
23
|
+
"""Read a file from the Drupal project using a relative path."""
|
|
24
|
+
response = requests.post(
|
|
25
|
+
f"{RAG_API}/read-file",
|
|
26
|
+
json={"path": path},
|
|
27
|
+
timeout=30,
|
|
28
|
+
)
|
|
29
|
+
response.raise_for_status()
|
|
30
|
+
return response.json()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@mcp.tool()
|
|
34
|
+
def write_drupal_file(path: str, content: str) -> dict:
|
|
35
|
+
"""Write a file in the Drupal project using a relative path."""
|
|
36
|
+
response = requests.post(
|
|
37
|
+
f"{RAG_API}/write-file",
|
|
38
|
+
json={"path": path, "content": content},
|
|
39
|
+
timeout=30,
|
|
40
|
+
)
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
return response.json()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
def run_drupal_command(command: list[str]) -> dict:
|
|
47
|
+
"""Run an allowed Drupal command such as drush, composer, or phpunit."""
|
|
48
|
+
response = requests.post(
|
|
49
|
+
f"{RAG_API}/run-command",
|
|
50
|
+
json={"command": command},
|
|
51
|
+
timeout=120,
|
|
52
|
+
)
|
|
53
|
+
response.raise_for_status()
|
|
54
|
+
return response.json()
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def list_project_files(directory: str = ".", max_files: int = 300) -> dict:
|
|
58
|
+
"""List code and text files in the project. No embeddings or vector database are used."""
|
|
59
|
+
response = requests.post(
|
|
60
|
+
f"{RAG_API}/list-files",
|
|
61
|
+
json={"directory": directory, "max_files": max_files},
|
|
62
|
+
timeout=30,
|
|
63
|
+
)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
return response.json()
|
|
66
|
+
|
|
67
|
+
@mcp.tool()
|
|
68
|
+
def find_routes(query: str = "", max_results: int = 50) -> dict:
|
|
69
|
+
"""Find Drupal routes by route name, path, controller, form, title, permission, or routing file."""
|
|
70
|
+
response = requests.post(
|
|
71
|
+
f"{RAG_API}/find-routes",
|
|
72
|
+
json={"query": query, "max_results": max_results},
|
|
73
|
+
timeout=30,
|
|
74
|
+
)
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
return response.json()
|
|
77
|
+
|
|
78
|
+
def main():
|
|
79
|
+
mcp.run()
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: drupal-rag-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Drupal RAG, file access, routes, and safe Drupal commands
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: mcp>=1.27.2
|
|
9
|
+
Requires-Dist: requests>=2.34.2
|
|
10
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
11
|
+
Requires-Dist: PyYAML>=6.0.3
|
|
12
|
+
Requires-Dist: psutil>=7.2.2
|
|
13
|
+
Requires-Dist: ipython>=9.14.1
|
|
14
|
+
Provides-Extra: rag
|
|
15
|
+
Requires-Dist: chromadb>=1.5.9; extra == "rag"
|
|
16
|
+
Requires-Dist: langchain>=1.3.2; extra == "rag"
|
|
17
|
+
Requires-Dist: langchain-chroma>=1.1.0; extra == "rag"
|
|
18
|
+
Requires-Dist: langchain-community>=0.4.2; extra == "rag"
|
|
19
|
+
Requires-Dist: langchain-core>=1.4.0; extra == "rag"
|
|
20
|
+
Requires-Dist: langchain-huggingface>=1.2.2; extra == "rag"
|
|
21
|
+
Requires-Dist: langchain-text-splitters>=1.1.2; extra == "rag"
|
|
22
|
+
Requires-Dist: sentence-transformers>=5.5.1; extra == "rag"
|
|
23
|
+
Requires-Dist: fastapi>=0.136.3; extra == "rag"
|
|
24
|
+
Requires-Dist: uvicorn>=0.48.0; extra == "rag"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Drupal RAG MCP
|
|
28
|
+
<img src="media/screenshot.png" alt="Drupal Agent Screenshot" style="max-width: 150px;">
|
|
29
|
+
|
|
30
|
+
A Model Context Protocol (MCP) server that gives AI coding assistants direct access to Drupal projects through:
|
|
31
|
+
|
|
32
|
+
* Drupal codebase search (RAG)
|
|
33
|
+
* File reading
|
|
34
|
+
* File writing
|
|
35
|
+
* Drupal route discovery
|
|
36
|
+
* Safe Drupal command execution
|
|
37
|
+
* Project file listing
|
|
38
|
+
|
|
39
|
+
This server is designed for use with MCP-compatible clients such as:
|
|
40
|
+
|
|
41
|
+
* Claude Desktop
|
|
42
|
+
* Claude Code
|
|
43
|
+
* Cursor
|
|
44
|
+
* Windsurf
|
|
45
|
+
* Cline
|
|
46
|
+
* OpenAI Codex CLI
|
|
47
|
+
* Other MCP-compatible tools
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
### Search Drupal Knowledge Base
|
|
54
|
+
|
|
55
|
+
Search indexed Drupal source code and documentation using semantic search.
|
|
56
|
+
|
|
57
|
+
Tool:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
search_drupal_docs
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Read Drupal Files
|
|
64
|
+
|
|
65
|
+
Read any file within the configured Drupal project.
|
|
66
|
+
|
|
67
|
+
Tool:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
read_drupal_file
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Write Drupal Files
|
|
74
|
+
|
|
75
|
+
Create or update files inside the Drupal project.
|
|
76
|
+
|
|
77
|
+
Tool:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
write_drupal_file
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Run Drupal Commands
|
|
84
|
+
|
|
85
|
+
Execute approved Drupal development commands.
|
|
86
|
+
|
|
87
|
+
Supported commands:
|
|
88
|
+
|
|
89
|
+
* drush
|
|
90
|
+
* composer
|
|
91
|
+
* phpunit
|
|
92
|
+
|
|
93
|
+
Tool:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
run_drupal_command
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### List Project Files
|
|
100
|
+
|
|
101
|
+
Browse project source files without using embeddings.
|
|
102
|
+
|
|
103
|
+
Tool:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
list_project_files
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Discover Drupal Routes
|
|
110
|
+
|
|
111
|
+
Search Drupal routes by:
|
|
112
|
+
|
|
113
|
+
* Route name
|
|
114
|
+
* Path
|
|
115
|
+
* Controller
|
|
116
|
+
* Form
|
|
117
|
+
* Permission
|
|
118
|
+
* Title
|
|
119
|
+
|
|
120
|
+
Tool:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
find_routes
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
# Architecture
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
AI Client
|
|
132
|
+
│
|
|
133
|
+
▼
|
|
134
|
+
Drupal MCP Server
|
|
135
|
+
│
|
|
136
|
+
▼
|
|
137
|
+
Drupal RAG API
|
|
138
|
+
│
|
|
139
|
+
├── Chroma Vector Database
|
|
140
|
+
├── Drupal Source Code
|
|
141
|
+
└── Drupal Documentation
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The MCP server communicates with a local FastAPI service that performs indexing, search, file operations, and command execution.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
# Installation
|
|
149
|
+
|
|
150
|
+
Install from PyPI:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install drupal-rag-mcp
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Or install with RAG dependencies:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
pip install "drupal-rag-mcp[rag]"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
# Requirements
|
|
165
|
+
|
|
166
|
+
* Python 3.10+
|
|
167
|
+
* Drupal project
|
|
168
|
+
* Composer
|
|
169
|
+
* Drush (optional)
|
|
170
|
+
* ChromaDB
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Build Your Drupal RAG
|
|
176
|
+
Create a .drupal-rag folder under your home directory
|
|
177
|
+
```bash
|
|
178
|
+
mkdir ~/.drupal-rag
|
|
179
|
+
cd ~/.drupal-rag
|
|
180
|
+
touch config.toml
|
|
181
|
+
```
|
|
182
|
+
Paste this settings
|
|
183
|
+
```toml
|
|
184
|
+
drupal_root = "/path_to_drupal_project_directory"
|
|
185
|
+
db_dir = "~/.drupal-rag/chroma"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Inside ~/.drupal-rag
|
|
189
|
+
```bash
|
|
190
|
+
mkdir docs
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Inside docs add all your drupal documentation (PDF, MD, txt).
|
|
194
|
+
Start indexing your docs to create rag
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
drupal-rag-index
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
This will:
|
|
201
|
+
|
|
202
|
+
1. Scan Drupal files
|
|
203
|
+
2. Split documents into chunks
|
|
204
|
+
3. Generate embeddings
|
|
205
|
+
4. Store vectors in Chroma
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
# Start the API Server
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
drupal-rag-api
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The API provides:
|
|
216
|
+
|
|
217
|
+
```text
|
|
218
|
+
POST /search
|
|
219
|
+
POST /read-file
|
|
220
|
+
POST /write-file
|
|
221
|
+
POST /run-command
|
|
222
|
+
POST /list-files
|
|
223
|
+
POST /find-routes
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
# Start the MCP Server
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
drupal-rag-mcp
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The MCP server uses stdio transport and is intended to be launched by an MCP client.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
# MCP Client Configuration
|
|
239
|
+
|
|
240
|
+
## Claude Desktop
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"mcpServers": {
|
|
245
|
+
"drupal-rag": {
|
|
246
|
+
"command": "drupal-rag-mcp"
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Cursor
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"mcpServers": {
|
|
257
|
+
"drupal-rag": {
|
|
258
|
+
"command": "drupal-rag-mcp"
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## VSCODE
|
|
265
|
+
|
|
266
|
+
### Installing and Running Ollama on Mac
|
|
267
|
+
```bash
|
|
268
|
+
brew install ollama
|
|
269
|
+
brew services start ollama
|
|
270
|
+
brew services stop ollama
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Run and Download an AI Model
|
|
274
|
+
```bash
|
|
275
|
+
ollama pull qwen3
|
|
276
|
+
ollama run qwen3
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Set Up Continue
|
|
280
|
+
1. Add Extension Continue
|
|
281
|
+
2. Configure Continue config.yaml
|
|
282
|
+
```yml
|
|
283
|
+
name: Local Config
|
|
284
|
+
version: 1.0.0
|
|
285
|
+
schema: v1
|
|
286
|
+
|
|
287
|
+
models:
|
|
288
|
+
- name: Qwen3
|
|
289
|
+
provider: ollama
|
|
290
|
+
model: qwen3
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
3. Add MCP server in Continue
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
which drupal-rag-mcp
|
|
297
|
+
```
|
|
298
|
+
Example output: `/opt/anaconda3/envs/drupal_rag/bin/drupal-rag-mcp`
|
|
299
|
+
|
|
300
|
+
Configure Continue mcp-server.yaml
|
|
301
|
+
```yml
|
|
302
|
+
name: Drupal RAG MCP
|
|
303
|
+
version: 0.0.1
|
|
304
|
+
schema: v1
|
|
305
|
+
|
|
306
|
+
mcpServers:
|
|
307
|
+
- name: drupal-rag
|
|
308
|
+
command: /opt/anaconda3/envs/drupal_rag/bin/drupal-rag-mcp
|
|
309
|
+
args: []
|
|
310
|
+
env:
|
|
311
|
+
DRUPAL_ROOT: /path_to_your_drupal
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
## Codex CLI
|
|
316
|
+
|
|
317
|
+
```toml
|
|
318
|
+
[mcp_servers.drupal-rag]
|
|
319
|
+
command = "drupal-rag-mcp"
|
|
320
|
+
args = []
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
# Example Prompts
|
|
326
|
+
|
|
327
|
+
```text
|
|
328
|
+
Find the route responsible for user login.
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
```text
|
|
332
|
+
Search Drupal documentation for cache tags.
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
```text
|
|
336
|
+
Show me all custom routes under /admin.
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
```text
|
|
340
|
+
Read web/modules/custom/example/src/Form/ExampleForm.php
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
```text
|
|
344
|
+
Run drush cr
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
# Security
|
|
350
|
+
|
|
351
|
+
The server includes several safeguards:
|
|
352
|
+
|
|
353
|
+
* Path traversal protection
|
|
354
|
+
* Drupal root restriction
|
|
355
|
+
* Command allow-list
|
|
356
|
+
* Ignored directories and file types
|
|
357
|
+
* Local-only API access
|
|
358
|
+
|
|
359
|
+
Only approved commands can be executed.
|
|
360
|
+
|
|
361
|
+
Review the source code before enabling write access in production environments.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
# License
|
|
366
|
+
|
|
367
|
+
MIT License
|
|
368
|
+
|
|
369
|
+
Copyright (c) 2026 John Paul Mariano
|
|
370
|
+
|
|
371
|
+
See the LICENSE file for full license text.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
drupal_rag_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
drupal_rag_mcp/api_server.py,sha256=3kQFRgbjsxgg9RV8duajQ6H6Dsb5k2tH4NisDSTir1Q,6750
|
|
3
|
+
drupal_rag_mcp/config.py,sha256=oc7DzrdQNqCPET8mgq-_0cVTxMSm9OmMNNReoX2oOCo,1201
|
|
4
|
+
drupal_rag_mcp/index.py,sha256=O3pGzxjj1qqjz1h4Coqx6XExWxgKnvoyBk-uh0Tc2HU,3821
|
|
5
|
+
drupal_rag_mcp/mcp_server.py,sha256=KLIMtVLQgZotwmonKvOeTQHv2XYhuHkGL9rgq4jKfNg,2218
|
|
6
|
+
drupal_rag_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=jj6Zey70W4se0XC-iAe2-0PWCO6vXKFRKEZc-bDmod0,1073
|
|
7
|
+
drupal_rag_mcp-0.1.0.dist-info/METADATA,sha256=cNepwptAyvDCzf9b5pQYrem6MoPDQ5SIYzkIgGcilZU,5724
|
|
8
|
+
drupal_rag_mcp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
drupal_rag_mcp-0.1.0.dist-info/entry_points.txt,sha256=7yq7PMXDo_fouNXYwnt6MlTy-dCo2bPvYANbEBPGH7Q,159
|
|
10
|
+
drupal_rag_mcp-0.1.0.dist-info/top_level.txt,sha256=oT9RQCaIOn7pLaTuL9tQt0vtw9DzluAiXaU9eZKCH0I,15
|
|
11
|
+
drupal_rag_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Paul Mariano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
drupal_rag_mcp
|