@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
|
@@ -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
|
+
}
|
package/dist/version.js
ADDED
|
@@ -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
|
+
}
|