strapi-content-embeddings 0.1.0
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.
- package/README.md +276 -0
- package/dist/_chunks/App-7LMg3lrX.mjs +1004 -0
- package/dist/_chunks/App-wC2qv6kC.js +1008 -0
- package/dist/_chunks/en-B4KWt_jN.js +4 -0
- package/dist/_chunks/en-Byx4XI2L.mjs +4 -0
- package/dist/_chunks/index-Cz9cEuvw.mjs +411 -0
- package/dist/_chunks/index-o-tbBpJG.js +413 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.mjs +5 -0
- package/dist/admin/src/components/Initializer.d.ts +5 -0
- package/dist/admin/src/components/PluginIcon.d.ts +2 -0
- package/dist/admin/src/components/custom/BackLink.d.ts +5 -0
- package/dist/admin/src/components/custom/ChatModal.d.ts +1 -0
- package/dist/admin/src/components/custom/EmbeddingsModal.d.ts +1 -0
- package/dist/admin/src/components/custom/EmbeddingsTable.d.ts +12 -0
- package/dist/admin/src/components/custom/EmbeddingsWidget.d.ts +1 -0
- package/dist/admin/src/components/custom/EmptyState.d.ts +1 -0
- package/dist/admin/src/components/custom/Illo.d.ts +1 -0
- package/dist/admin/src/components/custom/Markdown.d.ts +5 -0
- package/dist/admin/src/components/custom/MarkdownEditor.d.ts +9 -0
- package/dist/admin/src/components/custom/RobotIcon.d.ts +6 -0
- package/dist/admin/src/components/forms/CreateEmbeddingForm.d.ts +15 -0
- package/dist/admin/src/index.d.ts +12 -0
- package/dist/admin/src/pages/App.d.ts +2 -0
- package/dist/admin/src/pages/CreateEmbeddings.d.ts +1 -0
- package/dist/admin/src/pages/EmbeddingDetails.d.ts +1 -0
- package/dist/admin/src/pages/HomePage.d.ts +1 -0
- package/dist/admin/src/pluginId.d.ts +1 -0
- package/dist/admin/src/utils/api.d.ts +33 -0
- package/dist/admin/src/utils/getTranslation.d.ts +2 -0
- package/dist/server/index.js +1359 -0
- package/dist/server/index.mjs +1360 -0
- package/dist/server/src/bootstrap.d.ts +5 -0
- package/dist/server/src/config/index.d.ts +26 -0
- package/dist/server/src/content-types/embedding/index.d.ts +54 -0
- package/dist/server/src/content-types/index.d.ts +56 -0
- package/dist/server/src/controllers/controller.d.ts +12 -0
- package/dist/server/src/controllers/index.d.ts +18 -0
- package/dist/server/src/controllers/mcp.d.ts +18 -0
- package/dist/server/src/destroy.d.ts +5 -0
- package/dist/server/src/index.d.ts +154 -0
- package/dist/server/src/mcp/index.d.ts +6 -0
- package/dist/server/src/mcp/schemas/index.d.ts +65 -0
- package/dist/server/src/mcp/server.d.ts +8 -0
- package/dist/server/src/mcp/tools/create-embedding.d.ts +38 -0
- package/dist/server/src/mcp/tools/get-embedding.d.ts +34 -0
- package/dist/server/src/mcp/tools/index.d.ts +114 -0
- package/dist/server/src/mcp/tools/list-embeddings.d.ts +40 -0
- package/dist/server/src/mcp/tools/rag-query.d.ts +34 -0
- package/dist/server/src/mcp/tools/semantic-search.d.ts +34 -0
- package/dist/server/src/middlewares/index.d.ts +2 -0
- package/dist/server/src/plugin-manager.d.ts +45 -0
- package/dist/server/src/policies/index.d.ts +2 -0
- package/dist/server/src/register.d.ts +5 -0
- package/dist/server/src/routes/admin.d.ts +14 -0
- package/dist/server/src/routes/content-api.d.ts +15 -0
- package/dist/server/src/routes/index.d.ts +36 -0
- package/dist/server/src/services/embeddings.d.ts +45 -0
- package/dist/server/src/services/index.d.ts +26 -0
- package/dist/style.css +95 -0
- package/package.json +104 -0
|
@@ -0,0 +1,1360 @@
|
|
|
1
|
+
import { OpenAIEmbeddings, ChatOpenAI } from "@langchain/openai";
|
|
2
|
+
import { PGVectorStore } from "@langchain/community/vectorstores/pgvector";
|
|
3
|
+
import { Document } from "@langchain/core/documents";
|
|
4
|
+
import { StringOutputParser } from "@langchain/core/output_parsers";
|
|
5
|
+
import { ChatPromptTemplate } from "@langchain/core/prompts";
|
|
6
|
+
import { RunnableSequence, RunnablePassthrough } from "@langchain/core/runnables";
|
|
7
|
+
import { Pool } from "pg";
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
13
|
+
const EMBEDDING_MODELS = {
|
|
14
|
+
"text-embedding-3-small": { dimensions: 1536 },
|
|
15
|
+
"text-embedding-3-large": { dimensions: 3072 },
|
|
16
|
+
"text-embedding-ada-002": { dimensions: 1536 }
|
|
17
|
+
};
|
|
18
|
+
const config = {
|
|
19
|
+
default: {
|
|
20
|
+
openAIApiKey: "",
|
|
21
|
+
neonConnectionString: "",
|
|
22
|
+
embeddingModel: "text-embedding-3-small"
|
|
23
|
+
},
|
|
24
|
+
validator(config2) {
|
|
25
|
+
if (!config2.openAIApiKey) {
|
|
26
|
+
console.warn(
|
|
27
|
+
"strapi-content-embeddings: openAIApiKey is not configured. Plugin features will be disabled."
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (!config2.neonConnectionString) {
|
|
31
|
+
console.warn(
|
|
32
|
+
"strapi-content-embeddings: neonConnectionString is not configured. Plugin features will be disabled."
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (config2.embeddingModel && !EMBEDDING_MODELS[config2.embeddingModel]) {
|
|
36
|
+
console.warn(
|
|
37
|
+
`strapi-content-embeddings: Invalid embeddingModel "${config2.embeddingModel}". Valid options: ${Object.keys(EMBEDDING_MODELS).join(", ")}. Defaulting to "text-embedding-3-small".`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
class PluginManager {
|
|
43
|
+
constructor() {
|
|
44
|
+
this.embeddings = null;
|
|
45
|
+
this.chat = null;
|
|
46
|
+
this.pool = null;
|
|
47
|
+
this.embeddingModel = "text-embedding-3-small";
|
|
48
|
+
this.dimensions = 1536;
|
|
49
|
+
this.vectorStoreConfig = null;
|
|
50
|
+
}
|
|
51
|
+
async initializePool(connectionString) {
|
|
52
|
+
console.log("Initializing Neon DB Pool");
|
|
53
|
+
if (this.pool) return this.pool;
|
|
54
|
+
try {
|
|
55
|
+
const poolConfig = {
|
|
56
|
+
connectionString,
|
|
57
|
+
ssl: { rejectUnauthorized: false },
|
|
58
|
+
max: 10
|
|
59
|
+
};
|
|
60
|
+
this.pool = new Pool(poolConfig);
|
|
61
|
+
const client = await this.pool.connect();
|
|
62
|
+
await client.query("SELECT 1");
|
|
63
|
+
client.release();
|
|
64
|
+
await this.initializeVectorTable();
|
|
65
|
+
console.log("Neon DB Pool initialized successfully");
|
|
66
|
+
return this.pool;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(`Failed to initialize Neon DB Pool: ${error}`);
|
|
69
|
+
throw new Error(`Failed to initialize Neon DB Pool: ${error}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async initializeVectorTable() {
|
|
73
|
+
if (!this.pool) throw new Error("Pool not initialized");
|
|
74
|
+
const client = await this.pool.connect();
|
|
75
|
+
try {
|
|
76
|
+
await client.query("CREATE EXTENSION IF NOT EXISTS vector");
|
|
77
|
+
await client.query(`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS embeddings_documents (
|
|
79
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
80
|
+
content TEXT,
|
|
81
|
+
metadata JSONB,
|
|
82
|
+
embedding vector(${this.dimensions})
|
|
83
|
+
)
|
|
84
|
+
`);
|
|
85
|
+
await client.query(`
|
|
86
|
+
DROP INDEX IF EXISTS embeddings_documents_embedding_idx
|
|
87
|
+
`);
|
|
88
|
+
await client.query(`
|
|
89
|
+
CREATE INDEX IF NOT EXISTS embeddings_documents_embedding_hnsw_idx
|
|
90
|
+
ON embeddings_documents
|
|
91
|
+
USING hnsw (embedding vector_cosine_ops)
|
|
92
|
+
`);
|
|
93
|
+
await client.query(`
|
|
94
|
+
CREATE INDEX IF NOT EXISTS embeddings_documents_metadata_idx
|
|
95
|
+
ON embeddings_documents
|
|
96
|
+
USING gin (metadata)
|
|
97
|
+
`);
|
|
98
|
+
console.log(`Vector table initialized (dimensions: ${this.dimensions})`);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.log("Note: Index creation may require more data");
|
|
101
|
+
} finally {
|
|
102
|
+
client.release();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async initializeEmbeddings(openAIApiKey) {
|
|
106
|
+
console.log(`Initializing OpenAI Embeddings (model: ${this.embeddingModel})`);
|
|
107
|
+
if (this.embeddings) return this.embeddings;
|
|
108
|
+
try {
|
|
109
|
+
this.embeddings = new OpenAIEmbeddings({
|
|
110
|
+
openAIApiKey,
|
|
111
|
+
modelName: this.embeddingModel,
|
|
112
|
+
dimensions: this.dimensions
|
|
113
|
+
});
|
|
114
|
+
return this.embeddings;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(`Failed to initialize Embeddings: ${error}`);
|
|
117
|
+
throw new Error(`Failed to initialize Embeddings: ${error}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async initializeChat(openAIApiKey) {
|
|
121
|
+
console.log("Initializing Chat Model");
|
|
122
|
+
if (this.chat) return this.chat;
|
|
123
|
+
try {
|
|
124
|
+
this.chat = new ChatOpenAI({
|
|
125
|
+
modelName: "gpt-4o-mini",
|
|
126
|
+
temperature: 0.7,
|
|
127
|
+
openAIApiKey
|
|
128
|
+
});
|
|
129
|
+
return this.chat;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(`Failed to initialize Chat: ${error}`);
|
|
132
|
+
throw new Error(`Failed to initialize Chat: ${error}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async initialize(config2) {
|
|
136
|
+
const model = config2.embeddingModel || "text-embedding-3-small";
|
|
137
|
+
if (EMBEDDING_MODELS[model]) {
|
|
138
|
+
this.embeddingModel = model;
|
|
139
|
+
this.dimensions = EMBEDDING_MODELS[model].dimensions;
|
|
140
|
+
} else {
|
|
141
|
+
console.warn(`Invalid embedding model "${model}", using default`);
|
|
142
|
+
this.embeddingModel = "text-embedding-3-small";
|
|
143
|
+
this.dimensions = EMBEDDING_MODELS["text-embedding-3-small"].dimensions;
|
|
144
|
+
}
|
|
145
|
+
console.log(`Using embedding model: ${this.embeddingModel} (${this.dimensions} dimensions)`);
|
|
146
|
+
await this.initializePool(config2.neonConnectionString);
|
|
147
|
+
await this.initializeEmbeddings(config2.openAIApiKey);
|
|
148
|
+
await this.initializeChat(config2.openAIApiKey);
|
|
149
|
+
if (this.pool) {
|
|
150
|
+
this.vectorStoreConfig = {
|
|
151
|
+
pool: this.pool,
|
|
152
|
+
tableName: "embeddings_documents",
|
|
153
|
+
columns: {
|
|
154
|
+
idColumnName: "id",
|
|
155
|
+
vectorColumnName: "embedding",
|
|
156
|
+
contentColumnName: "content",
|
|
157
|
+
metadataColumnName: "metadata"
|
|
158
|
+
},
|
|
159
|
+
distanceStrategy: "cosine"
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
console.log("Plugin Manager Initialization Complete");
|
|
163
|
+
}
|
|
164
|
+
async createEmbedding(docData) {
|
|
165
|
+
if (!this.embeddings || !this.vectorStoreConfig) {
|
|
166
|
+
throw new Error("Plugin manager not initialized");
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const embeddingVector = await this.embeddings.embedQuery(docData.content);
|
|
170
|
+
const doc = new Document({
|
|
171
|
+
pageContent: docData.content,
|
|
172
|
+
metadata: {
|
|
173
|
+
id: docData.id,
|
|
174
|
+
title: docData.title,
|
|
175
|
+
collectionType: docData.collectionType || "standalone",
|
|
176
|
+
fieldName: docData.fieldName || "content"
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
await PGVectorStore.fromDocuments(
|
|
180
|
+
[doc],
|
|
181
|
+
this.embeddings,
|
|
182
|
+
this.vectorStoreConfig
|
|
183
|
+
);
|
|
184
|
+
const result = await this.pool.query(
|
|
185
|
+
`SELECT id FROM embeddings_documents
|
|
186
|
+
WHERE metadata->>'id' = $1
|
|
187
|
+
ORDER BY id DESC LIMIT 1`,
|
|
188
|
+
[docData.id]
|
|
189
|
+
);
|
|
190
|
+
return {
|
|
191
|
+
embeddingId: result.rows[0]?.id || "",
|
|
192
|
+
embedding: embeddingVector
|
|
193
|
+
};
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.error(`Failed to create embedding: ${error}`);
|
|
196
|
+
throw new Error(`Failed to create embedding: ${error}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async deleteEmbedding(strapiId) {
|
|
200
|
+
if (!this.pool) {
|
|
201
|
+
throw new Error("Plugin manager not initialized");
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
await this.pool.query(
|
|
205
|
+
`DELETE FROM embeddings_documents WHERE metadata->>'id' = $1`,
|
|
206
|
+
[strapiId]
|
|
207
|
+
);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error(`Failed to delete embedding: ${error}`);
|
|
210
|
+
throw new Error(`Failed to delete embedding: ${error}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async queryEmbedding(query) {
|
|
214
|
+
if (!this.embeddings || !this.chat || !this.vectorStoreConfig) {
|
|
215
|
+
throw new Error("Plugin manager not initialized");
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const vectorStore = await PGVectorStore.initialize(
|
|
219
|
+
this.embeddings,
|
|
220
|
+
this.vectorStoreConfig
|
|
221
|
+
);
|
|
222
|
+
const retriever = vectorStore.asRetriever({ k: 4 });
|
|
223
|
+
const sourceDocuments = await retriever.invoke(query);
|
|
224
|
+
const formatDocs = (docs) => {
|
|
225
|
+
return docs.map((doc) => {
|
|
226
|
+
const title = doc.metadata?.title ? `Title: ${doc.metadata.title}
|
|
227
|
+
` : "";
|
|
228
|
+
return `${title}${doc.pageContent}`;
|
|
229
|
+
}).join("\n\n");
|
|
230
|
+
};
|
|
231
|
+
const ragPrompt = ChatPromptTemplate.fromMessages([
|
|
232
|
+
[
|
|
233
|
+
"system",
|
|
234
|
+
`You are a helpful assistant that answers questions based on the provided context.
|
|
235
|
+
If you cannot find the answer in the context, say so. Be concise and accurate.
|
|
236
|
+
|
|
237
|
+
Context:
|
|
238
|
+
{context}`
|
|
239
|
+
],
|
|
240
|
+
["human", "{question}"]
|
|
241
|
+
]);
|
|
242
|
+
const ragChain = RunnableSequence.from([
|
|
243
|
+
{
|
|
244
|
+
context: async () => formatDocs(sourceDocuments),
|
|
245
|
+
question: new RunnablePassthrough()
|
|
246
|
+
},
|
|
247
|
+
ragPrompt,
|
|
248
|
+
this.chat,
|
|
249
|
+
new StringOutputParser()
|
|
250
|
+
]);
|
|
251
|
+
const text = await ragChain.invoke(query);
|
|
252
|
+
return {
|
|
253
|
+
text,
|
|
254
|
+
sourceDocuments
|
|
255
|
+
};
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.error(`Failed to query embeddings: ${error}`);
|
|
258
|
+
throw new Error(`Failed to query embeddings: ${error}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async similaritySearch(query, k = 4) {
|
|
262
|
+
if (!this.embeddings || !this.vectorStoreConfig) {
|
|
263
|
+
throw new Error("Plugin manager not initialized");
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const vectorStore = await PGVectorStore.initialize(
|
|
267
|
+
this.embeddings,
|
|
268
|
+
this.vectorStoreConfig
|
|
269
|
+
);
|
|
270
|
+
return await vectorStore.similaritySearch(query, k);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error(`Failed to perform similarity search: ${error}`);
|
|
273
|
+
throw new Error(`Failed to perform similarity search: ${error}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
isInitialized() {
|
|
277
|
+
return !!(this.embeddings && this.chat && this.pool);
|
|
278
|
+
}
|
|
279
|
+
async destroy() {
|
|
280
|
+
if (this.pool) {
|
|
281
|
+
await this.pool.end();
|
|
282
|
+
this.pool = null;
|
|
283
|
+
}
|
|
284
|
+
this.embeddings = null;
|
|
285
|
+
this.chat = null;
|
|
286
|
+
this.vectorStoreConfig = null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const pluginManager = new PluginManager();
|
|
290
|
+
const SemanticSearchSchema = z.object({
|
|
291
|
+
query: z.string().min(1, "Query is required"),
|
|
292
|
+
limit: z.number().min(1).max(20).optional().default(5)
|
|
293
|
+
});
|
|
294
|
+
const RagQuerySchema = z.object({
|
|
295
|
+
query: z.string().min(1, "Query is required"),
|
|
296
|
+
includeSourceDocuments: z.boolean().optional().default(true)
|
|
297
|
+
});
|
|
298
|
+
const ListEmbeddingsSchema = z.object({
|
|
299
|
+
page: z.number().min(1).optional().default(1),
|
|
300
|
+
pageSize: z.number().min(1).max(50).optional().default(25),
|
|
301
|
+
search: z.string().optional()
|
|
302
|
+
});
|
|
303
|
+
const GetEmbeddingSchema = z.object({
|
|
304
|
+
documentId: z.string().min(1, "Document ID is required"),
|
|
305
|
+
includeContent: z.boolean().optional().default(true)
|
|
306
|
+
});
|
|
307
|
+
const CreateEmbeddingSchema = z.object({
|
|
308
|
+
title: z.string().min(1, "Title is required"),
|
|
309
|
+
content: z.string().min(1, "Content is required"),
|
|
310
|
+
metadata: z.record(z.any()).optional()
|
|
311
|
+
});
|
|
312
|
+
const ToolSchemas = {
|
|
313
|
+
semantic_search: SemanticSearchSchema,
|
|
314
|
+
rag_query: RagQuerySchema,
|
|
315
|
+
list_embeddings: ListEmbeddingsSchema,
|
|
316
|
+
get_embedding: GetEmbeddingSchema,
|
|
317
|
+
create_embedding: CreateEmbeddingSchema
|
|
318
|
+
};
|
|
319
|
+
function validateToolInput(toolName, input) {
|
|
320
|
+
const schema2 = ToolSchemas[toolName];
|
|
321
|
+
if (!schema2) {
|
|
322
|
+
throw new Error(`No schema defined for tool: ${toolName}`);
|
|
323
|
+
}
|
|
324
|
+
const result = schema2.safeParse(input);
|
|
325
|
+
if (!result.success) {
|
|
326
|
+
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
|
|
327
|
+
throw new Error(`Validation failed for ${toolName}: ${errors}`);
|
|
328
|
+
}
|
|
329
|
+
return result.data;
|
|
330
|
+
}
|
|
331
|
+
const semanticSearchTool = {
|
|
332
|
+
name: "semantic_search",
|
|
333
|
+
description: 'TRIGGER: Use when user types "/rag" or asks to search embeddings/content. Search for semantically similar content using vector embeddings. Returns the most relevant documents matching your query based on meaning, not just keywords.',
|
|
334
|
+
inputSchema: {
|
|
335
|
+
type: "object",
|
|
336
|
+
properties: {
|
|
337
|
+
query: {
|
|
338
|
+
type: "string",
|
|
339
|
+
description: "The search query text to find similar content"
|
|
340
|
+
},
|
|
341
|
+
limit: {
|
|
342
|
+
type: "number",
|
|
343
|
+
description: "Maximum number of results to return (default: 5, max: 20)",
|
|
344
|
+
default: 5
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
required: ["query"]
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
async function handleSemanticSearch(strapi, args) {
|
|
351
|
+
const { query, limit = 5 } = args;
|
|
352
|
+
const maxLimit = Math.min(limit, 20);
|
|
353
|
+
try {
|
|
354
|
+
const pluginManager2 = strapi.contentEmbeddingsManager;
|
|
355
|
+
if (!pluginManager2) {
|
|
356
|
+
throw new Error("Content embeddings plugin not initialized");
|
|
357
|
+
}
|
|
358
|
+
const results = await pluginManager2.similaritySearch(query, maxLimit);
|
|
359
|
+
const formattedResults = results.map((doc, index2) => ({
|
|
360
|
+
rank: index2 + 1,
|
|
361
|
+
content: doc.pageContent,
|
|
362
|
+
metadata: doc.metadata,
|
|
363
|
+
score: doc.score || null
|
|
364
|
+
}));
|
|
365
|
+
return {
|
|
366
|
+
content: [
|
|
367
|
+
{
|
|
368
|
+
type: "text",
|
|
369
|
+
text: JSON.stringify(
|
|
370
|
+
{
|
|
371
|
+
query,
|
|
372
|
+
resultCount: formattedResults.length,
|
|
373
|
+
results: formattedResults
|
|
374
|
+
},
|
|
375
|
+
null,
|
|
376
|
+
2
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
]
|
|
380
|
+
};
|
|
381
|
+
} catch (error) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`Semantic search failed: ${error instanceof Error ? error.message : String(error)}`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const ragQueryTool = {
|
|
388
|
+
name: "rag_query",
|
|
389
|
+
description: 'TRIGGER: Use when user types "/rag" followed by a question. Ask a question and get an AI-generated answer based on your embedded content. Uses RAG (Retrieval-Augmented Generation) to find relevant documents and generate a contextual response. This is the PRIMARY tool for /rag queries.',
|
|
390
|
+
inputSchema: {
|
|
391
|
+
type: "object",
|
|
392
|
+
properties: {
|
|
393
|
+
query: {
|
|
394
|
+
type: "string",
|
|
395
|
+
description: "The question or query to answer using embedded content"
|
|
396
|
+
},
|
|
397
|
+
includeSourceDocuments: {
|
|
398
|
+
type: "boolean",
|
|
399
|
+
description: "Include the source documents used to generate the answer (default: true)",
|
|
400
|
+
default: true
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
required: ["query"]
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
async function handleRagQuery(strapi, args) {
|
|
407
|
+
const { query, includeSourceDocuments = true } = args;
|
|
408
|
+
try {
|
|
409
|
+
const embeddingsService = strapi.plugin("strapi-content-embeddings").service("embeddings");
|
|
410
|
+
const result = await embeddingsService.queryEmbeddings(query);
|
|
411
|
+
const response = {
|
|
412
|
+
query,
|
|
413
|
+
answer: result.text
|
|
414
|
+
};
|
|
415
|
+
if (includeSourceDocuments && result.sourceDocuments) {
|
|
416
|
+
response.sourceDocuments = result.sourceDocuments.map((doc, index2) => ({
|
|
417
|
+
rank: index2 + 1,
|
|
418
|
+
content: doc.pageContent?.substring(0, 500) + (doc.pageContent?.length > 500 ? "..." : ""),
|
|
419
|
+
metadata: doc.metadata
|
|
420
|
+
}));
|
|
421
|
+
response.sourceCount = result.sourceDocuments.length;
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
content: [
|
|
425
|
+
{
|
|
426
|
+
type: "text",
|
|
427
|
+
text: JSON.stringify(response, null, 2)
|
|
428
|
+
}
|
|
429
|
+
]
|
|
430
|
+
};
|
|
431
|
+
} catch (error) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`RAG query failed: ${error instanceof Error ? error.message : String(error)}`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const listEmbeddingsTool = {
|
|
438
|
+
name: "list_embeddings",
|
|
439
|
+
description: "List all embeddings stored in the database. Returns metadata without the full content to avoid context overflow.",
|
|
440
|
+
inputSchema: {
|
|
441
|
+
type: "object",
|
|
442
|
+
properties: {
|
|
443
|
+
page: {
|
|
444
|
+
type: "number",
|
|
445
|
+
description: "Page number (starts at 1)",
|
|
446
|
+
default: 1
|
|
447
|
+
},
|
|
448
|
+
pageSize: {
|
|
449
|
+
type: "number",
|
|
450
|
+
description: "Number of items per page (max: 50)",
|
|
451
|
+
default: 25
|
|
452
|
+
},
|
|
453
|
+
search: {
|
|
454
|
+
type: "string",
|
|
455
|
+
description: "Search filter for title"
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
required: []
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
async function handleListEmbeddings(strapi, args) {
|
|
462
|
+
const { page = 1, pageSize = 25, search } = args;
|
|
463
|
+
const limit = Math.min(pageSize, 50);
|
|
464
|
+
try {
|
|
465
|
+
const embeddingsService = strapi.plugin("strapi-content-embeddings").service("embeddings");
|
|
466
|
+
const filters = {};
|
|
467
|
+
if (search) {
|
|
468
|
+
filters.title = { $containsi: search };
|
|
469
|
+
}
|
|
470
|
+
const result = await embeddingsService.getEmbeddings({
|
|
471
|
+
page,
|
|
472
|
+
pageSize: limit,
|
|
473
|
+
filters
|
|
474
|
+
});
|
|
475
|
+
const embeddings2 = (result.results || []).map((emb) => ({
|
|
476
|
+
id: emb.id,
|
|
477
|
+
documentId: emb.documentId,
|
|
478
|
+
title: emb.title,
|
|
479
|
+
collectionType: emb.collectionType,
|
|
480
|
+
fieldName: emb.fieldName,
|
|
481
|
+
metadata: emb.metadata,
|
|
482
|
+
contentPreview: emb.content?.substring(0, 200) + (emb.content?.length > 200 ? "..." : ""),
|
|
483
|
+
createdAt: emb.createdAt,
|
|
484
|
+
updatedAt: emb.updatedAt
|
|
485
|
+
}));
|
|
486
|
+
return {
|
|
487
|
+
content: [
|
|
488
|
+
{
|
|
489
|
+
type: "text",
|
|
490
|
+
text: JSON.stringify(
|
|
491
|
+
{
|
|
492
|
+
embeddings: embeddings2,
|
|
493
|
+
pagination: result.pagination || {
|
|
494
|
+
page,
|
|
495
|
+
pageSize: limit,
|
|
496
|
+
total: embeddings2.length
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
null,
|
|
500
|
+
2
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
]
|
|
504
|
+
};
|
|
505
|
+
} catch (error) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`Failed to list embeddings: ${error instanceof Error ? error.message : String(error)}`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const getEmbeddingTool = {
|
|
512
|
+
name: "get_embedding",
|
|
513
|
+
description: "Get a specific embedding by its document ID. Returns the full content and metadata.",
|
|
514
|
+
inputSchema: {
|
|
515
|
+
type: "object",
|
|
516
|
+
properties: {
|
|
517
|
+
documentId: {
|
|
518
|
+
type: "string",
|
|
519
|
+
description: "The document ID of the embedding to retrieve"
|
|
520
|
+
},
|
|
521
|
+
includeContent: {
|
|
522
|
+
type: "boolean",
|
|
523
|
+
description: "Include the full content text (default: true)",
|
|
524
|
+
default: true
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
required: ["documentId"]
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
async function handleGetEmbedding(strapi, args) {
|
|
531
|
+
const { documentId, includeContent = true } = args;
|
|
532
|
+
try {
|
|
533
|
+
const embeddingsService = strapi.plugin("strapi-content-embeddings").service("embeddings");
|
|
534
|
+
const embedding2 = await embeddingsService.getEmbedding(documentId);
|
|
535
|
+
if (!embedding2) {
|
|
536
|
+
return {
|
|
537
|
+
content: [
|
|
538
|
+
{
|
|
539
|
+
type: "text",
|
|
540
|
+
text: JSON.stringify({
|
|
541
|
+
error: true,
|
|
542
|
+
message: `Embedding not found with documentId: ${documentId}`
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
]
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const result = {
|
|
549
|
+
id: embedding2.id,
|
|
550
|
+
documentId: embedding2.documentId,
|
|
551
|
+
title: embedding2.title,
|
|
552
|
+
collectionType: embedding2.collectionType,
|
|
553
|
+
fieldName: embedding2.fieldName,
|
|
554
|
+
metadata: embedding2.metadata,
|
|
555
|
+
embeddingId: embedding2.embeddingId,
|
|
556
|
+
createdAt: embedding2.createdAt,
|
|
557
|
+
updatedAt: embedding2.updatedAt
|
|
558
|
+
};
|
|
559
|
+
if (includeContent) {
|
|
560
|
+
result.content = embedding2.content;
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
content: [
|
|
564
|
+
{
|
|
565
|
+
type: "text",
|
|
566
|
+
text: JSON.stringify(result, null, 2)
|
|
567
|
+
}
|
|
568
|
+
]
|
|
569
|
+
};
|
|
570
|
+
} catch (error) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
`Failed to get embedding: ${error instanceof Error ? error.message : String(error)}`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const createEmbeddingTool = {
|
|
577
|
+
name: "create_embedding",
|
|
578
|
+
description: "Create a new embedding from text content. The content will be vectorized and stored for semantic search.",
|
|
579
|
+
inputSchema: {
|
|
580
|
+
type: "object",
|
|
581
|
+
properties: {
|
|
582
|
+
title: {
|
|
583
|
+
type: "string",
|
|
584
|
+
description: "A descriptive title for the embedding"
|
|
585
|
+
},
|
|
586
|
+
content: {
|
|
587
|
+
type: "string",
|
|
588
|
+
description: "The text content to embed (will be vectorized)"
|
|
589
|
+
},
|
|
590
|
+
metadata: {
|
|
591
|
+
type: "object",
|
|
592
|
+
description: "Optional metadata to associate with the embedding (tags, source, etc.)"
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
required: ["title", "content"]
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
async function handleCreateEmbedding(strapi, args) {
|
|
599
|
+
const { title, content, metadata } = args;
|
|
600
|
+
try {
|
|
601
|
+
const embeddingsService = strapi.plugin("strapi-content-embeddings").service("embeddings");
|
|
602
|
+
const embedding2 = await embeddingsService.createEmbedding({
|
|
603
|
+
title,
|
|
604
|
+
content,
|
|
605
|
+
metadata: metadata || {},
|
|
606
|
+
collectionType: "standalone",
|
|
607
|
+
fieldName: "content"
|
|
608
|
+
});
|
|
609
|
+
return {
|
|
610
|
+
content: [
|
|
611
|
+
{
|
|
612
|
+
type: "text",
|
|
613
|
+
text: JSON.stringify(
|
|
614
|
+
{
|
|
615
|
+
success: true,
|
|
616
|
+
message: "Embedding created successfully",
|
|
617
|
+
embedding: {
|
|
618
|
+
id: embedding2.id,
|
|
619
|
+
documentId: embedding2.documentId,
|
|
620
|
+
title: embedding2.title,
|
|
621
|
+
embeddingId: embedding2.embeddingId,
|
|
622
|
+
contentLength: content.length,
|
|
623
|
+
metadata: embedding2.metadata,
|
|
624
|
+
createdAt: embedding2.createdAt
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
null,
|
|
628
|
+
2
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
]
|
|
632
|
+
};
|
|
633
|
+
} catch (error) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`Failed to create embedding: ${error instanceof Error ? error.message : String(error)}`
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const tools = [
|
|
640
|
+
semanticSearchTool,
|
|
641
|
+
ragQueryTool,
|
|
642
|
+
listEmbeddingsTool,
|
|
643
|
+
getEmbeddingTool,
|
|
644
|
+
createEmbeddingTool
|
|
645
|
+
];
|
|
646
|
+
const toolHandlers = {
|
|
647
|
+
semantic_search: handleSemanticSearch,
|
|
648
|
+
rag_query: handleRagQuery,
|
|
649
|
+
list_embeddings: handleListEmbeddings,
|
|
650
|
+
get_embedding: handleGetEmbedding,
|
|
651
|
+
create_embedding: handleCreateEmbedding
|
|
652
|
+
};
|
|
653
|
+
async function handleToolCall(strapi, request) {
|
|
654
|
+
const { name, arguments: args } = request.params;
|
|
655
|
+
const handler = toolHandlers[name];
|
|
656
|
+
if (!handler) {
|
|
657
|
+
return {
|
|
658
|
+
content: [
|
|
659
|
+
{
|
|
660
|
+
type: "text",
|
|
661
|
+
text: JSON.stringify({
|
|
662
|
+
error: true,
|
|
663
|
+
message: `Unknown tool: ${name}`,
|
|
664
|
+
availableTools: Object.keys(toolHandlers)
|
|
665
|
+
})
|
|
666
|
+
}
|
|
667
|
+
]
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
const validatedArgs = validateToolInput(name, args || {});
|
|
672
|
+
const result = await handler(strapi, validatedArgs);
|
|
673
|
+
return result;
|
|
674
|
+
} catch (error) {
|
|
675
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
676
|
+
strapi.log.error(`[strapi-content-embeddings] Tool ${name} error:`, { error: errorMessage });
|
|
677
|
+
return {
|
|
678
|
+
content: [
|
|
679
|
+
{
|
|
680
|
+
type: "text",
|
|
681
|
+
text: JSON.stringify({
|
|
682
|
+
error: true,
|
|
683
|
+
tool: name,
|
|
684
|
+
message: errorMessage
|
|
685
|
+
}, null, 2)
|
|
686
|
+
}
|
|
687
|
+
]
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function createMcpServer(strapi) {
|
|
692
|
+
const server = new Server(
|
|
693
|
+
{
|
|
694
|
+
name: "strapi-content-embeddings-mcp",
|
|
695
|
+
version: "1.0.0"
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
capabilities: {
|
|
699
|
+
tools: {}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
704
|
+
return { tools };
|
|
705
|
+
});
|
|
706
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
707
|
+
return handleToolCall(strapi, request);
|
|
708
|
+
});
|
|
709
|
+
return server;
|
|
710
|
+
}
|
|
711
|
+
const PLUGIN_ID$4 = "strapi-content-embeddings";
|
|
712
|
+
const OAUTH_PLUGIN_ID = "strapi-oauth-mcp-manager";
|
|
713
|
+
function createFallbackAuthMiddleware(strapi) {
|
|
714
|
+
const mcpPath = `/api/${PLUGIN_ID$4}/mcp`;
|
|
715
|
+
return async (ctx, next) => {
|
|
716
|
+
if (!ctx.path.startsWith(mcpPath)) {
|
|
717
|
+
return next();
|
|
718
|
+
}
|
|
719
|
+
const authHeader = ctx.request.headers.authorization;
|
|
720
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
721
|
+
ctx.status = 401;
|
|
722
|
+
ctx.body = {
|
|
723
|
+
error: "Unauthorized",
|
|
724
|
+
message: "Bearer token required. Provide a Strapi API token."
|
|
725
|
+
};
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const token = authHeader.slice(7);
|
|
729
|
+
ctx.state.strapiToken = token;
|
|
730
|
+
ctx.state.authMethod = "api-token";
|
|
731
|
+
return next();
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
const bootstrap = async ({ strapi }) => {
|
|
735
|
+
const actions = [
|
|
736
|
+
{
|
|
737
|
+
section: "plugins",
|
|
738
|
+
displayName: "Read",
|
|
739
|
+
uid: "read",
|
|
740
|
+
pluginName: PLUGIN_ID$4
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
section: "plugins",
|
|
744
|
+
displayName: "Update",
|
|
745
|
+
uid: "update",
|
|
746
|
+
pluginName: PLUGIN_ID$4
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
section: "plugins",
|
|
750
|
+
displayName: "Create",
|
|
751
|
+
uid: "create",
|
|
752
|
+
pluginName: PLUGIN_ID$4
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
section: "plugins",
|
|
756
|
+
displayName: "Delete",
|
|
757
|
+
uid: "delete",
|
|
758
|
+
pluginName: PLUGIN_ID$4
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
section: "plugins",
|
|
762
|
+
displayName: "Chat",
|
|
763
|
+
uid: "chat",
|
|
764
|
+
pluginName: PLUGIN_ID$4
|
|
765
|
+
}
|
|
766
|
+
];
|
|
767
|
+
await strapi.admin.services.permission.actionProvider.registerMany(actions);
|
|
768
|
+
const pluginConfig = strapi.config.get(`plugin::${PLUGIN_ID$4}`);
|
|
769
|
+
if (pluginConfig?.openAIApiKey && pluginConfig?.neonConnectionString) {
|
|
770
|
+
try {
|
|
771
|
+
await pluginManager.initialize({
|
|
772
|
+
openAIApiKey: pluginConfig.openAIApiKey,
|
|
773
|
+
neonConnectionString: pluginConfig.neonConnectionString,
|
|
774
|
+
embeddingModel: pluginConfig.embeddingModel
|
|
775
|
+
});
|
|
776
|
+
strapi.contentEmbeddingsManager = pluginManager;
|
|
777
|
+
strapi.log.info(`[${PLUGIN_ID$4}] Plugin initialized successfully`);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
strapi.log.error(`[${PLUGIN_ID$4}] Failed to initialize:`, error);
|
|
780
|
+
}
|
|
781
|
+
} else {
|
|
782
|
+
strapi.log.warn(
|
|
783
|
+
`[${PLUGIN_ID$4}] Missing configuration. Set openAIApiKey and neonConnectionString in plugin config.`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
const plugin = strapi.plugin(PLUGIN_ID$4);
|
|
787
|
+
plugin.createMcpServer = () => createMcpServer(strapi);
|
|
788
|
+
plugin.sessions = /* @__PURE__ */ new Map();
|
|
789
|
+
const oauthPlugin = strapi.plugin(OAUTH_PLUGIN_ID);
|
|
790
|
+
if (oauthPlugin) {
|
|
791
|
+
strapi.log.info(`[${PLUGIN_ID$4}] OAuth manager detected - OAuth + API token auth enabled`);
|
|
792
|
+
} else {
|
|
793
|
+
const fallbackMiddleware = createFallbackAuthMiddleware();
|
|
794
|
+
strapi.server.use(fallbackMiddleware);
|
|
795
|
+
strapi.log.info(`[${PLUGIN_ID$4}] Using API token authentication (OAuth manager not installed)`);
|
|
796
|
+
}
|
|
797
|
+
strapi.log.info(`[${PLUGIN_ID$4}] MCP endpoint available at: /api/${PLUGIN_ID$4}/mcp`);
|
|
798
|
+
};
|
|
799
|
+
const destroy = async ({ strapi }) => {
|
|
800
|
+
await pluginManager.destroy();
|
|
801
|
+
console.log("Content Embeddings plugin destroyed");
|
|
802
|
+
};
|
|
803
|
+
const PLUGIN_ID$3 = "strapi-content-embeddings";
|
|
804
|
+
const register = ({ strapi }) => {
|
|
805
|
+
Object.values(strapi.contentTypes).forEach((contentType) => {
|
|
806
|
+
if (contentType.uid.startsWith("admin::") || contentType.uid.startsWith("strapi::") || contentType.uid === `plugin::${PLUGIN_ID$3}.embedding`) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
contentType.attributes.embedding = {
|
|
810
|
+
type: "relation",
|
|
811
|
+
relation: "morphOne",
|
|
812
|
+
target: `plugin::${PLUGIN_ID$3}.embedding`,
|
|
813
|
+
morphBy: "related",
|
|
814
|
+
private: false,
|
|
815
|
+
configurable: false
|
|
816
|
+
};
|
|
817
|
+
});
|
|
818
|
+
};
|
|
819
|
+
const kind = "collectionType";
|
|
820
|
+
const collectionName = "strapi_content_embeddings";
|
|
821
|
+
const info = {
|
|
822
|
+
singularName: "embedding",
|
|
823
|
+
pluralName: "embeddings",
|
|
824
|
+
displayName: "Embedding"
|
|
825
|
+
};
|
|
826
|
+
const options = {
|
|
827
|
+
draftAndPublish: false
|
|
828
|
+
};
|
|
829
|
+
const pluginOptions = {
|
|
830
|
+
"content-manager": {
|
|
831
|
+
visible: true
|
|
832
|
+
},
|
|
833
|
+
"content-type-builder": {
|
|
834
|
+
visible: false
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
const attributes = {
|
|
838
|
+
title: {
|
|
839
|
+
type: "string",
|
|
840
|
+
required: true
|
|
841
|
+
},
|
|
842
|
+
content: {
|
|
843
|
+
type: "text"
|
|
844
|
+
},
|
|
845
|
+
embedding: {
|
|
846
|
+
type: "json"
|
|
847
|
+
},
|
|
848
|
+
embeddingId: {
|
|
849
|
+
type: "string"
|
|
850
|
+
},
|
|
851
|
+
collectionType: {
|
|
852
|
+
type: "string",
|
|
853
|
+
"default": "standalone"
|
|
854
|
+
},
|
|
855
|
+
fieldName: {
|
|
856
|
+
type: "string",
|
|
857
|
+
"default": "content"
|
|
858
|
+
},
|
|
859
|
+
metadata: {
|
|
860
|
+
type: "json"
|
|
861
|
+
},
|
|
862
|
+
related: {
|
|
863
|
+
type: "relation",
|
|
864
|
+
relation: "morphToOne",
|
|
865
|
+
configurable: false
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
const schema = {
|
|
869
|
+
kind,
|
|
870
|
+
collectionName,
|
|
871
|
+
info,
|
|
872
|
+
options,
|
|
873
|
+
pluginOptions,
|
|
874
|
+
attributes
|
|
875
|
+
};
|
|
876
|
+
const embedding = {
|
|
877
|
+
schema
|
|
878
|
+
};
|
|
879
|
+
const contentTypes = {
|
|
880
|
+
embedding
|
|
881
|
+
};
|
|
882
|
+
const PLUGIN_ID$2 = "strapi-content-embeddings";
|
|
883
|
+
const controller = ({ strapi }) => ({
|
|
884
|
+
async createEmbedding(ctx) {
|
|
885
|
+
try {
|
|
886
|
+
const result = await strapi.plugin(PLUGIN_ID$2).service("embeddings").createEmbedding(ctx.request.body);
|
|
887
|
+
ctx.body = result;
|
|
888
|
+
} catch (error) {
|
|
889
|
+
ctx.throw(500, error.message || "Failed to create embedding");
|
|
890
|
+
}
|
|
891
|
+
},
|
|
892
|
+
async deleteEmbedding(ctx) {
|
|
893
|
+
try {
|
|
894
|
+
const { id } = ctx.params;
|
|
895
|
+
const result = await strapi.plugin(PLUGIN_ID$2).service("embeddings").deleteEmbedding(id);
|
|
896
|
+
ctx.body = result;
|
|
897
|
+
} catch (error) {
|
|
898
|
+
ctx.throw(500, error.message || "Failed to delete embedding");
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
async updateEmbedding(ctx) {
|
|
902
|
+
try {
|
|
903
|
+
const { id } = ctx.params;
|
|
904
|
+
const result = await strapi.plugin(PLUGIN_ID$2).service("embeddings").updateEmbedding(id, ctx.request.body);
|
|
905
|
+
ctx.body = result;
|
|
906
|
+
} catch (error) {
|
|
907
|
+
ctx.throw(500, error.message || "Failed to update embedding");
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
async getEmbeddings(ctx) {
|
|
911
|
+
try {
|
|
912
|
+
const { page, pageSize, filters } = ctx.query;
|
|
913
|
+
const result = await strapi.plugin(PLUGIN_ID$2).service("embeddings").getEmbeddings({
|
|
914
|
+
page: page ? parseInt(page, 10) : 1,
|
|
915
|
+
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
|
|
916
|
+
filters
|
|
917
|
+
});
|
|
918
|
+
ctx.body = result;
|
|
919
|
+
} catch (error) {
|
|
920
|
+
ctx.throw(500, error.message || "Failed to get embeddings");
|
|
921
|
+
}
|
|
922
|
+
},
|
|
923
|
+
async getEmbedding(ctx) {
|
|
924
|
+
try {
|
|
925
|
+
const { id } = ctx.params;
|
|
926
|
+
const result = await strapi.plugin(PLUGIN_ID$2).service("embeddings").getEmbedding(id);
|
|
927
|
+
if (!result) {
|
|
928
|
+
ctx.throw(404, "Embedding not found");
|
|
929
|
+
}
|
|
930
|
+
ctx.body = result;
|
|
931
|
+
} catch (error) {
|
|
932
|
+
if (error.status === 404) {
|
|
933
|
+
ctx.throw(404, error.message);
|
|
934
|
+
}
|
|
935
|
+
ctx.throw(500, error.message || "Failed to get embedding");
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
async queryEmbeddings(ctx) {
|
|
939
|
+
try {
|
|
940
|
+
const { query } = ctx.query;
|
|
941
|
+
const result = await strapi.plugin(PLUGIN_ID$2).service("embeddings").queryEmbeddings(query);
|
|
942
|
+
ctx.body = result;
|
|
943
|
+
} catch (error) {
|
|
944
|
+
ctx.throw(500, error.message || "Failed to query embeddings");
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
const PLUGIN_ID$1 = "strapi-content-embeddings";
|
|
949
|
+
const SESSION_TIMEOUT_MS = 4 * 60 * 60 * 1e3;
|
|
950
|
+
function isSessionExpired(session) {
|
|
951
|
+
return Date.now() - session.createdAt > SESSION_TIMEOUT_MS;
|
|
952
|
+
}
|
|
953
|
+
function cleanupExpiredSessions(plugin, strapi) {
|
|
954
|
+
let cleaned = 0;
|
|
955
|
+
for (const [sessionId, session] of plugin.sessions.entries()) {
|
|
956
|
+
if (isSessionExpired(session)) {
|
|
957
|
+
try {
|
|
958
|
+
session.server.close();
|
|
959
|
+
} catch {
|
|
960
|
+
}
|
|
961
|
+
plugin.sessions.delete(sessionId);
|
|
962
|
+
cleaned++;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (cleaned > 0) {
|
|
966
|
+
strapi.log.debug(`[${PLUGIN_ID$1}] Cleaned up ${cleaned} expired MCP sessions`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const mcpController = ({ strapi }) => ({
|
|
970
|
+
/**
|
|
971
|
+
* Handle MCP requests (POST, GET, DELETE)
|
|
972
|
+
*/
|
|
973
|
+
async handle(ctx) {
|
|
974
|
+
const plugin = strapi.plugin(PLUGIN_ID$1);
|
|
975
|
+
if (!plugin.createMcpServer) {
|
|
976
|
+
ctx.status = 503;
|
|
977
|
+
ctx.body = {
|
|
978
|
+
error: "MCP not initialized",
|
|
979
|
+
message: "The MCP server is not available. Check plugin configuration."
|
|
980
|
+
};
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (Math.random() < 0.01) {
|
|
984
|
+
cleanupExpiredSessions(plugin, strapi);
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
const requestedSessionId = ctx.request.headers["mcp-session-id"];
|
|
988
|
+
let session = requestedSessionId ? plugin.sessions.get(requestedSessionId) : null;
|
|
989
|
+
if (session && isSessionExpired(session)) {
|
|
990
|
+
strapi.log.debug(`[${PLUGIN_ID$1}] Session expired, removing: ${requestedSessionId}`);
|
|
991
|
+
try {
|
|
992
|
+
session.server.close();
|
|
993
|
+
} catch {
|
|
994
|
+
}
|
|
995
|
+
plugin.sessions.delete(requestedSessionId);
|
|
996
|
+
session = null;
|
|
997
|
+
}
|
|
998
|
+
if (requestedSessionId && !session) {
|
|
999
|
+
ctx.status = 400;
|
|
1000
|
+
ctx.body = {
|
|
1001
|
+
jsonrpc: "2.0",
|
|
1002
|
+
error: {
|
|
1003
|
+
code: -32e3,
|
|
1004
|
+
message: "Session expired or invalid. Please reinitialize the connection."
|
|
1005
|
+
},
|
|
1006
|
+
id: null
|
|
1007
|
+
};
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (!session) {
|
|
1011
|
+
const sessionId = randomUUID();
|
|
1012
|
+
const server = plugin.createMcpServer();
|
|
1013
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1014
|
+
sessionIdGenerator: () => sessionId
|
|
1015
|
+
});
|
|
1016
|
+
await server.connect(transport);
|
|
1017
|
+
session = {
|
|
1018
|
+
server,
|
|
1019
|
+
transport,
|
|
1020
|
+
createdAt: Date.now(),
|
|
1021
|
+
strapiToken: ctx.state.strapiToken
|
|
1022
|
+
};
|
|
1023
|
+
plugin.sessions.set(sessionId, session);
|
|
1024
|
+
strapi.log.debug(
|
|
1025
|
+
`[${PLUGIN_ID$1}] New MCP session created: ${sessionId} (auth: ${ctx.state.authMethod || "unknown"})`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
await session.transport.handleRequest(ctx.req, ctx.res, ctx.request.body);
|
|
1030
|
+
} catch (transportError) {
|
|
1031
|
+
strapi.log.warn(`[${PLUGIN_ID$1}] Transport error, cleaning up session: ${requestedSessionId}`, {
|
|
1032
|
+
error: transportError instanceof Error ? transportError.message : String(transportError)
|
|
1033
|
+
});
|
|
1034
|
+
try {
|
|
1035
|
+
session.server.close();
|
|
1036
|
+
} catch {
|
|
1037
|
+
}
|
|
1038
|
+
plugin.sessions.delete(requestedSessionId);
|
|
1039
|
+
if (!ctx.res.headersSent) {
|
|
1040
|
+
ctx.status = 400;
|
|
1041
|
+
ctx.body = {
|
|
1042
|
+
jsonrpc: "2.0",
|
|
1043
|
+
error: {
|
|
1044
|
+
code: -32e3,
|
|
1045
|
+
message: "Session transport error. Please reinitialize the connection."
|
|
1046
|
+
},
|
|
1047
|
+
id: null
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
ctx.respond = false;
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
strapi.log.error(`[${PLUGIN_ID$1}] Error handling MCP request`, {
|
|
1055
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1056
|
+
method: ctx.method,
|
|
1057
|
+
path: ctx.path
|
|
1058
|
+
});
|
|
1059
|
+
if (!ctx.res.headersSent) {
|
|
1060
|
+
ctx.status = 500;
|
|
1061
|
+
ctx.body = {
|
|
1062
|
+
error: "MCP request failed",
|
|
1063
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
const controllers = {
|
|
1070
|
+
controller,
|
|
1071
|
+
mcp: mcpController
|
|
1072
|
+
};
|
|
1073
|
+
const middlewares = {};
|
|
1074
|
+
const policies = {};
|
|
1075
|
+
const contentApi = [
|
|
1076
|
+
{
|
|
1077
|
+
method: "GET",
|
|
1078
|
+
path: "/embeddings-query",
|
|
1079
|
+
handler: "controller.queryEmbeddings"
|
|
1080
|
+
},
|
|
1081
|
+
// MCP routes - auth handled by middleware
|
|
1082
|
+
{
|
|
1083
|
+
method: "POST",
|
|
1084
|
+
path: "/mcp",
|
|
1085
|
+
handler: "mcp.handle",
|
|
1086
|
+
config: {
|
|
1087
|
+
auth: false,
|
|
1088
|
+
policies: []
|
|
1089
|
+
}
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
method: "GET",
|
|
1093
|
+
path: "/mcp",
|
|
1094
|
+
handler: "mcp.handle",
|
|
1095
|
+
config: {
|
|
1096
|
+
auth: false,
|
|
1097
|
+
policies: []
|
|
1098
|
+
}
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
method: "DELETE",
|
|
1102
|
+
path: "/mcp",
|
|
1103
|
+
handler: "mcp.handle",
|
|
1104
|
+
config: {
|
|
1105
|
+
auth: false,
|
|
1106
|
+
policies: []
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
];
|
|
1110
|
+
const admin = [
|
|
1111
|
+
{
|
|
1112
|
+
method: "POST",
|
|
1113
|
+
path: "/embeddings/create-embedding",
|
|
1114
|
+
handler: "controller.createEmbedding",
|
|
1115
|
+
config: {
|
|
1116
|
+
policies: [
|
|
1117
|
+
{
|
|
1118
|
+
name: "admin::hasPermissions",
|
|
1119
|
+
config: { actions: ["plugin::strapi-content-embeddings.create"] }
|
|
1120
|
+
}
|
|
1121
|
+
]
|
|
1122
|
+
}
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
method: "DELETE",
|
|
1126
|
+
path: "/embeddings/delete-embedding/:id",
|
|
1127
|
+
handler: "controller.deleteEmbedding",
|
|
1128
|
+
config: {
|
|
1129
|
+
policies: [
|
|
1130
|
+
{
|
|
1131
|
+
name: "admin::hasPermissions",
|
|
1132
|
+
config: { actions: ["plugin::strapi-content-embeddings.delete"] }
|
|
1133
|
+
}
|
|
1134
|
+
]
|
|
1135
|
+
}
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
method: "PUT",
|
|
1139
|
+
path: "/embeddings/update-embedding/:id",
|
|
1140
|
+
handler: "controller.updateEmbedding",
|
|
1141
|
+
config: {
|
|
1142
|
+
policies: [
|
|
1143
|
+
{
|
|
1144
|
+
name: "admin::hasPermissions",
|
|
1145
|
+
config: { actions: ["plugin::strapi-content-embeddings.update"] }
|
|
1146
|
+
}
|
|
1147
|
+
]
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
method: "GET",
|
|
1152
|
+
path: "/embeddings/embeddings-query",
|
|
1153
|
+
handler: "controller.queryEmbeddings",
|
|
1154
|
+
config: {
|
|
1155
|
+
policies: [
|
|
1156
|
+
{
|
|
1157
|
+
name: "admin::hasPermissions",
|
|
1158
|
+
config: { actions: ["plugin::strapi-content-embeddings.chat"] }
|
|
1159
|
+
}
|
|
1160
|
+
]
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
method: "GET",
|
|
1165
|
+
path: "/embeddings/find/:id",
|
|
1166
|
+
handler: "controller.getEmbedding",
|
|
1167
|
+
config: {
|
|
1168
|
+
policies: [
|
|
1169
|
+
{
|
|
1170
|
+
name: "admin::hasPermissions",
|
|
1171
|
+
config: { actions: ["plugin::strapi-content-embeddings.read"] }
|
|
1172
|
+
}
|
|
1173
|
+
]
|
|
1174
|
+
}
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
method: "GET",
|
|
1178
|
+
path: "/embeddings/find",
|
|
1179
|
+
handler: "controller.getEmbeddings",
|
|
1180
|
+
config: {
|
|
1181
|
+
policies: [
|
|
1182
|
+
{
|
|
1183
|
+
name: "admin::hasPermissions",
|
|
1184
|
+
config: { actions: ["plugin::strapi-content-embeddings.read"] }
|
|
1185
|
+
}
|
|
1186
|
+
]
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
];
|
|
1190
|
+
const routes = {
|
|
1191
|
+
"content-api": {
|
|
1192
|
+
type: "content-api",
|
|
1193
|
+
routes: [...contentApi]
|
|
1194
|
+
},
|
|
1195
|
+
admin: {
|
|
1196
|
+
type: "admin",
|
|
1197
|
+
routes: [...admin]
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
const PLUGIN_ID = "strapi-content-embeddings";
|
|
1201
|
+
const CONTENT_TYPE_UID = `plugin::${PLUGIN_ID}.embedding`;
|
|
1202
|
+
const embeddings = ({ strapi }) => ({
|
|
1203
|
+
async createEmbedding(data) {
|
|
1204
|
+
const { title, content, collectionType, fieldName, metadata, related } = data.data;
|
|
1205
|
+
const entityData = {
|
|
1206
|
+
title,
|
|
1207
|
+
content,
|
|
1208
|
+
collectionType: collectionType || "standalone",
|
|
1209
|
+
fieldName: fieldName || "content",
|
|
1210
|
+
metadata: metadata || null
|
|
1211
|
+
};
|
|
1212
|
+
if (related && related.__type && related.id) {
|
|
1213
|
+
entityData.related = related;
|
|
1214
|
+
}
|
|
1215
|
+
const entity = await strapi.documents(CONTENT_TYPE_UID).create({
|
|
1216
|
+
data: entityData
|
|
1217
|
+
});
|
|
1218
|
+
if (!pluginManager.isInitialized()) {
|
|
1219
|
+
console.warn("Plugin manager not initialized, skipping vector embedding");
|
|
1220
|
+
return entity;
|
|
1221
|
+
}
|
|
1222
|
+
try {
|
|
1223
|
+
const result = await pluginManager.createEmbedding({
|
|
1224
|
+
id: entity.documentId,
|
|
1225
|
+
title,
|
|
1226
|
+
content,
|
|
1227
|
+
collectionType: collectionType || "standalone",
|
|
1228
|
+
fieldName: fieldName || "content"
|
|
1229
|
+
});
|
|
1230
|
+
const updatedEntity = await strapi.documents(CONTENT_TYPE_UID).update({
|
|
1231
|
+
documentId: entity.documentId,
|
|
1232
|
+
data: {
|
|
1233
|
+
embeddingId: result.embeddingId,
|
|
1234
|
+
embedding: result.embedding
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
return updatedEntity;
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
console.error("Failed to create embedding in vector store:", error);
|
|
1240
|
+
return entity;
|
|
1241
|
+
}
|
|
1242
|
+
},
|
|
1243
|
+
async deleteEmbedding(id) {
|
|
1244
|
+
const currentEntry = await strapi.documents(CONTENT_TYPE_UID).findOne({
|
|
1245
|
+
documentId: String(id)
|
|
1246
|
+
});
|
|
1247
|
+
if (!currentEntry) {
|
|
1248
|
+
throw new Error(`Embedding with id ${id} not found`);
|
|
1249
|
+
}
|
|
1250
|
+
if (pluginManager.isInitialized()) {
|
|
1251
|
+
try {
|
|
1252
|
+
await pluginManager.deleteEmbedding(String(id));
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
console.error("Failed to delete from vector store:", error);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const deletedEntry = await strapi.documents(CONTENT_TYPE_UID).delete({
|
|
1258
|
+
documentId: String(id)
|
|
1259
|
+
});
|
|
1260
|
+
return deletedEntry;
|
|
1261
|
+
},
|
|
1262
|
+
async updateEmbedding(id, data) {
|
|
1263
|
+
const { title, content, metadata } = data.data;
|
|
1264
|
+
const currentEntry = await strapi.documents(CONTENT_TYPE_UID).findOne({
|
|
1265
|
+
documentId: id
|
|
1266
|
+
});
|
|
1267
|
+
if (!currentEntry) {
|
|
1268
|
+
throw new Error(`Embedding with id ${id} not found`);
|
|
1269
|
+
}
|
|
1270
|
+
const updateData = {};
|
|
1271
|
+
if (title !== void 0) updateData.title = title;
|
|
1272
|
+
if (content !== void 0) updateData.content = content;
|
|
1273
|
+
if (metadata !== void 0) updateData.metadata = metadata;
|
|
1274
|
+
const contentChanged = content !== void 0 && content !== currentEntry.content;
|
|
1275
|
+
let updatedEntity = await strapi.documents(CONTENT_TYPE_UID).update({
|
|
1276
|
+
documentId: id,
|
|
1277
|
+
data: updateData
|
|
1278
|
+
});
|
|
1279
|
+
if (contentChanged && pluginManager.isInitialized()) {
|
|
1280
|
+
try {
|
|
1281
|
+
await pluginManager.deleteEmbedding(id);
|
|
1282
|
+
const result = await pluginManager.createEmbedding({
|
|
1283
|
+
id,
|
|
1284
|
+
title: title || currentEntry.title,
|
|
1285
|
+
content,
|
|
1286
|
+
collectionType: currentEntry.collectionType || "standalone",
|
|
1287
|
+
fieldName: currentEntry.fieldName || "content"
|
|
1288
|
+
});
|
|
1289
|
+
updatedEntity = await strapi.documents(CONTENT_TYPE_UID).update({
|
|
1290
|
+
documentId: id,
|
|
1291
|
+
data: {
|
|
1292
|
+
embeddingId: result.embeddingId,
|
|
1293
|
+
embedding: result.embedding
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
console.error("Failed to update embedding in vector store:", error);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return updatedEntity;
|
|
1301
|
+
},
|
|
1302
|
+
async queryEmbeddings(query) {
|
|
1303
|
+
if (!query || query.trim() === "") {
|
|
1304
|
+
return { error: "Please provide a query" };
|
|
1305
|
+
}
|
|
1306
|
+
if (!pluginManager.isInitialized()) {
|
|
1307
|
+
return { error: "Plugin not initialized. Check your configuration." };
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
const response = await pluginManager.queryEmbedding(query);
|
|
1311
|
+
return response;
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
console.error("Query failed:", error);
|
|
1314
|
+
return { error: "Failed to query embeddings" };
|
|
1315
|
+
}
|
|
1316
|
+
},
|
|
1317
|
+
async getEmbedding(id) {
|
|
1318
|
+
return await strapi.documents(CONTENT_TYPE_UID).findOne({
|
|
1319
|
+
documentId: String(id)
|
|
1320
|
+
});
|
|
1321
|
+
},
|
|
1322
|
+
async getEmbeddings(params) {
|
|
1323
|
+
const page = params?.page || 1;
|
|
1324
|
+
const pageSize = params?.pageSize || 10;
|
|
1325
|
+
const start = (page - 1) * pageSize;
|
|
1326
|
+
const [data, totalCount] = await Promise.all([
|
|
1327
|
+
strapi.documents(CONTENT_TYPE_UID).findMany({
|
|
1328
|
+
limit: pageSize,
|
|
1329
|
+
start,
|
|
1330
|
+
filters: params?.filters
|
|
1331
|
+
}),
|
|
1332
|
+
strapi.documents(CONTENT_TYPE_UID).count({
|
|
1333
|
+
filters: params?.filters
|
|
1334
|
+
})
|
|
1335
|
+
]);
|
|
1336
|
+
return {
|
|
1337
|
+
data,
|
|
1338
|
+
count: data.length,
|
|
1339
|
+
totalCount
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
const services = {
|
|
1344
|
+
embeddings
|
|
1345
|
+
};
|
|
1346
|
+
const index = {
|
|
1347
|
+
register,
|
|
1348
|
+
bootstrap,
|
|
1349
|
+
destroy,
|
|
1350
|
+
config,
|
|
1351
|
+
controllers,
|
|
1352
|
+
routes,
|
|
1353
|
+
services,
|
|
1354
|
+
contentTypes,
|
|
1355
|
+
policies,
|
|
1356
|
+
middlewares
|
|
1357
|
+
};
|
|
1358
|
+
export {
|
|
1359
|
+
index as default
|
|
1360
|
+
};
|