@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,18 @@
1
+ /**
2
+ * Error classification for the GitBook API. Turns a thrown error (a
3
+ * @gitbook/api `GitBookAPIError`, an aborted/timed-out request, or any other
4
+ * failure) into a stable, non-leaky shape the tool layer can surface to the
5
+ * model as an `isError` result. The raw token is never included.
6
+ */
7
+ export type ErrorKind = "auth" | "forbidden" | "not_found" | "conflict" | "rate_limit" | "validation" | "server" | "timeout" | "network" | "unknown";
8
+ export interface ClassifiedError {
9
+ readonly kind: ErrorKind;
10
+ readonly status: number | undefined;
11
+ /** Safe, human-readable message (no secrets). */
12
+ readonly message: string;
13
+ /** Whether a retry could plausibly succeed (informational; the fetch layer already retried). */
14
+ readonly retryable: boolean;
15
+ }
16
+ export declare function classifyError(err: unknown): ClassifiedError;
17
+ /** A single-line safe summary, e.g. for a tool's isError text. */
18
+ export declare function describeError(err: unknown): string;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Error classification for the GitBook API. Turns a thrown error (a
3
+ * @gitbook/api `GitBookAPIError`, an aborted/timed-out request, or any other
4
+ * failure) into a stable, non-leaky shape the tool layer can surface to the
5
+ * model as an `isError` result. The raw token is never included.
6
+ */
7
+ function isGitBookApiError(err) {
8
+ return (typeof err === "object" &&
9
+ err !== null &&
10
+ err.name === "GitBookAPIError" &&
11
+ typeof err.code === "number");
12
+ }
13
+ function kindForStatus(status) {
14
+ switch (status) {
15
+ case 400:
16
+ return "validation";
17
+ case 401:
18
+ return "auth";
19
+ case 403:
20
+ return "forbidden";
21
+ case 404:
22
+ return "not_found";
23
+ case 409:
24
+ return "conflict";
25
+ case 429:
26
+ return "rate_limit";
27
+ default:
28
+ return status >= 500 ? "server" : "unknown";
29
+ }
30
+ }
31
+ const FRIENDLY = {
32
+ auth: "Authentication failed — check GITBOOK_TOKEN (it may be invalid or revoked).",
33
+ forbidden: "The token lacks permission for this resource.",
34
+ not_found: "The requested resource was not found.",
35
+ conflict: "The request conflicts with the current state (e.g. the change request was already merged).",
36
+ rate_limit: "GitBook rate limit hit; the request was retried but kept being throttled.",
37
+ validation: "The request was rejected as invalid — check the arguments.",
38
+ server: "GitBook returned a server error.",
39
+ timeout: "The request to GitBook timed out.",
40
+ network: "Could not reach the GitBook API (network error).",
41
+ unknown: "The GitBook API request failed.",
42
+ };
43
+ export function classifyError(err) {
44
+ if (isGitBookApiError(err)) {
45
+ const status = err.code;
46
+ const kind = kindForStatus(status);
47
+ const detail = err.errorMessage ?? err.message ?? "";
48
+ return {
49
+ kind,
50
+ status,
51
+ message: detail ? `${FRIENDLY[kind]} (${detail})` : FRIENDLY[kind],
52
+ retryable: kind === "rate_limit" || kind === "server",
53
+ };
54
+ }
55
+ if (err instanceof Error) {
56
+ // Our own deliberate validation errors (import-URL guard; tool input guards
57
+ // like search's orgId/spaceId mutual-exclusion). Preserve their message —
58
+ // these are written FOR the model and must reach it verbatim, not be flattened
59
+ // to the generic "unknown" string.
60
+ if (err.name === "ImportUrlError" || err.name === "ToolInputError") {
61
+ return { kind: "validation", status: undefined, message: err.message, retryable: false };
62
+ }
63
+ // AbortError from our timeout controller.
64
+ if (err.name === "AbortError" || err.name === "TimeoutError") {
65
+ return { kind: "timeout", status: undefined, message: FRIENDLY.timeout, retryable: true };
66
+ }
67
+ // Node fetch failures surface as TypeError("fetch failed") with a `.cause`.
68
+ if (err.name === "TypeError" ||
69
+ /fetch failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT/.test(err.message)) {
70
+ return { kind: "network", status: undefined, message: FRIENDLY.network, retryable: true };
71
+ }
72
+ }
73
+ return { kind: "unknown", status: undefined, message: FRIENDLY.unknown, retryable: false };
74
+ }
75
+ /** A single-line safe summary, e.g. for a tool's isError text. */
76
+ export function describeError(err) {
77
+ const c = classifyError(err);
78
+ return c.status ? `[${c.status} ${c.kind}] ${c.message}` : `[${c.kind}] ${c.message}`;
79
+ }
@@ -0,0 +1,23 @@
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
4
+ * blocks non-web schemes (file:, gopher:, …); the latter stops a third-party
5
+ * 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).
7
+ *
8
+ * NOTE — this is NOT a full SSRF guard. This process never resolves or fetches
9
+ * the URL; GitBook does, server-side. So protection against internal / loopback
10
+ * / cloud-metadata targets (127.0.0.1, 169.254.169.254, etc.) is GitBook's
11
+ * responsibility, not something a host/IP denylist here could enforce.
12
+ *
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.
16
+ */
17
+ export declare class ImportUrlError extends Error {
18
+ readonly name = "ImportUrlError";
19
+ }
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
+ export declare function assertSafeImportUrl(raw: string): string;
@@ -0,0 +1,51 @@
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
4
+ * blocks non-web schemes (file:, gopher:, …); the latter stops a third-party
5
+ * 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).
7
+ *
8
+ * NOTE — this is NOT a full SSRF guard. This process never resolves or fetches
9
+ * the URL; GitBook does, server-side. So protection against internal / loopback
10
+ * / cloud-metadata targets (127.0.0.1, 169.254.169.254, etc.) is GitBook's
11
+ * responsibility, not something a host/IP denylist here could enforce.
12
+ *
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.
16
+ */
17
+ export class ImportUrlError extends Error {
18
+ name = "ImportUrlError";
19
+ }
20
+ /** Predicate form for zod `.refine`. Returns false for any unsafe/invalid URL. */
21
+ export function isSafeImportUrl(raw) {
22
+ let url;
23
+ try {
24
+ url = new URL(raw);
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ if (url.protocol !== "http:" && url.protocol !== "https:")
30
+ return false;
31
+ if (url.username !== "" || url.password !== "")
32
+ return false;
33
+ return true;
34
+ }
35
+ /** Throwing/normalizing form for the client. Returns the normalized URL string. */
36
+ export function assertSafeImportUrl(raw) {
37
+ let url;
38
+ try {
39
+ url = new URL(raw);
40
+ }
41
+ catch {
42
+ throw new ImportUrlError("sourceUrl is not a valid URL.");
43
+ }
44
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
45
+ throw new ImportUrlError(`sourceUrl must use http(s); got scheme "${url.protocol.replace(":", "")}".`);
46
+ }
47
+ if (url.username !== "" || url.password !== "") {
48
+ throw new ImportUrlError("sourceUrl must not contain embedded credentials (user:pass@).");
49
+ }
50
+ return url.toString();
51
+ }
@@ -0,0 +1,42 @@
1
+ import type { Logger } from "../logger.js";
2
+ /**
3
+ * A `fetch`-shaped wrapper that adds per-attempt timeouts and bounded retries
4
+ * with backoff. Injected into the @gitbook/api client via its `serviceBinding`,
5
+ * so every one of the client's ~200 methods gets resilience for free.
6
+ *
7
+ * Retry policy (grounded in GitBook's documented behavior):
8
+ * - Retry ONLY on HTTP 429 and 5xx, and on transport/timeout errors.
9
+ * - Never retry other 4xx (they are deterministic client errors).
10
+ * - IDEMPOTENCY: a 5xx or transport/timeout may arrive AFTER the server already
11
+ * applied the request, so retrying a non-idempotent write would duplicate the
12
+ * side effect (a second change request, comment, or import run). We therefore
13
+ * retry 5xx/transport errors ONLY for idempotent methods (GET/HEAD/PUT/…).
14
+ * 429 is the exception: it means the request was rejected by rate limiting
15
+ * BEFORE processing, so it is safe to retry for any method (including POST).
16
+ * - On a retryable response, honor `Retry-After` (seconds or HTTP-date) and,
17
+ * failing that, `X-RateLimit-Reset` (UTC epoch seconds) — for 503s during
18
+ * maintenance as well as 429s. If the server asks us to wait longer than
19
+ * `maxHeaderDelayMs`, stop retrying and return the response to the caller
20
+ * (better than hanging a tool call or hammering the limit).
21
+ * - Otherwise exponential backoff with FULL jitter: random(0, min(cap, base*2^n)).
22
+ * - Respect a caller-supplied AbortSignal (do not retry a caller abort, and
23
+ * short-circuit if the caller aborts during a backoff sleep).
24
+ */
25
+ export interface ResilientFetchOptions {
26
+ timeoutMs: number;
27
+ maxRetries: number;
28
+ /** Cap concurrent in-flight requests (undefined = unbounded). */
29
+ maxConcurrency?: number;
30
+ logger?: Logger;
31
+ /** Injectable seams for tests. */
32
+ fetchImpl?: typeof fetch;
33
+ sleepImpl?: (ms: number) => Promise<void>;
34
+ randomImpl?: () => number;
35
+ nowMsImpl?: () => number;
36
+ /** Backoff base/cap in ms (defaults: 500 / 30000). */
37
+ baseDelayMs?: number;
38
+ maxDelayMs?: number;
39
+ /** Max server-directed (Retry-After/Reset) wait we will honor (default 60000). */
40
+ maxHeaderDelayMs?: number;
41
+ }
42
+ export declare function createResilientFetch(opts: ResilientFetchOptions): typeof fetch;
@@ -0,0 +1,155 @@
1
+ import { createLimiter } from "../limiter.js";
2
+ import { metrics } from "../metrics.js";
3
+ const RETRYABLE_STATUS = (status) => status === 429 || status >= 500;
4
+ /**
5
+ * Safe/idempotent methods per RFC 9110 — retrying them on a 5xx/transport error
6
+ * cannot duplicate a side effect. POST/PATCH are excluded: their 5xx/timeout
7
+ * retries are suppressed so a write that may have landed is not replayed.
8
+ */
9
+ const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"]);
10
+ function isIdempotentMethod(init) {
11
+ const method = (init?.method ?? "GET").toUpperCase();
12
+ return IDEMPOTENT_METHODS.has(method);
13
+ }
14
+ function defaultSleep(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+ /** Parse Retry-After (delta-seconds or HTTP-date) into ms, or undefined. */
18
+ function parseRetryAfter(value, nowMs) {
19
+ if (!value)
20
+ return undefined;
21
+ const secs = Number(value);
22
+ if (Number.isFinite(secs))
23
+ return Math.max(0, secs * 1000);
24
+ const date = Date.parse(value);
25
+ if (Number.isFinite(date))
26
+ return Math.max(0, date - nowMs);
27
+ return undefined;
28
+ }
29
+ /** Parse X-RateLimit-Reset (UTC epoch seconds) into ms-until-reset, or undefined. */
30
+ function parseRateLimitReset(value, nowMs) {
31
+ if (!value)
32
+ return undefined;
33
+ const epochSecs = Number(value);
34
+ if (!Number.isFinite(epochSecs))
35
+ return undefined;
36
+ return Math.max(0, epochSecs * 1000 - nowMs);
37
+ }
38
+ function abortError(signal) {
39
+ return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
40
+ }
41
+ export function createResilientFetch(opts) {
42
+ const fetchImpl = opts.fetchImpl ?? fetch;
43
+ const sleep = opts.sleepImpl ?? defaultSleep;
44
+ const random = opts.randomImpl ?? Math.random;
45
+ const nowMs = opts.nowMsImpl ?? Date.now;
46
+ const base = opts.baseDelayMs ?? 500;
47
+ const cap = opts.maxDelayMs ?? 30_000;
48
+ const maxHeaderDelayMs = opts.maxHeaderDelayMs ?? 60_000;
49
+ const { maxRetries, timeoutMs, logger } = opts;
50
+ // Bound concurrent in-flight requests so a fanned-out caller cannot exhaust
51
+ // GitBook's rate limit in one burst. A slot is held from request initiation
52
+ // until the response HEADERS arrive (not full body transfer — @gitbook/api
53
+ // reads the body downstream), and is released across backoff sleeps so retries
54
+ // don't starve the pool. Adequate for GitBook's small JSON responses.
55
+ const limiter = opts.maxConcurrency
56
+ ? createLimiter(opts.maxConcurrency)
57
+ : undefined;
58
+ const backoff = (attempt) => random() * Math.min(cap, base * 2 ** attempt);
59
+ /** Delay for a retryable response, or `null` to stop retrying and return it. */
60
+ const responseDelay = (res, attempt) => {
61
+ // Honor an explicit server directive on ANY retryable status — a 503 during
62
+ // maintenance/overload commonly carries Retry-After too, not just a 429.
63
+ const headerDelay = parseRetryAfter(res.headers.get("retry-after"), nowMs()) ??
64
+ parseRateLimitReset(res.headers.get("x-ratelimit-reset"), nowMs());
65
+ if (headerDelay === undefined)
66
+ return backoff(attempt);
67
+ // Honor the server directive, but not beyond what a tool call can wait for.
68
+ return headerDelay > maxHeaderDelayMs ? null : headerDelay;
69
+ };
70
+ const resilientFetch = async (input, init) => {
71
+ const callerSignal = init?.signal ?? undefined;
72
+ const idempotent = isIdempotentMethod(init);
73
+ for (let attempt = 0;; attempt++) {
74
+ // The per-attempt timeout is armed INSIDE doFetch — i.e. only once the
75
+ // limiter has granted a slot and the request is actually in flight — so a
76
+ // request that waits in the concurrency queue does not burn its timeout
77
+ // budget (which would spuriously abort it before the network call runs).
78
+ const doFetch = async () => {
79
+ const timeoutController = new AbortController();
80
+ const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
81
+ const signal = callerSignal
82
+ ? AbortSignal.any([callerSignal, timeoutController.signal])
83
+ : timeoutController.signal;
84
+ try {
85
+ return await fetchImpl(input, { ...init, signal });
86
+ }
87
+ finally {
88
+ clearTimeout(timer);
89
+ }
90
+ };
91
+ // Only the fetch itself is inside the try — a later sleep() rejection must
92
+ // NOT be misclassified as a retryable transport error.
93
+ let res;
94
+ let caught;
95
+ try {
96
+ res = limiter ? await limiter.run(doFetch) : await doFetch();
97
+ }
98
+ catch (err) {
99
+ caught = err;
100
+ }
101
+ if (res) {
102
+ if (!RETRYABLE_STATUS(res.status) || attempt >= maxRetries)
103
+ return res;
104
+ // Non-idempotent write: only a 429 is safe to replay (the request was
105
+ // rejected before processing). A 5xx may have applied — return it so the
106
+ // caller sees the failure rather than risking a duplicate side effect.
107
+ if (!idempotent && res.status !== 429)
108
+ return res;
109
+ const delay = responseDelay(res, attempt);
110
+ if (delay === null)
111
+ return res; // server wants longer than we'll wait
112
+ if (res.status === 429)
113
+ metrics.inc("gitbook_rate_limited_total");
114
+ metrics.inc("gitbook_fetch_retries_total", {
115
+ reason: res.status === 429 ? "rate_limit" : "server",
116
+ });
117
+ logger?.warn("gitbook request retrying", {
118
+ status: res.status,
119
+ attempt: attempt + 1,
120
+ maxRetries,
121
+ delayMs: Math.round(delay),
122
+ });
123
+ await res.body?.cancel().catch(() => { }); // free the connection
124
+ if (callerSignal?.aborted)
125
+ throw abortError(callerSignal); // pre-sleep guard
126
+ await sleep(delay);
127
+ if (callerSignal?.aborted)
128
+ throw abortError(callerSignal);
129
+ continue;
130
+ }
131
+ // Transport/timeout error.
132
+ if (callerSignal?.aborted)
133
+ throw caught; // caller abort — never retry
134
+ // A non-idempotent write that errored may still have reached GitBook (a
135
+ // timed-out POST can land server-side); do not replay it.
136
+ if (!idempotent)
137
+ throw caught;
138
+ if (attempt >= maxRetries)
139
+ throw caught;
140
+ const delay = backoff(attempt);
141
+ const reason = caught instanceof Error && caught.name === "AbortError" ? "timeout" : "network";
142
+ metrics.inc("gitbook_fetch_retries_total", { reason });
143
+ logger?.warn("gitbook request error, retrying", {
144
+ attempt: attempt + 1,
145
+ maxRetries,
146
+ delayMs: Math.round(delay),
147
+ reason,
148
+ });
149
+ await sleep(delay);
150
+ if (callerSignal?.aborted)
151
+ throw abortError(callerSignal);
152
+ }
153
+ };
154
+ return resilientFetch;
155
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { ConfigError, loadConfig } from "./config.js";
3
+ import { createLogger } from "./logger.js";
4
+ import { runStdio } from "./transports/stdio.js";
5
+ import { runHttp } from "./transports/http.js";
6
+ import { SERVER_NAME, SERVER_VERSION } from "./version.js";
7
+ function getConfig() {
8
+ try {
9
+ return loadConfig();
10
+ }
11
+ catch (err) {
12
+ const msg = err instanceof ConfigError ? err.message : String(err);
13
+ process.stderr.write(`[gitbook-mcp] ${msg}\n`);
14
+ process.exit(1);
15
+ }
16
+ }
17
+ async function main() {
18
+ const config = getConfig();
19
+ const logger = createLogger({ level: config.logLevel, redactSecret: config.token });
20
+ logger.info("starting", {
21
+ name: SERVER_NAME,
22
+ version: SERVER_VERSION,
23
+ transport: config.transport,
24
+ readOnly: config.readOnly,
25
+ });
26
+ const running = config.transport === "http" ? await runHttp(config, logger) : await runStdio(config, logger);
27
+ let shuttingDown = false;
28
+ const shutdown = async (signal) => {
29
+ if (shuttingDown)
30
+ return;
31
+ shuttingDown = true;
32
+ logger.info("shutting down", { signal });
33
+ try {
34
+ // Bound the close so a stuck transport still lets the process exit.
35
+ await Promise.race([
36
+ running.close(),
37
+ new Promise((resolve) => setTimeout(resolve, 5000)),
38
+ ]);
39
+ }
40
+ catch (err) {
41
+ logger.error("shutdown error", { error: err instanceof Error ? err.message : String(err) });
42
+ }
43
+ process.exit(0);
44
+ };
45
+ process.on("SIGINT", () => void shutdown("SIGINT"));
46
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
47
+ // Last-resort diagnostics so a long-running server never dies silently.
48
+ process.on("unhandledRejection", (reason) => {
49
+ logger.error("unhandledRejection", {
50
+ error: reason instanceof Error ? (reason.stack ?? reason.message) : String(reason),
51
+ });
52
+ });
53
+ process.on("uncaughtException", (err) => {
54
+ logger.error("uncaughtException", { error: err.stack ?? err.message });
55
+ process.exit(1);
56
+ });
57
+ }
58
+ main().catch((err) => {
59
+ process.stderr.write(`[gitbook-mcp] fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
60
+ process.exit(1);
61
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A minimal async concurrency limiter (counting semaphore). Bounds how many
3
+ * `run`-wrapped operations execute at once; the rest queue FIFO until a slot
4
+ * frees. Used to cap concurrent outbound GitBook HTTP requests so a fanned-out
5
+ * model cannot exhaust the upstream rate limit in one burst.
6
+ */
7
+ export interface Limiter {
8
+ run<T>(fn: () => Promise<T>): Promise<T>;
9
+ readonly active: number;
10
+ readonly pending: number;
11
+ }
12
+ export declare function createLimiter(maxConcurrency: number): Limiter;
@@ -0,0 +1,44 @@
1
+ export function createLimiter(maxConcurrency) {
2
+ if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
3
+ throw new RangeError(`maxConcurrency must be a positive integer (got ${maxConcurrency})`);
4
+ }
5
+ let active = 0;
6
+ const queue = [];
7
+ const acquire = () => {
8
+ if (active < maxConcurrency) {
9
+ active++;
10
+ return Promise.resolve();
11
+ }
12
+ return new Promise((resolve) => queue.push(resolve));
13
+ };
14
+ const release = () => {
15
+ const next = queue.shift();
16
+ if (next) {
17
+ // Hand the slot directly to the next waiter (active stays unchanged).
18
+ next();
19
+ }
20
+ else {
21
+ active--;
22
+ }
23
+ };
24
+ return {
25
+ run(fn) {
26
+ return acquire().then(() => {
27
+ try {
28
+ return Promise.resolve(fn()).finally(release);
29
+ }
30
+ catch (err) {
31
+ // Synchronous throw from fn(): release the slot before propagating.
32
+ release();
33
+ throw err;
34
+ }
35
+ });
36
+ },
37
+ get active() {
38
+ return active;
39
+ },
40
+ get pending() {
41
+ return queue.length;
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,20 @@
1
+ import type { LogLevel } from "./config.js";
2
+ export type LogFields = Record<string, unknown>;
3
+ export interface Logger {
4
+ debug(msg: string, fields?: LogFields): void;
5
+ info(msg: string, fields?: LogFields): void;
6
+ warn(msg: string, fields?: LogFields): void;
7
+ error(msg: string, fields?: LogFields): void;
8
+ child(bindings: LogFields): Logger;
9
+ }
10
+ /**
11
+ * Scrub a literal secret (the token) out of a string. Shared with the tool/error
12
+ * path. The >=6 floor avoids masking trivially-short substrings; config enforces
13
+ * the same minimum on GITBOOK_TOKEN so an accepted token is never below it.
14
+ */
15
+ export declare function redactSecret(value: string, secret: string | undefined): string;
16
+ export declare function createLogger(opts: {
17
+ level: LogLevel;
18
+ /** Literal secret to scrub from all log strings (the GitBook token). */
19
+ redactSecret?: string;
20
+ }): Logger;
package/dist/logger.js ADDED
@@ -0,0 +1,92 @@
1
+ import { getRequestContext } from "./request-context.js";
2
+ /**
3
+ * Minimal structured logger. Writes one JSON object per line to STDERR.
4
+ *
5
+ * stderr is mandatory: in stdio transport, stdout carries the JSON-RPC frames,
6
+ * so any stray stdout write corrupts the protocol. We therefore never use
7
+ * `console.log` anywhere in this server.
8
+ *
9
+ * Secrets are redacted: the configured token's literal value is scrubbed from
10
+ * every emitted string, and any field whose key looks secret is masked.
11
+ */
12
+ const LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
13
+ const SECRET_KEY = /(token|authorization|auth|secret|password|apikey|api_key)/i;
14
+ /**
15
+ * Scrub a literal secret (the token) out of a string. Shared with the tool/error
16
+ * path. The >=6 floor avoids masking trivially-short substrings; config enforces
17
+ * the same minimum on GITBOOK_TOKEN so an accepted token is never below it.
18
+ */
19
+ export function redactSecret(value, secret) {
20
+ if (secret && secret.length >= 6 && value.includes(secret)) {
21
+ return value.split(secret).join("[REDACTED]");
22
+ }
23
+ return value;
24
+ }
25
+ function maskSecrets(value, literalSecret) {
26
+ if (typeof value === "string") {
27
+ return redactSecret(value, literalSecret);
28
+ }
29
+ if (Array.isArray(value))
30
+ return value.map((v) => maskSecrets(v, literalSecret));
31
+ if (value && typeof value === "object") {
32
+ const out = {};
33
+ for (const [k, v] of Object.entries(value)) {
34
+ out[k] = SECRET_KEY.test(k) ? "[REDACTED]" : maskSecrets(v, literalSecret);
35
+ }
36
+ return out;
37
+ }
38
+ return value;
39
+ }
40
+ class StderrLogger {
41
+ minLevel;
42
+ literalSecret;
43
+ bindings;
44
+ constructor(minLevel, literalSecret, bindings = {}) {
45
+ this.minLevel = minLevel;
46
+ this.literalSecret = literalSecret;
47
+ this.bindings = bindings;
48
+ }
49
+ emit(level, msg, fields) {
50
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel])
51
+ return;
52
+ // Stamp the active request correlation (requestId/tool) so a tool call and
53
+ // the GitBook fetch lines it spawns share an id. Explicit fields win.
54
+ const reqCtx = getRequestContext();
55
+ const record = {
56
+ level,
57
+ time: new Date().toISOString(),
58
+ name: "gitbook-mcp",
59
+ msg,
60
+ ...(reqCtx
61
+ ? { requestId: reqCtx.requestId, ...(reqCtx.tool ? { tool: reqCtx.tool } : {}) }
62
+ : {}),
63
+ ...this.bindings,
64
+ ...fields,
65
+ };
66
+ const safe = maskSecrets(record, this.literalSecret);
67
+ try {
68
+ process.stderr.write(`${JSON.stringify(safe)}\n`);
69
+ }
70
+ catch {
71
+ // Last-resort: never let logging throw and take down a request.
72
+ }
73
+ }
74
+ debug(msg, fields) {
75
+ this.emit("debug", msg, fields);
76
+ }
77
+ info(msg, fields) {
78
+ this.emit("info", msg, fields);
79
+ }
80
+ warn(msg, fields) {
81
+ this.emit("warn", msg, fields);
82
+ }
83
+ error(msg, fields) {
84
+ this.emit("error", msg, fields);
85
+ }
86
+ child(bindings) {
87
+ return new StderrLogger(this.minLevel, this.literalSecret, { ...this.bindings, ...bindings });
88
+ }
89
+ }
90
+ export function createLogger(opts) {
91
+ return new StderrLogger(opts.level, opts.redactSecret);
92
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Minimal in-process metrics — no external dependency. A single process-global
3
+ * registry, scraped via `GET /metrics` on the HTTP transport in Prometheus text
4
+ * exposition format. Counters are monotonic; gauges can move both ways.
5
+ *
6
+ * Tool/fetch/transport code increments via the exported `metrics` singleton.
7
+ * Tests call `metrics.reset()` in a `beforeEach` to isolate. Label values are
8
+ * only ever fixed identifiers we control (tool names, error kinds, retry
9
+ * reasons), so they need no escaping; we still reject characters that would
10
+ * break the exposition format defensively.
11
+ */
12
+ export type MetricLabels = Record<string, string>;
13
+ declare class Metrics {
14
+ private series;
15
+ /** Increment a monotonic counter (default by 1). */
16
+ inc(name: string, labels?: MetricLabels, by?: number): void;
17
+ /** Set a gauge to an absolute value. */
18
+ setGauge(name: string, value: number, labels?: MetricLabels): void;
19
+ /** Render the registry in Prometheus text exposition format. */
20
+ render(): string;
21
+ /** Test seam: clear all series. */
22
+ reset(): void;
23
+ }
24
+ export declare const metrics: Metrics;
25
+ export type { Metrics };