@sondt2709/wikijs-mcp 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1020 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { parseArgs } from "node:util";
4
+ import { z } from "zod";
5
+ import pino, { destination } from "pino";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ //#region src/config.ts
8
+ const ConfigSchema = z.object({
9
+ wikijsUrl: z.url({ message: "must be a valid URL like https://wiki.example.com" }),
10
+ wikijsToken: z.string().min(1, "must not be empty"),
11
+ transport: z.enum(["stdio", "http"]).default("stdio"),
12
+ port: z.coerce.number().int().min(1).max(65535).default(3e3),
13
+ logLevel: z.enum([
14
+ "fatal",
15
+ "error",
16
+ "warn",
17
+ "info",
18
+ "debug",
19
+ "trace"
20
+ ]).default("info")
21
+ });
22
+ function loadConfig(argv = process.argv.slice(2)) {
23
+ const { values } = parseArgs({
24
+ args: argv,
25
+ options: {
26
+ url: { type: "string" },
27
+ token: { type: "string" },
28
+ transport: { type: "string" },
29
+ port: { type: "string" },
30
+ "log-level": { type: "string" }
31
+ }
32
+ });
33
+ const parsed = ConfigSchema.safeParse({
34
+ wikijsUrl: values.url ?? process.env.WIKIJS_URL,
35
+ wikijsToken: values.token ?? process.env.WIKIJS_TOKEN,
36
+ transport: values.transport ?? process.env.TRANSPORT,
37
+ port: values.port ?? process.env.PORT,
38
+ logLevel: values["log-level"] ?? process.env.LOG_LEVEL
39
+ });
40
+ if (!parsed.success) {
41
+ const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
42
+ throw new Error(`Invalid configuration — ${issues}. Set WIKIJS_URL and WIKIJS_TOKEN env vars (or --url/--token flags). See .env.example.`);
43
+ }
44
+ return parsed.data;
45
+ }
46
+ //#endregion
47
+ //#region src/logger.ts
48
+ function createLogger(level) {
49
+ return pino({
50
+ level,
51
+ base: void 0
52
+ }, destination(2));
53
+ }
54
+ //#endregion
55
+ //#region package.json
56
+ var version = "0.1.0";
57
+ //#endregion
58
+ //#region src/client/graphql.ts
59
+ var WikiJsError = class extends Error {
60
+ kind;
61
+ slug;
62
+ errorCode;
63
+ constructor(message, kind, slug, errorCode) {
64
+ super(message);
65
+ this.name = "WikiJsError";
66
+ this.kind = kind;
67
+ this.slug = slug;
68
+ this.errorCode = errorCode;
69
+ }
70
+ };
71
+ function assertSucceeded(rr, action) {
72
+ if (!rr.succeeded) throw new WikiJsError(`Wiki.js error ${rr.slug} while ${action}: ${rr.message ?? "no message provided"}`, "application", rr.slug, rr.errorCode);
73
+ }
74
+ const AUTH_HINT = " (check that WIKIJS_TOKEN is a valid, unexpired API key and that its group has the required page permissions)";
75
+ var GraphQLClient = class {
76
+ endpoint;
77
+ token;
78
+ timeoutMs;
79
+ constructor(opts) {
80
+ this.endpoint = new URL("/graphql", opts.baseUrl).toString();
81
+ this.token = opts.token;
82
+ this.timeoutMs = opts.timeoutMs ?? 3e4;
83
+ }
84
+ async request(query, variables) {
85
+ let res;
86
+ try {
87
+ res = await fetch(this.endpoint, {
88
+ method: "POST",
89
+ headers: {
90
+ "content-type": "application/json",
91
+ authorization: `Bearer ${this.token}`
92
+ },
93
+ body: JSON.stringify({
94
+ query,
95
+ variables
96
+ }),
97
+ signal: AbortSignal.timeout(this.timeoutMs)
98
+ });
99
+ } catch (err) {
100
+ throw new WikiJsError(`Cannot reach Wiki.js at ${this.endpoint}: ${err.message}`, "transport");
101
+ }
102
+ if (res.status === 401 || res.status === 403) throw new WikiJsError(`Wiki.js rejected the request with HTTP ${res.status}${AUTH_HINT}`, "transport");
103
+ if (!res.ok) throw new WikiJsError(`Wiki.js returned HTTP ${res.status} for GraphQL request`, "transport");
104
+ let body;
105
+ try {
106
+ body = await res.json();
107
+ } catch {
108
+ throw new WikiJsError(`Wiki.js returned a non-JSON response (HTTP ${res.status})`, "transport");
109
+ }
110
+ if (body.errors?.length) {
111
+ const message = body.errors.map((e) => e.message).join("; ");
112
+ throw new WikiJsError(`GraphQL error: ${message}${/forbidden|unauthorized|not authorized/i.test(message) ? AUTH_HINT : ""}`, "graphql");
113
+ }
114
+ if (body.data == null) throw new WikiJsError("Wiki.js returned an empty GraphQL response", "graphql");
115
+ return body.data;
116
+ }
117
+ };
118
+ //#endregion
119
+ //#region src/client/wikijs.ts
120
+ function isPageNotFoundError(err) {
121
+ return err instanceof WikiJsError && err.kind === "graphql" && /does not exist/i.test(err.message);
122
+ }
123
+ const SearchResultSchema = z.object({
124
+ id: z.string(),
125
+ title: z.string(),
126
+ description: z.string(),
127
+ path: z.string(),
128
+ locale: z.string()
129
+ });
130
+ const SearchResponseSchema = z.object({
131
+ results: z.array(SearchResultSchema),
132
+ suggestions: z.array(z.string().nullable()).transform((a) => a.filter((s) => s != null)),
133
+ totalHits: z.number()
134
+ });
135
+ const PageListItemSchema = z.object({
136
+ id: z.number(),
137
+ path: z.string(),
138
+ locale: z.string(),
139
+ title: z.string().nullish(),
140
+ description: z.string().nullish(),
141
+ contentType: z.string(),
142
+ isPublished: z.boolean(),
143
+ isPrivate: z.boolean(),
144
+ createdAt: z.string(),
145
+ updatedAt: z.string(),
146
+ tags: z.array(z.string().nullable()).nullish().transform((a) => (a ?? []).filter((t) => t != null))
147
+ });
148
+ const PageDetailSchema = z.object({
149
+ id: z.number(),
150
+ path: z.string(),
151
+ locale: z.string(),
152
+ title: z.string(),
153
+ description: z.string(),
154
+ content: z.string(),
155
+ contentType: z.string(),
156
+ editor: z.string(),
157
+ isPublished: z.boolean(),
158
+ isPrivate: z.boolean(),
159
+ tags: z.array(z.object({ tag: z.string() }).nullable()).transform((a) => a.filter((t) => t != null).map((t) => t.tag)),
160
+ createdAt: z.string(),
161
+ updatedAt: z.string()
162
+ });
163
+ const TreeItemSchema = z.object({
164
+ id: z.number(),
165
+ path: z.string(),
166
+ depth: z.number(),
167
+ title: z.string(),
168
+ isFolder: z.boolean(),
169
+ pageId: z.number().nullish(),
170
+ parent: z.number().nullish(),
171
+ locale: z.string()
172
+ });
173
+ const TagSchema = z.object({
174
+ id: z.number(),
175
+ tag: z.string(),
176
+ title: z.string().nullish()
177
+ });
178
+ const PAGE_DETAIL_FIELDS = `
179
+ id path locale title description content contentType editor
180
+ isPublished isPrivate tags { tag } createdAt updatedAt
181
+ `;
182
+ const PageRefSchema = z.object({
183
+ id: z.number(),
184
+ path: z.string(),
185
+ locale: z.string(),
186
+ title: z.string()
187
+ });
188
+ const MutationPageSchema = z.object({
189
+ id: z.number(),
190
+ path: z.string(),
191
+ title: z.string()
192
+ });
193
+ const MutationResponseSchema = z.object({
194
+ responseResult: z.object({
195
+ succeeded: z.boolean(),
196
+ errorCode: z.number(),
197
+ slug: z.string(),
198
+ message: z.string().nullish()
199
+ }),
200
+ page: MutationPageSchema.nullish()
201
+ });
202
+ const HistoryEntrySchema = z.object({
203
+ versionId: z.number(),
204
+ versionDate: z.string(),
205
+ authorId: z.number(),
206
+ authorName: z.string(),
207
+ actionType: z.string(),
208
+ valueBefore: z.string().nullish(),
209
+ valueAfter: z.string().nullish()
210
+ });
211
+ const PageVersionSchema = z.object({
212
+ versionId: z.number(),
213
+ versionDate: z.string(),
214
+ action: z.string(),
215
+ authorName: z.string(),
216
+ pageId: z.number(),
217
+ path: z.string(),
218
+ locale: z.string(),
219
+ title: z.string(),
220
+ description: z.string(),
221
+ content: z.string(),
222
+ contentType: z.string(),
223
+ editor: z.string(),
224
+ isPublished: z.boolean(),
225
+ tags: z.array(z.string().nullable()).transform((a) => a.filter((t) => t != null)),
226
+ createdAt: z.string()
227
+ });
228
+ const AssetFolderSchema = z.object({
229
+ id: z.number(),
230
+ slug: z.string(),
231
+ name: z.string().nullish()
232
+ });
233
+ const AssetItemSchema = z.object({
234
+ id: z.number(),
235
+ filename: z.string(),
236
+ ext: z.string(),
237
+ kind: z.string(),
238
+ mime: z.string(),
239
+ fileSize: z.number(),
240
+ createdAt: z.string(),
241
+ updatedAt: z.string()
242
+ });
243
+ var WikiJsClient = class {
244
+ gql;
245
+ constructor(gql) {
246
+ this.gql = gql;
247
+ }
248
+ async searchPages(args) {
249
+ const data = await this.gql.request(`query ($query: String!, $path: String, $locale: String) {
250
+ pages { search(query: $query, path: $path, locale: $locale) {
251
+ results { id title description path locale }
252
+ suggestions
253
+ totalHits
254
+ } }
255
+ }`, args);
256
+ return SearchResponseSchema.parse(data.pages.search);
257
+ }
258
+ async listPages(args) {
259
+ const data = await this.gql.request(`query ($limit: Int, $orderBy: PageOrderBy, $orderByDirection: PageOrderByDirection, $tags: [String!], $locale: String) {
260
+ pages { list(limit: $limit, orderBy: $orderBy, orderByDirection: $orderByDirection, tags: $tags, locale: $locale) {
261
+ id path locale title description contentType isPublished isPrivate createdAt updatedAt tags
262
+ } }
263
+ }`, args);
264
+ return z.array(PageListItemSchema).parse(data.pages.list);
265
+ }
266
+ async getPageById(id) {
267
+ try {
268
+ const data = await this.gql.request(`query ($id: Int!) { pages { single(id: $id) { ${PAGE_DETAIL_FIELDS} } } }`, { id });
269
+ return data.pages.single == null ? null : PageDetailSchema.parse(data.pages.single);
270
+ } catch (err) {
271
+ if (isPageNotFoundError(err)) return null;
272
+ throw err;
273
+ }
274
+ }
275
+ async getPageByPath(path, locale) {
276
+ try {
277
+ const data = await this.gql.request(`query ($path: String!, $locale: String!) { pages { singleByPath(path: $path, locale: $locale) { ${PAGE_DETAIL_FIELDS} } } }`, {
278
+ path,
279
+ locale
280
+ });
281
+ return data.pages.singleByPath == null ? null : PageDetailSchema.parse(data.pages.singleByPath);
282
+ } catch (err) {
283
+ if (isPageNotFoundError(err)) return null;
284
+ throw err;
285
+ }
286
+ }
287
+ async getTree(args) {
288
+ const requestMode = args.mode === "PAGES" ? "ALL" : args.mode;
289
+ const data = await this.gql.request(`query ($parent: Int, $path: String, $mode: PageTreeMode!, $locale: String!, $includeAncestors: Boolean) {
290
+ pages { tree(parent: $parent, path: $path, mode: $mode, locale: $locale, includeAncestors: $includeAncestors) {
291
+ id path depth title isFolder pageId parent locale
292
+ } }
293
+ }`, {
294
+ ...args,
295
+ mode: requestMode
296
+ });
297
+ const items = z.array(TreeItemSchema.nullable()).parse(data.pages.tree ?? []).filter((t) => t != null);
298
+ return args.mode === "PAGES" ? items.filter((t) => !t.isFolder) : items;
299
+ }
300
+ async listTags() {
301
+ const data = await this.gql.request(`query { pages { tags { id tag title } } }`);
302
+ return z.array(TagSchema.nullable()).parse(data.pages.tags).filter((t) => t != null);
303
+ }
304
+ async searchTags(query) {
305
+ const data = await this.gql.request(`query ($query: String!) { pages { searchTags(query: $query) } }`, { query });
306
+ return z.array(z.string().nullable()).parse(data.pages.searchTags).filter((t) => t != null);
307
+ }
308
+ async createPage(input) {
309
+ const data = await this.gql.request(`mutation ($content: String!, $description: String!, $editor: String!, $isPublished: Boolean!,
310
+ $isPrivate: Boolean!, $locale: String!, $path: String!, $tags: [String]!, $title: String!) {
311
+ pages { create(content: $content, description: $description, editor: $editor, isPublished: $isPublished,
312
+ isPrivate: $isPrivate, locale: $locale, path: $path, tags: $tags, title: $title) {
313
+ responseResult { succeeded errorCode slug message }
314
+ page { id path title }
315
+ } }
316
+ }`, {
317
+ content: input.content,
318
+ description: input.description ?? "",
319
+ editor: input.editor ?? "markdown",
320
+ isPublished: input.isPublished ?? true,
321
+ isPrivate: input.isPrivate ?? false,
322
+ locale: input.locale ?? "en",
323
+ path: input.path,
324
+ tags: input.tags ?? [],
325
+ title: input.title
326
+ });
327
+ const res = MutationResponseSchema.parse(data.pages.create);
328
+ assertSucceeded(res.responseResult, `creating page at ${input.path}`);
329
+ if (!res.page) throw new WikiJsError("Wiki.js reported success but returned no page", "application");
330
+ return PageRefSchema.parse({
331
+ ...res.page,
332
+ locale: input.locale ?? "en"
333
+ });
334
+ }
335
+ async updatePage(input) {
336
+ const { id, ...fields } = input;
337
+ const current = fields.tags == null || fields.content == null || fields.isPublished == null || fields.isPrivate == null ? await this.getPageById(id) : null;
338
+ const tags = fields.tags ?? current?.tags ?? [];
339
+ const content = fields.content ?? current?.content;
340
+ const isPublished = fields.isPublished ?? current?.isPublished;
341
+ const isPrivate = fields.isPrivate ?? current?.isPrivate;
342
+ const data = await this.gql.request(`mutation ($id: Int!, $content: String, $description: String, $isPrivate: Boolean, $isPublished: Boolean,
343
+ $locale: String, $path: String, $tags: [String]!, $title: String) {
344
+ pages { update(id: $id, content: $content, description: $description, isPrivate: $isPrivate,
345
+ isPublished: $isPublished, locale: $locale, path: $path, tags: $tags, title: $title) {
346
+ responseResult { succeeded errorCode slug message }
347
+ page { id path title }
348
+ } }
349
+ }`, {
350
+ id,
351
+ ...fields,
352
+ tags,
353
+ content,
354
+ isPublished,
355
+ isPrivate
356
+ });
357
+ const res = MutationResponseSchema.parse(data.pages.update);
358
+ assertSucceeded(res.responseResult, `updating page ${id}`);
359
+ if (!res.page) throw new WikiJsError("Wiki.js reported success but returned no page", "application");
360
+ const updated = await this.getPageById(id);
361
+ if (!updated) throw new WikiJsError(`Page ${id} vanished immediately after a successful update`, "application");
362
+ return PageRefSchema.parse({
363
+ id: updated.id,
364
+ path: updated.path,
365
+ locale: updated.locale,
366
+ title: updated.title
367
+ });
368
+ }
369
+ async movePage(id, destinationPath, destinationLocale) {
370
+ const data = await this.gql.request(`mutation ($id: Int!, $destinationPath: String!, $destinationLocale: String!) {
371
+ pages { move(id: $id, destinationPath: $destinationPath, destinationLocale: $destinationLocale) {
372
+ responseResult { succeeded errorCode slug message }
373
+ } }
374
+ }`, {
375
+ id,
376
+ destinationPath,
377
+ destinationLocale
378
+ });
379
+ assertSucceeded(z.object({ responseResult: MutationResponseSchema.shape.responseResult }).parse(data.pages.move).responseResult, `moving page ${id} to ${destinationLocale}/${destinationPath}`);
380
+ }
381
+ async deletePage(id) {
382
+ const data = await this.gql.request(`mutation ($id: Int!) {
383
+ pages { delete(id: $id) { responseResult { succeeded errorCode slug message } } }
384
+ }`, { id });
385
+ assertSucceeded(z.object({ responseResult: MutationResponseSchema.shape.responseResult }).parse(data.pages.delete).responseResult, `deleting page ${id}`);
386
+ }
387
+ async getHistory(id, offsetPage, offsetSize) {
388
+ const data = await this.gql.request(`query ($id: Int!, $offsetPage: Int, $offsetSize: Int) {
389
+ pages { history(id: $id, offsetPage: $offsetPage, offsetSize: $offsetSize) {
390
+ trail { versionId versionDate authorId authorName actionType valueBefore valueAfter }
391
+ total
392
+ } }
393
+ }`, {
394
+ id,
395
+ offsetPage,
396
+ offsetSize
397
+ });
398
+ return z.object({
399
+ trail: z.array(HistoryEntrySchema.nullable()).nullish().transform((a) => (a ?? []).filter((e) => e != null)),
400
+ total: z.number()
401
+ }).parse(data.pages.history);
402
+ }
403
+ async getVersion(pageId, versionId) {
404
+ const data = await this.gql.request(`query ($pageId: Int!, $versionId: Int!) {
405
+ pages { version(pageId: $pageId, versionId: $versionId) {
406
+ versionId versionDate action authorName pageId path locale title description
407
+ content contentType editor isPublished tags createdAt
408
+ } }
409
+ }`, {
410
+ pageId,
411
+ versionId
412
+ });
413
+ if (data.pages.version == null) throw new WikiJsError(`Version ${versionId} of page ${pageId} not found`, "application");
414
+ return PageVersionSchema.parse(data.pages.version);
415
+ }
416
+ async restoreVersion(pageId, versionId) {
417
+ const data = await this.gql.request(`mutation ($pageId: Int!, $versionId: Int!) {
418
+ pages { restore(pageId: $pageId, versionId: $versionId) {
419
+ responseResult { succeeded errorCode slug message }
420
+ } }
421
+ }`, {
422
+ pageId,
423
+ versionId
424
+ });
425
+ assertSucceeded(z.object({ responseResult: MutationResponseSchema.shape.responseResult }).parse(data.pages.restore).responseResult, `restoring page ${pageId} to version ${versionId}`);
426
+ }
427
+ async listAssetFolders(parentFolderId) {
428
+ const data = await this.gql.request(`query ($parentFolderId: Int!) { assets { folders(parentFolderId: $parentFolderId) { id slug name } } }`, { parentFolderId });
429
+ return z.array(AssetFolderSchema.nullable()).parse(data.assets.folders ?? []).filter((f) => f != null);
430
+ }
431
+ async listAssets(folderId, kind) {
432
+ const data = await this.gql.request(`query ($folderId: Int!, $kind: AssetKind!) {
433
+ assets { list(folderId: $folderId, kind: $kind) {
434
+ id filename ext kind mime fileSize createdAt updatedAt
435
+ } }
436
+ }`, {
437
+ folderId,
438
+ kind
439
+ });
440
+ return z.array(AssetItemSchema.nullable()).parse(data.assets.list ?? []).filter((a) => a != null);
441
+ }
442
+ };
443
+ //#endregion
444
+ //#region src/tools/result.ts
445
+ /**
446
+ * Wraps a tool handler to log its name and duration (ms) at debug level on
447
+ * every call. Never logs arguments or tokens — timing/name only.
448
+ */
449
+ function instrument(logger, name, handler) {
450
+ return async (...args) => {
451
+ const start = performance.now();
452
+ try {
453
+ return await handler(...args);
454
+ } finally {
455
+ const durationMs = performance.now() - start;
456
+ logger.debug({
457
+ tool: name,
458
+ durationMs
459
+ }, "tool call");
460
+ }
461
+ };
462
+ }
463
+ function ok(structured, summary) {
464
+ const json = JSON.stringify(structured);
465
+ return {
466
+ content: [{
467
+ type: "text",
468
+ text: summary ? `${summary}\n\n${json}` : json
469
+ }],
470
+ structuredContent: structured
471
+ };
472
+ }
473
+ function fail(err) {
474
+ return {
475
+ content: [{
476
+ type: "text",
477
+ text: err instanceof WikiJsError || err instanceof Error ? err.message : String(err)
478
+ }],
479
+ isError: true
480
+ };
481
+ }
482
+ /**
483
+ * Slices text content by 0-based, inclusive line range. Negative bounds
484
+ * count from the end (Python-style: -1 = last line). Bounds are clamped to
485
+ * the available lines, and an empty result is returned if start > end.
486
+ */
487
+ function sliceLines(content, startLine, endLine) {
488
+ const lines = content === "" ? [] : content.split("\n");
489
+ const totalLines = lines.length;
490
+ if (totalLines === 0) return {
491
+ content: "",
492
+ totalLines: 0,
493
+ startLine: 0,
494
+ endLine: 0
495
+ };
496
+ const resolveIndex = (n) => n < 0 ? totalLines + n : n;
497
+ const clamp = (n) => Math.min(Math.max(n, 0), totalLines - 1);
498
+ const resolvedStart = clamp(resolveIndex(startLine));
499
+ const resolvedEnd = clamp(resolveIndex(endLine));
500
+ return {
501
+ content: resolvedStart > resolvedEnd ? "" : lines.slice(resolvedStart, resolvedEnd + 1).join("\n"),
502
+ totalLines,
503
+ startLine: resolvedStart,
504
+ endLine: resolvedEnd
505
+ };
506
+ }
507
+ //#endregion
508
+ //#region src/tools/discovery.ts
509
+ const pageSummaryShape = {
510
+ id: z.number(),
511
+ path: z.string(),
512
+ locale: z.string(),
513
+ title: z.string().nullable(),
514
+ description: z.string().nullable(),
515
+ isPublished: z.boolean(),
516
+ isPrivate: z.boolean(),
517
+ tags: z.array(z.string()),
518
+ createdAt: z.string(),
519
+ updatedAt: z.string()
520
+ };
521
+ function registerDiscoveryTools(server, wiki, logger) {
522
+ server.registerTool("search_pages", {
523
+ title: "Search pages",
524
+ description: "Full-text search Wiki.js pages. Returns matching pages with id, title, path and locale. Use get_page to fetch content.",
525
+ inputSchema: {
526
+ query: z.string().min(1).describe("Search keywords"),
527
+ path: z.string().optional().describe("Restrict results to this path prefix"),
528
+ locale: z.string().optional().describe("Locale code, e.g. \"en\"")
529
+ },
530
+ outputSchema: {
531
+ results: z.array(z.object({
532
+ id: z.string(),
533
+ title: z.string(),
534
+ description: z.string(),
535
+ path: z.string(),
536
+ locale: z.string()
537
+ })),
538
+ suggestions: z.array(z.string()),
539
+ totalHits: z.number()
540
+ }
541
+ }, instrument(logger, "search_pages", async (args) => {
542
+ try {
543
+ return ok(await wiki.searchPages(args));
544
+ } catch (err) {
545
+ return fail(err);
546
+ }
547
+ }));
548
+ server.registerTool("list_pages", {
549
+ title: "List pages",
550
+ description: "List Wiki.js pages with optional ordering, tag and locale filters.",
551
+ inputSchema: {
552
+ limit: z.number().int().min(1).max(500).default(50).describe("Max pages to return"),
553
+ orderBy: z.enum([
554
+ "CREATED",
555
+ "ID",
556
+ "PATH",
557
+ "TITLE",
558
+ "UPDATED"
559
+ ]).optional(),
560
+ orderByDirection: z.enum(["ASC", "DESC"]).optional(),
561
+ tags: z.array(z.string()).optional().describe("Only pages having all these tags"),
562
+ locale: z.string().optional()
563
+ },
564
+ outputSchema: {
565
+ pages: z.array(z.object({
566
+ ...pageSummaryShape,
567
+ contentType: z.string()
568
+ })),
569
+ total: z.number()
570
+ }
571
+ }, instrument(logger, "list_pages", async (args) => {
572
+ try {
573
+ const pages = await wiki.listPages(args);
574
+ return ok({
575
+ pages: pages.map((p) => ({
576
+ ...p,
577
+ title: p.title ?? null,
578
+ description: p.description ?? null
579
+ })),
580
+ total: pages.length
581
+ });
582
+ } catch (err) {
583
+ return fail(err);
584
+ }
585
+ }));
586
+ server.registerTool("get_page", {
587
+ title: "Get page",
588
+ description: "Fetch one page by numeric id OR by path (+ locale, default \"en\"). Optionally slice its content by line range with startLine/endLine: 0-based, inclusive on both ends. Default (0..-1) returns the whole page. Negative values count from the end, Python-style (-1 = last line, -2 = second-to-last).",
589
+ inputSchema: {
590
+ id: z.number().int().optional().describe("Page id (preferred when known)"),
591
+ path: z.string().optional().describe("Page path, e.g. \"guides/install\""),
592
+ locale: z.string().default("en").describe("Locale for path lookup"),
593
+ startLine: z.number().int().default(0).describe("0-based start line, inclusive. Negative counts from the end."),
594
+ endLine: z.number().int().default(-1).describe("0-based end line, inclusive. -1 (default) means the last line.")
595
+ },
596
+ outputSchema: {
597
+ page: z.object({
598
+ id: z.number(),
599
+ path: z.string(),
600
+ locale: z.string(),
601
+ title: z.string(),
602
+ description: z.string(),
603
+ content: z.string(),
604
+ contentType: z.string(),
605
+ editor: z.string(),
606
+ isPublished: z.boolean(),
607
+ isPrivate: z.boolean(),
608
+ tags: z.array(z.string()),
609
+ createdAt: z.string(),
610
+ updatedAt: z.string()
611
+ }),
612
+ totalLines: z.number(),
613
+ startLine: z.number(),
614
+ endLine: z.number()
615
+ }
616
+ }, instrument(logger, "get_page", async (args) => {
617
+ try {
618
+ if (args.id == null && !args.path) return fail(/* @__PURE__ */ new Error("Provide either \"id\" or \"path\""));
619
+ const page = args.id != null ? await wiki.getPageById(args.id) : await wiki.getPageByPath(args.path, args.locale);
620
+ if (!page) return fail(/* @__PURE__ */ new Error(`Page not found: ${args.id != null ? `id ${args.id}` : `${args.locale}/${args.path}`}`));
621
+ const sliced = sliceLines(page.content, args.startLine, args.endLine);
622
+ return ok({
623
+ page: {
624
+ ...page,
625
+ content: sliced.content
626
+ },
627
+ totalLines: sliced.totalLines,
628
+ startLine: sliced.startLine,
629
+ endLine: sliced.endLine
630
+ });
631
+ } catch (err) {
632
+ return fail(err);
633
+ }
634
+ }));
635
+ server.registerTool("get_page_tree", {
636
+ title: "Browse page tree",
637
+ description: "Browse the page/folder hierarchy one level at a time. Omit parent for the root level; pass a folder item id as parent to descend.",
638
+ inputSchema: {
639
+ parent: z.number().int().optional().describe("Tree item id to list children of (omit for root)"),
640
+ mode: z.enum([
641
+ "ALL",
642
+ "PAGES",
643
+ "FOLDERS"
644
+ ]).default("ALL"),
645
+ locale: z.string().default("en"),
646
+ includeAncestors: z.boolean().optional()
647
+ },
648
+ outputSchema: { items: z.array(z.object({
649
+ id: z.number(),
650
+ path: z.string(),
651
+ depth: z.number(),
652
+ title: z.string(),
653
+ isFolder: z.boolean(),
654
+ pageId: z.number().nullable(),
655
+ parent: z.number().nullable(),
656
+ locale: z.string()
657
+ })) }
658
+ }, instrument(logger, "get_page_tree", async (args) => {
659
+ try {
660
+ return ok({ items: (await wiki.getTree({
661
+ ...args,
662
+ parent: args.parent ?? 0
663
+ })).map((i) => ({
664
+ ...i,
665
+ pageId: i.pageId ?? null,
666
+ parent: i.parent ?? null
667
+ })) });
668
+ } catch (err) {
669
+ return fail(err);
670
+ }
671
+ }));
672
+ server.registerTool("list_tags", {
673
+ title: "List tags",
674
+ description: "List all page tags, or search tags matching a query string.",
675
+ inputSchema: { query: z.string().optional().describe("If set, search tags matching this text") },
676
+ outputSchema: { tags: z.array(z.object({
677
+ tag: z.string(),
678
+ title: z.string().nullable()
679
+ })) }
680
+ }, instrument(logger, "list_tags", async (args) => {
681
+ try {
682
+ if (args.query) return ok({ tags: (await wiki.searchTags(args.query)).map((t) => ({
683
+ tag: t,
684
+ title: null
685
+ })) });
686
+ return ok({ tags: (await wiki.listTags()).map((t) => ({
687
+ tag: t.tag,
688
+ title: t.title ?? null
689
+ })) });
690
+ } catch (err) {
691
+ return fail(err);
692
+ }
693
+ }));
694
+ }
695
+ //#endregion
696
+ //#region src/tools/pages.ts
697
+ const pageRefShape = {
698
+ id: z.number(),
699
+ path: z.string(),
700
+ locale: z.string(),
701
+ title: z.string()
702
+ };
703
+ function registerPageTools(server, wiki, logger) {
704
+ server.registerTool("create_page", {
705
+ title: "Create page",
706
+ description: "Create a new Wiki.js page. Path must not start with the locale (use \"guides/install\", not \"en/guides/install\").",
707
+ inputSchema: {
708
+ path: z.string().min(1).describe("Page path, e.g. \"guides/install\""),
709
+ title: z.string().min(1),
710
+ content: z.string().min(1).describe("Page content (markdown by default)"),
711
+ description: z.string().default("").describe("Short page description"),
712
+ editor: z.string().default("markdown").describe("Editor type: markdown, ckeditor, code"),
713
+ locale: z.string().default("en"),
714
+ isPublished: z.boolean().default(true),
715
+ isPrivate: z.boolean().default(false),
716
+ tags: z.array(z.string()).default([])
717
+ },
718
+ outputSchema: {
719
+ succeeded: z.literal(true),
720
+ page: z.object(pageRefShape)
721
+ }
722
+ }, instrument(logger, "create_page", async (args) => {
723
+ try {
724
+ const page = await wiki.createPage(args);
725
+ return ok({
726
+ succeeded: true,
727
+ page
728
+ }, `Created page ${page.id} at ${page.locale}/${page.path}`);
729
+ } catch (err) {
730
+ return fail(err);
731
+ }
732
+ }));
733
+ server.registerTool("update_page", {
734
+ title: "Update page",
735
+ description: "Update an existing page by id. Only the provided fields change. Get the id from get_page, list_pages or search_pages first.",
736
+ inputSchema: {
737
+ id: z.number().int().describe("Page id"),
738
+ content: z.string().optional(),
739
+ title: z.string().optional(),
740
+ description: z.string().optional(),
741
+ isPublished: z.boolean().optional(),
742
+ isPrivate: z.boolean().optional(),
743
+ tags: z.array(z.string()).optional().describe("Replaces the whole tag list")
744
+ },
745
+ outputSchema: {
746
+ succeeded: z.literal(true),
747
+ page: z.object(pageRefShape)
748
+ }
749
+ }, instrument(logger, "update_page", async (args) => {
750
+ try {
751
+ const page = await wiki.updatePage(args);
752
+ return ok({
753
+ succeeded: true,
754
+ page
755
+ }, `Updated page ${page.id} at ${page.locale}/${page.path}`);
756
+ } catch (err) {
757
+ return fail(err);
758
+ }
759
+ }));
760
+ server.registerTool("move_page", {
761
+ title: "Move / rename page",
762
+ description: "Move a page to a new path (and optionally another locale). Requires the manage:pages permission on the API key.",
763
+ inputSchema: {
764
+ id: z.number().int().describe("Page id"),
765
+ destinationPath: z.string().min(1),
766
+ destinationLocale: z.string().default("en")
767
+ },
768
+ outputSchema: {
769
+ succeeded: z.literal(true),
770
+ id: z.number(),
771
+ destinationPath: z.string(),
772
+ destinationLocale: z.string()
773
+ }
774
+ }, instrument(logger, "move_page", async (args) => {
775
+ try {
776
+ await wiki.movePage(args.id, args.destinationPath, args.destinationLocale);
777
+ return ok({
778
+ succeeded: true,
779
+ id: args.id,
780
+ destinationPath: args.destinationPath,
781
+ destinationLocale: args.destinationLocale
782
+ }, `Moved page ${args.id} to ${args.destinationLocale}/${args.destinationPath}`);
783
+ } catch (err) {
784
+ return fail(err);
785
+ }
786
+ }));
787
+ server.registerTool("delete_page", {
788
+ title: "Delete page",
789
+ description: "Permanently delete a page by id. DESTRUCTIVE and irreversible — confirm with the user before calling this.",
790
+ inputSchema: { id: z.number().int().describe("Page id to delete") },
791
+ outputSchema: {
792
+ succeeded: z.literal(true),
793
+ id: z.number()
794
+ }
795
+ }, instrument(logger, "delete_page", async (args) => {
796
+ try {
797
+ await wiki.deletePage(args.id);
798
+ return ok({
799
+ succeeded: true,
800
+ id: args.id
801
+ }, `Deleted page ${args.id}`);
802
+ } catch (err) {
803
+ return fail(err);
804
+ }
805
+ }));
806
+ }
807
+ //#endregion
808
+ //#region src/tools/history.ts
809
+ function registerHistoryTools(server, wiki, logger) {
810
+ server.registerTool("get_page_history", {
811
+ title: "Get page history",
812
+ description: "List the version history trail of a page (newest first), paginated.",
813
+ inputSchema: {
814
+ id: z.number().int().describe("Page id"),
815
+ offsetPage: z.number().int().min(0).default(0).describe("Zero-based page of the trail"),
816
+ offsetSize: z.number().int().min(1).max(100).default(25).describe("Entries per page")
817
+ },
818
+ outputSchema: {
819
+ trail: z.array(z.object({
820
+ versionId: z.number(),
821
+ versionDate: z.string(),
822
+ authorId: z.number(),
823
+ authorName: z.string(),
824
+ actionType: z.string(),
825
+ valueBefore: z.string().nullable(),
826
+ valueAfter: z.string().nullable()
827
+ })),
828
+ total: z.number()
829
+ }
830
+ }, instrument(logger, "get_page_history", async (args) => {
831
+ try {
832
+ const history = await wiki.getHistory(args.id, args.offsetPage, args.offsetSize);
833
+ return ok({
834
+ trail: history.trail.map((e) => ({
835
+ ...e,
836
+ valueBefore: e.valueBefore ?? null,
837
+ valueAfter: e.valueAfter ?? null
838
+ })),
839
+ total: history.total
840
+ });
841
+ } catch (err) {
842
+ return fail(err);
843
+ }
844
+ }));
845
+ server.registerTool("get_page_version", {
846
+ title: "Get page version",
847
+ description: "Fetch the content of a specific historical version of a page. Optionally slice its content by line range with startLine/endLine: 0-based, inclusive on both ends. Default (0..-1) returns the whole version content. Negative values count from the end, Python-style (-1 = last line, -2 = second-to-last).",
848
+ inputSchema: {
849
+ pageId: z.number().int(),
850
+ versionId: z.number().int().describe("From get_page_history trail"),
851
+ startLine: z.number().int().default(0).describe("0-based start line, inclusive. Negative counts from the end."),
852
+ endLine: z.number().int().default(-1).describe("0-based end line, inclusive. -1 (default) means the last line.")
853
+ },
854
+ outputSchema: {
855
+ version: z.object({
856
+ versionId: z.number(),
857
+ versionDate: z.string(),
858
+ action: z.string(),
859
+ authorName: z.string(),
860
+ pageId: z.number(),
861
+ path: z.string(),
862
+ locale: z.string(),
863
+ title: z.string(),
864
+ description: z.string(),
865
+ content: z.string(),
866
+ contentType: z.string(),
867
+ editor: z.string(),
868
+ isPublished: z.boolean(),
869
+ tags: z.array(z.string()),
870
+ createdAt: z.string()
871
+ }),
872
+ totalLines: z.number(),
873
+ startLine: z.number(),
874
+ endLine: z.number()
875
+ }
876
+ }, instrument(logger, "get_page_version", async (args) => {
877
+ try {
878
+ const version = await wiki.getVersion(args.pageId, args.versionId);
879
+ const sliced = sliceLines(version.content, args.startLine, args.endLine);
880
+ return ok({
881
+ version: {
882
+ ...version,
883
+ content: sliced.content
884
+ },
885
+ totalLines: sliced.totalLines,
886
+ startLine: sliced.startLine,
887
+ endLine: sliced.endLine
888
+ });
889
+ } catch (err) {
890
+ return fail(err);
891
+ }
892
+ }));
893
+ server.registerTool("restore_page_version", {
894
+ title: "Restore page version",
895
+ description: "Restore a page to a previous version from its history. The current state is preserved as a new history entry.",
896
+ inputSchema: {
897
+ pageId: z.number().int(),
898
+ versionId: z.number().int().describe("Version to restore, from get_page_history")
899
+ },
900
+ outputSchema: {
901
+ succeeded: z.literal(true),
902
+ pageId: z.number(),
903
+ versionId: z.number()
904
+ }
905
+ }, instrument(logger, "restore_page_version", async (args) => {
906
+ try {
907
+ await wiki.restoreVersion(args.pageId, args.versionId);
908
+ return ok({
909
+ succeeded: true,
910
+ pageId: args.pageId,
911
+ versionId: args.versionId
912
+ }, `Restored page ${args.pageId} to version ${args.versionId}`);
913
+ } catch (err) {
914
+ return fail(err);
915
+ }
916
+ }));
917
+ }
918
+ //#endregion
919
+ //#region src/tools/assets.ts
920
+ function registerAssetTools(server, wiki, logger) {
921
+ server.registerTool("list_assets", {
922
+ title: "List assets",
923
+ description: "List uploaded assets (images/files) and subfolders in an asset folder. folderId 0 is the root folder.",
924
+ inputSchema: {
925
+ folderId: z.number().int().min(0).default(0),
926
+ kind: z.enum([
927
+ "IMAGE",
928
+ "BINARY",
929
+ "ALL"
930
+ ]).default("ALL")
931
+ },
932
+ outputSchema: {
933
+ folders: z.array(z.object({
934
+ id: z.number(),
935
+ slug: z.string(),
936
+ name: z.string().nullable()
937
+ })),
938
+ assets: z.array(z.object({
939
+ id: z.number(),
940
+ filename: z.string(),
941
+ ext: z.string(),
942
+ kind: z.string(),
943
+ mime: z.string(),
944
+ fileSize: z.number(),
945
+ createdAt: z.string(),
946
+ updatedAt: z.string()
947
+ }))
948
+ }
949
+ }, instrument(logger, "list_assets", async (args) => {
950
+ try {
951
+ const [folders, assets] = await Promise.all([wiki.listAssetFolders(args.folderId), wiki.listAssets(args.folderId, args.kind)]);
952
+ return ok({
953
+ folders: folders.map((f) => ({
954
+ ...f,
955
+ name: f.name ?? null
956
+ })),
957
+ assets
958
+ });
959
+ } catch (err) {
960
+ return fail(err);
961
+ }
962
+ }));
963
+ }
964
+ //#endregion
965
+ //#region src/server.ts
966
+ const VERSION = version;
967
+ function buildServer(config, logger) {
968
+ const wiki = new WikiJsClient(new GraphQLClient({
969
+ baseUrl: config.wikijsUrl,
970
+ token: config.wikijsToken
971
+ }));
972
+ const server = new McpServer({
973
+ name: "wikijs-mcp",
974
+ version: VERSION
975
+ });
976
+ registerDiscoveryTools(server, wiki, logger);
977
+ registerPageTools(server, wiki, logger);
978
+ registerHistoryTools(server, wiki, logger);
979
+ registerAssetTools(server, wiki, logger);
980
+ logger.debug("MCP server built");
981
+ return server;
982
+ }
983
+ async function probeConnection(config, logger) {
984
+ const wiki = new WikiJsClient(new GraphQLClient({
985
+ baseUrl: config.wikijsUrl,
986
+ token: config.wikijsToken,
987
+ timeoutMs: 1e4
988
+ }));
989
+ try {
990
+ await wiki.listPages({ limit: 1 });
991
+ logger.info({ url: config.wikijsUrl }, "Connected to Wiki.js");
992
+ } catch (err) {
993
+ logger.warn({
994
+ url: config.wikijsUrl,
995
+ err: err.message
996
+ }, "Wiki.js connectivity check failed — starting anyway");
997
+ }
998
+ }
999
+ //#endregion
1000
+ //#region src/index.ts
1001
+ async function main() {
1002
+ const config = loadConfig();
1003
+ const logger = createLogger(config.logLevel);
1004
+ await probeConnection(config, logger);
1005
+ if (config.transport === "http") {
1006
+ const { startHttpServer } = await import("./http-D7MoxUTD.js");
1007
+ startHttpServer(config, logger);
1008
+ return;
1009
+ }
1010
+ await buildServer(config, logger).connect(new StdioServerTransport());
1011
+ logger.info("wikijs-mcp listening on stdio");
1012
+ }
1013
+ main().catch((err) => {
1014
+ console.error(err instanceof Error ? err.message : String(err));
1015
+ process.exit(1);
1016
+ });
1017
+ //#endregion
1018
+ export { buildServer as t };
1019
+
1020
+ //# sourceMappingURL=index.js.map