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/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
- 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
 
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
- server = new Server(
85
-
86
-
87
- {
88
-
89
- name:
90
-
91
- "nodebb-integration-mcp",
92
-
93
-
94
- version:
95
-
96
- "0.2.0"
97
-
98
-
99
- },
100
-
101
-
102
- {
103
-
104
-
105
- capabilities:
106
-
107
-
108
- {
109
-
110
-
111
- tools: {}
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
- return server;
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
- 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
-
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
- createServer,
225
-
226
-
227
- start
228
-
229
-
230
-
231
-
232
- };
396
+ start,
397
+ stop,
398
+ createApiClient,
399
+ validateConfig,
400
+ tokensMatch,
401
+ isAuthorized
402
+ };