Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
82f939b
ci: automate releases with release-please and OIDC publishing
pnocera Jul 17, 2026
c3c75a4
docs: correct the release runbook's failure and recovery paths
pnocera Jul 17, 2026
7396a5d
docs: make the manual release procedures fail closed
pnocera Jul 17, 2026
3d991f8
fix(release): check out the release tag by fully qualified ref
pnocera Jul 17, 2026
84ac766
fix(release): bind the published worktree to the triggering merge
pnocera Jul 17, 2026
66b2db8
fix(release): bind the release to the Release PR that produced it
pnocera Jul 17, 2026
abdca1a
fix(release): make the npm-publish environment the boundary; split th…
pnocera Jul 17, 2026
c950f63
fix(release): serialize the release state machine with queue: max
pnocera Jul 17, 2026
227cce1
fix: report the package's real version, not a 1.0.0 literal
pnocera Jul 17, 2026
b4b6984
test: assert the version at the surfaces that announce it
pnocera Jul 17, 2026
d7561e0
test: assert the outbound User-Agent on the wire, not in axios defaults
pnocera Jul 17, 2026
93c202a
test: cover the default version path, not just the override branch
pnocera Jul 17, 2026
3e48100
test: assert the package default at initialize and GET /, and stop le…
pnocera Jul 17, 2026
84e2783
test: make the teardown postcondition and env restore actually check
pnocera Jul 17, 2026
f39b707
test: prove the singleton is absent instead of interrogating an error
pnocera Jul 17, 2026
a24919a
docs: stop the local recovery from dragging npm `latest` backwards
pnocera Jul 17, 2026
0359a72
docs: make local recovery mode-explicit and check the published versi…
pnocera Jul 17, 2026
5e50b3e
docs: make backfill prove it is one, and pin npm before the pack preview
pnocera Jul 17, 2026
de499ad
docs: let npm enforce the tag policy instead of a homegrown state mac…
pnocera Jul 17, 2026
b49fb8d
docs: arm npm's tag guard properly, and stop claiming it is atomic
pnocera Jul 17, 2026
e942a46
docs: do not treat a failed npm config read as proof of a clean config
pnocera Jul 17, 2026
4c695d4
docs: match the npm config with bash, not a matcher that can fail
pnocera Jul 17, 2026
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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ BOOKSTACK_TIMEOUT=30000

# Server Configuration
SERVER_NAME=bookstack-mcp-server
SERVER_VERSION=1.0.0
# SERVER_VERSION is deliberately unset: it defaults to this package's real
# version from package.json. Setting it here would pin a literal that no
# release updates, so the server would keep announcing a stale version.
# Override only if you intentionally want to report something else.
# SERVER_VERSION=
SERVER_PORT=3000

# Rate Limiting
Expand Down
393 changes: 393 additions & 0 deletions .github/workflows/release-please.yml

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "1.0.0"
}
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,7 @@ and closes a number of correctness and security defects.

[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
[Biome]: https://biomejs.dev/
[Unreleased]: https://github.com/pnocera/bookstack-mcp-server/compare/main...HEAD
<!-- No v1.0.0 tag exists: 1.0.0 was published to npm before this repo tagged
releases, so compare/v1.0.0...HEAD 404s. v2.0.0 is the first tag; from then
on these are ordinary compare links. -->
[Unreleased]: https://github.com/pnocera/bookstack-mcp-server/commits/main
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export MCP_TRANSPORT="http"
| `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. |
| `SERVER_NAME` | `bookstack-mcp-server` | Server name reported over MCP and by `GET /`. |
| `SERVER_VERSION` | `1.0.0` | Server version reported over MCP and by `GET /`. |
| `SERVER_VERSION` | the package's own version | Version reported over MCP `initialize`, by `GET /`, and by `bookstack_server_info`. Defaults to `package.json#version`; leave it unset unless you deliberately want a different value. |
| `RATE_LIMIT_REQUESTS_PER_MINUTE` | `60` | Outbound rate limit toward BookStack. |
| `RATE_LIMIT_BURST_LIMIT` | `10` | Outbound burst allowance toward BookStack. |
| `VALIDATION_ENABLED` | `true` | Input validation. Set to `false` to disable. |
Expand Down Expand Up @@ -277,6 +277,8 @@ Find comprehensive guides in the `docs/` folder:
- **[Tools Overview](docs/tools-overview.md)** - Every tool explained
- **[Resources Guide](docs/resources-guide.md)** - Resource access patterns
- **[Examples & Workflows](docs/examples-and-workflows.md)** - Real-world usage
- **[Integration Testing](docs/integration-testing.md)** - Running the live suite against a real BookStack
- **[Releasing](docs/releasing.md)** - How versions are cut and published

## ⚡ Quick Examples

Expand Down
653 changes: 653 additions & 0 deletions docs/releasing.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,11 @@ BOOKSTACK_TIMEOUT=30000

# Server Configuration
SERVER_NAME=bookstack-mcp-server
SERVER_VERSION=1.0.0
# SERVER_VERSION is deliberately unset: it defaults to this package's real
# version from package.json. Setting it here would pin a literal that no
# release updates, so the server would keep announcing a stale version.
# Override only if you intentionally want to report something else.
# SERVER_VERSION=
SERVER_PORT=3000

# Rate Limiting
Expand Down
51 changes: 51 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",

"//bootstrap-sha": [
"Bounds the FIRST history scan. Without it, release-please walks every commit",
"in the repository to compute the next version. ddfbd25 is the parent of the",
"feat! Bun migration (4476d87), so the first scan sees exactly one releasable",
"commit and proposes 2.0.0 — which is the release that is actually due.",
"",
"This is a one-shot: once a release exists, release-please reads from its tag",
"and ignores this. It is NOT a rewrite of history — 1.0.0 in the manifest is",
"the real last released version (npm has 1.0.0; the repo has no tags at all)."
],
"bootstrap-sha": "ddfbd255881e219c80e4bcc45ab457083c4a521a",

"packages": {
".": {
"release-type": "node",
"package-name": "bookstack-mcp-server",
"changelog-path": "CHANGELOG.md",

"//tags": [
"Both flags are required for plain `vX.Y.Z` tags. include-v-in-tag only adds",
"the `v`; in manifest mode include-component-in-tag defaults to TRUE, which",
"would produce `bookstack-mcp-server-v2.1.0` and a Release PR titled",
"'chore(main): release bookstack-mcp-server 2.1.0'. This is a single-package",
"repo, so the component prefix carries no information — and every reference",
"in CHANGELOG.md and docs/releasing.md assumes vX.Y.Z."
],
"include-v-in-tag": true,
"include-component-in-tag": false,

"bump-minor-pre-major": false,
"draft": false,
"prerelease": false,
"changelog-sections": [
{ "type": "feat", "section": "Added" },
{ "type": "fix", "section": "Fixed" },
{ "type": "perf", "section": "Changed" },
{ "type": "refactor", "section": "Changed" },
{ "type": "revert", "section": "Fixed" },
{ "type": "docs", "section": "Documentation", "hidden": false },
{ "type": "build", "section": "Build & tooling", "hidden": false },
{ "type": "ci", "section": "Build & tooling", "hidden": false },
{ "type": "chore", "section": "Build & tooling", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true },
{ "type": "style", "section": "Build & tooling", "hidden": true }
]
}
}
}
35 changes: 32 additions & 3 deletions src/config/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { config as dotenvConfig } from 'dotenv';
import { z } from 'zod';
import { Logger } from '../utils/logger';
import { canonicalBaseUrl } from '../utils/rateLimit';
import { VERSION } from '../version';

// Load environment variables.
//
Expand Down Expand Up @@ -65,7 +66,7 @@ export const ConfigSchema = z.object({
}),
server: z.object({
name: z.string().default('bookstack-mcp-server'),
version: z.string().default('1.0.0'),
version: z.string().default(VERSION),
port: z.number().positive().default(3000),
}),
rateLimit: z.object({
Expand Down Expand Up @@ -234,7 +235,7 @@ export function loadHttpTransportConfig(env: EnvSource = process.env): HttpTrans
* Configuration manager singleton
*/
export class ConfigManager {
private static instance: ConfigManager;
private static instance: ConfigManager | undefined;
private config: Config;
private logger: Logger;

Expand All @@ -250,6 +251,34 @@ export class ConfigManager {
return ConfigManager.instance;
}

/**
* Drop the singleton so the next getInstance() rebuilds from the current environment.
*
* For tests, and load-bearing there. `reload()` assigns only after loadConfig()
* SUCCEEDS, so a suite that restores an environment the config cannot validate -
* the normal case: no BOOKSTACK_API_TOKEN outside a fixture - is left holding the
* previous test's configuration. bun runs every test file in one process against
* this singleton, so the next file can then read a credential that is not in its
* environment, or find a stub base URL whose server has already stopped. Resetting
* makes the next getInstance() revalidate and fail honestly instead.
*/
static resetInstance(): void {
ConfigManager.instance = undefined;
}

/**
* Whether a singleton is currently cached.
*
* For tests, so a teardown can PROVE it left no instance behind without calling
* getInstance() — which would construct one, and so destroy the very state it is
* checking. Probing by construction also forces a try/catch, and a catch that
* decides what happened from an error's message text accepts any unrelated
* failure wearing the same prefix.
*/
static hasInstance(): boolean {
return ConfigManager.instance !== undefined;
}

/**
* Load and validate configuration from environment variables
*/
Expand All @@ -262,7 +291,7 @@ export class ConfigManager {
},
server: {
name: process.env.SERVER_NAME || 'bookstack-mcp-server',
version: process.env.SERVER_VERSION || '1.0.0',
version: process.env.SERVER_VERSION || VERSION,
port: parseInt(process.env.SERVER_PORT || '3000', 10),
},
rateLimit: {
Expand Down
4 changes: 3 additions & 1 deletion src/tools/server-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ export class ServerInfoTools {

const serverInfo: MCPServerInfo = {
name: 'BookStack MCP Server',
version: '1.0.0',
// From config, like MCP initialize and GET / — not a literal. A literal
// here is invisible to release-please and would outlive every release.
version: config.server.version,
description:
'Comprehensive MCP server providing full access to BookStack knowledge management system. Enables LLMs to read, write, organize, and manage documentation, books, pages, chapters, users, and system settings.',
capabilities: {
Expand Down
15 changes: 15 additions & 0 deletions src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import pkg from '../package.json' with { type: 'json' };

/**
* The single source of truth for the version this server reports.
*
* It is read from package.json because that is the ONE file release-please's node
* strategy actually rewrites — it updates package.json, the lockfile and the
* CHANGELOG, and does not touch arbitrary TypeScript or env files. A version
* literal anywhere else in src/ silently keeps its old value through a release,
* so a 2.0.0 tarball would introduce itself as 1.0.0 over MCP initialize, from
* GET /, and from bookstack_server_info — exactly the artifact clients use to
* tell the Node-capable 1.0.0 server from the Bun-only 2.0.0 one. npm always
* ships package.json in the tarball, so this resolves for consumers too.
*/
export const VERSION: string = pkg.version;
78 changes: 73 additions & 5 deletions tests/transport/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import pkg from '../../package.json' with { type: 'json' };
import {
type Config,
ConfigManager,
Expand Down Expand Up @@ -44,6 +45,11 @@ const PINNED_ENV = [
'LOG_FORMAT',
'RATE_LIMIT_REQUESTS_PER_MINUTE',
'RATE_LIMIT_BURST_LIMIT',
// The identity tests below set and clear this. Without it here,
// savedEnv.get('SERVER_VERSION') is always undefined, so restoreEnv() DELETES an
// inherited value rather than restoring it — and afterAll, iterating this same
// list, cannot put it back either.
'SERVER_VERSION',
] as const;

const savedEnv = new Map<string, string | undefined>();
Expand Down Expand Up @@ -92,11 +98,17 @@ afterAll(() => {
for (const key of PINNED_ENV) {
restoreEnv(key);
}
try {
ConfigManager.getInstance().reload();
} catch {
// The restored environment may not validate on its own (e.g. no BOOKSTACK_API_TOKEN
// in a plain `bun test`). That is the state we found it in, so leave it there.
// NOT `try { reload() } catch {}`. reload() assigns only when loadConfig()
// succeeds, so on the ordinary inherited environment — no BOOKSTACK_API_TOKEN —
// it throws before assigning and the catch leaves the singleton holding THIS
// suite's snapshot: a closed-port BookStack fixture, and whatever version the
// identity tests last loaded. The environment and ConfigManager would then
// describe different processes, and the next file believes the singleton.
// Dropping it makes the next getInstance() revalidate what is really there.
ConfigManager.resetInstance();

if (ConfigManager.hasInstance()) {
throw new Error('http transport suite left a ConfigManager singleton for the next file');
}
});

Expand Down Expand Up @@ -270,6 +282,62 @@ describe('HTTP transport configuration', () => {
});
});

describe('GET / identity', () => {
/**
* The other surface that announces a version (MCP `initialize` is asserted in
* stdio.test.ts). release-please rewrites only package.json, so a literal here
* would survive a release and the 2.0.0 tarball would report 1.0.0 from an
* artifact npm cannot replace.
*
* The sentinel is deliberately NOT the package's own version: while package.json
* says 1.0.0, asserting package.json#version passes just as happily against a
* hard-coded '1.0.0'. Driving the real route with a value the source cannot
* contain is what makes the assertion mean something.
*/
it('reports the configured version, not a literal', async () => {
setEnv('SERVER_VERSION', '9.8.7-sentinel');
const appConfig = ConfigManager.getInstance().reload();
const { url } = await startApp(
{ bodyLimitBytes: DEFAULT_HTTP_BODY_LIMIT_BYTES, authToken: TEST_AUTH_TOKEN },
appConfig
);

const body = (await (await fetch(`${url}/`)).json()) as { version?: string };

expect(body.version).toBe('9.8.7-sentinel');

restoreEnv('SERVER_VERSION');
config = ConfigManager.getInstance().reload();
});

/**
* The path a RELEASE takes. The test above only proves this route can read the
* override; the sentinel arrives through the very branch a default-only-stale bug
* keeps working. `.env.example` and the runbook tell operators to leave
* SERVER_VERSION unset, so THIS is the branch the published artifact runs —
* demonstrated, not theorised: routing just the two reads in src/server.ts through
* such a branch left the whole suite green.
*
* Coincidence-bound while package.json says 1.0.0; decisive the moment
* release-please writes 2.0.0, which is the release this must not get wrong.
*/
it('reports the package version when SERVER_VERSION is unset', async () => {
restoreEnv('SERVER_VERSION');
delete process.env.SERVER_VERSION;
const appConfig = ConfigManager.getInstance().reload();
const { url } = await startApp(
{ bodyLimitBytes: DEFAULT_HTTP_BODY_LIMIT_BYTES, authToken: TEST_AUTH_TOKEN },
appConfig
);

const body = (await (await fetch(`${url}/`)).json()) as { version?: string };

expect(body.version).toBe(pkg.version);

config = ConfigManager.getInstance().reload();
});
});

describe('HTTP transport startup', () => {
it('fails closed when MCP_AUTH_TOKEN is unset', () => {
// No app is built at all, so "unset" can never degrade into "no auth required".
Expand Down
41 changes: 41 additions & 0 deletions tests/transport/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import { afterEach, beforeAll, describe, expect, it } from 'bun:test';
import { join } from 'node:path';
import type { Subprocess } from 'bun';
import pkg from '../../package.json' with { type: 'json' };
import { type BookStackStub, startBookStackStub } from './stub-bookstack';

const REPO_ROOT = join(import.meta.dir, '..', '..');
Expand Down Expand Up @@ -256,6 +257,46 @@ async function initialize(server: StdioServer): Promise<JsonRpcMessage> {
}

describe('stdio entry point', () => {
/**
* The identity an MCP client actually reads. release-please rewrites only
* package.json, so a version literal anywhere in src/ would survive a release and
* the 2.0.0 tarball would introduce itself as 1.0.0 here — in an artifact npm
* cannot replace.
*
* This proves the override is honoured, and nothing more. It CANNOT see a
* consumer that reads config only when SERVER_VERSION is set and falls back to a
* stale literal otherwise — the sentinel arrives through the very branch such a
* bug keeps working. The test below covers the other branch.
*/
it('announces the configured version to an MCP client', async () => {
const server = spawnStdioServer({ SERVER_VERSION: '9.8.7-sentinel' });

const initReply = await initialize(server);

expect(initReply.error).toBeUndefined();
expect(initReply.result?.serverInfo?.version).toBe('9.8.7-sentinel');
}, 20_000);

/**
* The path a RELEASE takes: `.env.example` and the runbook tell operators to
* leave SERVER_VERSION unset, so this branch — not the override above — is what
* the published artifact runs. Demonstrated rather than theorised: routing just
* the two reads in src/server.ts through a default-only-stale branch left the
* whole suite green.
*
* Coincidence-bound while package.json says 1.0.0 — a literal matches it — which
* is why the override test and the spelling scan stay. It becomes decisive in the
* real Release PR, the moment release-please writes 2.0.0.
*/
it('announces the package version when SERVER_VERSION is unset', async () => {
const server = spawnStdioServer();

const initReply = await initialize(server);

expect(initReply.error).toBeUndefined();
expect(initReply.result?.serverInfo?.version).toBe(pkg.version);
}, 20_000);

it(
'completes an MCP handshake and lists all 56 tools',
async () => {
Expand Down
10 changes: 10 additions & 0 deletions tests/transport/stub-bookstack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ export interface RecordedRequest {
/** Query string parsed into a plain object; repeated keys keep the last value. */
query: Record<string, string>;
authorization: string | undefined;
/**
* The `User-Agent` as it arrived ON THE WIRE.
*
* Recorded rather than inferred from the client's construction-time defaults: an
* interceptor or a per-request header can replace that default after the fact, so
* reading `axios.defaults` proves what was configured, not what was sent. This is
* the server's view — the only one that settles it.
*/
userAgent: string | undefined;
}

export interface BookStackStub {
Expand Down Expand Up @@ -114,6 +123,7 @@ export function startBookStackStub(): BookStackStub {
path,
query: Object.fromEntries(url.searchParams),
authorization,
userAgent: request.headers.get('user-agent') ?? undefined,
});

// The client sends `Authorization: Token <id>:<secret>`. Checking it here is what
Expand Down
Loading