@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +231 -0
  4. package/dist/config.d.ts +58 -0
  5. package/dist/config.js +115 -0
  6. package/dist/gitbook/client.d.ts +56 -0
  7. package/dist/gitbook/client.js +109 -0
  8. package/dist/gitbook/errors.d.ts +18 -0
  9. package/dist/gitbook/errors.js +79 -0
  10. package/dist/gitbook/import-url.d.ts +23 -0
  11. package/dist/gitbook/import-url.js +51 -0
  12. package/dist/gitbook/resilient-fetch.d.ts +42 -0
  13. package/dist/gitbook/resilient-fetch.js +155 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +61 -0
  16. package/dist/limiter.d.ts +12 -0
  17. package/dist/limiter.js +44 -0
  18. package/dist/logger.d.ts +20 -0
  19. package/dist/logger.js +92 -0
  20. package/dist/metrics.d.ts +25 -0
  21. package/dist/metrics.js +71 -0
  22. package/dist/request-context.d.ts +18 -0
  23. package/dist/request-context.js +10 -0
  24. package/dist/resources.d.ts +9 -0
  25. package/dist/resources.js +56 -0
  26. package/dist/server.d.ts +14 -0
  27. package/dist/server.js +31 -0
  28. package/dist/tools/index.d.ts +9 -0
  29. package/dist/tools/index.js +17 -0
  30. package/dist/tools/read.d.ts +4 -0
  31. package/dist/tools/read.js +91 -0
  32. package/dist/tools/shared.d.ts +48 -0
  33. package/dist/tools/shared.js +99 -0
  34. package/dist/tools/write.d.ts +8 -0
  35. package/dist/tools/write.js +88 -0
  36. package/dist/transports/http.d.ts +20 -0
  37. package/dist/transports/http.js +336 -0
  38. package/dist/transports/stdio.d.ts +7 -0
  39. package/dist/transports/stdio.js +17 -0
  40. package/dist/version.d.ts +2 -0
  41. package/dist/version.js +9 -0
  42. package/package.json +72 -0
@@ -0,0 +1,336 @@
1
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import express from "express";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
+ import { ConfigError } from "../config.js";
6
+ import { createServer } from "../server.js";
7
+ import { metrics } from "../metrics.js";
8
+ import { SERVER_NAME, SERVER_VERSION } from "../version.js";
9
+ const SESSION_HEADER = "mcp-session-id";
10
+ function jsonRpcError(res, status, code, message) {
11
+ res.status(status).json({ jsonrpc: "2.0", error: { code, message }, id: null });
12
+ }
13
+ /** Constant-time string comparison that does not leak length. Exported for tests. */
14
+ export function secretEquals(a, b) {
15
+ const ah = createHash("sha256").update(a).digest();
16
+ const bh = createHash("sha256").update(b).digest();
17
+ return timingSafeEqual(ah, bh);
18
+ }
19
+ /** Bound on distinct rate-limit buckets so a spoofable-IP flood cannot grow the map without limit. */
20
+ const RATE_MAP_MAX = 50_000;
21
+ /**
22
+ * Run the server over Streamable HTTP with one transport+server per session.
23
+ *
24
+ * Security: binds to 127.0.0.1 by default, enables DNS-rebinding protection
25
+ * (Host/Origin allow-lists), and — when GITBOOK_HTTP_AUTH_TOKEN is set —
26
+ * requires a matching bearer token on every request. Running without an auth
27
+ * token is allowed only for localhost development and is logged as a warning.
28
+ *
29
+ * Operability: sessions are capped (new initializes past the cap get 503) and
30
+ * idle sessions are reaped on a TTL so the in-memory session store cannot grow
31
+ * unbounded; a per-IP rate limiter guards the endpoint; unauthenticated
32
+ * `/healthz` `/livez` `/readyz` serve orchestration probes; and a bearer-gated
33
+ * `/metrics` exposes Prometheus counters.
34
+ */
35
+ export async function runHttp(config, logger) {
36
+ const { host, port, authToken, maxSessions, sessionIdleTtlMs, sessionMaxLifetimeMs, trustProxy, allowedHosts: extraHosts, allowedOrigins: extraOrigins, rateLimit, } = config.http;
37
+ const loopbackHosts = new Set(["127.0.0.1", "::1", "localhost", "[::1]"]);
38
+ const isLoopback = loopbackHosts.has(host);
39
+ // The SDK matches the FULL incoming Host header (host:port) exactly against this
40
+ // list. The derived entries only cover the bind host + loopback, so any proxied
41
+ // or non-loopback access MUST add its public host via GITBOOK_HTTP_ALLOWED_HOSTS
42
+ // (e.g. "mcp.example.com" / "mcp.example.com:8080") or every request 403s.
43
+ const allowedHosts = [
44
+ `${host}:${port}`,
45
+ `localhost:${port}`,
46
+ `127.0.0.1:${port}`,
47
+ `[::1]:${port}`,
48
+ ...extraHosts,
49
+ ];
50
+ const allowedOrigins = [
51
+ `http://${host}:${port}`,
52
+ `http://localhost:${port}`,
53
+ `http://127.0.0.1:${port}`,
54
+ `http://[::1]:${port}`,
55
+ ...extraOrigins,
56
+ ];
57
+ // A wildcard bind (0.0.0.0 / ::) is only reachable externally, where the Host
58
+ // header is the public name — never "0.0.0.0:port". Without an explicit
59
+ // allow-list entry, DNS-rebinding protection rejects all such traffic, so warn
60
+ // loudly (the Docker image binds 0.0.0.0 by default).
61
+ const isWildcardBind = host === "0.0.0.0" || host === "::" || host === "[::]";
62
+ if (isWildcardBind && extraHosts.length === 0) {
63
+ logger.warn("HTTP bound to a wildcard host with no GITBOOK_HTTP_ALLOWED_HOSTS — DNS-rebinding protection will 403 any request whose Host header is not localhost/127.0.0.1. Set GITBOOK_HTTP_ALLOWED_HOSTS to your public host(s) (e.g. mcp.example.com) for proxied/hosted access.", { host, port });
64
+ }
65
+ if (!authToken) {
66
+ // Fail closed: an unauthenticated server reachable beyond loopback is a
67
+ // remote-write exposure (esp. with the change-request write tools).
68
+ if (!isLoopback) {
69
+ throw new ConfigError(`Refusing to bind the HTTP transport to non-loopback host "${host}" without GITBOOK_HTTP_AUTH_TOKEN. ` +
70
+ `Set GITBOOK_HTTP_AUTH_TOKEN, or bind to 127.0.0.1.`);
71
+ }
72
+ logger.warn("HTTP transport has NO auth token (GITBOOK_HTTP_AUTH_TOKEN unset) — only safe on a trusted local interface", { host, port });
73
+ }
74
+ const app = express();
75
+ app.disable("x-powered-by");
76
+ // Only trust X-Forwarded-* when explicitly placed behind a trusted proxy.
77
+ if (trustProxy)
78
+ app.set("trust proxy", true);
79
+ /** Bearer check, constant-time. True when no auth token is configured. */
80
+ const bearerOk = (req) => {
81
+ if (!authToken)
82
+ return true;
83
+ const header = req.headers.authorization ?? "";
84
+ const match = /^Bearer\s+(.+)$/i.exec(header);
85
+ return Boolean(match && secretEquals(match[1], authToken));
86
+ };
87
+ const sessions = new Map();
88
+ // In-flight initializes hold a synchronous reservation so the cap is a true
89
+ // hard limit even under a concurrent burst (the real insert happens later, in
90
+ // the SDK's onsessioninitialized callback).
91
+ let pendingInits = 0;
92
+ const setSessionGauge = () => metrics.setGauge("gitbook_http_sessions_active", sessions.size);
93
+ // ── orchestration probes: unauthenticated, no rate limit, no access log
94
+ // (registered first so they never touch the heavier middleware below).
95
+ app.get("/healthz", (_req, res) => {
96
+ res.json({ status: "ok", name: SERVER_NAME, version: SERVER_VERSION });
97
+ });
98
+ app.get("/livez", (_req, res) => {
99
+ res.json({ status: "ok" });
100
+ });
101
+ app.get("/readyz", (_req, res) => {
102
+ res.json({ status: "ok", sessions: sessions.size, maxSessions });
103
+ });
104
+ // ── access log for everything below (probes already responded above)
105
+ app.use((req, res, next) => {
106
+ const startedAt = Date.now();
107
+ res.on("finish", () => {
108
+ logger.info("http request", {
109
+ method: req.method,
110
+ path: req.path,
111
+ status: res.statusCode,
112
+ ms: Date.now() - startedAt,
113
+ sessionId: req.headers[SESSION_HEADER],
114
+ });
115
+ });
116
+ next();
117
+ });
118
+ // ── metrics: bearer-gated when an auth token is configured.
119
+ app.get("/metrics", (req, res) => {
120
+ if (!bearerOk(req)) {
121
+ jsonRpcError(res, 401, -32001, "Unauthorized");
122
+ return;
123
+ }
124
+ res.setHeader("content-type", "text/plain; version=0.0.4");
125
+ res.send(metrics.render());
126
+ });
127
+ // ── per-IP rate limiting on /mcp (fixed window). max=0 disables.
128
+ const rateHits = new Map();
129
+ app.use("/mcp", (req, res, next) => {
130
+ if (rateLimit.max <= 0)
131
+ return next();
132
+ const now = Date.now();
133
+ const key = req.ip ?? req.socket.remoteAddress ?? "unknown";
134
+ let entry = rateHits.get(key);
135
+ if (!entry || now >= entry.resetAt) {
136
+ // Bound peak memory: evict the oldest bucket before inserting a brand-new
137
+ // key once at the ceiling (Map preserves insertion order → oldest first).
138
+ if (!entry && rateHits.size >= RATE_MAP_MAX) {
139
+ const oldest = rateHits.keys().next().value;
140
+ if (oldest !== undefined)
141
+ rateHits.delete(oldest);
142
+ }
143
+ entry = { count: 0, resetAt: now + rateLimit.windowMs };
144
+ rateHits.set(key, entry);
145
+ }
146
+ entry.count++;
147
+ if (entry.count > rateLimit.max) {
148
+ res.setHeader("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000)));
149
+ metrics.inc("gitbook_http_rate_limited_total");
150
+ jsonRpcError(res, 429, -32000, "Too Many Requests");
151
+ return;
152
+ }
153
+ next();
154
+ });
155
+ // ── bearer auth BEFORE body parsing so unauthenticated requests are rejected
156
+ // before any body is buffered.
157
+ app.use("/mcp", (req, res, next) => {
158
+ if (bearerOk(req))
159
+ return next();
160
+ jsonRpcError(res, 401, -32001, "Unauthorized");
161
+ });
162
+ app.use("/mcp", express.json({ limit: "4mb" }));
163
+ app.post("/mcp", async (req, res) => {
164
+ try {
165
+ const sessionId = req.headers[SESSION_HEADER];
166
+ const existing = sessionId ? sessions.get(sessionId) : undefined;
167
+ if (existing) {
168
+ existing.lastActivity = Date.now();
169
+ await existing.transport.handleRequest(req, res, req.body);
170
+ return;
171
+ }
172
+ // No existing session: must be a fresh initialize with no session header.
173
+ if (sessionId || !isInitializeRequest(req.body)) {
174
+ jsonRpcError(res, 400, -32000, "No valid session; send an initialize request first");
175
+ return;
176
+ }
177
+ // Enforce the cap BEFORE allocating, counting in-flight initializes so a
178
+ // concurrent burst cannot overshoot it (reservation released below).
179
+ if (sessions.size + pendingInits >= maxSessions) {
180
+ metrics.inc("gitbook_http_sessions_rejected_total");
181
+ logger.warn("http session cap reached; rejecting initialize", {
182
+ maxSessions,
183
+ sessions: sessions.size,
184
+ pending: pendingInits,
185
+ });
186
+ res.setHeader("Retry-After", "5");
187
+ jsonRpcError(res, 503, -32000, "Server at capacity; try again later");
188
+ return;
189
+ }
190
+ // New session: build a dedicated transport + server, logger bound to the id.
191
+ const newSessionId = randomUUID();
192
+ pendingInits++;
193
+ let reserved = true;
194
+ const releaseReservation = () => {
195
+ if (reserved) {
196
+ reserved = false;
197
+ pendingInits--;
198
+ }
199
+ };
200
+ try {
201
+ const transport = new StreamableHTTPServerTransport({
202
+ sessionIdGenerator: () => newSessionId,
203
+ enableDnsRebindingProtection: true,
204
+ allowedHosts,
205
+ allowedOrigins,
206
+ onsessioninitialized: (id) => {
207
+ sessions.set(id, {
208
+ transport,
209
+ createdAt: Date.now(),
210
+ lastActivity: Date.now(),
211
+ sseStreams: 0,
212
+ });
213
+ releaseReservation(); // reservation becomes a real session
214
+ setSessionGauge();
215
+ logger.info("http session opened", { sessionId: id, sessions: sessions.size });
216
+ },
217
+ });
218
+ transport.onclose = () => {
219
+ const id = transport.sessionId;
220
+ if (id && sessions.delete(id)) {
221
+ setSessionGauge();
222
+ logger.info("http session closed", { sessionId: id, sessions: sessions.size });
223
+ }
224
+ };
225
+ const { server } = createServer(config, logger.child({ sessionId: newSessionId }));
226
+ await server.connect(transport);
227
+ await transport.handleRequest(req, res, req.body);
228
+ }
229
+ finally {
230
+ // If the init failed before onsessioninitialized fired, free the slot.
231
+ releaseReservation();
232
+ }
233
+ }
234
+ catch (err) {
235
+ logger.error("http POST /mcp failed", {
236
+ error: err instanceof Error ? err.message : String(err),
237
+ });
238
+ if (!res.headersSent)
239
+ jsonRpcError(res, 500, -32603, "Internal server error");
240
+ }
241
+ });
242
+ // GET = open the SSE stream for an existing session; DELETE = terminate it.
243
+ const bySession = async (req, res) => {
244
+ const sessionId = req.headers[SESSION_HEADER];
245
+ const session = sessionId ? sessions.get(sessionId) : undefined;
246
+ if (!session) {
247
+ jsonRpcError(res, 400, -32000, "Unknown or missing session id");
248
+ return;
249
+ }
250
+ session.lastActivity = Date.now();
251
+ // A GET opens a long-lived standalone SSE stream — mark it so the idle reaper
252
+ // doesn't close a connected-but-quiet client mid-stream.
253
+ if (req.method === "GET") {
254
+ session.sseStreams++;
255
+ res.on("close", () => {
256
+ session.sseStreams = Math.max(0, session.sseStreams - 1);
257
+ session.lastActivity = Date.now();
258
+ });
259
+ }
260
+ await session.transport.handleRequest(req, res);
261
+ };
262
+ app.get("/mcp", bySession);
263
+ app.delete("/mcp", bySession);
264
+ // ── session reaper: bounds the in-memory session store regardless of client
265
+ // behavior. A session is closed when it exceeds its absolute lifetime
266
+ // (always — defeats a keep-alive slow-loris that pins a slot forever), OR
267
+ // when it has been idle past the TTL AND has no open SSE stream (so a quiet
268
+ // but connected notification stream is not dropped mid-flight). The reaper
269
+ // deletes the map entry itself so cap relief never waits on the SDK's
270
+ // onclose firing.
271
+ const closeSession = (id, s, reason, detail) => {
272
+ logger.info("http session reaped", { sessionId: id, reason, ...detail });
273
+ if (sessions.delete(id))
274
+ setSessionGauge();
275
+ s.transport.close().catch(() => { });
276
+ };
277
+ const reaper = setInterval(() => {
278
+ const now = Date.now();
279
+ for (const [id, s] of sessions) {
280
+ if (now - s.createdAt > sessionMaxLifetimeMs) {
281
+ closeSession(id, s, "max-lifetime", { ageMs: now - s.createdAt });
282
+ }
283
+ else if (s.sseStreams === 0 && now - s.lastActivity > sessionIdleTtlMs) {
284
+ closeSession(id, s, "idle", { idleMs: now - s.lastActivity });
285
+ }
286
+ }
287
+ }, Math.max(1000, Math.floor(sessionIdleTtlMs / 2)));
288
+ reaper.unref(); // never keep the process alive on the reaper alone
289
+ // Expired rate-limit buckets are swept on their own cadence (the rate window),
290
+ // decoupled from the session TTL which can be set to many hours.
291
+ const rateSweep = setInterval(() => {
292
+ const now = Date.now();
293
+ for (const [key, entry] of rateHits) {
294
+ if (now >= entry.resetAt)
295
+ rateHits.delete(key);
296
+ }
297
+ }, Math.max(1000, rateLimit.windowMs));
298
+ rateSweep.unref();
299
+ const httpServer = app.listen(port, host);
300
+ // Await bind so a startup failure (EADDRINUSE/EACCES) REJECTS runHttp and is
301
+ // reported by index.ts's clean fatal path, instead of escalating to an
302
+ // unhandled 'error' event / uncaughtException stack.
303
+ await new Promise((resolve, reject) => {
304
+ const onStartupError = (err) => reject(err);
305
+ httpServer.once("error", onStartupError);
306
+ httpServer.once("listening", () => {
307
+ httpServer.removeListener("error", onStartupError);
308
+ logger.info("listening on http", {
309
+ host,
310
+ port,
311
+ readOnly: config.readOnly,
312
+ gated: Boolean(authToken), // bearer auth required? (field name avoids log redaction)
313
+ maxSessions,
314
+ version: SERVER_VERSION,
315
+ });
316
+ resolve();
317
+ });
318
+ });
319
+ // Steady-state: a post-startup server error must not crash the process.
320
+ httpServer.on("error", (err) => {
321
+ logger.error("http server error", { code: err.code, error: err.message });
322
+ });
323
+ return {
324
+ close: async () => {
325
+ clearInterval(reaper);
326
+ clearInterval(rateSweep);
327
+ // Close per-session transports FIRST — this ends their SSE streams so the
328
+ // keep-alive connections drain; otherwise httpServer.close() never fires.
329
+ for (const { transport } of sessions.values()) {
330
+ await transport.close().catch(() => { });
331
+ }
332
+ httpServer.closeAllConnections();
333
+ await new Promise((resolve) => httpServer.close(() => resolve()));
334
+ },
335
+ };
336
+ }
@@ -0,0 +1,7 @@
1
+ import type { Config } from "../config.js";
2
+ import type { Logger } from "../logger.js";
3
+ export interface RunningTransport {
4
+ close(): Promise<void>;
5
+ }
6
+ /** Connect a single MCP server over stdio. */
7
+ export declare function runStdio(config: Config, logger: Logger): Promise<RunningTransport>;
@@ -0,0 +1,17 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { createServer } from "../server.js";
3
+ /** Connect a single MCP server over stdio. */
4
+ export async function runStdio(config, logger) {
5
+ const { server, registeredTools } = createServer(config, logger);
6
+ const transport = new StdioServerTransport();
7
+ await server.connect(transport);
8
+ logger.info("listening on stdio", {
9
+ tools: registeredTools.length,
10
+ readOnly: config.readOnly,
11
+ });
12
+ return {
13
+ close: async () => {
14
+ await server.close();
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,2 @@
1
+ export declare const SERVER_NAME = "gitbook-mcp";
2
+ export declare const SERVER_VERSION: string;
@@ -0,0 +1,9 @@
1
+ import { createRequire } from "node:module";
2
+ // Stable MCP server identity (independent of the npm package's scope/name).
3
+ export const SERVER_NAME = "gitbook-mcp";
4
+ // Version is read from package.json at runtime so it never drifts from the
5
+ // published manifest. dist/version.js → ../package.json resolves both in-repo
6
+ // (dist/ sibling of package.json) and when installed (package root).
7
+ const require = createRequire(import.meta.url);
8
+ const pkg = require("../package.json");
9
+ export const SERVER_VERSION = pkg.version;
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@hoyongjin/gitbook-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Model Context Protocol server for GitBook — read content and drive a change-request write workflow over stdio or streamable HTTP.",
5
+ "keywords": [
6
+ "gitbook",
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "modelcontextprotocol",
10
+ "claude",
11
+ "documentation"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "JHY",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/HoYongJin/gitbook-mcp.git"
18
+ },
19
+ "homepage": "https://github.com/HoYongJin/gitbook-mcp#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/HoYongJin/gitbook-mcp/issues"
22
+ },
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "bin": {
28
+ "gitbook-mcp": "dist/index.js"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md",
33
+ "CHANGELOG.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "build": "shx rm -rf dist && tsc -p tsconfig.json && shx chmod +x dist/index.js",
38
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
39
+ "lint": "eslint .",
40
+ "format": "prettier --write .",
41
+ "format:check": "prettier --check .",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest",
44
+ "test:coverage": "vitest run --coverage",
45
+ "start": "node dist/index.js",
46
+ "dev": "npm run build && node dist/index.js",
47
+ "prepare": "npm run build",
48
+ "prepublishOnly": "npm run typecheck && npm run lint && npm test"
49
+ },
50
+ "dependencies": {
51
+ "@gitbook/api": "^0.183.0",
52
+ "@modelcontextprotocol/sdk": "^1.29.0",
53
+ "express": "^5.2.1",
54
+ "zod": "^4.4.3"
55
+ },
56
+ "devDependencies": {
57
+ "@eslint/js": "^10.0.1",
58
+ "@types/express": "^5.0.6",
59
+ "@types/node": "^25.9.3",
60
+ "@vitest/coverage-v8": "^4.1.8",
61
+ "eslint": "^10.4.1",
62
+ "eslint-config-prettier": "^10.1.8",
63
+ "prettier": "^3.8.4",
64
+ "shx": "^0.4.0",
65
+ "typescript": "5.7.3",
66
+ "typescript-eslint": "^8.61.0",
67
+ "vitest": "^4.1.8"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public"
71
+ }
72
+ }