@hoyongjin/gitbook-mcp 1.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 +56 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/dist/config.d.ts +58 -0
- package/dist/config.js +115 -0
- package/dist/gitbook/client.d.ts +56 -0
- package/dist/gitbook/client.js +109 -0
- package/dist/gitbook/errors.d.ts +18 -0
- package/dist/gitbook/errors.js +79 -0
- package/dist/gitbook/import-url.d.ts +23 -0
- package/dist/gitbook/import-url.js +51 -0
- package/dist/gitbook/resilient-fetch.d.ts +42 -0
- package/dist/gitbook/resilient-fetch.js +155 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +61 -0
- package/dist/limiter.d.ts +12 -0
- package/dist/limiter.js +44 -0
- package/dist/logger.d.ts +20 -0
- package/dist/logger.js +92 -0
- package/dist/metrics.d.ts +25 -0
- package/dist/metrics.js +71 -0
- package/dist/request-context.d.ts +18 -0
- package/dist/request-context.js +10 -0
- package/dist/resources.d.ts +9 -0
- package/dist/resources.js +56 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +31 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +17 -0
- package/dist/tools/read.d.ts +4 -0
- package/dist/tools/read.js +91 -0
- package/dist/tools/shared.d.ts +48 -0
- package/dist/tools/shared.js +99 -0
- package/dist/tools/write.d.ts +8 -0
- package/dist/tools/write.js +88 -0
- package/dist/transports/http.d.ts +20 -0
- package/dist/transports/http.js +336 -0
- package/dist/transports/stdio.d.ts +7 -0
- package/dist/transports/stdio.js +17 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +9 -0
- package/package.json +72 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [1.0.0] - 2026-06-23
|
|
8
|
+
|
|
9
|
+
First public release: an MCP server for GitBook exposing **7 read tools** and a
|
|
10
|
+
**4-tool change-request write workflow** plus a page resource, over **stdio** or
|
|
11
|
+
**Streamable HTTP**. Built on `@gitbook/api` 0.183 and `@modelcontextprotocol/sdk`
|
|
12
|
+
1.29; 103 unit/protocol tests, and verified end-to-end against the built artifact.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Read tools:** `gitbook_whoami`, `gitbook_list_orgs`, `gitbook_list_spaces`,
|
|
17
|
+
`gitbook_get_space`, `gitbook_list_pages`, `gitbook_get_page` (markdown or
|
|
18
|
+
structured document), `gitbook_search` (org- or space-scoped — exactly one).
|
|
19
|
+
- **Change-request write tools:** `gitbook_create_change_request`,
|
|
20
|
+
`gitbook_import_content` (web-URL import; http(s)-only, embedded-credential
|
|
21
|
+
guard), `gitbook_comment_change_request`, and `gitbook_merge_change_request`
|
|
22
|
+
(destructive — a `result:"conflicts"` outcome is surfaced as an error because it
|
|
23
|
+
does not publish). Write tools are **not registered** in read-only mode, so they
|
|
24
|
+
never appear in `tools/list`.
|
|
25
|
+
- **Resource:** GitBook pages at `gitbook://{spaceId}/{pageId}` (markdown), with the
|
|
26
|
+
same error-classification + token-redaction guarantees as the tools.
|
|
27
|
+
- **Transports:** stdio (default, local) and Streamable HTTP — per-session
|
|
28
|
+
server/transport; constant-time bearer auth; DNS-rebinding protection with a
|
|
29
|
+
configurable public-host allow-list (`GITBOOK_HTTP_ALLOWED_HOSTS` /
|
|
30
|
+
`GITBOOK_HTTP_ALLOWED_ORIGINS`); loopback-bound by default and fail-closed on a
|
|
31
|
+
non-loopback bind without an auth token; a bounded session store (cap + idle and
|
|
32
|
+
absolute-lifetime reapers); per-IP rate limiting; `/healthz` `/livez` `/readyz`
|
|
33
|
+
probes; and a bearer-gated Prometheus `/metrics`.
|
|
34
|
+
- **Resilience:** per-request timeouts, bounded retries with full-jitter backoff,
|
|
35
|
+
`Retry-After` / `X-RateLimit-Reset` honored on 429 **and** 5xx, an outbound
|
|
36
|
+
concurrency limiter, and **idempotency-aware retries** (5xx/timeout retries are
|
|
37
|
+
suppressed for non-idempotent writes to avoid duplicate change requests, comments,
|
|
38
|
+
or import runs).
|
|
39
|
+
- **Security:** token read from the environment only (never `argv`); secret
|
|
40
|
+
redaction across logs, error messages, and tool/resource results; read-only mode;
|
|
41
|
+
and a token-length floor that keeps every accepted token redactable.
|
|
42
|
+
- **Observability:** in-process Prometheus metrics (tool calls/errors, fetch
|
|
43
|
+
retries, rate-limit hits, active sessions, resource reads); request-correlation
|
|
44
|
+
ids stamped on every log line via AsyncLocalStorage; info-level audit logs for
|
|
45
|
+
write/destructive tools. Logs are JSON to **stderr only** (stdout is the protocol).
|
|
46
|
+
- **Packaging / CI:** npm provenance (`--provenance` + `id-token`) with a resolvable
|
|
47
|
+
`repository`; a `files` allowlist with no shipped source maps; a release-tag ==
|
|
48
|
+
`package.json` version guard before publish; Node 20/22 CI
|
|
49
|
+
(typecheck → lint → format → build → coverage gate → `npm audit`); Dependabot;
|
|
50
|
+
CodeQL; and a Dockerfile for the HTTP transport.
|
|
51
|
+
|
|
52
|
+
> Published as `@hoyongjin/gitbook-mcp` from `github.com/HoYongJin/gitbook-mcp`.
|
|
53
|
+
> The CI publish job runs on a GitHub Release and requires the `@hoyongjin` scope to
|
|
54
|
+
> be owned by the publishing npm account and an `NPM_TOKEN` secret in the repo.
|
|
55
|
+
|
|
56
|
+
[1.0.0]: https://github.com/HoYongJin/gitbook-mcp/releases/tag/v1.0.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gitbook-mcp contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# gitbook-mcp
|
|
2
|
+
|
|
3
|
+
A **[Model Context Protocol](https://modelcontextprotocol.io) server for GitBook.**
|
|
4
|
+
It lets an MCP host (Claude Code, Claude Desktop, Cursor, …) **read** your GitBook
|
|
5
|
+
content and drive a **change-request write workflow**, over **stdio** or
|
|
6
|
+
**streamable HTTP**.
|
|
7
|
+
|
|
8
|
+
Built on the official [`@gitbook/api`](https://www.npmjs.com/package/@gitbook/api)
|
|
9
|
+
client and [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) 1.x.
|
|
10
|
+
|
|
11
|
+
- **11 tools** — 7 read + 4 change-request write (write tools are hidden in read-only mode).
|
|
12
|
+
- **Resources** — pages addressable as `gitbook://{spaceId}/{pageId}`.
|
|
13
|
+
- **Two transports** — stdio (local) and streamable HTTP (multi-session, bearer-gated).
|
|
14
|
+
- **Resilient** — request timeouts, bounded retries with full-jitter backoff, rate-limit aware.
|
|
15
|
+
- **Safe** — read-only mode, env-only token, secret redaction, localhost-bound HTTP.
|
|
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
|
|
19
|
+
> IDE/CLI use; the **HTTP** transport is hardened for hosted use — bearer auth,
|
|
20
|
+
> DNS-rebinding protection, a session cap + idle reaper, per-IP rate limiting,
|
|
21
|
+
> health/readiness probes, and Prometheus metrics. To publish: push to
|
|
22
|
+
> `github.com/HoYongJin/gitbook-mcp`, ensure your npm account owns the `@hoyongjin`
|
|
23
|
+
> scope, then create a GitHub release (CI publishes to npm with provenance).
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## ⚠️ What GitBook's API can and cannot write
|
|
28
|
+
|
|
29
|
+
GitBook's public API has **no direct "edit this page body" endpoint**
|
|
30
|
+
(`createPage`/`updatePage` exist only as the GitBook Assistant's _internal_ tool
|
|
31
|
+
names, not as REST methods). Content authoring goes through:
|
|
32
|
+
|
|
33
|
+
1. **Change requests** — open a draft (`gitbook_create_change_request`), review, then `gitbook_merge_change_request` to publish.
|
|
34
|
+
2. **Content import** — `gitbook_import_content` imports a **web page URL** into a space/change-request/page, AI-enhanced by default. This is the supported "write content" primitive. Imports are asynchronous, and GitBook exposes no status-poll endpoint.
|
|
35
|
+
3. **Git Sync** — for fine-grained, paragraph-level authoring, connect the space to a Git repo and edit markdown there. _(Out of scope for this server; it is the right path if you need precise prose edits.)_
|
|
36
|
+
|
|
37
|
+
So this server is **read-rich** with a **coarse, review-gated write** path. It
|
|
38
|
+
cannot rewrite a specific paragraph via the API.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# From source (local use):
|
|
46
|
+
cd gitbook-mcp && npm install && npm run build
|
|
47
|
+
# → run with: node dist/index.js (or `npm start`)
|
|
48
|
+
|
|
49
|
+
# Or, once published:
|
|
50
|
+
npx @hoyongjin/gitbook-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> The package is **scoped** to `@hoyongjin/gitbook-mcp` (the unscoped `gitbook-mcp`
|
|
54
|
+
> on npm belongs to a different project). Make sure your npm account owns the
|
|
55
|
+
> `@hoyongjin` scope before publishing.
|
|
56
|
+
|
|
57
|
+
Create a token at **GitBook → Settings → Developer → Personal access tokens**.
|
|
58
|
+
|
|
59
|
+
## Configure in your MCP client
|
|
60
|
+
|
|
61
|
+
### Claude Code
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Local build:
|
|
65
|
+
claude mcp add gitbook \
|
|
66
|
+
--env GITBOOK_TOKEN=gb_api_xxx \
|
|
67
|
+
-- node /absolute/path/to/gitbook-mcp/dist/index.js
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `.mcp.json` / Claude Desktop config
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"gitbook": {
|
|
76
|
+
"command": "node",
|
|
77
|
+
"args": ["/absolute/path/to/gitbook-mcp/dist/index.js"],
|
|
78
|
+
"env": { "GITBOOK_TOKEN": "gb_api_xxx" }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
(After publishing, replace `command`/`args` with
|
|
85
|
+
`"command": "npx", "args": ["@hoyongjin/gitbook-mcp"]`.)
|
|
86
|
+
|
|
87
|
+
Run read-only (recommended unless you need writes) by adding `"--read-only"` to
|
|
88
|
+
`args`, or `"GITBOOK_READONLY": "true"` to `env`.
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
All configuration is via environment variables (see [`.env.example`](./.env.example)).
|
|
93
|
+
The token is **never** taken from a CLI argument.
|
|
94
|
+
|
|
95
|
+
| Variable | Default | Description |
|
|
96
|
+
| -------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
97
|
+
| `GITBOOK_TOKEN` | — | **Required.** Personal access token (account-wide privileges). |
|
|
98
|
+
| `GITBOOK_ENDPOINT` | `https://api.gitbook.com` | API base URL. |
|
|
99
|
+
| `GITBOOK_READONLY` | `false` | Hide all write tools when true. |
|
|
100
|
+
| `GITBOOK_TRANSPORT` | `stdio` | `stdio` or `http`. |
|
|
101
|
+
| `GITBOOK_HTTP_HOST` | `127.0.0.1` | HTTP bind host. |
|
|
102
|
+
| `GITBOOK_HTTP_PORT` | `3000` | HTTP port. |
|
|
103
|
+
| `GITBOOK_HTTP_AUTH_TOKEN` | — | Bearer token required on the HTTP transport. |
|
|
104
|
+
| `GITBOOK_HTTP_MAX_SESSIONS` | `256` | Concurrent-session cap; new initializes past it get 503 (1–100000). |
|
|
105
|
+
| `GITBOOK_HTTP_SESSION_TTL_MS` | `300000` | Idle-session reaper TTL (1 000–86 400 000). |
|
|
106
|
+
| `GITBOOK_HTTP_SESSION_MAX_LIFETIME_MS` | `3600000` | Absolute session lifetime; reaped regardless of activity. |
|
|
107
|
+
| `GITBOOK_HTTP_TRUST_PROXY` | `false` | Trust `X-Forwarded-*` (enable only behind a trusted reverse proxy). |
|
|
108
|
+
| `GITBOOK_HTTP_ALLOWED_HOSTS` | — | Comma-separated hosts appended to the DNS-rebinding allow-list. **Required for proxied/non-loopback access** (e.g. `mcp.example.com`). |
|
|
109
|
+
| `GITBOOK_HTTP_ALLOWED_ORIGINS` | — | Comma-separated origins appended to the allow-list (e.g. `https://mcp.example.com`). |
|
|
110
|
+
| `GITBOOK_HTTP_RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window per client IP. |
|
|
111
|
+
| `GITBOOK_HTTP_RATE_LIMIT_MAX` | `120` | Max `/mcp` requests per window per IP; `0` disables. |
|
|
112
|
+
| `GITBOOK_LOG_LEVEL` | `info` | `debug`/`info`/`warn`/`error` (logs → stderr). |
|
|
113
|
+
| `GITBOOK_TIMEOUT_MS` | `30000` | Per-request timeout (1 000–120 000). |
|
|
114
|
+
| `GITBOOK_MAX_RETRIES` | `5` | Max retries on 429/5xx/transport errors (0–10). |
|
|
115
|
+
| `GITBOOK_MAX_CONCURRENCY` | `8` | Max concurrent outbound GitBook HTTP requests per instance (1–256). |
|
|
116
|
+
|
|
117
|
+
CLI flags: `--stdio` / `--http` (transport), `--read-only`, `--port <n>`.
|
|
118
|
+
|
|
119
|
+
## Tools
|
|
120
|
+
|
|
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`.
|
|
141
|
+
|
|
142
|
+
## Resources
|
|
143
|
+
|
|
144
|
+
Pages are exposed as resources at `gitbook://{spaceId}/{pageId}` (rendered as
|
|
145
|
+
markdown). Enumeration is intentionally not provided — discover page ids via
|
|
146
|
+
`gitbook_list_pages` / `gitbook_search`, then read by URI.
|
|
147
|
+
|
|
148
|
+
## Transports
|
|
149
|
+
|
|
150
|
+
- **stdio** (default): one server per process; the token stays in-process. Best
|
|
151
|
+
for local IDE/CLI integrations.
|
|
152
|
+
- **streamable HTTP** (`--http`): one MCP session per `mcp-session-id`. Binds to
|
|
153
|
+
`127.0.0.1`, enables DNS-rebinding protection (Host/Origin allow-lists), and —
|
|
154
|
+
when `GITBOOK_HTTP_AUTH_TOKEN` is set — requires a constant-time-compared bearer
|
|
155
|
+
token on every request. Binding to a non-loopback host **without** an auth token
|
|
156
|
+
is refused (fail-closed). Sessions are capped and idle-reaped; the `/mcp`
|
|
157
|
+
endpoint is per-IP rate-limited. Operational endpoints: `GET /healthz` `/livez`
|
|
158
|
+
`/readyz` (unauthenticated probes) and `GET /metrics` (Prometheus; bearer-gated
|
|
159
|
+
when an auth token is set). Behind a TLS-terminating reverse proxy, set
|
|
160
|
+
`GITBOOK_HTTP_TRUST_PROXY=true` **and** `GITBOOK_HTTP_ALLOWED_HOSTS=<your-public-host>`
|
|
161
|
+
— DNS-rebinding protection matches the exact `Host` header, so a proxied or
|
|
162
|
+
non-loopback deployment (including the Docker image, which binds `0.0.0.0`) must
|
|
163
|
+
add its public host(s) or every request is rejected with 403 `Invalid Host`. A
|
|
164
|
+
[`Dockerfile`](./Dockerfile) is provided. See [`SECURITY.md`](./SECURITY.md).
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
GITBOOK_TOKEN=… GITBOOK_HTTP_AUTH_TOKEN=… gitbook-mcp --http --port 3000
|
|
168
|
+
# → POST/GET/DELETE http://127.0.0.1:3000/mcp · GET /healthz · GET /metrics
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
npm install
|
|
175
|
+
npm run typecheck # tsc on src + test
|
|
176
|
+
npm run lint # eslint
|
|
177
|
+
npm run format # prettier --write (format:check verifies)
|
|
178
|
+
npm test # vitest — 103 tests across 12 files
|
|
179
|
+
npm run test:coverage # + v8 coverage with an 80% gate
|
|
180
|
+
npm run build # tsc → dist/ (+ chmod the bin)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
CI (`.github/workflows/ci.yml`) runs `npm audit` → typecheck → lint → format:check
|
|
184
|
+
→ build → coverage on Node 20 & 22, then asserts a clean `git diff`. CodeQL and
|
|
185
|
+
Dependabot run separately.
|
|
186
|
+
|
|
187
|
+
## Architecture
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
src/
|
|
191
|
+
index.ts launcher: load config → pick transport → graceful shutdown
|
|
192
|
+
config.ts zod-validated env + CLI overrides (frozen Config)
|
|
193
|
+
logger.ts structured stderr logging + secret redaction + correlation
|
|
194
|
+
request-context.ts AsyncLocalStorage requestId/tool (log correlation)
|
|
195
|
+
metrics.ts in-process Prometheus registry (served at /metrics)
|
|
196
|
+
limiter.ts async concurrency semaphore (outbound rate control)
|
|
197
|
+
server.ts createServer(): McpServer factory (tools + resources)
|
|
198
|
+
resources.ts gitbook://{spaceId}/{pageId}
|
|
199
|
+
version.ts stable SERVER_NAME + runtime SERVER_VERSION (from package.json)
|
|
200
|
+
gitbook/
|
|
201
|
+
client.ts typed wrapper over @gitbook/api (cursor pagination → {items,nextCursor})
|
|
202
|
+
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)
|
|
204
|
+
errors.ts HTTP/transport error classification → safe messages
|
|
205
|
+
tools/
|
|
206
|
+
read.ts write.ts one registrar each; index.ts gates writes by read-only mode
|
|
207
|
+
shared.ts ToolContext + result/guard helpers + annotation presets
|
|
208
|
+
transports/
|
|
209
|
+
stdio.ts http.ts the two transports (http: sessions, rate limit, health, metrics)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
For agent-facing contributor conventions and the dependency gotchas below, see
|
|
213
|
+
[`CLAUDE.md`](./CLAUDE.md).
|
|
214
|
+
|
|
215
|
+
### Notes / gotchas (pinned to @gitbook/api 0.183.0)
|
|
216
|
+
|
|
217
|
+
- **tsconfig uses `moduleResolution: "Bundler"`**, not `NodeNext`. `@gitbook/api`
|
|
218
|
+
0.183 ships a `.d.ts` that re-exports with extensionless relative specifiers
|
|
219
|
+
(`export * from './client'`); under NodeNext those don't resolve and the entire
|
|
220
|
+
client surface (and the `GitBookAPI` namespace members) silently disappears.
|
|
221
|
+
Bundler resolution accepts them, giving full typing with no facade. Internal
|
|
222
|
+
imports use explicit `.js` extensions so the emitted ESM runs on Node.
|
|
223
|
+
- **Import runs live under the singular `client.org` namespace**, not the plural
|
|
224
|
+
`client.orgs` (which holds list/search). They are different runtime objects.
|
|
225
|
+
- **`gitbook_get_page` markdown vs document**: `format=markdown` returns rendered
|
|
226
|
+
markdown; `format=document` returns GitBook's structured Document JSON.
|
|
227
|
+
- Node ≥ 20 (global `fetch`, `AbortSignal.any`).
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
[MIT](./LICENSE)
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized, validated configuration. Read `process.env` (and a few CLI
|
|
3
|
+
* flags) EXACTLY ONCE, here, into a frozen typed object. Nothing else in the
|
|
4
|
+
* codebase should touch `process.env`.
|
|
5
|
+
*
|
|
6
|
+
* Security: the GitBook token is accepted ONLY via the environment, never a
|
|
7
|
+
* CLI argument — process arguments leak through `ps`, `/proc`, and shell
|
|
8
|
+
* history. (GitHub's and Notion's official MCP servers do the same.)
|
|
9
|
+
*/
|
|
10
|
+
export type Transport = "stdio" | "http";
|
|
11
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
12
|
+
export interface Config {
|
|
13
|
+
readonly token: string;
|
|
14
|
+
readonly endpoint: string;
|
|
15
|
+
readonly userAgent: string;
|
|
16
|
+
readonly readOnly: boolean;
|
|
17
|
+
readonly transport: Transport;
|
|
18
|
+
readonly http: {
|
|
19
|
+
readonly host: string;
|
|
20
|
+
readonly port: number;
|
|
21
|
+
/** Optional bearer token required on the HTTP transport (undefined = none). */
|
|
22
|
+
readonly authToken: string | undefined;
|
|
23
|
+
/** Hard cap on concurrent MCP sessions; new initializes past it are rejected (503). */
|
|
24
|
+
readonly maxSessions: number;
|
|
25
|
+
/** Idle session time-to-live in ms; sessions idle longer are reaped. */
|
|
26
|
+
readonly sessionIdleTtlMs: number;
|
|
27
|
+
/** Absolute session lifetime in ms; sessions older than this are reaped regardless of activity. */
|
|
28
|
+
readonly sessionMaxLifetimeMs: number;
|
|
29
|
+
/** Trust X-Forwarded-* (set true only behind a trusted reverse proxy). */
|
|
30
|
+
readonly trustProxy: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Extra entries appended to the DNS-rebinding Host allow-list, matched against
|
|
33
|
+
* the full incoming `Host` header (e.g. "mcp.example.com" or "mcp.example.com:8080").
|
|
34
|
+
* REQUIRED for proxied/non-loopback access: the derived list only covers the
|
|
35
|
+
* bind host + loopback, so a public hostname must be added here or every
|
|
36
|
+
* request is rejected with 403 "Invalid Host header".
|
|
37
|
+
*/
|
|
38
|
+
readonly allowedHosts: readonly string[];
|
|
39
|
+
/** Extra entries appended to the DNS-rebinding Origin allow-list (e.g. "https://mcp.example.com"). */
|
|
40
|
+
readonly allowedOrigins: readonly string[];
|
|
41
|
+
readonly rateLimit: {
|
|
42
|
+
readonly windowMs: number;
|
|
43
|
+
/** Max requests per window per client IP; 0 disables rate limiting. */
|
|
44
|
+
readonly max: number;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
readonly logLevel: LogLevel;
|
|
48
|
+
readonly request: {
|
|
49
|
+
readonly timeoutMs: number;
|
|
50
|
+
readonly maxRetries: number;
|
|
51
|
+
/** Max concurrent outbound GitBook requests per instance (bounds initiation→headers, not body transfer). */
|
|
52
|
+
readonly maxConcurrency: number;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export declare class ConfigError extends Error {
|
|
56
|
+
readonly name = "ConfigError";
|
|
57
|
+
}
|
|
58
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv, argv?: readonly string[]): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export class ConfigError extends Error {
|
|
3
|
+
name = "ConfigError";
|
|
4
|
+
}
|
|
5
|
+
/** Coerce common truthy/falsy env strings to a boolean. */
|
|
6
|
+
const boolish = z.union([z.boolean(), z.string()]).transform((v) => {
|
|
7
|
+
if (typeof v === "boolean")
|
|
8
|
+
return v;
|
|
9
|
+
return ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
|
|
10
|
+
});
|
|
11
|
+
/** Split a comma-separated env value into a trimmed, non-empty list. */
|
|
12
|
+
const csv = (v) => v
|
|
13
|
+
? v
|
|
14
|
+
.split(",")
|
|
15
|
+
.map((s) => s.trim())
|
|
16
|
+
.filter((s) => s.length > 0)
|
|
17
|
+
: [];
|
|
18
|
+
const EnvSchema = z.object({
|
|
19
|
+
GITBOOK_TOKEN: z
|
|
20
|
+
.string({ message: "GITBOOK_TOKEN is required" })
|
|
21
|
+
.trim()
|
|
22
|
+
// Floor matches redactSecret's >=6 guard (logger.ts) so EVERY accepted token
|
|
23
|
+
// is always redactable — there is no length window where the literal token
|
|
24
|
+
// would pass config yet be exempt from log/error scrubbing. Real GitBook
|
|
25
|
+
// tokens are far longer (gb_api_… + 40+ chars), so this rejects nothing real.
|
|
26
|
+
.min(6, "GITBOOK_TOKEN looks too short to be a valid GitBook token"),
|
|
27
|
+
GITBOOK_ENDPOINT: z.string().url().default("https://api.gitbook.com"),
|
|
28
|
+
GITBOOK_USER_AGENT: z.string().min(1).optional(),
|
|
29
|
+
GITBOOK_READONLY: boolish.default(false),
|
|
30
|
+
GITBOOK_TRANSPORT: z.enum(["stdio", "http"]).default("stdio"),
|
|
31
|
+
GITBOOK_HTTP_HOST: z.string().min(1).default("127.0.0.1"),
|
|
32
|
+
GITBOOK_HTTP_PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
|
33
|
+
GITBOOK_HTTP_AUTH_TOKEN: z.string().min(1).optional(),
|
|
34
|
+
GITBOOK_HTTP_MAX_SESSIONS: z.coerce.number().int().min(1).max(100000).default(256),
|
|
35
|
+
GITBOOK_HTTP_SESSION_TTL_MS: z.coerce.number().int().min(1000).max(86400000).default(300000),
|
|
36
|
+
GITBOOK_HTTP_SESSION_MAX_LIFETIME_MS: z.coerce
|
|
37
|
+
.number()
|
|
38
|
+
.int()
|
|
39
|
+
.min(1000)
|
|
40
|
+
.max(86400000)
|
|
41
|
+
.default(3600000),
|
|
42
|
+
GITBOOK_HTTP_TRUST_PROXY: boolish.default(false),
|
|
43
|
+
GITBOOK_HTTP_ALLOWED_HOSTS: z.string().optional(),
|
|
44
|
+
GITBOOK_HTTP_ALLOWED_ORIGINS: z.string().optional(),
|
|
45
|
+
GITBOOK_HTTP_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().min(1000).max(3600000).default(60000),
|
|
46
|
+
GITBOOK_HTTP_RATE_LIMIT_MAX: z.coerce.number().int().min(0).max(1000000).default(120),
|
|
47
|
+
GITBOOK_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
48
|
+
GITBOOK_TIMEOUT_MS: z.coerce.number().int().min(1000).max(120000).default(30000),
|
|
49
|
+
GITBOOK_MAX_RETRIES: z.coerce.number().int().min(0).max(10).default(5),
|
|
50
|
+
GITBOOK_MAX_CONCURRENCY: z.coerce.number().int().min(1).max(256).default(8),
|
|
51
|
+
});
|
|
52
|
+
/** CLI flags that override env (transport selection + read-only safety). */
|
|
53
|
+
function parseArgvOverrides(argv) {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const a = argv[i];
|
|
57
|
+
if (a === "--http")
|
|
58
|
+
out.transport = "http";
|
|
59
|
+
else if (a === "--stdio")
|
|
60
|
+
out.transport = "stdio";
|
|
61
|
+
else if (a === "--read-only" || a === "--readonly")
|
|
62
|
+
out.readOnly = true;
|
|
63
|
+
else if (a === "--port") {
|
|
64
|
+
const next = argv[i + 1];
|
|
65
|
+
const n = next ? Number(next) : NaN;
|
|
66
|
+
if (Number.isInteger(n) && n >= 1 && n <= 65535) {
|
|
67
|
+
out.port = n;
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
throw new ConfigError(`--port requires an integer 1-65535 (got "${next ?? ""}")`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
export function loadConfig(env = process.env, argv = process.argv.slice(2)) {
|
|
78
|
+
const parsed = EnvSchema.safeParse(env);
|
|
79
|
+
if (!parsed.success) {
|
|
80
|
+
const issues = parsed.error.issues
|
|
81
|
+
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
82
|
+
.join("\n");
|
|
83
|
+
throw new ConfigError(`Invalid configuration:\n${issues}`);
|
|
84
|
+
}
|
|
85
|
+
const e = parsed.data;
|
|
86
|
+
const overrides = parseArgvOverrides(argv);
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
token: e.GITBOOK_TOKEN,
|
|
89
|
+
endpoint: e.GITBOOK_ENDPOINT,
|
|
90
|
+
userAgent: e.GITBOOK_USER_AGENT ?? `gitbook-mcp`,
|
|
91
|
+
readOnly: overrides.readOnly ?? e.GITBOOK_READONLY,
|
|
92
|
+
transport: overrides.transport ?? e.GITBOOK_TRANSPORT,
|
|
93
|
+
http: Object.freeze({
|
|
94
|
+
host: e.GITBOOK_HTTP_HOST,
|
|
95
|
+
port: overrides.port ?? e.GITBOOK_HTTP_PORT,
|
|
96
|
+
authToken: e.GITBOOK_HTTP_AUTH_TOKEN,
|
|
97
|
+
maxSessions: e.GITBOOK_HTTP_MAX_SESSIONS,
|
|
98
|
+
sessionIdleTtlMs: e.GITBOOK_HTTP_SESSION_TTL_MS,
|
|
99
|
+
sessionMaxLifetimeMs: e.GITBOOK_HTTP_SESSION_MAX_LIFETIME_MS,
|
|
100
|
+
trustProxy: e.GITBOOK_HTTP_TRUST_PROXY,
|
|
101
|
+
allowedHosts: Object.freeze(csv(e.GITBOOK_HTTP_ALLOWED_HOSTS)),
|
|
102
|
+
allowedOrigins: Object.freeze(csv(e.GITBOOK_HTTP_ALLOWED_ORIGINS)),
|
|
103
|
+
rateLimit: Object.freeze({
|
|
104
|
+
windowMs: e.GITBOOK_HTTP_RATE_LIMIT_WINDOW_MS,
|
|
105
|
+
max: e.GITBOOK_HTTP_RATE_LIMIT_MAX,
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
logLevel: e.GITBOOK_LOG_LEVEL,
|
|
109
|
+
request: Object.freeze({
|
|
110
|
+
timeoutMs: e.GITBOOK_TIMEOUT_MS,
|
|
111
|
+
maxRetries: e.GITBOOK_MAX_RETRIES,
|
|
112
|
+
maxConcurrency: e.GITBOOK_MAX_CONCURRENCY,
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { GitBookAPI } from "@gitbook/api";
|
|
2
|
+
import type { ChangeRequest, Comment, ContentImportRun, Organization, RevisionPage, SearchPageResult, SearchSpaceResult, Space, User } from "@gitbook/api";
|
|
3
|
+
import type { Config } from "../config.js";
|
|
4
|
+
import type { Logger } from "../logger.js";
|
|
5
|
+
/** A single page of a cursor-paginated GitBook list. */
|
|
6
|
+
export interface Page<T> {
|
|
7
|
+
readonly items: T[];
|
|
8
|
+
/** Opaque cursor for the next page; absent on the last page. */
|
|
9
|
+
readonly nextCursor: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
export interface ListParams {
|
|
12
|
+
limit?: number;
|
|
13
|
+
cursor?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ImportContentParams {
|
|
16
|
+
orgId: string;
|
|
17
|
+
spaceId: string;
|
|
18
|
+
sourceUrl: string;
|
|
19
|
+
changeRequestId?: string;
|
|
20
|
+
pageId?: string;
|
|
21
|
+
enhance?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export type PageFormat = "markdown" | "document";
|
|
24
|
+
/**
|
|
25
|
+
* Thin, typed, resilient wrapper over @gitbook/api. Exposes only the domain
|
|
26
|
+
* operations the MCP tools need, normalizes cursor pagination, and centralizes
|
|
27
|
+
* the namespace gotchas (import runs live on the SINGULAR `org`; list/search on
|
|
28
|
+
* the PLURAL `orgs`). Inject a mock in tests.
|
|
29
|
+
*/
|
|
30
|
+
export declare class GitBookClient {
|
|
31
|
+
private readonly api;
|
|
32
|
+
constructor(config: Config, logger: Logger,
|
|
33
|
+
/** Test seam: override the underlying client (e.g. a stub). */
|
|
34
|
+
api?: GitBookAPI);
|
|
35
|
+
getAuthenticatedUser(): Promise<User>;
|
|
36
|
+
listOrganizations(params?: ListParams): Promise<Page<Organization>>;
|
|
37
|
+
listSpaces(orgId: string, params?: ListParams): Promise<Page<Space>>;
|
|
38
|
+
getSpace(spaceId: string): Promise<Space>;
|
|
39
|
+
/** Page tree of the space's current revision. NOT paginated (no cursor). */
|
|
40
|
+
listPages(spaceId: string): Promise<RevisionPage[]>;
|
|
41
|
+
getPage(spaceId: string, pageId: string, format: PageFormat): Promise<RevisionPage>;
|
|
42
|
+
searchOrganization(orgId: string, query: string, params?: ListParams): Promise<Page<SearchSpaceResult>>;
|
|
43
|
+
searchSpace(spaceId: string, query: string, params?: ListParams): Promise<Page<SearchPageResult>>;
|
|
44
|
+
createChangeRequest(spaceId: string, subject?: string): Promise<ChangeRequest>;
|
|
45
|
+
/**
|
|
46
|
+
* GitBook's content-write primitive. Import runs are async and live on the
|
|
47
|
+
* SINGULAR `org` namespace. Returns the initial ContentImportRun (status
|
|
48
|
+
* pending/in-progress); GitBook exposes no poll endpoint.
|
|
49
|
+
*/
|
|
50
|
+
importContent(params: ImportContentParams): Promise<ContentImportRun>;
|
|
51
|
+
commentOnChangeRequest(spaceId: string, changeRequestId: string, markdown: string, page?: string): Promise<Comment>;
|
|
52
|
+
mergeChangeRequest(spaceId: string, changeRequestId: string): Promise<{
|
|
53
|
+
revision: string;
|
|
54
|
+
result: "merge" | "conflicts";
|
|
55
|
+
}>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { GitBookAPI } from "@gitbook/api";
|
|
2
|
+
import { SERVER_VERSION } from "../version.js";
|
|
3
|
+
import { createResilientFetch } from "./resilient-fetch.js";
|
|
4
|
+
import { assertSafeImportUrl } from "./import-url.js";
|
|
5
|
+
/**
|
|
6
|
+
* Thin, typed, resilient wrapper over @gitbook/api. Exposes only the domain
|
|
7
|
+
* operations the MCP tools need, normalizes cursor pagination, and centralizes
|
|
8
|
+
* the namespace gotchas (import runs live on the SINGULAR `org`; list/search on
|
|
9
|
+
* the PLURAL `orgs`). Inject a mock in tests.
|
|
10
|
+
*/
|
|
11
|
+
export class GitBookClient {
|
|
12
|
+
api;
|
|
13
|
+
constructor(config, logger,
|
|
14
|
+
/** Test seam: override the underlying client (e.g. a stub). */
|
|
15
|
+
api) {
|
|
16
|
+
this.api =
|
|
17
|
+
api ??
|
|
18
|
+
new GitBookAPI({
|
|
19
|
+
authToken: config.token,
|
|
20
|
+
endpoint: config.endpoint,
|
|
21
|
+
userAgent: `${config.userAgent}/${SERVER_VERSION}`,
|
|
22
|
+
// The 0.183 client wraps `serviceBinding.fetch`; injecting our
|
|
23
|
+
// resilient fetch here gives retries+timeouts to every method.
|
|
24
|
+
serviceBinding: {
|
|
25
|
+
fetch: createResilientFetch({
|
|
26
|
+
timeoutMs: config.request.timeoutMs,
|
|
27
|
+
maxRetries: config.request.maxRetries,
|
|
28
|
+
maxConcurrency: config.request.maxConcurrency,
|
|
29
|
+
logger: logger.child({ component: "gitbook-fetch" }),
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// ── reads ──────────────────────────────────────────────────────────────────
|
|
35
|
+
async getAuthenticatedUser() {
|
|
36
|
+
return (await this.api.user.getAuthenticatedUser()).data;
|
|
37
|
+
}
|
|
38
|
+
async listOrganizations(params = {}) {
|
|
39
|
+
const res = await this.api.orgs.listOrganizationsForAuthenticatedUser({
|
|
40
|
+
limit: params.limit,
|
|
41
|
+
page: params.cursor,
|
|
42
|
+
});
|
|
43
|
+
return { items: res.data.items, nextCursor: res.data.next?.page };
|
|
44
|
+
}
|
|
45
|
+
async listSpaces(orgId, params = {}) {
|
|
46
|
+
const res = await this.api.orgs.listSpacesInOrganizationById(orgId, {
|
|
47
|
+
limit: params.limit,
|
|
48
|
+
page: params.cursor,
|
|
49
|
+
});
|
|
50
|
+
return { items: res.data.items, nextCursor: res.data.next?.page };
|
|
51
|
+
}
|
|
52
|
+
async getSpace(spaceId) {
|
|
53
|
+
return (await this.api.spaces.getSpaceById(spaceId)).data;
|
|
54
|
+
}
|
|
55
|
+
/** Page tree of the space's current revision. NOT paginated (no cursor). */
|
|
56
|
+
async listPages(spaceId) {
|
|
57
|
+
return (await this.api.spaces.listPages(spaceId)).data.pages;
|
|
58
|
+
}
|
|
59
|
+
async getPage(spaceId, pageId, format) {
|
|
60
|
+
return (await this.api.spaces.getPageById(spaceId, pageId, { format })).data;
|
|
61
|
+
}
|
|
62
|
+
async searchOrganization(orgId, query, params = {}) {
|
|
63
|
+
const res = await this.api.orgs.searchOrganizationContent(orgId, {
|
|
64
|
+
query,
|
|
65
|
+
limit: params.limit,
|
|
66
|
+
page: params.cursor,
|
|
67
|
+
});
|
|
68
|
+
return { items: res.data.items, nextCursor: res.data.next?.page };
|
|
69
|
+
}
|
|
70
|
+
async searchSpace(spaceId, query, params = {}) {
|
|
71
|
+
const res = await this.api.spaces.searchSpaceContent(spaceId, {
|
|
72
|
+
query,
|
|
73
|
+
limit: params.limit,
|
|
74
|
+
page: params.cursor,
|
|
75
|
+
});
|
|
76
|
+
return { items: res.data.items, nextCursor: res.data.next?.page };
|
|
77
|
+
}
|
|
78
|
+
// ── writes (change-request workflow) ────────────────────────────────────────
|
|
79
|
+
async createChangeRequest(spaceId, subject) {
|
|
80
|
+
return (await this.api.spaces.createChangeRequest(spaceId, subject ? { subject } : {})).data;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* GitBook's content-write primitive. Import runs are async and live on the
|
|
84
|
+
* SINGULAR `org` namespace. Returns the initial ContentImportRun (status
|
|
85
|
+
* pending/in-progress); GitBook exposes no poll endpoint.
|
|
86
|
+
*/
|
|
87
|
+
async importContent(params) {
|
|
88
|
+
return (await this.api.org.startImportRun(params.orgId, {
|
|
89
|
+
// Scheme/credential validation + normalize: http(s) only, reject embedded
|
|
90
|
+
// credentials. (Internal-target/SSRF protection is GitBook-side; it performs the fetch.)
|
|
91
|
+
source: { type: "website", url: assertSafeImportUrl(params.sourceUrl) },
|
|
92
|
+
target: {
|
|
93
|
+
space: params.spaceId,
|
|
94
|
+
changeRequest: params.changeRequestId,
|
|
95
|
+
page: params.pageId,
|
|
96
|
+
},
|
|
97
|
+
enhance: params.enhance ?? true,
|
|
98
|
+
})).data;
|
|
99
|
+
}
|
|
100
|
+
async commentOnChangeRequest(spaceId, changeRequestId, markdown, page) {
|
|
101
|
+
return (await this.api.spaces.postCommentInChangeRequest(spaceId, changeRequestId, {
|
|
102
|
+
body: { markdown },
|
|
103
|
+
...(page ? { page } : {}),
|
|
104
|
+
})).data;
|
|
105
|
+
}
|
|
106
|
+
async mergeChangeRequest(spaceId, changeRequestId) {
|
|
107
|
+
return (await this.api.spaces.mergeChangeRequest(spaceId, changeRequestId)).data;
|
|
108
|
+
}
|
|
109
|
+
}
|