@hoyongjin/gitbook-mcp 1.0.0 → 2.0.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/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.0.0] - 2026-06-23
8
+
9
+ Full GitBook API coverage: the server now exposes **all 345 practical operations**
10
+ (346 of 355 namespaced ops; 8 streaming/trivial carve-outs and 1 root system-info op
11
+ are intentionally not exposed), organized into **config-gated toolsets**.
12
+
13
+ > **Compatibility:** the original **11 tools keep their exact names and behavior** and
14
+ > are **enabled by default** (in the `core` + `change-requests` toolsets), including
15
+ > `gitbook_merge_change_request`. Upgrading changes the _default_ `tools/list` surface
16
+ > (now ~89 tools: the 11 + the change-request review/merge loop + content reads) and the
17
+ > test/conformance contract, hence the major bump.
18
+
19
+ ### Added
20
+
21
+ - **Toolsets:** `GITBOOK_TOOLSETS` (CSV of toolset keys, or `all`; unset → `core,change-requests`;
22
+ unknown key fails closed). Toolsets: `core`, `change-requests`, `collections`, `sites`,
23
+ `site-ai`, `site-insights`, `integrations`, `data`, `git`, `public`, `org-admin`. Plus
24
+ `--toolsets` CLI flag.
25
+ - **Destructive opt-in:** `GITBOOK_ALLOW_DESTRUCTIVE` (+ `--allow-destructive`). NEW delete/
26
+ unpublish/admin operations register only with this opt-in **and** `!readOnly`; the legacy
27
+ `merge` tool is exempt (back-compat).
28
+ - **Generated tool manifest** (`scripts/generate-manifest.mjs` → `src/tools/manifest.ts`):
29
+ the single source of truth for the tool surface (counts, namespace, verb, tags, toolset,
30
+ kind, return shape, path/body params). Re-run with `npm run generate:manifest`; a CI step
31
+ fails on drift, and a contract test asserts every operation exists on `@gitbook/api`.
32
+ - **Generic invoker + factory:** `GitBookClient.call`/`callList` and a manifest-driven handler
33
+ factory register every non-bespoke operation uniformly.
34
+ - Generalized URL safety (`isSafeFetchUrl`) now covers git import/export and context-connection
35
+ URLs (http(s)-only, no embedded credentials), not just `import_content`.
36
+
37
+ ### Changed
38
+
39
+ - The DNS-rebinding, redaction, resilient-fetch, metrics, audit-log, and read-only guarantees
40
+ are unchanged and apply to every new tool. `@gitbook/api` is pinned to an exact version
41
+ (churn guard for the `0.0.1-beta` API).
42
+
43
+ ## [1.0.1] - 2026-06-23
44
+
45
+ Re-released through CI with **OIDC trusted publishing** (no long-lived token) and a
46
+ signed **provenance** attestation — 1.0.0 was bootstrapped with a local publish and
47
+ is unsigned. **No functional changes**; the code is identical to 1.0.0.
48
+
7
49
  ## [1.0.0] - 2026-06-23
8
50
 
9
51
  First public release: an MCP server for GitBook exposing **7 read tools** and a
@@ -53,4 +95,6 @@ First public release: an MCP server for GitBook exposing **7 read tools** and a
53
95
  > The CI publish job runs on a GitHub Release and requires the `@hoyongjin` scope to
54
96
  > be owned by the publishing npm account and an `NPM_TOKEN` secret in the repo.
55
97
 
98
+ [2.0.0]: https://github.com/HoYongJin/gitbook-mcp/releases/tag/v2.0.0
99
+ [1.0.1]: https://github.com/HoYongJin/gitbook-mcp/releases/tag/v1.0.1
56
100
  [1.0.0]: https://github.com/HoYongJin/gitbook-mcp/releases/tag/v1.0.0
package/README.md CHANGED
@@ -8,14 +8,15 @@ content and drive a **change-request write workflow**, over **stdio** or
8
8
  Built on the official [`@gitbook/api`](https://www.npmjs.com/package/@gitbook/api)
9
9
  client and [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) 1.x.
10
10
 
11
- - **11 tools** — 7 read + 4 change-request write (write tools are hidden in read-only mode).
11
+ - **Full API coverage** — ~345 tools across 11 config-gated **toolsets** (default: `core` + `change-requests`; write/destructive tools gated by read-only mode + an opt-in).
12
12
  - **Resources** — pages addressable as `gitbook://{spaceId}/{pageId}`.
13
13
  - **Two transports** — stdio (local) and streamable HTTP (multi-session, bearer-gated).
14
14
  - **Resilient** — request timeouts, bounded retries with full-jitter backoff, rate-limit aware.
15
15
  - **Safe** — read-only mode, env-only token, secret redaction, localhost-bound HTTP.
16
16
 
17
- > **Status:** the read path and the change-request write workflow are complete and
18
- > tested (103 unit/protocol tests). The **stdio** transport is ready for local
17
+ > **Status:** full GitBook API coverage (~345 tools across 11 toolsets), with the
18
+ > original 11 tools enabled by default. Tested with unit, MCP-protocol, and
19
+ > manifest-contract tests. The **stdio** transport is ready for local
19
20
  > IDE/CLI use; the **HTTP** transport is hardened for hosted use — bearer auth,
20
21
  > DNS-rebinding protection, a session cap + idle reaper, per-IP rate limiting,
21
22
  > health/readiness probes, and Prometheus metrics. To publish: push to
@@ -96,7 +97,9 @@ The token is **never** taken from a CLI argument.
96
97
  | -------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
97
98
  | `GITBOOK_TOKEN` | — | **Required.** Personal access token (account-wide privileges). |
98
99
  | `GITBOOK_ENDPOINT` | `https://api.gitbook.com` | API base URL. |
99
- | `GITBOOK_READONLY` | `false` | Hide all write tools when true. |
100
+ | `GITBOOK_READONLY` | `false` | Hide all write/destructive tools when true. |
101
+ | `GITBOOK_TOOLSETS` | `core,change-requests` | Comma-separated toolsets to enable, or `all`. Unset/empty → the default two. Unknown key fails closed. See [Toolsets](#tools). |
102
+ | `GITBOOK_ALLOW_DESTRUCTIVE` | `false` | Opt-in to register NEW destructive ops (deletes, unpublish, admin removals). The legacy `merge` tool is exempt. |
100
103
  | `GITBOOK_TRANSPORT` | `stdio` | `stdio` or `http`. |
101
104
  | `GITBOOK_HTTP_HOST` | `127.0.0.1` | HTTP bind host. |
102
105
  | `GITBOOK_HTTP_PORT` | `3000` | HTTP port. |
@@ -114,30 +117,46 @@ The token is **never** taken from a CLI argument.
114
117
  | `GITBOOK_MAX_RETRIES` | `5` | Max retries on 429/5xx/transport errors (0–10). |
115
118
  | `GITBOOK_MAX_CONCURRENCY` | `8` | Max concurrent outbound GitBook HTTP requests per instance (1–256). |
116
119
 
117
- CLI flags: `--stdio` / `--http` (transport), `--read-only`, `--port <n>`.
120
+ CLI flags: `--stdio` / `--http` (transport), `--read-only`, `--toolsets <csv|all>`,
121
+ `--allow-destructive`, `--port <n>`.
118
122
 
119
123
  ## Tools
120
124
 
121
- | Tool | R/W | Purpose |
122
- | -------------------------------- | ----------------------- | ------------------------------------------------------------ |
123
- | `gitbook_whoami` | read | Authenticated user. |
124
- | `gitbook_list_orgs` | read | Organizations you can access. |
125
- | `gitbook_list_spaces` | read | Spaces in an org. |
126
- | `gitbook_get_space` | read | One space by id. |
127
- | `gitbook_list_pages` | read | Page tree of a space (not paginated). |
128
- | `gitbook_get_page` | read | A page as **markdown** (default) or structured **document**. |
129
- | `gitbook_search` | read | Full-text search (org- or space-scoped; exactly one). |
130
- | `gitbook_create_change_request` | write | Open a draft change request. |
131
- | `gitbook_import_content` | write | Import a URL into a space/change-request (AI-enhanced). |
132
- | `gitbook_comment_change_request` | write | Post a markdown comment on a change request. |
133
- | `gitbook_merge_change_request` | **write · destructive** | Publish a change request into the live space. |
134
-
135
- Tools that paginate (`list_orgs`, `list_spaces`, `search`) accept `limit` + a
136
- `cursor`, and return `nextCursor` when more results exist pass it back to fetch
137
- the next page.
138
-
139
- Typical write flow: `create_change_request` `import_content` (target that CR) →
140
- review in GitBook `merge_change_request`.
125
+ The server covers the **full GitBook API** — ~345 tools — organized into **toolsets**
126
+ you enable with `GITBOOK_TOOLSETS`. By default only **`core` + `change-requests`** are
127
+ on (~89 tools), which keeps `tools/list` small and the model's tool selection sharp.
128
+ Set `GITBOOK_TOOLSETS=all` for everything, or list the ones you need
129
+ (e.g. `GITBOOK_TOOLSETS=core,change-requests,sites`).
130
+
131
+ Every tool is one API endpoint with its own input schema; read tools are always
132
+ available, write tools need `!readOnly`, and NEW destructive tools (deletes, unpublish,
133
+ admin removals) additionally need `GITBOOK_ALLOW_DESTRUCTIVE=true`.
134
+
135
+ | Toolset | Default | Scope |
136
+ | ----------------- | :-----: | ------------------------------------------------------------------------- |
137
+ | `core` | | Identity, orgs/spaces/pages reads, search, content + `import_content`. |
138
+ | `change-requests` | ✅ | Full CR loop: create, list/diff, reviews, reviewers, comments, **merge**. |
139
+ | `collections` | | Collections + collection permissions. |
140
+ | `sites` | | Sites, structure, customization, redirects, share links, permissions. |
141
+ | `site-ai` | | Ask AI, agent settings, questions, glossary-adjacent AI. |
142
+ | `site-insights` | | Analytics/insights, context connections, channels, site MCP servers. |
143
+ | `integrations` | | Integration install/configure/token + space integrations. |
144
+ | `data` | | OpenAPI specs, translations, glossary, custom fonts, storage. |
145
+ | `git` | | Git Sync: import/export, provider installations, repo/branch listing. |
146
+ | `public` | | URL→content resolution, subdomains, custom hostnames. |
147
+ | `org-admin` | | **Org members, teams, invites, SSO, permissions** (highest privilege). |
148
+
149
+ The **11 original tools keep their exact names** and live in `core` / `change-requests`,
150
+ so existing setups are unchanged. Per-tool parameters are delivered live via each tool's
151
+ MCP description + input schema.
152
+
153
+ **Excluded carve-outs:** 5 streaming (SSE) AI endpoints, 2 ephemeral PDF-URL generators,
154
+ and a few trivial auth/heartbeat ops are not exposed as tools (the non-streaming AI
155
+ equivalents are).
156
+
157
+ List tools (`*_list_*`, `search`) accept a `body` with `limit`/`page` and return
158
+ `nextCursor` when more results exist. Typical write flow:
159
+ `create_change_request` → `import_content` (target that CR) → review/diff → `merge_change_request`.
141
160
 
142
161
  ## Resources
143
162
 
@@ -175,7 +194,8 @@ npm install
175
194
  npm run typecheck # tsc on src + test
176
195
  npm run lint # eslint
177
196
  npm run format # prettier --write (format:check verifies)
178
- npm test # vitest — 103 tests across 12 files
197
+ npm test # vitest — unit + protocol + manifest-contract tests
198
+ npm run generate:manifest # regenerate src/tools/manifest.ts from @gitbook/api (CI checks drift)
179
199
  npm run test:coverage # + v8 coverage with an 80% gate
180
200
  npm run build # tsc → dist/ (+ chmod the bin)
181
201
  ```
@@ -200,10 +220,14 @@ src/
200
220
  gitbook/
201
221
  client.ts typed wrapper over @gitbook/api (cursor pagination → {items,nextCursor})
202
222
  resilient-fetch.ts timeouts + retry/backoff + concurrency cap; injected as the client's fetch
203
- import-url.ts SSRF guard for import sourceUrl (http(s) only, no credentials)
223
+ import-url.ts URL guard for server-side-fetch ops (http(s) only, no credentials)
204
224
  errors.ts HTTP/transport error classification → safe messages
205
225
  tools/
206
- read.ts write.ts one registrar each; index.ts gates writes by read-only mode
226
+ manifest.ts GENERATED tool surface (one row per API op; source of truth)
227
+ toolsets.ts toolset keys + defaults
228
+ index.ts manifest-driven registration under toolset/read-only/destructive gates
229
+ factory.ts generic handler factory (every non-bespoke op)
230
+ bespoke.ts the 11 grandfathered tools (hand-written handlers)
207
231
  shared.ts ToolContext + result/guard helpers + annotation presets
208
232
  transports/
209
233
  stdio.ts http.ts the two transports (http: sessions, rate limit, health, metrics)
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ToolsetKey } from "./tools/toolsets.js";
1
2
  /**
2
3
  * Centralized, validated configuration. Read `process.env` (and a few CLI
3
4
  * flags) EXACTLY ONCE, here, into a frozen typed object. Nothing else in the
@@ -14,6 +15,14 @@ export interface Config {
14
15
  readonly endpoint: string;
15
16
  readonly userAgent: string;
16
17
  readonly readOnly: boolean;
18
+ /** Enabled toolsets (gating groups). Tools outside these are not registered. */
19
+ readonly enabledToolsets: ReadonlySet<ToolsetKey>;
20
+ /**
21
+ * Allow NEW destructive operations (deletes, unpublish, admin removals) to
22
+ * register. Off by default; the four legacy write tools (incl. merge) are
23
+ * exempt and remain available under `!readOnly` alone for back-compat.
24
+ */
25
+ readonly allowDestructive: boolean;
17
26
  readonly transport: Transport;
18
27
  readonly http: {
19
28
  readonly host: string;
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { TOOLSET_KEYS, DEFAULT_TOOLSETS } from "./tools/toolsets.js";
2
3
  export class ConfigError extends Error {
3
4
  name = "ConfigError";
4
5
  }
@@ -27,6 +28,10 @@ const EnvSchema = z.object({
27
28
  GITBOOK_ENDPOINT: z.string().url().default("https://api.gitbook.com"),
28
29
  GITBOOK_USER_AGENT: z.string().min(1).optional(),
29
30
  GITBOOK_READONLY: boolish.default(false),
31
+ // CSV of toolset keys to enable, or "all". Empty/unset → DEFAULT_TOOLSETS.
32
+ // Validated against TOOLSET_KEYS in resolveToolsets (unknown key → ConfigError).
33
+ GITBOOK_TOOLSETS: z.string().optional(),
34
+ GITBOOK_ALLOW_DESTRUCTIVE: boolish.default(false),
30
35
  GITBOOK_TRANSPORT: z.enum(["stdio", "http"]).default("stdio"),
31
36
  GITBOOK_HTTP_HOST: z.string().min(1).default("127.0.0.1"),
32
37
  GITBOOK_HTTP_PORT: z.coerce.number().int().min(1).max(65535).default(3000),
@@ -49,7 +54,26 @@ const EnvSchema = z.object({
49
54
  GITBOOK_MAX_RETRIES: z.coerce.number().int().min(0).max(10).default(5),
50
55
  GITBOOK_MAX_CONCURRENCY: z.coerce.number().int().min(1).max(256).default(8),
51
56
  });
52
- /** CLI flags that override env (transport selection + read-only safety). */
57
+ /**
58
+ * Resolve the enabled toolset set from env + CLI. Empty/unset → DEFAULT_TOOLSETS
59
+ * (back-compat); `all` → every key; any unknown key fails closed with a
60
+ * ConfigError (a typo must never silently widen or empty the surface).
61
+ */
62
+ function resolveToolsets(raw, cli) {
63
+ const tokens = [...csv(raw), ...csv(cli)];
64
+ if (tokens.length === 0)
65
+ return new Set(DEFAULT_TOOLSETS);
66
+ if (tokens.includes("all"))
67
+ return new Set(TOOLSET_KEYS);
68
+ const known = new Set(TOOLSET_KEYS);
69
+ const bad = tokens.filter((t) => !known.has(t));
70
+ if (bad.length > 0) {
71
+ throw new ConfigError(`Unknown GITBOOK_TOOLSETS value(s): ${bad.join(", ")}. ` +
72
+ `Valid: ${TOOLSET_KEYS.join(", ")}, all.`);
73
+ }
74
+ return new Set(tokens);
75
+ }
76
+ /** CLI flags that override env (transport selection + read-only safety + toolsets). */
53
77
  function parseArgvOverrides(argv) {
54
78
  const out = {};
55
79
  for (let i = 0; i < argv.length; i++) {
@@ -60,6 +84,16 @@ function parseArgvOverrides(argv) {
60
84
  out.transport = "stdio";
61
85
  else if (a === "--read-only" || a === "--readonly")
62
86
  out.readOnly = true;
87
+ else if (a === "--allow-destructive")
88
+ out.allowDestructive = true;
89
+ else if (a === "--toolsets") {
90
+ const next = argv[i + 1];
91
+ if (!next || next.startsWith("--")) {
92
+ throw new ConfigError("--toolsets requires a comma-separated value (or 'all')");
93
+ }
94
+ out.toolsets = next;
95
+ i++;
96
+ }
63
97
  else if (a === "--port") {
64
98
  const next = argv[i + 1];
65
99
  const n = next ? Number(next) : NaN;
@@ -89,6 +123,8 @@ export function loadConfig(env = process.env, argv = process.argv.slice(2)) {
89
123
  endpoint: e.GITBOOK_ENDPOINT,
90
124
  userAgent: e.GITBOOK_USER_AGENT ?? `gitbook-mcp`,
91
125
  readOnly: overrides.readOnly ?? e.GITBOOK_READONLY,
126
+ enabledToolsets: resolveToolsets(e.GITBOOK_TOOLSETS, overrides.toolsets),
127
+ allowDestructive: overrides.allowDestructive ?? e.GITBOOK_ALLOW_DESTRUCTIVE,
92
128
  transport: overrides.transport ?? e.GITBOOK_TRANSPORT,
93
129
  http: Object.freeze({
94
130
  host: e.GITBOOK_HTTP_HOST,
@@ -2,6 +2,7 @@ import { GitBookAPI } from "@gitbook/api";
2
2
  import type { ChangeRequest, Comment, ContentImportRun, Organization, RevisionPage, SearchPageResult, SearchSpaceResult, Space, User } from "@gitbook/api";
3
3
  import type { Config } from "../config.js";
4
4
  import type { Logger } from "../logger.js";
5
+ import type { ApiNamespace } from "../tools/manifest.js";
5
6
  /** A single page of a cursor-paginated GitBook list. */
6
7
  export interface Page<T> {
7
8
  readonly items: T[];
@@ -53,4 +54,18 @@ export declare class GitBookClient {
53
54
  revision: string;
54
55
  result: "merge" | "conflicts";
55
56
  }>;
57
+ /**
58
+ * Generic pass-through for the operations not worth a bespoke method. Calls
59
+ * `api[ns][method](...args)` and unwraps `HttpResponse.data`. Every call still
60
+ * flows through the injected resilient-fetch (retries/timeout/concurrency,
61
+ * idempotency-aware) because that lives on the api's serviceBinding — nothing
62
+ * here bypasses it. The `(ns, method)` pair is manifest-validated and covered
63
+ * by a contract test, so the not-a-function guard is purely defensive.
64
+ */
65
+ call<T = unknown>(ns: ApiNamespace, method: string, args: readonly unknown[]): Promise<T>;
66
+ /**
67
+ * Like `call`, but normalizes a cursor-paginated `{ items, next.page }` list
68
+ * response to `Page<T>`. Only for ops the manifest marks `returnShape:"items"`.
69
+ */
70
+ callList<T = unknown>(ns: ApiNamespace, method: string, args: readonly unknown[]): Promise<Page<T>>;
56
71
  }
@@ -1,5 +1,6 @@
1
1
  import { GitBookAPI } from "@gitbook/api";
2
2
  import { SERVER_VERSION } from "../version.js";
3
+ import { ToolInputError } from "../tools/shared.js";
3
4
  import { createResilientFetch } from "./resilient-fetch.js";
4
5
  import { assertSafeImportUrl } from "./import-url.js";
5
6
  /**
@@ -106,4 +107,32 @@ export class GitBookClient {
106
107
  async mergeChangeRequest(spaceId, changeRequestId) {
107
108
  return (await this.api.spaces.mergeChangeRequest(spaceId, changeRequestId)).data;
108
109
  }
110
+ // ── generic invoker (manifest-driven tools) ─────────────────────────────────
111
+ /**
112
+ * Generic pass-through for the operations not worth a bespoke method. Calls
113
+ * `api[ns][method](...args)` and unwraps `HttpResponse.data`. Every call still
114
+ * flows through the injected resilient-fetch (retries/timeout/concurrency,
115
+ * idempotency-aware) because that lives on the api's serviceBinding — nothing
116
+ * here bypasses it. The `(ns, method)` pair is manifest-validated and covered
117
+ * by a contract test, so the not-a-function guard is purely defensive.
118
+ */
119
+ async call(ns, method, args) {
120
+ const namespace = this.api[ns];
121
+ const fn = namespace?.[method];
122
+ if (typeof fn !== "function") {
123
+ throw new ToolInputError(`Unknown GitBook operation ${ns}.${method}`);
124
+ }
125
+ // The client's methods are arrow-function properties (no `this` binding to
126
+ // preserve), so a spread call is equivalent to apply and accepts readonly args.
127
+ const res = (await fn(...args));
128
+ return res.data;
129
+ }
130
+ /**
131
+ * Like `call`, but normalizes a cursor-paginated `{ items, next.page }` list
132
+ * response to `Page<T>`. Only for ops the manifest marks `returnShape:"items"`.
133
+ */
134
+ async callList(ns, method, args) {
135
+ const data = await this.call(ns, method, args);
136
+ return { items: data.items, nextCursor: data.next?.page };
137
+ }
109
138
  }
@@ -1,23 +1,30 @@
1
1
  /**
2
- * Scheme + credential validation for the `gitbook_import_content` source URL.
3
- * We require http(s) and forbid embedded credentials (`user:pass@`): the former
2
+ * Scheme + credential validation for any URL we hand to GitBook to fetch
3
+ * server-side: the `gitbook_import_content` source URL, and (when those toolsets
4
+ * ship) git import/export URLs and site context-connection website URLs. We
5
+ * require http(s) and forbid embedded credentials (`user:pass@`): the former
4
6
  * blocks non-web schemes (file:, gopher:, …); the latter stops a third-party
5
7
  * credential the user pasted into the URL from being echoed back (GitBook
6
- * returns the source URL in the import run, which the tool surfaces to the model).
8
+ * returns the source URL in its response, which the tool surfaces to the model).
7
9
  *
8
10
  * NOTE — this is NOT a full SSRF guard. This process never resolves or fetches
9
11
  * the URL; GitBook does, server-side. So protection against internal / loopback
10
12
  * / cloud-metadata targets (127.0.0.1, 169.254.169.254, etc.) is GitBook's
11
13
  * responsibility, not something a host/IP denylist here could enforce.
12
14
  *
13
- * `isSafeImportUrl` is used as a zod refinement at the tool boundary (so a bad
14
- * value becomes a clean validation error), and `assertSafeImportUrl` is used in
15
- * the client as defense-in-depth and to normalize the URL.
15
+ * `isSafeFetchUrl` is the zod-refinement form (a bad value becomes a clean
16
+ * validation error); `assertSafeFetchUrl` is the throwing/normalizing form used
17
+ * in the client as defense-in-depth. `isSafeImportUrl` / `assertSafeImportUrl`
18
+ * are the import_content-specific aliases kept for back-compat.
16
19
  */
17
20
  export declare class ImportUrlError extends Error {
18
21
  readonly name = "ImportUrlError";
19
22
  }
20
- /** Predicate form for zod `.refine`. Returns false for any unsafe/invalid URL. */
21
- export declare function isSafeImportUrl(raw: string): boolean;
22
- /** Throwing/normalizing form for the client. Returns the normalized URL string. */
23
+ /** Predicate form for zod `.refine`. http(s) only, no embedded credentials. */
24
+ export declare function isSafeFetchUrl(raw: string): boolean;
25
+ /** Throwing/normalizing form. `label` names the offending field in errors. */
26
+ export declare function assertSafeFetchUrl(raw: string, label?: string): string;
27
+ /** Back-compat alias used as the `import_content` source-URL zod refinement. */
28
+ export declare const isSafeImportUrl: typeof isSafeFetchUrl;
29
+ /** Back-compat throwing form for `import_content` (labels errors "sourceUrl"). */
23
30
  export declare function assertSafeImportUrl(raw: string): string;
@@ -1,24 +1,27 @@
1
1
  /**
2
- * Scheme + credential validation for the `gitbook_import_content` source URL.
3
- * We require http(s) and forbid embedded credentials (`user:pass@`): the former
2
+ * Scheme + credential validation for any URL we hand to GitBook to fetch
3
+ * server-side: the `gitbook_import_content` source URL, and (when those toolsets
4
+ * ship) git import/export URLs and site context-connection website URLs. We
5
+ * require http(s) and forbid embedded credentials (`user:pass@`): the former
4
6
  * blocks non-web schemes (file:, gopher:, …); the latter stops a third-party
5
7
  * credential the user pasted into the URL from being echoed back (GitBook
6
- * returns the source URL in the import run, which the tool surfaces to the model).
8
+ * returns the source URL in its response, which the tool surfaces to the model).
7
9
  *
8
10
  * NOTE — this is NOT a full SSRF guard. This process never resolves or fetches
9
11
  * the URL; GitBook does, server-side. So protection against internal / loopback
10
12
  * / cloud-metadata targets (127.0.0.1, 169.254.169.254, etc.) is GitBook's
11
13
  * responsibility, not something a host/IP denylist here could enforce.
12
14
  *
13
- * `isSafeImportUrl` is used as a zod refinement at the tool boundary (so a bad
14
- * value becomes a clean validation error), and `assertSafeImportUrl` is used in
15
- * the client as defense-in-depth and to normalize the URL.
15
+ * `isSafeFetchUrl` is the zod-refinement form (a bad value becomes a clean
16
+ * validation error); `assertSafeFetchUrl` is the throwing/normalizing form used
17
+ * in the client as defense-in-depth. `isSafeImportUrl` / `assertSafeImportUrl`
18
+ * are the import_content-specific aliases kept for back-compat.
16
19
  */
17
20
  export class ImportUrlError extends Error {
18
21
  name = "ImportUrlError";
19
22
  }
20
- /** Predicate form for zod `.refine`. Returns false for any unsafe/invalid URL. */
21
- export function isSafeImportUrl(raw) {
23
+ /** Predicate form for zod `.refine`. http(s) only, no embedded credentials. */
24
+ export function isSafeFetchUrl(raw) {
22
25
  let url;
23
26
  try {
24
27
  url = new URL(raw);
@@ -32,20 +35,26 @@ export function isSafeImportUrl(raw) {
32
35
  return false;
33
36
  return true;
34
37
  }
35
- /** Throwing/normalizing form for the client. Returns the normalized URL string. */
36
- export function assertSafeImportUrl(raw) {
38
+ /** Throwing/normalizing form. `label` names the offending field in errors. */
39
+ export function assertSafeFetchUrl(raw, label = "url") {
37
40
  let url;
38
41
  try {
39
42
  url = new URL(raw);
40
43
  }
41
44
  catch {
42
- throw new ImportUrlError("sourceUrl is not a valid URL.");
45
+ throw new ImportUrlError(`${label} is not a valid URL.`);
43
46
  }
44
47
  if (url.protocol !== "http:" && url.protocol !== "https:") {
45
- throw new ImportUrlError(`sourceUrl must use http(s); got scheme "${url.protocol.replace(":", "")}".`);
48
+ throw new ImportUrlError(`${label} must use http(s); got scheme "${url.protocol.replace(":", "")}".`);
46
49
  }
47
50
  if (url.username !== "" || url.password !== "") {
48
- throw new ImportUrlError("sourceUrl must not contain embedded credentials (user:pass@).");
51
+ throw new ImportUrlError(`${label} must not contain embedded credentials (user:pass@).`);
49
52
  }
50
53
  return url.toString();
51
54
  }
55
+ /** Back-compat alias used as the `import_content` source-URL zod refinement. */
56
+ export const isSafeImportUrl = isSafeFetchUrl;
57
+ /** Back-compat throwing form for `import_content` (labels errors "sourceUrl"). */
58
+ export function assertSafeImportUrl(raw) {
59
+ return assertSafeFetchUrl(raw, "sourceUrl");
60
+ }
@@ -0,0 +1,12 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type ToolContext } from "./shared.js";
3
+ /**
4
+ * The 11 original, hand-written tools. They keep their grandfathered names and
5
+ * bespoke logic (search's orgId-XOR-spaceId guard, import's SSRF refinement,
6
+ * merge's conflict→isError handling) that the generic factory cannot express.
7
+ * Each entry registers exactly one tool; the manifest (rows flagged `bespoke`)
8
+ * drives WHEN they register, under the same toolset/readOnly/destructive gates
9
+ * as every other tool. Keyed by tool name; `gitbook_search` maps two API ops
10
+ * (org + space search) to a single tool, so it appears once here.
11
+ */
12
+ export declare const BESPOKE_TOOLS: Record<string, (server: McpServer, ctx: ToolContext) => void>;
@@ -0,0 +1,166 @@
1
+ import { z } from "zod";
2
+ import { jsonResult, errorResult, guard, READ_ANNOTATIONS, WRITE_ANNOTATIONS, DESTRUCTIVE_ANNOTATIONS, ToolInputError, } from "./shared.js";
3
+ import { isSafeImportUrl } from "../gitbook/import-url.js";
4
+ /** Audit write/destructive tools at info level (invoke + ok lines). */
5
+ const AUDIT = { audit: true };
6
+ const limit = z
7
+ .number()
8
+ .int()
9
+ .min(1)
10
+ .max(1000)
11
+ .optional()
12
+ .describe("Max results to return (server caps apply).");
13
+ const cursor = z
14
+ .string()
15
+ .optional()
16
+ .describe("Pagination cursor from a previous call's nextCursor.");
17
+ /**
18
+ * The 11 original, hand-written tools. They keep their grandfathered names and
19
+ * bespoke logic (search's orgId-XOR-spaceId guard, import's SSRF refinement,
20
+ * merge's conflict→isError handling) that the generic factory cannot express.
21
+ * Each entry registers exactly one tool; the manifest (rows flagged `bespoke`)
22
+ * drives WHEN they register, under the same toolset/readOnly/destructive gates
23
+ * as every other tool. Keyed by tool name; `gitbook_search` maps two API ops
24
+ * (org + space search) to a single tool, so it appears once here.
25
+ */
26
+ export const BESPOKE_TOOLS = {
27
+ gitbook_whoami: (server, ctx) => server.registerTool("gitbook_whoami", {
28
+ title: "Get authenticated user",
29
+ description: "Return the GitBook user the configured token authenticates as (id, name, email).",
30
+ inputSchema: {},
31
+ annotations: READ_ANNOTATIONS,
32
+ }, guard(ctx, "gitbook_whoami", async () => jsonResult(await ctx.gitbook.getAuthenticatedUser()))),
33
+ gitbook_list_orgs: (server, ctx) => server.registerTool("gitbook_list_orgs", {
34
+ title: "List organizations",
35
+ description: "List organizations the authenticated user can access. Use the returned id as orgId for other tools.",
36
+ inputSchema: { limit, cursor },
37
+ annotations: READ_ANNOTATIONS,
38
+ }, guard(ctx, "gitbook_list_orgs", async ({ limit, cursor }) => jsonResult(await ctx.gitbook.listOrganizations({ limit, cursor })))),
39
+ gitbook_list_spaces: (server, ctx) => server.registerTool("gitbook_list_spaces", {
40
+ title: "List spaces in an organization",
41
+ description: "List the spaces inside an organization. Get orgId from gitbook_list_orgs.",
42
+ inputSchema: { orgId: z.string().describe("Organization id."), limit, cursor },
43
+ annotations: READ_ANNOTATIONS,
44
+ }, guard(ctx, "gitbook_list_spaces", async ({ orgId, limit, cursor }) => jsonResult(await ctx.gitbook.listSpaces(orgId, { limit, cursor })))),
45
+ gitbook_get_space: (server, ctx) => server.registerTool("gitbook_get_space", {
46
+ title: "Get a space",
47
+ description: "Fetch a single space by id (title, visibility, urls, default revision).",
48
+ inputSchema: { spaceId: z.string().describe("Space id.") },
49
+ annotations: READ_ANNOTATIONS,
50
+ }, guard(ctx, "gitbook_get_space", async ({ spaceId }) => jsonResult(await ctx.gitbook.getSpace(spaceId)))),
51
+ gitbook_list_pages: (server, ctx) => server.registerTool("gitbook_list_pages", {
52
+ title: "List pages in a space",
53
+ description: "List the page tree of a space's current revision (page ids, titles, paths). Not paginated.",
54
+ inputSchema: { spaceId: z.string().describe("Space id.") },
55
+ annotations: READ_ANNOTATIONS,
56
+ }, guard(ctx, "gitbook_list_pages", async ({ spaceId }) => jsonResult({ pages: await ctx.gitbook.listPages(spaceId) }))),
57
+ gitbook_get_page: (server, ctx) => server.registerTool("gitbook_get_page", {
58
+ title: "Get a page's content",
59
+ description: "Read a page by id. format='markdown' (default) returns rendered markdown; format='document' returns GitBook's structured Document JSON.",
60
+ inputSchema: {
61
+ spaceId: z.string().describe("Space id."),
62
+ pageId: z.string().describe("Page id (from gitbook_list_pages)."),
63
+ format: z
64
+ .enum(["markdown", "document"])
65
+ .optional()
66
+ .describe("Output format (default markdown)."),
67
+ },
68
+ annotations: READ_ANNOTATIONS,
69
+ }, guard(ctx, "gitbook_get_page", async ({ spaceId, pageId, format }) => jsonResult(await ctx.gitbook.getPage(spaceId, pageId, format ?? "markdown")))),
70
+ gitbook_search: (server, ctx) => server.registerTool("gitbook_search", {
71
+ title: "Search content",
72
+ description: "Full-text search. Provide orgId to search an organization, or spaceId to search a single space (exactly one is required).",
73
+ inputSchema: {
74
+ query: z.string().min(1).max(512).describe("Search query."),
75
+ orgId: z.string().optional().describe("Organization id (search org-wide)."),
76
+ spaceId: z.string().optional().describe("Space id (search one space)."),
77
+ limit,
78
+ cursor,
79
+ },
80
+ annotations: READ_ANNOTATIONS,
81
+ }, guard(ctx, "gitbook_search", async ({ query, orgId, spaceId, limit, cursor }) => {
82
+ if (!orgId && !spaceId) {
83
+ return errorResult(new ToolInputError("Provide either orgId or spaceId."), ctx.config.token);
84
+ }
85
+ if (orgId && spaceId) {
86
+ return errorResult(new ToolInputError("Provide only one of orgId or spaceId, not both."), ctx.config.token);
87
+ }
88
+ const page = spaceId
89
+ ? await ctx.gitbook.searchSpace(spaceId, query, { limit, cursor })
90
+ : await ctx.gitbook.searchOrganization(orgId, query, { limit, cursor });
91
+ return jsonResult(page);
92
+ })),
93
+ gitbook_create_change_request: (server, ctx) => server.registerTool("gitbook_create_change_request", {
94
+ title: "Open a change request",
95
+ description: "Create a change request (draft branch) on a space. Returns its id to target with gitbook_import_content / gitbook_comment_change_request, then gitbook_merge_change_request to publish.",
96
+ inputSchema: {
97
+ spaceId: z.string().describe("Space id."),
98
+ subject: z.string().optional().describe("Title/subject of the change request."),
99
+ },
100
+ annotations: WRITE_ANNOTATIONS,
101
+ }, guard(ctx, "gitbook_create_change_request", async ({ spaceId, subject }) => jsonResult(await ctx.gitbook.createChangeRequest(spaceId, subject)), AUDIT)),
102
+ gitbook_import_content: (server, ctx) => server.registerTool("gitbook_import_content", {
103
+ title: "Import content into a change request",
104
+ description: "GitBook's content-write primitive: import a public web page (sourceUrl) into a space — scoped to a change request (and optionally a page), AI-enhanced by default. There is NO direct 'set page body' API; this is the supported write path. The import is asynchronous (returns a run id + status); review in GitBook, then gitbook_merge_change_request to publish.",
105
+ inputSchema: {
106
+ orgId: z.string().describe("Organization id that owns the space."),
107
+ spaceId: z.string().describe("Target space id."),
108
+ sourceUrl: z
109
+ .string()
110
+ .url()
111
+ .refine(isSafeImportUrl, {
112
+ message: "sourceUrl must be an http(s) URL with no embedded credentials.",
113
+ })
114
+ .describe("Public http(s) URL of the page to import as content (no credentials)."),
115
+ changeRequestId: z
116
+ .string()
117
+ .optional()
118
+ .describe("Target change request id (recommended — keeps the import off the live branch)."),
119
+ pageId: z.string().optional().describe("Target page id to import into (optional)."),
120
+ enhance: z
121
+ .boolean()
122
+ .optional()
123
+ .describe("AI-enhance the imported content (default true)."),
124
+ },
125
+ annotations: WRITE_ANNOTATIONS,
126
+ }, guard(ctx, "gitbook_import_content", async (args) => jsonResult(await ctx.gitbook.importContent(args)), AUDIT)),
127
+ gitbook_comment_change_request: (server, ctx) => server.registerTool("gitbook_comment_change_request", {
128
+ title: "Comment on a change request",
129
+ description: "Post a markdown comment on a change request (review feedback / notes).",
130
+ inputSchema: {
131
+ spaceId: z.string().describe("Space id."),
132
+ changeRequestId: z.string().describe("Change request id."),
133
+ body: z.string().min(1).describe("Comment text (markdown)."),
134
+ page: z.string().optional().describe("Page id to attach the comment to (optional)."),
135
+ },
136
+ annotations: WRITE_ANNOTATIONS,
137
+ }, guard(ctx, "gitbook_comment_change_request", async ({ spaceId, changeRequestId, body, page }) => jsonResult(await ctx.gitbook.commentOnChangeRequest(spaceId, changeRequestId, body, page)), AUDIT)),
138
+ gitbook_merge_change_request: (server, ctx) => server.registerTool("gitbook_merge_change_request", {
139
+ title: "Merge (publish) a change request",
140
+ description: "Publish a change request into the live space. THIS IS DESTRUCTIVE — it changes the live published docs. Only call after the change request has been reviewed.",
141
+ inputSchema: {
142
+ spaceId: z.string().describe("Space id."),
143
+ changeRequestId: z.string().describe("Change request id to merge."),
144
+ },
145
+ annotations: DESTRUCTIVE_ANNOTATIONS,
146
+ }, guard(ctx, "gitbook_merge_change_request", async ({ spaceId, changeRequestId }) => {
147
+ const merge = await ctx.gitbook.mergeChangeRequest(spaceId, changeRequestId);
148
+ // GitBook returns HTTP 200 with result:"conflicts" when it could NOT merge
149
+ // — the change request is NOT published. Flag it as an error so the model
150
+ // never mistakes this no-op for a successful publish of the live docs.
151
+ if (merge.result === "conflicts") {
152
+ return {
153
+ content: [
154
+ {
155
+ type: "text",
156
+ text: "Merge did NOT publish: the change request has conflicts and remains open. " +
157
+ `Resolve them in GitBook, then retry.\n${JSON.stringify(merge, null, 2)}`,
158
+ },
159
+ ],
160
+ structuredContent: merge,
161
+ isError: true,
162
+ };
163
+ }
164
+ return jsonResult(merge);
165
+ }, AUDIT)),
166
+ };