bookstack-mcp 2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 BookStack MCP Server
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # BookStack MCP Server
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that gives AI assistants full access to your [BookStack](https://www.bookstackapp.com) documentation — search, read, create, and manage content.
4
+
5
+ ## Features
6
+
7
+ - 17 read-only tools + 8 write tools for complete BookStack API coverage
8
+ - Type-safe input validation with Zod (auto-coerces string/number params for broad client compatibility)
9
+ - Embedded URLs and content previews in all responses
10
+ - Write operations disabled by default for safety
11
+ - Works with Claude Desktop, LibreChat, and any MCP-compatible client
12
+
13
+ ## Quick Start
14
+
15
+ ### Install from npm
16
+
17
+ ```bash
18
+ npx bookstack-mcp
19
+ ```
20
+
21
+ ### Or clone and build
22
+
23
+ ```bash
24
+ git clone https://github.com/ttpears/bookstack-mcp.git
25
+ cd bookstack-mcp
26
+ npm install && npm run build
27
+ npm start
28
+ ```
29
+
30
+ ### Environment Variables
31
+
32
+ ```env
33
+ BOOKSTACK_BASE_URL=https://your-bookstack.com # Required
34
+ BOOKSTACK_TOKEN_ID=your-token-id # Required
35
+ BOOKSTACK_TOKEN_SECRET=your-token-secret # Required
36
+ BOOKSTACK_ENABLE_WRITE=false # Optional, default false
37
+ ```
38
+
39
+ ## Client Configuration
40
+
41
+ ### Claude Desktop
42
+
43
+ Add to your Claude Desktop config:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "bookstack": {
49
+ "command": "npx",
50
+ "args": ["-y", "bookstack-mcp"],
51
+ "env": {
52
+ "BOOKSTACK_BASE_URL": "https://your-bookstack.com",
53
+ "BOOKSTACK_TOKEN_ID": "your-token-id",
54
+ "BOOKSTACK_TOKEN_SECRET": "your-token-secret"
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ### LibreChat
62
+
63
+ Add to your `librechat.yaml`:
64
+
65
+ ```yaml
66
+ mcpServers:
67
+ bookstack:
68
+ command: npx
69
+ args:
70
+ - -y
71
+ - bookstack-mcp
72
+ env:
73
+ BOOKSTACK_BASE_URL: "https://your-bookstack.com"
74
+ BOOKSTACK_TOKEN_ID: "your-token-id"
75
+ BOOKSTACK_TOKEN_SECRET: "your-token-secret"
76
+ ```
77
+
78
+ Restart LibreChat after config changes.
79
+
80
+ ## Available Tools
81
+
82
+ ### Read Operations (always available)
83
+
84
+ | Tool | Description |
85
+ |------|-------------|
86
+ | `get_capabilities` | Server capabilities and configuration |
87
+ | `search_content` | Search across all content with filtering |
88
+ | `search_pages` | Search pages with optional book filtering |
89
+ | `get_books` / `get_book` | List or get details of books |
90
+ | `get_pages` / `get_page` | List or get full page content |
91
+ | `get_chapters` / `get_chapter` | List or get chapter details |
92
+ | `get_shelves` / `get_shelf` | List or get shelf details |
93
+ | `get_attachments` / `get_attachment` | List or get attachment details |
94
+ | `export_page` | Export page as HTML, PDF, Markdown, plaintext, or ZIP |
95
+ | `export_book` | Export entire book |
96
+ | `export_chapter` | Export chapter |
97
+ | `get_recent_changes` | Recently updated content |
98
+
99
+ ### Write Operations (requires `BOOKSTACK_ENABLE_WRITE=true`)
100
+
101
+ | Tool | Description |
102
+ |------|-------------|
103
+ | `create_page` / `update_page` | Create or update pages |
104
+ | `create_shelf` / `update_shelf` / `delete_shelf` | Manage shelves |
105
+ | `create_attachment` / `update_attachment` / `delete_attachment` | Manage attachments |
106
+
107
+ ## BookStack API Setup
108
+
109
+ 1. Log into BookStack as an admin
110
+ 2. Go to **Settings > Users > Edit your user**
111
+ 3. Ensure the user has **Access System API** permission
112
+ 4. In the **API Tokens** section, create a new token
113
+ 5. Copy the Token ID and Token Secret
114
+
115
+ ## Security
116
+
117
+ - Write operations are **disabled by default**
118
+ - Use HTTPS for production instances
119
+ - Store API tokens securely (never commit to git)
120
+ - Consider a dedicated BookStack user with limited permissions
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ npm run dev # Hot reload with tsx
126
+ npm run type-check # Type checking only
127
+ npm run build # Production build
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,552 @@
1
+ import axios from 'axios';
2
+ export class BookStackClient {
3
+ client;
4
+ enableWrite;
5
+ baseUrl;
6
+ bookSlugCache = new Map();
7
+ constructor(config) {
8
+ this.enableWrite = config.enableWrite || false;
9
+ this.baseUrl = config.baseUrl;
10
+ this.client = axios.create({
11
+ baseURL: `${config.baseUrl}/api`,
12
+ headers: {
13
+ 'Authorization': `Token ${config.tokenId}:${config.tokenSecret}`,
14
+ 'Content-Type': 'application/json'
15
+ }
16
+ });
17
+ }
18
+ async getBookSlug(bookId) {
19
+ // Check cache first
20
+ if (this.bookSlugCache.has(bookId)) {
21
+ return this.bookSlugCache.get(bookId);
22
+ }
23
+ try {
24
+ const response = await this.client.get(`/books/${bookId}`);
25
+ const slug = response.data.slug || String(bookId);
26
+ this.bookSlugCache.set(bookId, slug);
27
+ return slug;
28
+ }
29
+ catch (error) {
30
+ // Fallback to ID if book fetch fails
31
+ return String(bookId);
32
+ }
33
+ }
34
+ // URL generation utilities
35
+ generateBookUrl(book) {
36
+ return `${this.baseUrl}/books/${book.slug || book.id}`;
37
+ }
38
+ async generatePageUrl(page) {
39
+ const bookSlug = await this.getBookSlug(page.book_id);
40
+ return `${this.baseUrl}/books/${bookSlug}/page/${page.slug || page.id}`;
41
+ }
42
+ async generateChapterUrl(chapter) {
43
+ const bookSlug = await this.getBookSlug(chapter.book_id);
44
+ return `${this.baseUrl}/books/${bookSlug}/chapter/${chapter.slug || chapter.id}`;
45
+ }
46
+ generateShelfUrl(shelf) {
47
+ return `${this.baseUrl}/shelves/${shelf.slug || shelf.id}`;
48
+ }
49
+ generateSearchUrl(query) {
50
+ const encodedQuery = encodeURIComponent(query);
51
+ return `${this.baseUrl}/search?term=${encodedQuery}`;
52
+ }
53
+ // Enhanced response helpers
54
+ enhanceBookResponse(book) {
55
+ const lastUpdated = this.formatDate(book.updated_at);
56
+ const created = this.formatDate(book.created_at);
57
+ return {
58
+ ...book,
59
+ url: this.generateBookUrl(book),
60
+ direct_link: `[${book.name}](${this.generateBookUrl(book)})`,
61
+ last_updated_friendly: lastUpdated,
62
+ created_friendly: created,
63
+ summary: book.description ? `${book.description.substring(0, 100)}${book.description.length > 100 ? '...' : ''}` : 'No description available',
64
+ content_info: `Book created ${created}, last updated ${lastUpdated}`
65
+ };
66
+ }
67
+ async enhancePageResponse(page) {
68
+ const lastUpdated = this.formatDate(page.updated_at);
69
+ const created = this.formatDate(page.created_at);
70
+ const contentPreview = page.text ? `${page.text.substring(0, 200)}${page.text.length > 200 ? '...' : ''}` : 'No content preview available';
71
+ const url = await this.generatePageUrl(page);
72
+ return {
73
+ ...page,
74
+ url,
75
+ direct_link: `[${page.name}](${url})`,
76
+ last_updated_friendly: lastUpdated,
77
+ created_friendly: created,
78
+ content_preview: contentPreview,
79
+ content_info: `Page created ${created}, last updated ${lastUpdated}`,
80
+ word_count: page.text ? page.text.split(' ').length : 0,
81
+ location: `Book ID ${page.book_id}${page.chapter_id ? `, Chapter ID ${page.chapter_id}` : ''}`
82
+ };
83
+ }
84
+ async enhanceChapterResponse(chapter) {
85
+ const lastUpdated = this.formatDate(chapter.updated_at);
86
+ const created = this.formatDate(chapter.created_at);
87
+ const url = await this.generateChapterUrl(chapter);
88
+ return {
89
+ ...chapter,
90
+ url,
91
+ direct_link: `[${chapter.name}](${url})`,
92
+ last_updated_friendly: lastUpdated,
93
+ created_friendly: created,
94
+ summary: chapter.description ? `${chapter.description.substring(0, 100)}${chapter.description.length > 100 ? '...' : ''}` : 'No description available',
95
+ content_info: `Chapter created ${created}, last updated ${lastUpdated}`,
96
+ location: `In Book ID ${chapter.book_id}`
97
+ };
98
+ }
99
+ enhanceShelfResponse(shelf) {
100
+ const lastUpdated = this.formatDate(shelf.updated_at);
101
+ const created = this.formatDate(shelf.created_at);
102
+ const bookCount = shelf.books?.length || 0;
103
+ return {
104
+ ...shelf,
105
+ url: this.generateShelfUrl(shelf),
106
+ direct_link: `[${shelf.name}](${this.generateShelfUrl(shelf)})`,
107
+ last_updated_friendly: lastUpdated,
108
+ created_friendly: created,
109
+ summary: shelf.description ? `${shelf.description.substring(0, 100)}${shelf.description.length > 100 ? '...' : ''}` : 'No description available',
110
+ content_info: `Shelf with ${bookCount} book${bookCount !== 1 ? 's' : ''}, created ${created}, last updated ${lastUpdated}`,
111
+ book_count: bookCount,
112
+ books: shelf.books?.map(book => this.enhanceBookResponse(book)),
113
+ tags_summary: shelf.tags?.length ? `Tagged with: ${shelf.tags.map(t => `${t.name}${t.value ? `=${t.value}` : ''}`).join(', ')}` : 'No tags'
114
+ };
115
+ }
116
+ async enhanceSearchResults(results, originalQuery) {
117
+ const enhancedResults = await Promise.all(results.map(async (result) => {
118
+ const url = await this.generateContentUrl(result);
119
+ return {
120
+ ...result,
121
+ url,
122
+ direct_link: `[${result.name}](${url})`,
123
+ content_preview: result.preview_content?.content ? `${result.preview_content.content.substring(0, 150)}${result.preview_content.content.length > 150 ? '...' : ''}` : 'No preview available',
124
+ content_type: result.type.charAt(0).toUpperCase() + result.type.slice(1),
125
+ location_info: result.book_id ? `In book ID ${result.book_id}${result.chapter_id ? `, chapter ID ${result.chapter_id}` : ''}` : 'Location unknown'
126
+ };
127
+ }));
128
+ return {
129
+ search_query: originalQuery,
130
+ search_url: this.generateSearchUrl(originalQuery),
131
+ summary: `Found ${results.length} results for "${originalQuery}"`,
132
+ results: enhancedResults
133
+ };
134
+ }
135
+ async generateContentUrl(result) {
136
+ switch (result.type) {
137
+ case 'page':
138
+ if (result.book_id) {
139
+ const bookSlug = await this.getBookSlug(result.book_id);
140
+ return `${this.baseUrl}/books/${bookSlug}/page/${result.slug || result.id}`;
141
+ }
142
+ return `${this.baseUrl}/link/${result.id}`;
143
+ case 'chapter':
144
+ if (result.book_id) {
145
+ const bookSlug = await this.getBookSlug(result.book_id);
146
+ return `${this.baseUrl}/books/${bookSlug}/chapter/${result.slug || result.id}`;
147
+ }
148
+ return `${this.baseUrl}/link/${result.id}`;
149
+ case 'book':
150
+ return `${this.baseUrl}/books/${result.slug || result.id}`;
151
+ case 'bookshelf':
152
+ case 'shelf':
153
+ return `${this.baseUrl}/shelves/${result.slug || result.id}`;
154
+ default:
155
+ return `${this.baseUrl}/link/${result.id}`;
156
+ }
157
+ }
158
+ async searchContent(query, options) {
159
+ let searchQuery = query;
160
+ // Use advanced search syntax for type filtering
161
+ if (options?.type) {
162
+ searchQuery = `{type:${options.type}} ${query}`.trim();
163
+ }
164
+ const params = { query: searchQuery };
165
+ if (options?.count)
166
+ params.count = Math.min(options.count, 500); // BookStack max
167
+ if (options?.offset)
168
+ params.offset = options.offset;
169
+ const response = await this.client.get('/search', { params });
170
+ const results = response.data.data || response.data;
171
+ return await this.enhanceSearchResults(results, query);
172
+ }
173
+ async searchPages(query, options) {
174
+ let searchQuery = `{type:page} ${query}`.trim();
175
+ // Add book filtering if specified
176
+ if (options?.bookId) {
177
+ searchQuery = `{book_id:${options.bookId}} ${searchQuery}`;
178
+ }
179
+ const params = { query: searchQuery };
180
+ if (options?.count)
181
+ params.count = Math.min(options.count, 500);
182
+ if (options?.offset)
183
+ params.offset = options.offset;
184
+ const response = await this.client.get('/search', { params });
185
+ const results = response.data.data || response.data;
186
+ return await this.enhanceSearchResults(results, query);
187
+ }
188
+ async getBooks(options) {
189
+ const params = {
190
+ offset: options?.offset || 0,
191
+ count: Math.min(options?.count || 50, 500)
192
+ };
193
+ if (options?.sort)
194
+ params.sort = options.sort;
195
+ if (options?.filter)
196
+ params.filter = JSON.stringify(options.filter);
197
+ const response = await this.client.get('/books', { params });
198
+ const data = response.data;
199
+ return {
200
+ ...data,
201
+ data: data.data.map((book) => this.enhanceBookResponse(book))
202
+ };
203
+ }
204
+ async getBook(id) {
205
+ const response = await this.client.get(`/books/${id}`);
206
+ return this.enhanceBookResponse(response.data);
207
+ }
208
+ async getPages(options) {
209
+ const params = {
210
+ offset: options?.offset || 0,
211
+ count: Math.min(options?.count || 50, 500)
212
+ };
213
+ // Build filter object
214
+ const filter = { ...options?.filter };
215
+ if (options?.bookId)
216
+ filter.book_id = options.bookId;
217
+ if (options?.chapterId)
218
+ filter.chapter_id = options.chapterId;
219
+ if (Object.keys(filter).length > 0) {
220
+ params.filter = JSON.stringify(filter);
221
+ }
222
+ if (options?.sort)
223
+ params.sort = options.sort;
224
+ const response = await this.client.get('/pages', { params });
225
+ const data = response.data;
226
+ return {
227
+ ...data,
228
+ data: await Promise.all(data.data.map((page) => this.enhancePageResponse(page)))
229
+ };
230
+ }
231
+ async getPage(id) {
232
+ const response = await this.client.get(`/pages/${id}`);
233
+ return await this.enhancePageResponse(response.data);
234
+ }
235
+ async getChapters(bookId, offset = 0, count = 50) {
236
+ const params = { offset, count };
237
+ if (bookId)
238
+ params.filter = JSON.stringify({ book_id: bookId });
239
+ const response = await this.client.get('/chapters', { params });
240
+ const data = response.data;
241
+ return {
242
+ ...data,
243
+ data: await Promise.all(data.data.map((chapter) => this.enhanceChapterResponse(chapter)))
244
+ };
245
+ }
246
+ async getChapter(id) {
247
+ const response = await this.client.get(`/chapters/${id}`);
248
+ return await this.enhanceChapterResponse(response.data);
249
+ }
250
+ async createPage(data) {
251
+ if (!this.enableWrite) {
252
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
253
+ }
254
+ const response = await this.client.post('/pages', data);
255
+ return await this.enhancePageResponse(response.data);
256
+ }
257
+ async updatePage(id, data) {
258
+ if (!this.enableWrite) {
259
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
260
+ }
261
+ const response = await this.client.put(`/pages/${id}`, data);
262
+ return await this.enhancePageResponse(response.data);
263
+ }
264
+ async exportPage(id, format) {
265
+ try {
266
+ // For binary formats (PDF, ZIP), return BookStack web URL using slugs
267
+ if (format === 'pdf' || format === 'zip') {
268
+ // First fetch the page data to get slugs
269
+ const page = await this.getPage(id);
270
+ const book = await this.getBook(page.book_id);
271
+ // Construct the correct web URL with slugs
272
+ const directUrl = `${this.baseUrl}/books/${book.slug}/page/${page.slug}/export/${format}`;
273
+ const filename = `${page.slug}.${format}`;
274
+ const contentType = format === 'pdf' ? 'application/pdf' : 'application/zip';
275
+ return {
276
+ format: format,
277
+ filename: filename,
278
+ download_url: directUrl,
279
+ content_type: contentType,
280
+ export_success: true,
281
+ page_id: id,
282
+ page_name: page.name,
283
+ book_name: book.name,
284
+ direct_download: true,
285
+ note: "This is a direct link to BookStack's web export. You may need to be logged in to BookStack to access it."
286
+ };
287
+ }
288
+ else {
289
+ // For text formats, fetch the content via API
290
+ console.error(`Exporting page ${id} as ${format}...`);
291
+ const response = await this.client.get(`/pages/${id}/export/${format}`);
292
+ console.error(`Export response status: ${response.status}`);
293
+ // For text formats, validate and return as string
294
+ if (!response.data) {
295
+ throw new Error(`Empty ${format} content returned from BookStack API`);
296
+ }
297
+ console.error(`Text export length: ${response.data.length} characters`);
298
+ return response.data;
299
+ }
300
+ }
301
+ catch (error) {
302
+ console.error(`Export error for page ${id}:`, error);
303
+ throw new Error(`Failed to export page ${id} as ${format}: ${error instanceof Error ? error.message : String(error)}`);
304
+ }
305
+ }
306
+ async exportBook(id, format) {
307
+ // For binary formats (PDF, ZIP), return BookStack web URL using slug
308
+ if (format === 'pdf' || format === 'zip') {
309
+ // First fetch the book data to get slug
310
+ const book = await this.getBook(id);
311
+ // Construct the correct web URL with slug
312
+ const directUrl = `${this.baseUrl}/books/${book.slug}/export/${format}`;
313
+ const filename = `${book.slug}.${format}`;
314
+ const contentType = format === 'pdf' ? 'application/pdf' : 'application/zip';
315
+ return {
316
+ format: format,
317
+ filename: filename,
318
+ download_url: directUrl,
319
+ content_type: contentType,
320
+ export_success: true,
321
+ book_id: id,
322
+ book_name: book.name,
323
+ direct_download: true,
324
+ note: "This is a direct link to BookStack's web export. You may need to be logged in to BookStack to access it."
325
+ };
326
+ }
327
+ // For text formats, fetch the content via API
328
+ const response = await this.client.get(`/books/${id}/export/${format}`);
329
+ return response.data;
330
+ }
331
+ async exportChapter(id, format) {
332
+ // For binary formats (PDF, ZIP), return BookStack web URL using slugs
333
+ if (format === 'pdf' || format === 'zip') {
334
+ // First fetch the chapter data to get slugs
335
+ const chapter = await this.getChapter(id);
336
+ const book = await this.getBook(chapter.book_id);
337
+ // Construct the correct web URL with both book and chapter slugs
338
+ const directUrl = `${this.baseUrl}/books/${book.slug}/chapter/${chapter.slug}/export/${format}`;
339
+ const filename = `${chapter.slug}.${format}`;
340
+ const contentType = format === 'pdf' ? 'application/pdf' : 'application/zip';
341
+ return {
342
+ format: format,
343
+ filename: filename,
344
+ download_url: directUrl,
345
+ content_type: contentType,
346
+ export_success: true,
347
+ chapter_id: id,
348
+ chapter_name: chapter.name,
349
+ book_name: book.name,
350
+ direct_download: true,
351
+ note: "This is a direct link to BookStack's web export. You may need to be logged in to BookStack to access it."
352
+ };
353
+ }
354
+ // For text formats, fetch the content via API
355
+ const response = await this.client.get(`/chapters/${id}/export/${format}`);
356
+ return response.data;
357
+ }
358
+ async getRecentChanges(options) {
359
+ const limit = Math.min(options?.limit || 20, 100);
360
+ const days = options?.days || 30;
361
+ const type = options?.type || 'all';
362
+ // Calculate date threshold
363
+ const dateThreshold = new Date();
364
+ dateThreshold.setDate(dateThreshold.getDate() - days);
365
+ const dateFilter = dateThreshold.toISOString().split('T')[0]; // YYYY-MM-DD format
366
+ // Build search query for recent changes
367
+ let searchQuery = `{updated_at:>=${dateFilter}}`;
368
+ if (type !== 'all') {
369
+ searchQuery = `{type:${type}} ${searchQuery}`;
370
+ }
371
+ const params = {
372
+ query: searchQuery,
373
+ count: limit,
374
+ sort: 'updated_at' // Sort by most recently updated
375
+ };
376
+ const response = await this.client.get('/search', { params });
377
+ const results = response.data.data || response.data;
378
+ // Enhance results with additional context
379
+ const enhancedResults = await Promise.all(results.map(async (result) => {
380
+ let contextualInfo = '';
381
+ let contentPreview = result.preview_content?.content || '';
382
+ try {
383
+ // Get additional context based on content type
384
+ if (result.type === 'page' && result.id) {
385
+ const fullPage = await this.client.get(`/pages/${result.id}`);
386
+ const pageData = fullPage.data;
387
+ contentPreview = pageData.text?.substring(0, 200) || contentPreview;
388
+ contextualInfo = `Updated in book: ${pageData.book?.name || 'Unknown Book'}`;
389
+ if (pageData.chapter) {
390
+ contextualInfo += `, chapter: ${pageData.chapter.name}`;
391
+ }
392
+ }
393
+ else if (result.type === 'book' && result.id) {
394
+ const fullBook = await this.client.get(`/books/${result.id}`);
395
+ const bookData = fullBook.data;
396
+ contentPreview = bookData.description?.substring(0, 200) || 'No description available';
397
+ contextualInfo = `Book with ${bookData.page_count || 0} pages`;
398
+ }
399
+ else if (result.type === 'chapter' && result.id) {
400
+ const fullChapter = await this.client.get(`/chapters/${result.id}`);
401
+ const chapterData = fullChapter.data;
402
+ contentPreview = chapterData.description?.substring(0, 200) || 'No description available';
403
+ contextualInfo = `Chapter in book: ${chapterData.book?.name || 'Unknown Book'}`;
404
+ }
405
+ }
406
+ catch (error) {
407
+ // If we can't get additional context, use what we have
408
+ contextualInfo = `${result.type.charAt(0).toUpperCase() + result.type.slice(1)} content`;
409
+ }
410
+ const url = await this.generateContentUrl(result);
411
+ return {
412
+ ...result,
413
+ url,
414
+ direct_link: `[${result.name}](${url})`,
415
+ content_preview: contentPreview ? `${contentPreview}${contentPreview.length >= 200 ? '...' : ''}` : 'No preview available',
416
+ contextual_info: contextualInfo,
417
+ last_updated: this.formatDate(result.updated_at || result.created_at || ''),
418
+ change_summary: `${result.type === 'page' ? 'Page' : result.type === 'book' ? 'Book' : 'Chapter'} "${result.name}" was updated`
419
+ };
420
+ }));
421
+ return {
422
+ search_query: `Recent changes in the last ${days} days (${type})`,
423
+ date_threshold: dateFilter,
424
+ search_url: this.generateSearchUrl(searchQuery),
425
+ total_found: results.length,
426
+ summary: `Found ${results.length} items updated in the last ${days} days${type !== 'all' ? ` (${type}s only)` : ''}`,
427
+ results: enhancedResults
428
+ };
429
+ }
430
+ formatDate(dateString) {
431
+ if (!dateString)
432
+ return 'Unknown date';
433
+ const date = new Date(dateString);
434
+ const now = new Date();
435
+ const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
436
+ if (diffInHours < 1)
437
+ return 'Less than an hour ago';
438
+ if (diffInHours < 24)
439
+ return `${diffInHours} hours ago`;
440
+ const diffInDays = Math.floor(diffInHours / 24);
441
+ if (diffInDays < 7)
442
+ return `${diffInDays} days ago`;
443
+ const diffInWeeks = Math.floor(diffInDays / 7);
444
+ if (diffInWeeks < 4)
445
+ return `${diffInWeeks} weeks ago`;
446
+ return date.toLocaleDateString();
447
+ }
448
+ // Shelves (Book Collections) Management
449
+ async getShelves(options) {
450
+ const params = {
451
+ offset: options?.offset || 0,
452
+ count: Math.min(options?.count || 50, 500)
453
+ };
454
+ if (options?.sort)
455
+ params.sort = options.sort;
456
+ if (options?.filter)
457
+ params.filter = JSON.stringify(options.filter);
458
+ const response = await this.client.get('/shelves', { params });
459
+ const data = response.data;
460
+ return {
461
+ ...data,
462
+ data: data.data.map((shelf) => this.enhanceShelfResponse(shelf))
463
+ };
464
+ }
465
+ async getShelf(id) {
466
+ const response = await this.client.get(`/shelves/${id}`);
467
+ return this.enhanceShelfResponse(response.data);
468
+ }
469
+ async createShelf(data) {
470
+ if (!this.enableWrite) {
471
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
472
+ }
473
+ const response = await this.client.post('/shelves', data);
474
+ return this.enhanceShelfResponse(response.data);
475
+ }
476
+ async updateShelf(id, data) {
477
+ if (!this.enableWrite) {
478
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
479
+ }
480
+ const response = await this.client.put(`/shelves/${id}`, data);
481
+ return this.enhanceShelfResponse(response.data);
482
+ }
483
+ async deleteShelf(id) {
484
+ if (!this.enableWrite) {
485
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
486
+ }
487
+ const response = await this.client.delete(`/shelves/${id}`);
488
+ return response.data;
489
+ }
490
+ // Attachments Management
491
+ async getAttachments(options) {
492
+ const params = {
493
+ offset: options?.offset || 0,
494
+ count: Math.min(options?.count || 50, 500)
495
+ };
496
+ if (options?.sort)
497
+ params.sort = options.sort;
498
+ if (options?.filter)
499
+ params.filter = JSON.stringify(options.filter);
500
+ const response = await this.client.get('/attachments', { params });
501
+ const data = response.data;
502
+ return {
503
+ ...data,
504
+ data: data.data.map((attachment) => ({
505
+ ...attachment,
506
+ page_url: `${this.baseUrl}/books/${Math.floor(attachment.uploaded_to / 1000)}/page/${attachment.uploaded_to}`,
507
+ direct_link: `[${attachment.name}](${this.baseUrl}/attachments/${attachment.id})`
508
+ }))
509
+ };
510
+ }
511
+ async getAttachment(id) {
512
+ const response = await this.client.get(`/attachments/${id}`);
513
+ const attachment = response.data;
514
+ return {
515
+ ...attachment,
516
+ page_url: `${this.baseUrl}/books/${Math.floor(attachment.uploaded_to / 1000)}/page/${attachment.uploaded_to}`,
517
+ direct_link: `[${attachment.name}](${this.baseUrl}/attachments/${attachment.id})`,
518
+ download_url: `${this.baseUrl}/attachments/${attachment.id}`
519
+ };
520
+ }
521
+ async createAttachment(data) {
522
+ if (!this.enableWrite) {
523
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
524
+ }
525
+ const response = await this.client.post('/attachments', data);
526
+ const attachment = response.data;
527
+ return {
528
+ ...attachment,
529
+ page_url: `${this.baseUrl}/books/${Math.floor(attachment.uploaded_to / 1000)}/page/${attachment.uploaded_to}`,
530
+ direct_link: `[${attachment.name}](${this.baseUrl}/attachments/${attachment.id})`
531
+ };
532
+ }
533
+ async updateAttachment(id, data) {
534
+ if (!this.enableWrite) {
535
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
536
+ }
537
+ const response = await this.client.put(`/attachments/${id}`, data);
538
+ const attachment = response.data;
539
+ return {
540
+ ...attachment,
541
+ page_url: `${this.baseUrl}/books/${Math.floor(attachment.uploaded_to / 1000)}/page/${attachment.uploaded_to}`,
542
+ direct_link: `[${attachment.name}](${this.baseUrl}/attachments/${attachment.id})`
543
+ };
544
+ }
545
+ async deleteAttachment(id) {
546
+ if (!this.enableWrite) {
547
+ throw new Error('Write operations are disabled. Set BOOKSTACK_ENABLE_WRITE=true to enable.');
548
+ }
549
+ const response = await this.client.delete(`/attachments/${id}`);
550
+ return response.data;
551
+ }
552
+ }
package/dist/index.js ADDED
@@ -0,0 +1,507 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { BookStackClient } from "./bookstack-client.js";
6
+ function getRequiredEnvVar(name) {
7
+ const value = process.env[name];
8
+ if (!value) {
9
+ console.error(`Error: ${name} environment variable is required`);
10
+ process.exit(1);
11
+ }
12
+ return value;
13
+ }
14
+ async function main() {
15
+ // Load configuration from environment
16
+ const config = {
17
+ baseUrl: getRequiredEnvVar('BOOKSTACK_BASE_URL'),
18
+ tokenId: getRequiredEnvVar('BOOKSTACK_TOKEN_ID'),
19
+ tokenSecret: getRequiredEnvVar('BOOKSTACK_TOKEN_SECRET'),
20
+ enableWrite: process.env.BOOKSTACK_ENABLE_WRITE?.toLowerCase() === 'true'
21
+ };
22
+ console.error('Initializing BookStack MCP Server...');
23
+ console.error(`BookStack URL: ${config.baseUrl}`);
24
+ console.error(`Write operations: ${config.enableWrite ? 'ENABLED' : 'DISABLED'}`);
25
+ const client = new BookStackClient(config);
26
+ const server = new McpServer({
27
+ name: "bookstack-mcp",
28
+ version: "2.1.0"
29
+ });
30
+ // Register read-only tools
31
+ server.registerTool("get_capabilities", {
32
+ title: "Get BookStack Capabilities",
33
+ description: "Get information about available BookStack MCP capabilities and current configuration",
34
+ inputSchema: {}
35
+ }, async () => {
36
+ const capabilities = {
37
+ server_name: "BookStack MCP Server",
38
+ version: "2.1.0",
39
+ write_operations_enabled: config.enableWrite,
40
+ available_tools: config.enableWrite ? "All tools enabled" : "Read-only tools only",
41
+ security_note: config.enableWrite
42
+ ? "⚠️ Write operations are ENABLED - AI can create and modify BookStack content"
43
+ : "🛡️ Read-only mode - Safe for production use"
44
+ };
45
+ return {
46
+ content: [{ type: "text", text: JSON.stringify(capabilities, null, 2) }]
47
+ };
48
+ });
49
+ server.registerTool("search_content", {
50
+ title: "Search BookStack Content",
51
+ description: "Search across BookStack content with contextual previews and location info",
52
+ inputSchema: {
53
+ query: z.string().describe("Search query. Use BookStack advanced search syntax like {type:page} or {book_id:5}"),
54
+ type: z.enum(["book", "page", "chapter", "bookshelf"]).optional().describe("Filter by content type"),
55
+ count: z.coerce.number().max(500).optional().describe("Number of results to return (max 500)"),
56
+ offset: z.coerce.number().optional().describe("Number of results to skip for pagination")
57
+ }
58
+ }, async (args) => {
59
+ const results = await client.searchContent(args.query, {
60
+ type: args.type,
61
+ count: args.count,
62
+ offset: args.offset
63
+ });
64
+ return {
65
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
66
+ };
67
+ });
68
+ server.registerTool("search_pages", {
69
+ title: "Search Pages",
70
+ description: "Search specifically for pages with optional book filtering",
71
+ inputSchema: {
72
+ query: z.string().describe("Search query for pages"),
73
+ book_id: z.coerce.number().optional().describe("Filter results to pages within a specific book"),
74
+ count: z.coerce.number().max(500).optional().describe("Number of results to return"),
75
+ offset: z.coerce.number().optional().describe("Pagination offset")
76
+ }
77
+ }, async (args) => {
78
+ const results = await client.searchPages(args.query, {
79
+ bookId: args.book_id,
80
+ count: args.count,
81
+ offset: args.offset
82
+ });
83
+ return {
84
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
85
+ };
86
+ });
87
+ server.registerTool("get_books", {
88
+ title: "List Books",
89
+ description: "List available books with advanced filtering and sorting",
90
+ inputSchema: {
91
+ offset: z.coerce.number().default(0).describe("Pagination offset"),
92
+ count: z.coerce.number().max(500).default(50).describe("Number of results to return"),
93
+ sort: z.string().optional().describe("Sort field (e.g., 'name', '-created_at', 'updated_at')"),
94
+ filter: z.record(z.any()).optional().describe("Filter criteria")
95
+ }
96
+ }, async (args) => {
97
+ const books = await client.getBooks({
98
+ offset: args.offset,
99
+ count: args.count,
100
+ sort: args.sort,
101
+ filter: args.filter
102
+ });
103
+ return {
104
+ content: [{ type: "text", text: JSON.stringify(books, null, 2) }]
105
+ };
106
+ });
107
+ server.registerTool("get_book", {
108
+ title: "Get Book Details",
109
+ description: "Get detailed information about a specific book",
110
+ inputSchema: {
111
+ id: z.coerce.number().min(1).describe("Book ID")
112
+ }
113
+ }, async (args) => {
114
+ const book = await client.getBook(args.id);
115
+ return {
116
+ content: [{ type: "text", text: JSON.stringify(book, null, 2) }]
117
+ };
118
+ });
119
+ server.registerTool("get_pages", {
120
+ title: "List Pages",
121
+ description: "List pages with content previews, word counts, and contextual information",
122
+ inputSchema: {
123
+ book_id: z.coerce.number().optional().describe("Filter by book ID"),
124
+ chapter_id: z.coerce.number().optional().describe("Filter by chapter ID"),
125
+ offset: z.coerce.number().default(0).describe("Pagination offset"),
126
+ count: z.coerce.number().max(500).default(50).describe("Number of results to return"),
127
+ sort: z.string().optional().describe("Sort field"),
128
+ filter: z.record(z.any()).optional().describe("Additional filter criteria")
129
+ }
130
+ }, async (args) => {
131
+ const pages = await client.getPages({
132
+ bookId: args.book_id,
133
+ chapterId: args.chapter_id,
134
+ offset: args.offset,
135
+ count: args.count,
136
+ sort: args.sort,
137
+ filter: args.filter
138
+ });
139
+ return {
140
+ content: [{ type: "text", text: JSON.stringify(pages, null, 2) }]
141
+ };
142
+ });
143
+ server.registerTool("get_page", {
144
+ title: "Get Page Content",
145
+ description: "Get full content of a specific page",
146
+ inputSchema: {
147
+ id: z.coerce.number().min(1).describe("Page ID")
148
+ }
149
+ }, async (args) => {
150
+ const page = await client.getPage(args.id);
151
+ return {
152
+ content: [{ type: "text", text: JSON.stringify(page, null, 2) }]
153
+ };
154
+ });
155
+ server.registerTool("get_chapters", {
156
+ title: "List Chapters",
157
+ description: "List chapters, optionally filtered by book",
158
+ inputSchema: {
159
+ book_id: z.coerce.number().optional().describe("Filter by book ID"),
160
+ offset: z.coerce.number().default(0).describe("Pagination offset"),
161
+ count: z.coerce.number().default(50).describe("Number of results to return")
162
+ }
163
+ }, async (args) => {
164
+ const chapters = await client.getChapters(args.book_id, args.offset, args.count);
165
+ return {
166
+ content: [{ type: "text", text: JSON.stringify(chapters, null, 2) }]
167
+ };
168
+ });
169
+ server.registerTool("get_chapter", {
170
+ title: "Get Chapter Details",
171
+ description: "Get details of a specific chapter",
172
+ inputSchema: {
173
+ id: z.coerce.number().min(1).describe("Chapter ID")
174
+ }
175
+ }, async (args) => {
176
+ const chapter = await client.getChapter(args.id);
177
+ return {
178
+ content: [{ type: "text", text: JSON.stringify(chapter, null, 2) }]
179
+ };
180
+ });
181
+ server.registerTool("export_page", {
182
+ title: "Export Page",
183
+ description: "Export a page in various formats (PDF/ZIP provide direct BookStack download URLs)",
184
+ inputSchema: {
185
+ id: z.coerce.number().min(1).describe("Page ID"),
186
+ format: z.enum(["html", "pdf", "markdown", "plaintext", "zip"]).describe("Export format")
187
+ }
188
+ }, async (args) => {
189
+ const content = await client.exportPage(args.id, args.format);
190
+ // Handle binary formats with direct URLs
191
+ if (typeof content === 'object' && content.download_url && content.direct_download) {
192
+ const format = args.format.toUpperCase();
193
+ return {
194
+ content: [{
195
+ type: "text",
196
+ text: `✅ **${format} Export Ready**\n\n` +
197
+ `📄 **Page:** ${content.page_name}\n` +
198
+ `📚 **Book:** ${content.book_name}\n` +
199
+ `📁 **File:** ${content.filename}\n\n` +
200
+ `🚀 **Direct Download Link:**\n${content.download_url}\n\n` +
201
+ `ℹ️ **Note:** ${content.note}`
202
+ }]
203
+ };
204
+ }
205
+ // Handle text formats
206
+ const text = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
207
+ return {
208
+ content: [{ type: "text", text }]
209
+ };
210
+ });
211
+ server.registerTool("export_book", {
212
+ title: "Export Book",
213
+ description: "Export an entire book in various formats",
214
+ inputSchema: {
215
+ id: z.coerce.number().min(1).describe("Book ID"),
216
+ format: z.enum(["html", "pdf", "markdown", "plaintext", "zip"]).describe("Export format")
217
+ }
218
+ }, async (args) => {
219
+ const content = await client.exportBook(args.id, args.format);
220
+ if (typeof content === 'object' && content.download_url) {
221
+ const format = args.format.toUpperCase();
222
+ return {
223
+ content: [{
224
+ type: "text",
225
+ text: `✅ **${format} Book Export Ready**\n\n` +
226
+ `📚 **Book:** ${content.book_name}\n` +
227
+ `📁 **File:** ${content.filename}\n\n` +
228
+ `🚀 **Direct Download Link:**\n${content.download_url}\n\n` +
229
+ `ℹ️ **Note:** ${content.note}`
230
+ }]
231
+ };
232
+ }
233
+ const text = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
234
+ return {
235
+ content: [{ type: "text", text }]
236
+ };
237
+ });
238
+ server.registerTool("export_chapter", {
239
+ title: "Export Chapter",
240
+ description: "Export a chapter in various formats",
241
+ inputSchema: {
242
+ id: z.coerce.number().min(1).describe("Chapter ID"),
243
+ format: z.enum(["html", "pdf", "markdown", "plaintext", "zip"]).describe("Export format")
244
+ }
245
+ }, async (args) => {
246
+ const content = await client.exportChapter(args.id, args.format);
247
+ if (typeof content === 'object' && content.download_url) {
248
+ const format = args.format.toUpperCase();
249
+ return {
250
+ content: [{
251
+ type: "text",
252
+ text: `✅ **${format} Chapter Export Ready**\n\n` +
253
+ `📖 **Chapter:** ${content.chapter_name}\n` +
254
+ `📚 **Book:** ${content.book_name}\n` +
255
+ `📁 **File:** ${content.filename}\n\n` +
256
+ `🚀 **Direct Download Link:**\n${content.download_url}\n\n` +
257
+ `ℹ️ **Note:** ${content.note}`
258
+ }]
259
+ };
260
+ }
261
+ const text = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
262
+ return {
263
+ content: [{ type: "text", text }]
264
+ };
265
+ });
266
+ server.registerTool("get_recent_changes", {
267
+ title: "Get Recent Changes",
268
+ description: "Get recently updated content with contextual previews and change descriptions",
269
+ inputSchema: {
270
+ type: z.enum(["all", "page", "book", "chapter"]).default("all").describe("Filter by content type"),
271
+ limit: z.coerce.number().max(100).default(20).describe("Number of recent items to return"),
272
+ days: z.coerce.number().default(30).describe("Number of days back to look for changes")
273
+ }
274
+ }, async (args) => {
275
+ const changes = await client.getRecentChanges({
276
+ type: args.type,
277
+ limit: args.limit,
278
+ days: args.days
279
+ });
280
+ return {
281
+ content: [{ type: "text", text: JSON.stringify(changes, null, 2) }]
282
+ };
283
+ });
284
+ server.registerTool("get_shelves", {
285
+ title: "List Shelves",
286
+ description: "List available book shelves (collections) with filtering and sorting",
287
+ inputSchema: {
288
+ offset: z.coerce.number().default(0).describe("Pagination offset"),
289
+ count: z.coerce.number().max(500).default(50).describe("Number of results to return"),
290
+ sort: z.string().optional().describe("Sort field"),
291
+ filter: z.record(z.any()).optional().describe("Filter criteria")
292
+ }
293
+ }, async (args) => {
294
+ const shelves = await client.getShelves({
295
+ offset: args.offset,
296
+ count: args.count,
297
+ sort: args.sort,
298
+ filter: args.filter
299
+ });
300
+ return {
301
+ content: [{ type: "text", text: JSON.stringify(shelves, null, 2) }]
302
+ };
303
+ });
304
+ server.registerTool("get_shelf", {
305
+ title: "Get Shelf Details",
306
+ description: "Get details of a specific book shelf including all books",
307
+ inputSchema: {
308
+ id: z.coerce.number().min(1).describe("Shelf ID")
309
+ }
310
+ }, async (args) => {
311
+ const shelf = await client.getShelf(args.id);
312
+ return {
313
+ content: [{ type: "text", text: JSON.stringify(shelf, null, 2) }]
314
+ };
315
+ });
316
+ server.registerTool("get_attachments", {
317
+ title: "List Attachments",
318
+ description: "List attachments (files and links) with filtering and sorting",
319
+ inputSchema: {
320
+ offset: z.coerce.number().default(0).describe("Pagination offset"),
321
+ count: z.coerce.number().max(500).default(50).describe("Number of results to return"),
322
+ sort: z.string().optional().describe("Sort field"),
323
+ filter: z.record(z.any()).optional().describe("Filter criteria")
324
+ }
325
+ }, async (args) => {
326
+ const attachments = await client.getAttachments({
327
+ offset: args.offset,
328
+ count: args.count,
329
+ sort: args.sort,
330
+ filter: args.filter
331
+ });
332
+ return {
333
+ content: [{ type: "text", text: JSON.stringify(attachments, null, 2) }]
334
+ };
335
+ });
336
+ server.registerTool("get_attachment", {
337
+ title: "Get Attachment Details",
338
+ description: "Get details of a specific attachment including download links",
339
+ inputSchema: {
340
+ id: z.coerce.number().min(1).describe("Attachment ID")
341
+ }
342
+ }, async (args) => {
343
+ const attachment = await client.getAttachment(args.id);
344
+ return {
345
+ content: [{ type: "text", text: JSON.stringify(attachment, null, 2) }]
346
+ };
347
+ });
348
+ // Register write tools if enabled
349
+ if (config.enableWrite) {
350
+ server.registerTool("create_page", {
351
+ title: "Create Page",
352
+ description: "Create a new page in BookStack",
353
+ inputSchema: {
354
+ name: z.string().describe("Page name"),
355
+ book_id: z.coerce.number().min(1).describe("Book ID where the page will be created"),
356
+ chapter_id: z.coerce.number().optional().describe("Optional: Chapter ID if page should be in a chapter"),
357
+ html: z.string().optional().describe("Optional: HTML content"),
358
+ markdown: z.string().optional().describe("Optional: Markdown content")
359
+ }
360
+ }, async (args) => {
361
+ const page = await client.createPage({
362
+ name: args.name,
363
+ book_id: args.book_id,
364
+ chapter_id: args.chapter_id,
365
+ html: args.html,
366
+ markdown: args.markdown
367
+ });
368
+ return {
369
+ content: [{ type: "text", text: JSON.stringify(page, null, 2) }]
370
+ };
371
+ });
372
+ server.registerTool("update_page", {
373
+ title: "Update Page",
374
+ description: "Update an existing page",
375
+ inputSchema: {
376
+ id: z.coerce.number().min(1).describe("Page ID"),
377
+ name: z.string().optional().describe("Optional: New page name"),
378
+ html: z.string().optional().describe("Optional: New HTML content"),
379
+ markdown: z.string().optional().describe("Optional: New Markdown content")
380
+ }
381
+ }, async (args) => {
382
+ const page = await client.updatePage(args.id, {
383
+ name: args.name,
384
+ html: args.html,
385
+ markdown: args.markdown
386
+ });
387
+ return {
388
+ content: [{ type: "text", text: JSON.stringify(page, null, 2) }]
389
+ };
390
+ });
391
+ server.registerTool("create_shelf", {
392
+ title: "Create Shelf",
393
+ description: "Create a new book shelf (collection)",
394
+ inputSchema: {
395
+ name: z.string().describe("Shelf name"),
396
+ description: z.string().optional().describe("Shelf description"),
397
+ books: z.array(z.coerce.number()).optional().describe("Array of book IDs to add to the shelf"),
398
+ tags: z.array(z.object({
399
+ name: z.string(),
400
+ value: z.string()
401
+ }).strict()).optional().describe("Tags for the shelf")
402
+ }
403
+ }, async (args) => {
404
+ const shelf = await client.createShelf({
405
+ name: args.name,
406
+ description: args.description,
407
+ books: args.books,
408
+ tags: args.tags
409
+ });
410
+ return {
411
+ content: [{ type: "text", text: JSON.stringify(shelf, null, 2) }]
412
+ };
413
+ });
414
+ server.registerTool("update_shelf", {
415
+ title: "Update Shelf",
416
+ description: "Update an existing book shelf",
417
+ inputSchema: {
418
+ id: z.coerce.number().min(1).describe("Shelf ID"),
419
+ name: z.string().optional().describe("New shelf name"),
420
+ description: z.string().optional().describe("New shelf description"),
421
+ books: z.array(z.coerce.number()).optional().describe("Array of book IDs"),
422
+ tags: z.array(z.object({
423
+ name: z.string(),
424
+ value: z.string()
425
+ }).strict()).optional().describe("Tags for the shelf")
426
+ }
427
+ }, async (args) => {
428
+ const shelf = await client.updateShelf(args.id, {
429
+ name: args.name,
430
+ description: args.description,
431
+ books: args.books,
432
+ tags: args.tags
433
+ });
434
+ return {
435
+ content: [{ type: "text", text: JSON.stringify(shelf, null, 2) }]
436
+ };
437
+ });
438
+ server.registerTool("delete_shelf", {
439
+ title: "Delete Shelf",
440
+ description: "Delete a book shelf (collection)",
441
+ inputSchema: {
442
+ id: z.coerce.number().min(1).describe("Shelf ID")
443
+ }
444
+ }, async (args) => {
445
+ const result = await client.deleteShelf(args.id);
446
+ return {
447
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
448
+ };
449
+ });
450
+ server.registerTool("create_attachment", {
451
+ title: "Create Attachment",
452
+ description: "Create a new link attachment to a page",
453
+ inputSchema: {
454
+ name: z.string().describe("Attachment name"),
455
+ uploaded_to: z.coerce.number().min(1).describe("Page ID where attachment will be attached"),
456
+ link: z.string().describe("URL for link attachment")
457
+ }
458
+ }, async (args) => {
459
+ const attachment = await client.createAttachment({
460
+ name: args.name,
461
+ uploaded_to: args.uploaded_to,
462
+ link: args.link
463
+ });
464
+ return {
465
+ content: [{ type: "text", text: JSON.stringify(attachment, null, 2) }]
466
+ };
467
+ });
468
+ server.registerTool("update_attachment", {
469
+ title: "Update Attachment",
470
+ description: "Update an existing attachment",
471
+ inputSchema: {
472
+ id: z.coerce.number().min(1).describe("Attachment ID"),
473
+ name: z.string().optional().describe("New attachment name"),
474
+ link: z.string().optional().describe("New URL for link attachment"),
475
+ uploaded_to: z.coerce.number().optional().describe("Move attachment to different page")
476
+ }
477
+ }, async (args) => {
478
+ const attachment = await client.updateAttachment(args.id, {
479
+ name: args.name,
480
+ link: args.link,
481
+ uploaded_to: args.uploaded_to
482
+ });
483
+ return {
484
+ content: [{ type: "text", text: JSON.stringify(attachment, null, 2) }]
485
+ };
486
+ });
487
+ server.registerTool("delete_attachment", {
488
+ title: "Delete Attachment",
489
+ description: "Delete an attachment",
490
+ inputSchema: {
491
+ id: z.coerce.number().min(1).describe("Attachment ID")
492
+ }
493
+ }, async (args) => {
494
+ const result = await client.deleteAttachment(args.id);
495
+ return {
496
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
497
+ };
498
+ });
499
+ }
500
+ // Connect via stdio transport
501
+ const transport = new StdioServerTransport();
502
+ await server.connect(transport);
503
+ console.error("BookStack MCP server running on stdio");
504
+ }
505
+ if (import.meta.url === `file://${process.argv[1]}`) {
506
+ main().catch(console.error);
507
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "bookstack-mcp",
3
+ "version": "2.1.0",
4
+ "description": "MCP server for BookStack wiki — search, read, create, and manage documentation via AI assistants",
5
+ "type": "module",
6
+ "bin": {
7
+ "bookstack-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc && shx chmod +x dist/*.js",
14
+ "dev": "tsx src/index.ts",
15
+ "start": "node dist/index.js",
16
+ "type-check": "tsc --noEmit",
17
+ "prepare": "npm run build"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "bookstack",
22
+ "model-context-protocol",
23
+ "ai",
24
+ "wiki",
25
+ "documentation",
26
+ "claude",
27
+ "librechat"
28
+ ],
29
+ "author": "ttpears",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/ttpears/bookstack-mcp.git"
34
+ },
35
+ "homepage": "https://github.com/ttpears/bookstack-mcp#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/ttpears/bookstack-mcp/issues"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.25.3",
44
+ "axios": "^1.6.0",
45
+ "zod": "^3.25.76"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^20.10.0",
49
+ "shx": "^0.4.0",
50
+ "tsx": "^4.6.0",
51
+ "typescript": "^5.3.0"
52
+ }
53
+ }