nodebb-plugin-integration 0.4.2 → 0.5.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 +199 -320
- package/README.ru.md +199 -347
- package/library.js +93 -115
- package/package.json +60 -57
- package/plugin.json +50 -33
- package/public/js/admin.js +58 -45
- package/public/templates/admin/plugins/integration.tpl +213 -0
- package/src/ai/embeddings.js +46 -253
- package/src/ai/indexer.js +156 -343
- package/src/ai/openai.js +74 -330
- package/src/ai/qdrant.js +114 -517
- package/src/ai/search.js +138 -251
- package/src/config/index.js +176 -133
- package/src/config/validation.js +250 -0
- package/src/events/index.js +174 -324
- package/src/mcp/manager.js +396 -0
- package/src/mcp/server.js +347 -177
- package/src/mcp/tools.js +158 -617
- package/src/nodebb/categories.js +39 -0
- package/src/nodebb/index.js +72 -305
- package/src/nodebb/meta.js +24 -273
- package/src/nodebb/posts.js +93 -348
- package/src/nodebb/privileges.js +68 -0
- package/src/nodebb/topics.js +91 -355
- package/src/nodebb/users.js +62 -345
- package/src/routes/api.js +255 -97
- package/src/routes/index.js +64 -49
- package/templates/admin/plugins/integration.tpl +0 -130
package/src/mcp/server.js
CHANGED
|
@@ -1,232 +1,402 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
NodeBB Integration Layer
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
MCP Server
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
const http = require("node:http");
|
|
4
|
+
const crypto = require("node:crypto");
|
|
16
5
|
|
|
6
|
+
const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
|
|
17
7
|
const {
|
|
8
|
+
StreamableHTTPServerTransport
|
|
9
|
+
} = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
18
10
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
} = require("@modelcontextprotocol/sdk/server/index.js");
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const {
|
|
27
|
-
|
|
28
|
-
StdioServerTransport
|
|
29
|
-
|
|
30
|
-
} = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const Tools =
|
|
36
|
-
require("./tools");
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
let server = null;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
11
|
+
const Tools = require("./tools");
|
|
58
12
|
|
|
13
|
+
let httpServer = null;
|
|
14
|
+
let mcpServer = null;
|
|
15
|
+
let transport = null;
|
|
16
|
+
let messageQueue = Promise.resolve();
|
|
17
|
+
let shuttingDown = false;
|
|
18
|
+
let stopPromise = null;
|
|
59
19
|
|
|
20
|
+
function tokensMatch(expected, supplied) {
|
|
21
|
+
if (typeof expected !== "string" || typeof supplied !== "string") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
60
24
|
|
|
25
|
+
const expectedBuffer = Buffer.from(expected);
|
|
26
|
+
const suppliedBuffer = Buffer.from(supplied);
|
|
61
27
|
|
|
28
|
+
if (expectedBuffer.length !== suppliedBuffer.length) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
62
31
|
|
|
63
|
-
|
|
64
|
-
function createServer() {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if(server) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return server;
|
|
73
|
-
|
|
74
|
-
|
|
32
|
+
return crypto.timingSafeEqual(expectedBuffer, suppliedBuffer);
|
|
75
33
|
}
|
|
76
34
|
|
|
35
|
+
function isAuthorized(authorization, expectedToken) {
|
|
36
|
+
if (typeof authorization !== "string") {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
77
39
|
|
|
40
|
+
const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
|
|
78
41
|
|
|
42
|
+
return Boolean(match && tokensMatch(expectedToken, match[1]));
|
|
43
|
+
}
|
|
79
44
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
45
|
+
function createApiClient(config) {
|
|
46
|
+
const baseUrl = config.nodebbUrl.replace(/\/+$/, "");
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
async request(method, path, body) {
|
|
50
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
51
|
+
method,
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${config.internalApiToken}`,
|
|
54
|
+
Accept: "application/json",
|
|
55
|
+
...(body ? { "Content-Type": "application/json" } : {})
|
|
56
|
+
},
|
|
57
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
58
|
+
signal: AbortSignal.timeout(15000)
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const responseText = await response.text();
|
|
62
|
+
|
|
63
|
+
let result;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
result = responseText ? JSON.parse(responseText) : {};
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`NodeBB returned an invalid JSON response (${response.status})`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
const message = result.error || result.message ||
|
|
75
|
+
`NodeBB API request failed (${response.status})`;
|
|
76
|
+
|
|
77
|
+
throw new Error(message);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return result;
|
|
113
81
|
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
114
84
|
|
|
115
|
-
|
|
85
|
+
function validateConfig(config) {
|
|
86
|
+
if (!config || typeof config !== "object") {
|
|
87
|
+
throw new Error("MCP configuration is missing");
|
|
116
88
|
}
|
|
117
89
|
|
|
90
|
+
if (typeof config.nodebbUrl !== "string") {
|
|
91
|
+
throw new Error("A valid NodeBB HTTP URL is required");
|
|
92
|
+
}
|
|
118
93
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
Tools.register(
|
|
129
|
-
|
|
130
|
-
server
|
|
131
|
-
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
94
|
+
let nodebbUrl;
|
|
137
95
|
|
|
96
|
+
try {
|
|
97
|
+
nodebbUrl = new URL(config.nodebbUrl);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error("A valid NodeBB HTTP URL is required");
|
|
100
|
+
}
|
|
138
101
|
|
|
102
|
+
if (
|
|
103
|
+
!["http:", "https:"].includes(nodebbUrl.protocol) ||
|
|
104
|
+
!nodebbUrl.hostname ||
|
|
105
|
+
nodebbUrl.username ||
|
|
106
|
+
nodebbUrl.password ||
|
|
107
|
+
nodebbUrl.search ||
|
|
108
|
+
nodebbUrl.hash
|
|
109
|
+
) {
|
|
110
|
+
throw new Error("A valid NodeBB HTTP URL is required");
|
|
111
|
+
}
|
|
139
112
|
|
|
113
|
+
if (
|
|
114
|
+
typeof config.internalApiToken !== "string" ||
|
|
115
|
+
!config.internalApiToken
|
|
116
|
+
) {
|
|
117
|
+
throw new Error("NodeBB internal API token is missing");
|
|
118
|
+
}
|
|
140
119
|
|
|
141
|
-
|
|
120
|
+
if (
|
|
121
|
+
typeof config.bearerToken !== "string" ||
|
|
122
|
+
!config.bearerToken
|
|
123
|
+
) {
|
|
124
|
+
throw new Error("MCP bearer token is missing");
|
|
125
|
+
}
|
|
142
126
|
|
|
127
|
+
if (typeof config.host !== "string" || !config.host.trim()) {
|
|
128
|
+
throw new Error("MCP bind address is missing");
|
|
129
|
+
}
|
|
143
130
|
|
|
131
|
+
const port = Number(config.port);
|
|
144
132
|
|
|
133
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
134
|
+
throw new Error("MCP port is invalid");
|
|
135
|
+
}
|
|
145
136
|
|
|
137
|
+
return {
|
|
138
|
+
nodebbUrl: config.nodebbUrl.replace(/\/+$/, ""),
|
|
139
|
+
internalApiToken: config.internalApiToken,
|
|
140
|
+
bearerToken: config.bearerToken,
|
|
141
|
+
host: config.host.trim(),
|
|
142
|
+
port
|
|
143
|
+
};
|
|
146
144
|
}
|
|
147
145
|
|
|
146
|
+
function stop() {
|
|
147
|
+
if (stopPromise) {
|
|
148
|
+
return stopPromise;
|
|
149
|
+
}
|
|
148
150
|
|
|
151
|
+
stopPromise = (async () => {
|
|
152
|
+
const previousHttpServer = httpServer;
|
|
153
|
+
const previousTransport = transport;
|
|
154
|
+
const previousMcpServer = mcpServer;
|
|
149
155
|
|
|
156
|
+
httpServer = null;
|
|
157
|
+
transport = null;
|
|
158
|
+
mcpServer = null;
|
|
150
159
|
|
|
160
|
+
if (previousHttpServer) {
|
|
161
|
+
await new Promise((resolve) => {
|
|
162
|
+
previousHttpServer.close(() => resolve());
|
|
151
163
|
|
|
164
|
+
if (typeof previousHttpServer.closeAllConnections === "function") {
|
|
165
|
+
previousHttpServer.closeAllConnections();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
152
169
|
|
|
170
|
+
if (previousTransport) {
|
|
171
|
+
await previousTransport.close().catch(() => {});
|
|
172
|
+
}
|
|
153
173
|
|
|
174
|
+
if (previousMcpServer) {
|
|
175
|
+
await previousMcpServer.close().catch(() => {});
|
|
176
|
+
}
|
|
177
|
+
})();
|
|
154
178
|
|
|
179
|
+
return stopPromise.finally(() => {
|
|
180
|
+
stopPromise = null;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
155
183
|
|
|
184
|
+
async function start(rawConfig) {
|
|
185
|
+
const config = validateConfig(rawConfig);
|
|
186
|
+
|
|
187
|
+
await stop();
|
|
188
|
+
|
|
189
|
+
const api = createApiClient(config);
|
|
190
|
+
let nextHttpServer = null;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
nextHttpServer = http.createServer(async (req, res) => {
|
|
194
|
+
let pathname;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
pathname = new URL(
|
|
198
|
+
req.url || "/",
|
|
199
|
+
"http://localhost"
|
|
200
|
+
).pathname;
|
|
201
|
+
} catch {
|
|
202
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
203
|
+
res.end(JSON.stringify({ error: "Invalid request URL" }));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (pathname !== "/mcp") {
|
|
208
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
209
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const authorization = req.headers.authorization || "";
|
|
214
|
+
|
|
215
|
+
if (!isAuthorized(authorization, config.bearerToken)) {
|
|
216
|
+
res.writeHead(401, {
|
|
217
|
+
"Content-Type": "application/json",
|
|
218
|
+
"WWW-Authenticate": "Bearer"
|
|
219
|
+
});
|
|
220
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let requestMcpServer = null;
|
|
225
|
+
let requestTransport = null;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
requestMcpServer = new Server(
|
|
229
|
+
{
|
|
230
|
+
name: "nodebb-integration-mcp",
|
|
231
|
+
version: "0.5.0"
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
capabilities: {
|
|
235
|
+
tools: {}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
requestTransport = new StreamableHTTPServerTransport({
|
|
241
|
+
sessionIdGenerator: undefined
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
Tools.register(requestMcpServer, api);
|
|
245
|
+
|
|
246
|
+
await requestMcpServer.connect(requestTransport);
|
|
247
|
+
await requestTransport.handleRequest(req, res);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.error("[MCP] Request failed:", error.stack || error);
|
|
250
|
+
|
|
251
|
+
if (!res.headersSent) {
|
|
252
|
+
res.writeHead(500, {
|
|
253
|
+
"Content-Type": "application/json"
|
|
254
|
+
});
|
|
255
|
+
res.end(JSON.stringify({ error: "MCP request failed" }));
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
if (requestTransport) {
|
|
259
|
+
await requestTransport.close().catch(() => {});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (requestMcpServer) {
|
|
263
|
+
await requestMcpServer.close().catch(() => {});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await new Promise((resolve, reject) => {
|
|
269
|
+
const onError = (error) => {
|
|
270
|
+
nextHttpServer.removeListener("listening", onListening);
|
|
271
|
+
reject(error);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const onListening = () => {
|
|
275
|
+
nextHttpServer.removeListener("error", onError);
|
|
276
|
+
resolve();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
nextHttpServer.once("error", onError);
|
|
280
|
+
nextHttpServer.once("listening", onListening);
|
|
281
|
+
nextHttpServer.listen(config.port, config.host);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
httpServer = nextHttpServer;
|
|
285
|
+
|
|
286
|
+
console.log(
|
|
287
|
+
`[MCP] Streamable HTTP listening on ${config.host}:${config.port}/mcp`
|
|
288
|
+
);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (nextHttpServer && nextHttpServer.listening) {
|
|
291
|
+
await new Promise((resolve) => {
|
|
292
|
+
nextHttpServer.close(() => resolve());
|
|
293
|
+
nextHttpServer.closeAllConnections();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
156
296
|
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
157
300
|
|
|
301
|
+
async function sendShutdownComplete() {
|
|
302
|
+
if (!process.send) {
|
|
303
|
+
process.exit(0);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
158
306
|
|
|
307
|
+
await new Promise((resolve) => {
|
|
308
|
+
try {
|
|
309
|
+
process.send({ type: "shutdown-complete" }, () => resolve());
|
|
310
|
+
} catch (error) {
|
|
311
|
+
console.error("[MCP] Failed to send shutdown confirmation:", error.message);
|
|
312
|
+
resolve();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
159
315
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const mcpServer =
|
|
166
|
-
|
|
167
|
-
createServer();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const transport =
|
|
176
|
-
|
|
177
|
-
new StdioServerTransport();
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
await mcpServer.connect(
|
|
187
|
-
|
|
188
|
-
transport
|
|
189
|
-
|
|
190
|
-
);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
console.log(
|
|
200
|
-
|
|
201
|
-
"[MCP] server started"
|
|
202
|
-
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
316
|
+
process.exit(0);
|
|
208
317
|
}
|
|
209
318
|
|
|
319
|
+
async function handleMessage(message) {
|
|
320
|
+
if (!message || typeof message !== "object") {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
210
323
|
|
|
324
|
+
if (message.type === "configure") {
|
|
325
|
+
if (shuttingDown) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
211
328
|
|
|
329
|
+
try {
|
|
330
|
+
await start(message.config);
|
|
331
|
+
|
|
332
|
+
if (process.send) {
|
|
333
|
+
process.send({ type: "configured" });
|
|
334
|
+
}
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.error("[MCP] Configuration failed:", error.message);
|
|
337
|
+
|
|
338
|
+
if (process.send) {
|
|
339
|
+
process.send({
|
|
340
|
+
type: "configuration-error",
|
|
341
|
+
message: error.message
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
212
345
|
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
213
348
|
|
|
349
|
+
if (message.type === "shutdown") {
|
|
350
|
+
shuttingDown = true;
|
|
351
|
+
await stop();
|
|
352
|
+
await sendShutdownComplete();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
214
355
|
|
|
356
|
+
function enqueueMessage(message) {
|
|
357
|
+
messageQueue = messageQueue
|
|
358
|
+
.then(() => handleMessage(message))
|
|
359
|
+
.catch((error) => {
|
|
360
|
+
console.error("[MCP] IPC error:", error.message);
|
|
361
|
+
});
|
|
215
362
|
|
|
363
|
+
return messageQueue;
|
|
364
|
+
}
|
|
216
365
|
|
|
366
|
+
if (require.main === module) {
|
|
367
|
+
process.on("message", enqueueMessage);
|
|
217
368
|
|
|
369
|
+
process.once("disconnect", async () => {
|
|
370
|
+
if (shuttingDown) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
218
373
|
|
|
219
|
-
|
|
220
|
-
|
|
374
|
+
shuttingDown = true;
|
|
375
|
+
await messageQueue;
|
|
376
|
+
await stop();
|
|
377
|
+
process.exit(0);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
process.on("SIGTERM", async () => {
|
|
381
|
+
shuttingDown = true;
|
|
382
|
+
await messageQueue;
|
|
383
|
+
await stop();
|
|
384
|
+
process.exit(0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
process.on("SIGINT", async () => {
|
|
388
|
+
shuttingDown = true;
|
|
389
|
+
await messageQueue;
|
|
390
|
+
await stop();
|
|
391
|
+
process.exit(0);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
221
394
|
|
|
222
395
|
module.exports = {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
};
|
|
396
|
+
start,
|
|
397
|
+
stop,
|
|
398
|
+
createApiClient,
|
|
399
|
+
validateConfig,
|
|
400
|
+
tokensMatch,
|
|
401
|
+
isAuthorized
|
|
402
|
+
};
|