Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ npx bookstack-mcp
- Type-safe input validation with Zod (auto-coerces string/number params for broad client compatibility)
- Embedded URLs and content previews in all responses
- Markdown export fallback for HTML-authored pages, so AI clients always get usable content
- Token-efficient responses: compact JSON, no redundant fields, no N+1 fetches — ~30–55% smaller payloads than 3.x
- Write operations disabled by default for safety
- Works with Claude Desktop, Claude Code, LibreChat, and any MCP-compatible client
- Stdio and Streamable HTTP transports
Expand Down Expand Up @@ -214,13 +215,14 @@ Books and pages are also exposed as MCP resources, so clients that browse resour

Both templates support `id` autocompletion: as you type, the server searches BookStack and returns matching IDs so you don't have to remember numeric IDs by hand.

> **4.0.0 breaking changes:** tool responses were trimmed for token efficiency. Removed fields: `direct_link`, `*_friendly` date strings, `content_info`, `contextual_info`, `change_summary`, `pagination_hint`, `location`, `summary`/`tags_summary`/`book_count` on shelves, and the buggy `page_url` on attachments. Use `url`, the ISO date fields, and `download_url` instead. The `get_capabilities` tool was removed — clients should use `tools/list` (built into MCP). Responses are now compact JSON (no pretty-printing) and `get_recent_changes` no longer issues per-result fetches.

## Available Tools

### Read Operations (always available)

| Tool | Description |
|------|-------------|
| `get_capabilities` | Server capabilities and configuration |
| `search_content` | Search across all content with filtering |
| `search_pages` | Search pages with optional book filtering |
| `get_books` / `get_book` | List or get details of books |
Expand Down
2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bookstack-mcp",
"version": "3.5.0",
"version": "4.0.0",
"description": "BookStack wiki MCP server — search, read, create, and manage documentation",
"icon": "logo.png",
"mcpServers": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bookstack-mcp",
"version": "3.5.0",
"version": "4.0.0",
"description": "MCP server for BookStack wiki — search, read, create, and manage documentation via AI assistants",
"type": "module",
"bin": {
Expand Down
111 changes: 111 additions & 0 deletions scripts/bench-context.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env node
// Measure tools/list + a few read-only tool responses by speaking JSON-RPC
// over stdio to dist/index.js. Loads .env, prints sizes in bytes.
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..");

// Load .env
const env = { ...process.env };
try {
for (const line of readFileSync(join(root, ".env"), "utf8").split("\n")) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m) env[m[1]] = m[2];
}
} catch {}

const child = spawn("node", [join(root, "dist/index.js")], {
env,
stdio: ["pipe", "pipe", "inherit"]
});

let buf = "";
const pending = new Map();
let nextId = 1;

child.stdout.on("data", (chunk) => {
buf += chunk.toString("utf8");
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; }
if (msg.id != null && pending.has(msg.id)) {
const { resolve } = pending.get(msg.id);
pending.delete(msg.id);
resolve(msg);
}
}
});

function send(method, params) {
const id = nextId++;
const req = { jsonrpc: "2.0", id, method, params };
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
child.stdin.write(JSON.stringify(req) + "\n");
});
}

function bytes(obj) {
return Buffer.byteLength(JSON.stringify(obj), "utf8");
}

async function main() {
// 1. initialize
await send("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "bench", version: "0.0.0" }
});
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");

const results = {};

// 2. tools/list
const toolsList = await send("tools/list", {});
results["tools/list"] = bytes(toolsList.result);

// 3. Pick a small sample of read-only tool calls
const calls = [
["search_content", { query: "test", count: 5 }],
["get_books", { count: 5 }],
["get_pages", { count: 5 }],
["get_recent_changes", { limit: 5, days: 365 }],
["get_shelves", { count: 5 }],
];

// Find any page id for get_page
const pagesRes = await send("tools/call", { name: "get_pages", arguments: { count: 1 } });
try {
const payload = JSON.parse(pagesRes.result.content[0].text);
const pageId = payload.data?.[0]?.id;
if (pageId) calls.push(["get_page", { id: pageId, limit: 5000 }]);
} catch {}

// Find any book id
const booksRes = await send("tools/call", { name: "get_books", arguments: { count: 1 } });
try {
const payload = JSON.parse(booksRes.result.content[0].text);
const bookId = payload.data?.[0]?.id;
if (bookId) calls.push(["get_book", { id: bookId }]);
} catch {}

for (const [name, args] of calls) {
const r = await send("tools/call", { name, arguments: args });
// Measure the result content as the LLM would see it
const text = r.result?.content?.map(c => c.text ?? "").join("") ?? "";
results[`tools/call:${name}`] = Buffer.byteLength(text, "utf8");
}

console.log(JSON.stringify(results, null, 2));
child.kill();
}

main().catch((e) => { console.error(e); child.kill(); process.exit(1); });
Loading
Loading