Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ Both templates support `id` autocompletion: as you type, the server searches Boo
| `get_shelves` / `get_shelf` | List or get shelf details |
| `get_attachments` / `get_attachment` | List or get attachment details |
| `get_comments` / `get_comment` | List or get page comments (BookStack v25.11+) |
| `find_users` | Look up BookStack users by name, email, or slug to resolve numeric user IDs for search filters |
| `find_users` | Look up BookStack users by name, email, or slug to resolve user slugs for `{created_by:X}`-style search filters |
| `get_recycle_bin` | List items in the recycle bin |
| `export_page` | Export page as HTML, PDF, Markdown, plaintext, or ZIP |
| `export_book` | Export entire book |
Expand Down
20 changes: 12 additions & 8 deletions src/bookstack-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@ function parseRetryAfter(value: unknown): number | null {
return null;
}

// BookStack's advanced search silently ignores {created_by:X}/{updated_by:X}/{owned_by:X}
// when X isn't a numeric user ID — the query falls back to unfiltered results, which is
// a confusing footgun. Reject the bad form up front and point the caller at find_users.
// BookStack resolves {created_by:X}/{updated_by:X}/{owned_by:X} by user SLUG, with 'me'
// as a shortcut for the token's own user, and silently SKIPS the filter when X matches no
// user's slug — the query falls back to unfiltered results, which is a confusing footgun
// (SearchRunner::filterCreatedBy et al. look users up by slug only). A numeric user ID is
// the classic way to hit it, so reject that form up front and point the caller at
// find_users for the slug.
function validateUserIdFilters(query: string): void {
const re = /\{(created_by|updated_by|owned_by):([^}\s]+)\}/g;
const re = /\{(!?)(created_by|updated_by|owned_by):([^}\s]+)\}/g;
for (const match of query.matchAll(re)) {
const [, field, value] = match;
if (!/^\d+$/.test(value)) {
const [, neg, field, value] = match;
if (/^\d+$/.test(value)) {
throw new Error(
`Search filter {${field}:${value}} requires a numeric user ID, not "${value}". ` +
`Use the find_users tool to look up a user's ID by name, email, or slug.`
`Search filter {${neg}${field}:${value}} takes a user slug or 'me', not a numeric user ID — ` +
`BookStack resolves these filters by slug and silently returns unfiltered results for ` +
`unmatched values. Use the find_users tool to look up a user's slug by name or email.`
);
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ function registerTools(server: McpServer, client: BookStackClient, config: BookS
readTool(
"search_content",
{
description: "Search BookStack content. Supports advanced syntax like {type:page} or {book_id:5}. {created_by:X}/{updated_by:X}/{owned_by:X} need a numeric user ID — use find_users to resolve names.",
description: "Search BookStack content. Supports advanced syntax like {type:page} or {book_id:5}. {created_by:X}/{updated_by:X}/{owned_by:X} take a user slug or 'me' — use find_users to resolve names to slugs.",
inputSchema: {
query: z.string(),
type: z.enum(["book", "page", "chapter", "bookshelf"]).optional(),
Expand Down Expand Up @@ -567,7 +567,7 @@ function registerTools(server: McpServer, client: BookStackClient, config: BookS
readTool(
"find_users",
{
description: "List users; filter by name/email/slug (partial match). Resolves names to numeric user IDs for {created_by:X}/{updated_by:X}/{owned_by:X} search filters. Requires admin token.",
description: "List users; filter by name/email/slug (partial match). Resolves names to user slugs for {created_by:X}/{updated_by:X}/{owned_by:X} search filters. Requires admin token.",
inputSchema: {
name: z.string().optional(),
email: z.string().optional(),
Expand Down