nodebb-plugin-integration 0.4.4 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-integration",
3
- "version": "0.4.4",
3
+ "version": "0.6.0",
4
4
  "description": "AI integration layer for NodeBB forums with MCP, semantic search, RAG and Qdrant knowledge pipeline",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -16,7 +16,8 @@
16
16
  "languages",
17
17
  "README.md",
18
18
  "README.ru.md",
19
- "LICENSE"
19
+ "LICENSE",
20
+ "docs"
20
21
  ],
21
22
  "keywords": [
22
23
  "nodebb",
package/src/mcp/server.js CHANGED
@@ -187,30 +187,9 @@ async function start(rawConfig) {
187
187
  await stop();
188
188
 
189
189
  const api = createApiClient(config);
190
-
191
- const nextMcpServer = new Server(
192
- {
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
190
  let nextHttpServer = null;
208
191
 
209
192
  try {
210
- Tools.register(nextMcpServer, api);
211
-
212
- await nextMcpServer.connect(nextTransport);
213
-
214
193
  nextHttpServer = http.createServer(async (req, res) => {
215
194
  let pathname;
216
195
 
@@ -242,10 +221,32 @@ async function start(rawConfig) {
242
221
  return;
243
222
  }
244
223
 
224
+ let requestMcpServer = null;
225
+ let requestTransport = null;
226
+
245
227
  try {
246
- await nextTransport.handleRequest(req, res);
228
+ requestMcpServer = new Server(
229
+ {
230
+ name: "nodebb-integration-mcp",
231
+ version: "0.6.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);
247
248
  } catch (error) {
248
- console.error("[MCP] Request failed:", error.message);
249
+ console.error("[MCP] Request failed:", error.stack || error);
249
250
 
250
251
  if (!res.headersSent) {
251
252
  res.writeHead(500, {
@@ -253,6 +254,14 @@ async function start(rawConfig) {
253
254
  });
254
255
  res.end(JSON.stringify({ error: "MCP request failed" }));
255
256
  }
257
+ } finally {
258
+ if (requestTransport) {
259
+ await requestTransport.close().catch(() => {});
260
+ }
261
+
262
+ if (requestMcpServer) {
263
+ await requestMcpServer.close().catch(() => {});
264
+ }
256
265
  }
257
266
  });
258
267
 
@@ -273,8 +282,6 @@ async function start(rawConfig) {
273
282
  });
274
283
 
275
284
  httpServer = nextHttpServer;
276
- transport = nextTransport;
277
- mcpServer = nextMcpServer;
278
285
 
279
286
  console.log(
280
287
  `[MCP] Streamable HTTP listening on ${config.host}:${config.port}/mcp`
@@ -287,9 +294,6 @@ async function start(rawConfig) {
287
294
  });
288
295
  }
289
296
 
290
- await nextTransport.close().catch(() => {});
291
- await nextMcpServer.close().catch(() => {});
292
-
293
297
  throw error;
294
298
  }
295
299
  }
package/src/mcp/tools.js CHANGED
@@ -12,24 +12,20 @@ const definitions = [
12
12
  },
13
13
  {
14
14
  name: "get_post",
15
- description: "Get a forum post by its post ID. Access permissions are checked by NodeBB.",
15
+ description: "Get a forum post by ID. NodeBB access permissions are checked.",
16
16
  inputSchema: {
17
17
  type: "object",
18
- properties: {
19
- pid: { type: "integer", minimum: 1 }
20
- },
18
+ properties: { pid: { type: "integer", minimum: 1 } },
21
19
  required: ["pid"],
22
20
  additionalProperties: false
23
21
  }
24
22
  },
25
23
  {
26
24
  name: "get_topic",
27
- description: "Get a forum topic by its topic ID. Access permissions are checked by NodeBB.",
25
+ description: "Get a forum topic by ID. NodeBB access permissions are checked.",
28
26
  inputSchema: {
29
27
  type: "object",
30
- properties: {
31
- tid: { type: "integer", minimum: 1 }
32
- },
28
+ properties: { tid: { type: "integer", minimum: 1 } },
33
29
  required: ["tid"],
34
30
  additionalProperties: false
35
31
  }
@@ -39,9 +35,7 @@ const definitions = [
39
35
  description: "Get public profile information for a forum user",
40
36
  inputSchema: {
41
37
  type: "object",
42
- properties: {
43
- uid: { type: "integer", minimum: 1 }
44
- },
38
+ properties: { uid: { type: "integer", minimum: 1 } },
45
39
  required: ["uid"],
46
40
  additionalProperties: false
47
41
  }
@@ -58,66 +52,292 @@ const definitions = [
58
52
  required: ["query"],
59
53
  additionalProperties: false
60
54
  }
55
+ },
56
+ {
57
+ name: "create_topic",
58
+ description: "Create a topic in a NodeBB category as the configured MCP service user",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ cid: { type: "integer", minimum: 1 },
63
+ title: { type: "string", minLength: 1, maxLength: 255 },
64
+ content: { type: "string", minLength: 1, maxLength: 20000 },
65
+ tags: {
66
+ type: "array",
67
+ maxItems: 10,
68
+ items: { type: "string", minLength: 1, maxLength: 32 }
69
+ }
70
+ },
71
+ required: ["cid", "title", "content"],
72
+ additionalProperties: false
73
+ }
74
+ },
75
+ {
76
+ name: "delete_topic",
77
+ description: "Soft-delete a topic using NodeBB permissions and hooks",
78
+ inputSchema: {
79
+ type: "object",
80
+ properties: { tid: { type: "integer", minimum: 1 } },
81
+ required: ["tid"],
82
+ additionalProperties: false
83
+ }
84
+ },
85
+ {
86
+ name: "restore_topic",
87
+ description: "Restore a soft-deleted topic using NodeBB permissions and hooks",
88
+ inputSchema: {
89
+ type: "object",
90
+ properties: { tid: { type: "integer", minimum: 1 } },
91
+ required: ["tid"],
92
+ additionalProperties: false
93
+ }
94
+ },
95
+ {
96
+ name: "list_topics",
97
+ description: "List topics visible to the configured MCP service user",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ start: { type: "integer", minimum: 0 },
102
+ limit: { type: "integer", minimum: 1, maximum: 100 },
103
+ sort: {
104
+ type: "string",
105
+ enum: ["recent", "old", "create", "posts", "votes", "views"]
106
+ },
107
+ cid: { type: "integer", minimum: 1 }
108
+ },
109
+ additionalProperties: false
110
+ }
111
+ },
112
+ {
113
+ name: "create_post",
114
+ description: "Create a reply in a topic using NodeBB permissions and hooks",
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ tid: { type: "integer", minimum: 1 },
119
+ content: { type: "string", minLength: 1, maxLength: 20000 }
120
+ },
121
+ required: ["tid", "content"],
122
+ additionalProperties: false
123
+ }
124
+ },
125
+ {
126
+ name: "edit_post",
127
+ description: "Edit a post using NodeBB permissions and edit restrictions",
128
+ inputSchema: {
129
+ type: "object",
130
+ properties: {
131
+ pid: { type: "integer", minimum: 1 },
132
+ content: { type: "string", minLength: 1, maxLength: 20000 }
133
+ },
134
+ required: ["pid", "content"],
135
+ additionalProperties: false
136
+ }
137
+ },
138
+ {
139
+ name: "delete_post",
140
+ description: "Soft-delete a post using NodeBB permissions and hooks",
141
+ inputSchema: {
142
+ type: "object",
143
+ properties: { pid: { type: "integer", minimum: 1 } },
144
+ required: ["pid"],
145
+ additionalProperties: false
146
+ }
147
+ },
148
+ {
149
+ name: "restore_post",
150
+ description: "Restore a soft-deleted post using NodeBB permissions and hooks",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: { pid: { type: "integer", minimum: 1 } },
154
+ required: ["pid"],
155
+ additionalProperties: false
156
+ }
157
+ },
158
+ {
159
+ name: "delete_user_posts",
160
+ description: "Soft-delete up to 100 replies by a user. The first post of every topic is skipped. Use start to process subsequent batches.",
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: {
164
+ uid: { type: "integer", minimum: 1 },
165
+ start: { type: "integer", minimum: 0 },
166
+ limit: { type: "integer", minimum: 1, maximum: 100 }
167
+ },
168
+ required: ["uid"],
169
+ additionalProperties: false
170
+ }
171
+ },
172
+ {
173
+ name: "list_posts",
174
+ description: "List posts in a topic with NodeBB access filtering and pagination",
175
+ inputSchema: {
176
+ type: "object",
177
+ properties: {
178
+ tid: { type: "integer", minimum: 1 },
179
+ start: { type: "integer", minimum: 0 },
180
+ limit: { type: "integer", minimum: 1, maximum: 100 },
181
+ reverse: { type: "boolean" }
182
+ },
183
+ required: ["tid"],
184
+ additionalProperties: false
185
+ }
61
186
  }
62
187
  ];
63
188
 
189
+ const allowedArguments = {
190
+ get_forum_info: [],
191
+ get_post: ["pid"],
192
+ get_topic: ["tid"],
193
+ get_user: ["uid"],
194
+ search_knowledge: ["query", "limit"],
195
+ create_topic: ["cid", "title", "content", "tags"],
196
+ delete_topic: ["tid"],
197
+ restore_topic: ["tid"],
198
+ list_topics: ["start", "limit", "sort", "cid"],
199
+ create_post: ["tid", "content"],
200
+ edit_post: ["pid", "content"],
201
+ delete_post: ["pid"],
202
+ restore_post: ["pid"],
203
+ delete_user_posts: ["uid", "start", "limit"],
204
+ list_posts: ["tid", "start", "limit", "reverse"]
205
+ };
206
+
207
+ function requireId(args, key) {
208
+ if (!Number.isSafeInteger(args[key]) || args[key] < 1) {
209
+ throw new Error(`Invalid ${key}`);
210
+ }
211
+ }
212
+
213
+ function requireText(args, key, maximum) {
214
+ if (typeof args[key] !== "string" ||
215
+ !args[key].trim() || args[key].length > maximum) {
216
+ throw new Error(`Invalid ${key}`);
217
+ }
218
+ }
219
+
220
+ function pageValue(value, fallback, maximum, name) {
221
+ const result = value === undefined ? fallback : value;
222
+
223
+ if (!Number.isSafeInteger(result) || result < 0 || result > maximum) {
224
+ throw new Error(`Invalid ${name}`);
225
+ }
226
+
227
+ return result;
228
+ }
229
+
64
230
  function validateArguments(name, args) {
65
231
  if (!args || typeof args !== "object" || Array.isArray(args)) {
66
232
  throw new Error("Tool arguments must be an object");
67
233
  }
68
234
 
69
- const allowed = {
70
- get_forum_info: [],
71
- get_post: ["pid"],
72
- get_topic: ["tid"],
73
- get_user: ["uid"],
74
- search_knowledge: ["query", "limit"]
75
- }[name];
235
+ const allowed = allowedArguments[name];
76
236
 
77
- if (!allowed) {
78
- throw new Error(`Unknown MCP tool: ${name}`);
79
- }
237
+ if (!allowed) throw new Error(`Unknown MCP tool: ${name}`);
80
238
 
81
- if (Object.keys(args).some((key) => !allowed.includes(key))) {
239
+ if (Object.keys(args).some(key => !allowed.includes(key))) {
82
240
  throw new Error("Unexpected tool argument");
83
241
  }
84
242
 
85
- if (name === "get_forum_info") {
86
- return {};
243
+ if (name === "get_forum_info") return {};
244
+
245
+ if (["get_post", "get_topic", "get_user",
246
+ "delete_topic", "restore_topic",
247
+ "delete_post", "restore_post"].includes(name)) {
248
+ const key = name.endsWith("_post") || name === "get_post"
249
+ ? "pid" : name.endsWith("_topic") || name === "get_topic"
250
+ ? "tid" : "uid";
251
+
252
+ requireId(args, key);
253
+ return { [key]: args[key] };
87
254
  }
88
255
 
89
- if (name === "get_post" || name === "get_topic" || name === "get_user") {
90
- const key = name === "get_post"
91
- ? "pid"
92
- : name === "get_topic"
93
- ? "tid"
94
- : "uid";
256
+ if (name === "search_knowledge") {
257
+ requireText(args, "query", 2000);
258
+ const limit = args.limit === undefined ? 5 : args.limit;
95
259
 
96
- if (!Number.isSafeInteger(args[key]) || args[key] < 1) {
97
- throw new Error(`Invalid ${key}`);
260
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) {
261
+ throw new Error("Search limit must be from 1 to 20");
98
262
  }
99
263
 
100
- return { [key]: args[key] };
264
+ return { query: args.query.trim(), limit };
265
+ }
266
+
267
+ if (name === "create_topic") {
268
+ requireId(args, "cid");
269
+ requireText(args, "title", 255);
270
+ requireText(args, "content", 20000);
271
+
272
+ const tags = args.tags === undefined ? [] : args.tags;
273
+
274
+ if (!Array.isArray(tags) || tags.length > 10 ||
275
+ tags.some(tag => typeof tag !== "string" ||
276
+ !tag.trim() || tag.length > 32)) {
277
+ throw new Error("Invalid tags");
278
+ }
279
+
280
+ return {
281
+ cid: args.cid,
282
+ title: args.title.trim(),
283
+ content: args.content,
284
+ tags
285
+ };
101
286
  }
102
287
 
103
- if (
104
- typeof args.query !== "string" ||
105
- !args.query.trim() ||
106
- args.query.length > 2000
107
- ) {
108
- throw new Error("Search query must contain 1 to 2000 characters");
288
+ if (name === "create_post" || name === "edit_post") {
289
+ const key = name === "create_post" ? "tid" : "pid";
290
+ requireId(args, key);
291
+ requireText(args, "content", 20000);
292
+ return { [key]: args[key], content: args.content };
109
293
  }
110
294
 
111
- const limit = args.limit === undefined ? 5 : args.limit;
295
+ if (name === "list_topics") {
296
+ const result = {
297
+ start: pageValue(args.start, 0, 10000000, "start"),
298
+ limit: pageValue(args.limit, 20, 100, "limit"),
299
+ sort: args.sort === undefined ? "recent" : args.sort
300
+ };
301
+
302
+ if (!["recent", "old", "create", "posts", "votes", "views"]
303
+ .includes(result.sort)) {
304
+ throw new Error("Invalid sort");
305
+ }
112
306
 
113
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) {
114
- throw new Error("Search limit must be an integer from 1 to 20");
307
+ if (args.cid !== undefined) {
308
+ requireId(args, "cid");
309
+ result.cid = args.cid;
310
+ }
311
+
312
+ return result;
115
313
  }
116
314
 
117
- return {
118
- query: args.query.trim(),
119
- limit
120
- };
315
+ if (name === "delete_user_posts") {
316
+ requireId(args, "uid");
317
+
318
+ return {
319
+ uid: args.uid,
320
+ start: pageValue(args.start, 0, 10000000, "start"),
321
+ limit: pageValue(args.limit, 100, 100, "limit")
322
+ };
323
+ }
324
+
325
+ if (name === "list_posts") {
326
+ requireId(args, "tid");
327
+
328
+ if (args.reverse !== undefined && typeof args.reverse !== "boolean") {
329
+ throw new Error("Invalid reverse");
330
+ }
331
+
332
+ return {
333
+ tid: args.tid,
334
+ start: pageValue(args.start, 0, 10000000, "start"),
335
+ limit: pageValue(args.limit, 20, 100, "limit"),
336
+ reverse: args.reverse ?? false
337
+ };
338
+ }
339
+
340
+ throw new Error(`Unknown MCP tool: ${name}`);
121
341
  }
122
342
 
123
343
  async function execute(name, args = {}, api) {
@@ -127,25 +347,27 @@ async function execute(name, args = {}, api) {
127
347
 
128
348
  const validated = validateArguments(name, args);
129
349
 
130
- switch (name) {
131
- case "get_forum_info":
132
- return api.request("GET", "/api/v3/integration/internal/info");
133
-
134
- case "get_post":
135
- return api.request("POST", "/api/v3/integration/internal/post", validated);
136
-
137
- case "get_topic":
138
- return api.request("POST", "/api/v3/integration/internal/topic", validated);
139
-
140
- case "get_user":
141
- return api.request("POST", "/api/v3/integration/internal/user", validated);
350
+ const routes = {
351
+ get_forum_info: ["GET", "/api/v3/integration/internal/info"],
352
+ get_post: ["POST", "/api/v3/integration/internal/post"],
353
+ get_topic: ["POST", "/api/v3/integration/internal/topic"],
354
+ get_user: ["POST", "/api/v3/integration/internal/user"],
355
+ search_knowledge: ["POST", "/api/v3/integration/internal/search"],
356
+ create_topic: ["POST", "/api/v3/integration/internal/topic/create"],
357
+ delete_topic: ["POST", "/api/v3/integration/internal/topic/delete"],
358
+ restore_topic: ["POST", "/api/v3/integration/internal/topic/restore"],
359
+ list_topics: ["POST", "/api/v3/integration/internal/topics"],
360
+ create_post: ["POST", "/api/v3/integration/internal/post/create"],
361
+ edit_post: ["POST", "/api/v3/integration/internal/post/edit"],
362
+ delete_post: ["POST", "/api/v3/integration/internal/post/delete"],
363
+ restore_post: ["POST", "/api/v3/integration/internal/post/restore"],
364
+ delete_user_posts: ["POST", "/api/v3/integration/internal/user/delete-posts"],
365
+ list_posts: ["POST", "/api/v3/integration/internal/posts"]
366
+ };
142
367
 
143
- case "search_knowledge":
144
- return api.request("POST", "/api/v3/integration/internal/search", validated);
368
+ const [method, path] = routes[name];
145
369
 
146
- default:
147
- throw new Error(`Unknown MCP tool: ${name}`);
148
- }
370
+ return api.request(method, path, validated);
149
371
  }
150
372
 
151
373
  function register(server, api) {
@@ -158,7 +380,7 @@ function register(server, api) {
158
380
  tools: definitions
159
381
  }));
160
382
 
161
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
383
+ server.setRequestHandler(CallToolRequestSchema, async request => {
162
384
  const name = request.params.name;
163
385
  const args = request.params.arguments || {};
164
386
 
@@ -166,22 +388,18 @@ function register(server, api) {
166
388
  const result = await execute(name, args, api);
167
389
 
168
390
  return {
169
- content: [
170
- {
171
- type: "text",
172
- text: JSON.stringify(result, null, 2)
173
- }
174
- ]
391
+ content: [{
392
+ type: "text",
393
+ text: JSON.stringify(result, null, 2)
394
+ }]
175
395
  };
176
396
  } catch (error) {
177
397
  return {
178
398
  isError: true,
179
- content: [
180
- {
181
- type: "text",
182
- text: error.message || "Tool execution failed"
183
- }
184
- ]
399
+ content: [{
400
+ type: "text",
401
+ text: error.message || "Tool execution failed"
402
+ }]
185
403
  };
186
404
  }
187
405
  });