nodebb-plugin-integration 0.5.0 → 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.5.0",
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
@@ -228,7 +228,7 @@ async function start(rawConfig) {
228
228
  requestMcpServer = new Server(
229
229
  {
230
230
  name: "nodebb-integration-mcp",
231
- version: "0.5.0"
231
+ version: "0.6.0"
232
232
  },
233
233
  {
234
234
  capabilities: {
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
  });
@@ -3,18 +3,10 @@
3
3
  const NodeBB = require("./index");
4
4
 
5
5
  const META_FIELDS = [
6
- "pid",
7
- "tid",
8
- "uid",
9
- "timestamp",
10
- "deleted",
11
- "cid"
6
+ "pid", "tid", "uid", "timestamp", "deleted", "cid"
12
7
  ];
13
8
 
14
- const CONTENT_FIELDS = [
15
- "pid",
16
- "content"
17
- ];
9
+ const CONTENT_FIELDS = ["pid", "content"];
18
10
 
19
11
  function validatePid(pid) {
20
12
  pid = Number(pid);
@@ -26,9 +18,18 @@ function validatePid(pid) {
26
18
  return pid;
27
19
  }
28
20
 
21
+ function validateUid(uid) {
22
+ uid = Number(uid);
23
+
24
+ if (!Number.isSafeInteger(uid) || uid <= 0) {
25
+ throw new TypeError("Invalid user ID");
26
+ }
27
+
28
+ return uid;
29
+ }
30
+
29
31
  async function getPostMeta(pid) {
30
32
  pid = validatePid(pid);
31
-
32
33
  const posts = NodeBB.get("posts");
33
34
 
34
35
  if (typeof posts.getPostFields !== "function") {
@@ -40,7 +41,6 @@ async function getPostMeta(pid) {
40
41
 
41
42
  async function getPostContent(pid) {
42
43
  pid = validatePid(pid);
43
-
44
44
  const posts = NodeBB.get("posts");
45
45
 
46
46
  if (typeof posts.getPostFields !== "function") {
@@ -61,17 +61,13 @@ async function getPosts(pids = [], uid) {
61
61
  throw new TypeError("Invalid user ID");
62
62
  }
63
63
 
64
- const unique = [
65
- ...new Set(
66
- pids
67
- .map(Number)
68
- .filter(pid => Number.isSafeInteger(pid) && pid > 0)
64
+ const unique = [...new Set(
65
+ pids.map(Number).filter(
66
+ pid => Number.isSafeInteger(pid) && pid > 0
69
67
  )
70
- ];
68
+ )];
71
69
 
72
- if (!unique.length) {
73
- return [];
74
- }
70
+ if (!unique.length) return [];
75
71
 
76
72
  const posts = NodeBB.get("posts");
77
73
 
@@ -84,10 +80,65 @@ async function getPosts(pids = [], uid) {
84
80
  return result.posts || result;
85
81
  }
86
82
 
83
+ async function editPost(pid, uid, content) {
84
+ pid = validatePid(pid);
85
+ uid = validateUid(uid);
86
+
87
+ const posts = NodeBB.get("posts");
88
+
89
+ if (typeof posts.edit !== "function") {
90
+ throw new Error("NodeBB posts.edit is unavailable");
91
+ }
92
+
93
+ return posts.edit({ pid, uid, content });
94
+ }
95
+
96
+ async function deletePost(pid, uid) {
97
+ pid = validatePid(pid);
98
+ uid = validateUid(uid);
99
+
100
+ const posts = NodeBB.get("posts");
101
+
102
+ if (!posts.tools || typeof posts.tools.delete !== "function") {
103
+ throw new Error("NodeBB posts.tools.delete is unavailable");
104
+ }
105
+
106
+ return posts.tools.delete(uid, pid);
107
+ }
108
+
109
+ async function restorePost(pid, uid) {
110
+ pid = validatePid(pid);
111
+ uid = validateUid(uid);
112
+
113
+ const posts = NodeBB.get("posts");
114
+
115
+ if (!posts.tools || typeof posts.tools.restore !== "function") {
116
+ throw new Error("NodeBB posts.tools.restore is unavailable");
117
+ }
118
+
119
+ return posts.tools.restore(uid, pid);
120
+ }
121
+
122
+ async function getUserPostIds(uid, start, stop) {
123
+ uid = validateUid(uid);
124
+
125
+ const users = NodeBB.get("users");
126
+
127
+ if (typeof users.getPostIds !== "function") {
128
+ throw new Error("NodeBB users.getPostIds is unavailable");
129
+ }
130
+
131
+ return users.getPostIds(uid, start, stop);
132
+ }
133
+
87
134
  module.exports = {
88
135
  getPostMeta,
89
136
  getPostContent,
90
137
  getPosts,
138
+ editPost,
139
+ deletePost,
140
+ restorePost,
141
+ getUserPostIds,
91
142
  META_FIELDS,
92
143
  CONTENT_FIELDS
93
144
  };