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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ MCP_TRANSPORT=http
# Generate one with: openssl rand -hex 32
MCP_AUTH_TOKEN=

# Per-user OAuth instead of the shared secret (HTTP transport only)
# MCP_AUTH_MODE=oauth makes this server an OAuth resource server (MCP authorization spec):
# callers send an access token from MCP_OAUTH_ISSUER whose audience contains
# MCP_OAUTH_RESOURCE. The server exchanges it (RFC 8693) as MCP_OAUTH_CLIENT_ID - its own
# confidential client, not the one MCP clients sign in with - for a token with audience
# BOOKSTACK_OAUTH_AUDIENCE and calls BookStack as that user. The caller's own
# token is never forwarded. MCP_AUTH_TOKEN, BOOKSTACK_API_TOKEN and BOOKSTACK_UPLOAD_ROOT must
# then be unset, and BookStack must accept OIDC access tokens (OIDC_API_ACCESS_TOKENS=true).
# MCP_AUTH_MODE=oauth
# MCP_OAUTH_ISSUER=https://idp.example.com
# MCP_OAUTH_RESOURCE=https://bookstack-mcp.example.com/message
# MCP_OAUTH_CLIENT_ID=bookstack-mcp-server
# MCP_OAUTH_CLIENT_SECRET=
# BOOKSTACK_OAUTH_AUDIENCE=bookstack-api

# Maximum accepted POST /message request body, in bytes.
# Default: 73400320 (70 MiB). Sized from the largest inline upload the image and
# attachment tools advertise (50000 KB), which the parser sees base64-encoded and
Expand Down
96 changes: 96 additions & 0 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Image

# Builds the Docker image for a v<version>-r<revision> tag, smoke-tests it, then pushes it to GHCR.
# <version> is package.json's; bump <revision> to re-release the same version with changes on top.
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+-r[0-9]+']
workflow_dispatch:

permissions:
contents: read

concurrency:
group: image-${{ github.ref }}
cancel-in-progress: false

env:
# Must match the Dockerfile's BUN_IMAGE; the smoke suite fails if they differ.
BUN_VERSION: 1.3.14
IMAGE: ghcr.io/${{ github.repository }}

jobs:
image:
name: Build, smoke and push
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

- name: Read the version from the tag
id: version
env:
TAG: ${{ github.ref_name }}
run: |
if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-r([0-9]+)$ ]]; then
echo "::error::Tag ${TAG} is not v<version>-r<revision>, e.g. v2.1.0-r1." >&2
exit 1
fi
PKG_VERSION="$(node -p 'require("./package.json").version')"
if [ "${BASH_REMATCH[1]}" != "$PKG_VERSION" ]; then
echo "::error::Tag ${TAG} names version ${BASH_REMATCH[1]}, but package.json is ${PKG_VERSION}." >&2
exit 1
fi
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"

- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: ${{ env.BUN_VERSION }}

- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

- name: Image tags and labels
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
flavor: latest=false
tags: type=raw,value=${{ steps.version.outputs.version }}

- name: Build image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
load: true
tags: bookstack-mcp-server:ci
labels: ${{ steps.meta.outputs.labels }}
build-args: SERVER_VERSION=${{ steps.version.outputs.version }}

- name: Smoke the image
run: bun test tests/transport/docker-smoke.test.ts
env:
RUN_DOCKER_SMOKE: '1'
DOCKER_SMOKE_IMAGE: bookstack-mcp-server:ci
DOCKER_SMOKE_VERSION: ${{ steps.version.outputs.version }}
RUN_INTEGRATION: '0'

- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Rebuilds from the builder cache above, so the pushed layers are the ones just smoked.
- name: Push image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: SERVER_VERSION=${{ steps.version.outputs.version }}
provenance: mode=max
sbom: true
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ RUN bun install --production --frozen-lockfile
FROM ${BUN_IMAGE} AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Set by the image workflow from the release tag; empty falls back to package.json's version.
ARG SERVER_VERSION=""
ENV SERVER_VERSION=${SERVER_VERSION}

# Bring in the resolved production dependencies.
COPY --from=deps /app/node_modules ./node_modules
Expand Down
56 changes: 51 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,14 @@ export MCP_TRANSPORT="http"
| Variable | Default | Description |
| --- | --- | --- |
| `MCP_TRANSPORT` | `http` | Transport mode. Only the exact value `stdio` selects stdio; **any other value (or unset) starts the HTTP server**. |
| `MCP_AUTH_TOKEN` | _(none — **required** for HTTP)_ | **Inbound** secret callers must present as `Authorization: Bearer <value>` on `POST /message`. The HTTP transport **refuses to start** without it — there is no "no auth" mode. Unrelated to `BOOKSTACK_API_TOKEN`, and must not be the same value. Ignored in stdio mode. Generate with `openssl rand -hex 32`. |
| `MCP_AUTH_TOKEN` | _(none — **required** for HTTP, unless `MCP_AUTH_MODE=oauth`)_ | **Inbound** secret callers must present as `Authorization: Bearer <value>` on `POST /message`. The HTTP transport **refuses to start** without it — there is no "no auth" mode. Unrelated to `BOOKSTACK_API_TOKEN`, and must not be the same value. Ignored in stdio mode. Generate with `openssl rand -hex 32`. |
| `MCP_AUTH_MODE` | `token` | `token` uses `MCP_AUTH_TOKEN`; `oauth` makes the HTTP transport an OAuth resource server that calls BookStack as each user. See [Per-user OAuth](#per-user-oauth). |
| `MCP_OAUTH_ISSUER` | _(required with `oauth`)_ | OIDC issuer the access tokens come from (its exact `iss` value). |
| `MCP_OAUTH_RESOURCE` | _(required with `oauth`)_ | This server's public `/message` URL. Access tokens must carry it in `aud`. |
| `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET` | _(required with `oauth`)_ | Confidential client only this server holds, used for token exchange. Not the client MCP clients sign in with. |
| `BOOKSTACK_OAUTH_AUDIENCE` | _(required with `oauth`)_ | Audience BookStack requires on access tokens (its `OIDC_API_AUDIENCE`). |
| `BOOKSTACK_BASE_URL` | `http://localhost:8080/api` | Full URL to the BookStack API. Must be a valid URL and include the `/api` suffix. |
| `BOOKSTACK_API_TOKEN` | _(none — required)_ | **Outbound** BookStack API token as `token_id:token_secret`. This is the credential the server spends on every tool call. Startup fails if unset. |
| `BOOKSTACK_API_TOKEN` | _(none — required, unless `MCP_AUTH_MODE=oauth`)_ | **Outbound** BookStack API token as `token_id:token_secret`. This is the credential the server spends on every tool call. Startup fails if unset. |
| `BOOKSTACK_TIMEOUT` | `30000` | BookStack request timeout in milliseconds. |
| `SERVER_PORT` | `3000` | Port the HTTP transport listens on. Ignored in stdio mode. |
| `HTTP_BODY_LIMIT` | `73400320` (70 MiB) | Maximum accepted `POST /message` body, in bytes. Sized for the largest inline base64 upload the image/attachment tools advertise (50,000 KB). Express's own default is ~100 KB, which would reject real uploads with a `413`. Lower it if untrusted callers can reach the port. |
Expand Down Expand Up @@ -122,8 +127,9 @@ The transport is chosen at startup from `MCP_TRANSPORT`:

### HTTP endpoints

When running in HTTP mode the server exposes exactly three endpoints. Any other
path returns a JSON `404` listing the valid ones.
When running in HTTP mode the server exposes three endpoints, plus the OAuth metadata
document in [OAuth mode](#per-user-oauth). Any other path returns a JSON `404` listing
the valid ones.

| Method & path | Purpose | Status codes |
| --- | --- | --- |
Expand Down Expand Up @@ -210,7 +216,47 @@ curl -X POST http://localhost:3000/message \

Per-request credential overrides are supported on `POST /message` via the
`x-bookstack-url` and `x-bookstack-token` headers; both fall back to the
`BOOKSTACK_BASE_URL` / `BOOKSTACK_API_TOKEN` environment variables.
`BOOKSTACK_BASE_URL` / `BOOKSTACK_API_TOKEN` environment variables. OAuth mode refuses them.

### Per-user OAuth

With `MCP_AUTH_MODE=oauth` the HTTP transport follows the
[MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization):
each person signs in with your OIDC provider, and BookStack sees their own account,
permissions and audit trail instead of one shared API token.

1. A request without a token gets `401` with
`WWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource/message"`,
which points the client at `MCP_OAUTH_ISSUER`.
2. Each access token is checked against the issuer's published keys (RS256), plus `iss`, `exp`, `sub`, and
an `aud` containing `MCP_OAUTH_RESOURCE`.
3. The server exchanges it (RFC 8693) as `MCP_OAUTH_CLIENT_ID` for a token with
`aud=BOOKSTACK_OAUTH_AUDIENCE`, cached until shortly before it expires, and calls
BookStack with that. **The caller's token is never forwarded.**

Requirements:

- BookStack accepts OIDC access tokens on its API (`OIDC_API_ACCESS_TOKENS=true`, from
[BookStack PR 6237](https://codeberg.org/bookstack/bookstack/pulls/6237)), with
`OIDC_API_ALLOWED_CLIENTS` set to `MCP_OAUTH_CLIENT_ID`. Users must have logged in to
BookStack once.
- Two OAuth clients. The one MCP clients sign in with issues access tokens whose `aud`
contains `MCP_OAUTH_RESOURCE` and `MCP_OAUTH_CLIENT_ID`, but **not** BookStack's audience,
so those tokens are useless against BookStack directly. Most providers ignore RFC 8707's
`resource` parameter, so configure that audience as a fixed one. `MCP_OAUTH_CLIENT_ID` is
a separate confidential client, held only by this server, that may exchange those tokens
for `BOOKSTACK_OAUTH_AUDIENCE`.
- `MCP_AUTH_TOKEN`, `BOOKSTACK_API_TOKEN` and `BOOKSTACK_UPLOAD_ROOT` are unset; the server
refuses to start otherwise (an upload root would be readable by every user).

`GET /health` then checks issuer discovery and that BookStack answers, since there is
no service token to call it with. Connect Claude Code with the sign-in client, using the
callback port registered for it:

```bash
claude mcp add --transport http --client-id <sign-in client id> --client-secret \
--callback-port <port> bookstack https://bookstack-mcp.example.com/message
```

### Using with n8n

Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ services:
# /message dispatches all 59 tools (permanent-delete, users, roles,
# permissions) using BOOKSTACK_API_TOKEN. Generate with `openssl rand -hex 32`.
MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:-}
# Per-user OAuth instead of the two tokens above; see .env.example.
MCP_AUTH_MODE: ${MCP_AUTH_MODE:-}
MCP_OAUTH_ISSUER: ${MCP_OAUTH_ISSUER:-}
MCP_OAUTH_RESOURCE: ${MCP_OAUTH_RESOURCE:-}
MCP_OAUTH_CLIENT_ID: ${MCP_OAUTH_CLIENT_ID:-}
MCP_OAUTH_CLIENT_SECRET: ${MCP_OAUTH_CLIENT_SECRET:-}
BOOKSTACK_OAUTH_AUDIENCE: ${BOOKSTACK_OAUTH_AUDIENCE:-}
SERVER_PORT: "3000"
NODE_ENV: production
ports:
Expand Down
24 changes: 24 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,30 @@ Recover with one of:
npm pack --dry-run # exactly what a release would contain
```

## Docker image

Images are versioned `<version>-r<revision>`: `<version>` is `package.json#version`, and
`<revision>` counts releases of that version, so a change made here without a new
`package.json` version still gets a new image. Start each version at `r1`.

Pushing a tag like `v2.1.0-r1` runs `.github/workflows/image.yml`, which:

1. refuses a tag whose `<version>` is not `package.json#version`;
2. builds the image with `SERVER_VERSION=2.1.0-r1`, so `GET /` and MCP `initialize` report it;
3. runs the image smoke suite against it;
4. only then pushes `ghcr.io/<owner>/bookstack-mcp-server:2.1.0-r1`, with provenance and
an SBOM. No `latest` or moving tags: deploy by digest.

```bash
git tag v2.1.0-r1 <commit> && git push origin v2.1.0-r1
gh workflow run image.yml --ref v2.1.0-r1 # rebuild an existing tag
```

Plain `vX.Y.Z` tags, including the ones release-please creates, build no image. To
follow these tags with Renovate, use regex versioning such as
`regex:^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)-r(?<build>\d+)$`; its default
Docker versioning reads `-r1` and `-r2` as different variants and never upgrades between them.

---

## Doing a release by hand (escape hatch)
Expand Down
4 changes: 4 additions & 0 deletions docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ MCP_TRANSPORT=http
# The stdio transport ignores it.
MCP_AUTH_TOKEN=

# Or per-user OAuth instead of MCP_AUTH_TOKEN/BOOKSTACK_API_TOKEN; see the README's
# "Per-user OAuth" section.
# MCP_AUTH_MODE=oauth

# BookStack API Configuration
BOOKSTACK_BASE_URL=http://localhost:8080/api
BOOKSTACK_API_TOKEN=your-api-token-here
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"axios": "1.18.1",
"dotenv": "17.4.2",
"express": "5.2.1",
"jose": "6.2.3",
"winston": "3.19.0",
"zod": "4.4.3"
},
Expand Down
37 changes: 34 additions & 3 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,30 @@ function uploadFilename(params: UploadParams, fileField: string, bytes: Buffer):
return `${name || 'upload'}${fallbackExt}`;
}

/**
* What this client authenticates to BookStack with.
*
* `Token` is a BookStack API token (`id:secret`). `Bearer` is an OIDC access token for the
* BookStack API audience; `principal` names its user, so rate limits follow the user rather
* than each short-lived token.
*/
export type BookStackCredential =
| { scheme: 'Token'; secret: string }
| { scheme: 'Bearer'; secret: string; principal: string };

/** Startup refusal when no BookStack credential is configured; worded as the config check always was. */
export const MISSING_API_TOKEN_MESSAGE =
'Configuration validation failed: bookstack.apiToken: BookStack API token is required - ' +
'set BOOKSTACK_API_TOKEN environment variable';

/** The configured API token as a credential; refuses when none is configured. */
function configuredCredential(config: Config): BookStackCredential {
if (!config.bookstack.apiToken) {
throw new Error(MISSING_API_TOKEN_MESSAGE);
}
return { scheme: 'Token', secret: config.bookstack.apiToken };
}

/**
* BookStack API Client
*
Expand All @@ -441,7 +465,12 @@ export class BookStackClient implements BookStackAPIClient {
private rateLimiter: RateLimiter;
private config: Config;

constructor(config: Config, logger: Logger, errorHandler: ErrorHandler) {
constructor(
config: Config,
logger: Logger,
errorHandler: ErrorHandler,
credential: BookStackCredential = configuredCredential(config)
) {
this.config = config;
this.logger = logger;
this.errorHandler = errorHandler;
Expand All @@ -468,7 +497,9 @@ export class BookStackClient implements BookStackAPIClient {
// their own budget rather than draining someone else's.
this.rateLimiter = getSharedRateLimiter({
baseUrl,
apiToken: config.bookstack.apiToken,
// A Bearer token rotates every few minutes, so its bucket is keyed on the user instead.
apiToken:
credential.scheme === 'Token' ? credential.secret : `Bearer\u0000${credential.principal}`,
requestsPerMinute: config.rateLimit.requestsPerMinute,
burstLimit: config.rateLimit.burstLimit,
});
Expand All @@ -487,7 +518,7 @@ export class BookStackClient implements BookStackAPIClient {
timeout: config.bookstack.timeout,
httpsAgent,
headers: {
Authorization: `Token ${config.bookstack.apiToken}`,
Authorization: `${credential.scheme} ${credential.secret}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': `${config.server.name}/${config.server.version}`,
Expand Down
Loading
Loading