nodebb-plugin-integration 0.4.1 → 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/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
- Server
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 createServer() {
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
- return server;
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
- server = new Server(
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
- name:
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
- "nodebb-integration-mcp",
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
- version:
156
+ httpServer = null;
157
+ transport = null;
158
+ mcpServer = null;
95
159
 
96
- "0.2.0"
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
- capabilities:
187
+ await stop();
106
188
 
189
+ const api = createApiClient(config);
107
190
 
191
+ const nextMcpServer = new Server(
108
192
  {
109
-
110
-
111
- tools: {}
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
- createServer,
225
-
226
-
227
- start
228
-
229
-
230
-
231
-
232
- };
392
+ start,
393
+ stop,
394
+ createApiClient,
395
+ validateConfig,
396
+ tokensMatch,
397
+ isAuthorized
398
+ };