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