@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,71 @@
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
+ const NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
13
+ function sanitizeLabelValue(value) {
14
+ // Escape backslash, double-quote, and newline per the Prometheus text format.
15
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
16
+ }
17
+ function seriesKey(name, labels) {
18
+ if (!labels)
19
+ return name;
20
+ const keys = Object.keys(labels).sort();
21
+ if (keys.length === 0)
22
+ return name;
23
+ const inner = keys.map((k) => `${k}="${sanitizeLabelValue(labels[k] ?? "")}"`).join(",");
24
+ return `${name}{${inner}}`;
25
+ }
26
+ class Metrics {
27
+ series = new Map();
28
+ /** Increment a monotonic counter (default by 1). */
29
+ inc(name, labels, by = 1) {
30
+ if (!NAME_RE.test(name))
31
+ return;
32
+ const key = seriesKey(name, labels);
33
+ const existing = this.series.get(key);
34
+ if (existing)
35
+ existing.value += by;
36
+ else
37
+ this.series.set(key, { name, type: "counter", value: by });
38
+ }
39
+ /** Set a gauge to an absolute value. */
40
+ setGauge(name, value, labels) {
41
+ if (!NAME_RE.test(name))
42
+ return;
43
+ const key = seriesKey(name, labels);
44
+ const existing = this.series.get(key);
45
+ if (existing)
46
+ existing.value = value;
47
+ else
48
+ this.series.set(key, { name, type: "gauge", value });
49
+ }
50
+ /** Render the registry in Prometheus text exposition format. */
51
+ render() {
52
+ // Emit one HELP/TYPE per metric name (not per series), then the series.
53
+ const typesByName = new Map();
54
+ for (const s of this.series.values())
55
+ typesByName.set(s.name, s.type);
56
+ const lines = [];
57
+ for (const [name, type] of typesByName) {
58
+ lines.push(`# TYPE ${name} ${type}`);
59
+ for (const [key, s] of this.series) {
60
+ if (s.name === name)
61
+ lines.push(`${key} ${s.value}`);
62
+ }
63
+ }
64
+ return lines.length ? `${lines.join("\n")}\n` : "";
65
+ }
66
+ /** Test seam: clear all series. */
67
+ reset() {
68
+ this.series.clear();
69
+ }
70
+ }
71
+ export const metrics = new Metrics();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Per-request correlation, threaded implicitly via AsyncLocalStorage so we do
3
+ * not have to plumb a request id through every function signature. The logger
4
+ * reads the active context and stamps `requestId` (and `tool`) onto every line,
5
+ * so a tool call and the GitBook fetch/retry lines it triggers share an id —
6
+ * the difference between debuggable and un-debuggable logs in production.
7
+ *
8
+ * The per-session HTTP logger separately binds `sessionId` via `logger.child`,
9
+ * so HTTP logs carry both session and request correlation.
10
+ */
11
+ export interface RequestContext {
12
+ readonly requestId: string;
13
+ readonly tool?: string;
14
+ }
15
+ /** Run `fn` with the given request context active for all async work it spawns. */
16
+ export declare function runWithRequestContext<T>(ctx: RequestContext, fn: () => T): T;
17
+ /** The active request context, or undefined outside any request scope. */
18
+ export declare function getRequestContext(): RequestContext | undefined;
@@ -0,0 +1,10 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const storage = new AsyncLocalStorage();
3
+ /** Run `fn` with the given request context active for all async work it spawns. */
4
+ export function runWithRequestContext(ctx, fn) {
5
+ return storage.run(ctx, fn);
6
+ }
7
+ /** The active request context, or undefined outside any request scope. */
8
+ export function getRequestContext() {
9
+ return storage.getStore();
10
+ }
@@ -0,0 +1,9 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ToolContext } from "./tools/shared.js";
3
+ /**
4
+ * Expose GitBook pages as MCP resources addressable by `gitbook://{spaceId}/{pageId}`,
5
+ * so clients can attach a specific page as context. Enumeration (`list`) is left
6
+ * undefined — listing every page across every space would be unbounded; pages are
7
+ * read by their known URI (e.g. surfaced from gitbook_list_pages / gitbook_search).
8
+ */
9
+ export declare function registerResources(server: McpServer, ctx: ToolContext): void;
@@ -0,0 +1,56 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { redactSecret } from "./logger.js";
4
+ import { describeError } from "./gitbook/errors.js";
5
+ import { runWithRequestContext } from "./request-context.js";
6
+ import { metrics } from "./metrics.js";
7
+ /**
8
+ * Expose GitBook pages as MCP resources addressable by `gitbook://{spaceId}/{pageId}`,
9
+ * so clients can attach a specific page as context. Enumeration (`list`) is left
10
+ * undefined — listing every page across every space would be unbounded; pages are
11
+ * read by their known URI (e.g. surfaced from gitbook_list_pages / gitbook_search).
12
+ */
13
+ export function registerResources(server, ctx) {
14
+ server.registerResource("gitbook-page", new ResourceTemplate("gitbook://{spaceId}/{pageId}", { list: undefined }), {
15
+ title: "GitBook page",
16
+ description: "A GitBook page rendered as markdown.",
17
+ mimeType: "text/markdown",
18
+ },
19
+ // Wrapped with the same discipline as guard() for tools: a request-correlation
20
+ // scope (so the fetch/retry log lines share an id), metrics, and — critically —
21
+ // error classification + token redaction. The SDK surfaces a thrown error's
22
+ // `.message` verbatim to the client, so we must never let a raw, unredacted
23
+ // error escape this path the way the tool path is protected by errorResult().
24
+ async (uri, variables) => runWithRequestContext({ requestId: randomUUID(), tool: "resource:gitbook-page" }, async () => {
25
+ metrics.inc("gitbook_resource_reads_total");
26
+ try {
27
+ const spaceId = String(variables.spaceId);
28
+ const pageId = String(variables.pageId);
29
+ const page = await ctx.gitbook.getPage(spaceId, pageId, "markdown");
30
+ // format=markdown surfaces a `markdown` string on the page payload; fall
31
+ // back to the structured JSON if it is absent.
32
+ const md = page.markdown;
33
+ if (typeof md === "string") {
34
+ return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: md }] };
35
+ }
36
+ return {
37
+ contents: [
38
+ {
39
+ uri: uri.href,
40
+ mimeType: "application/json",
41
+ text: JSON.stringify(page, null, 2),
42
+ },
43
+ ],
44
+ };
45
+ }
46
+ catch (err) {
47
+ metrics.inc("gitbook_resource_errors_total");
48
+ ctx.logger.error("resource read failed", { error: describeError(err) });
49
+ // Re-throw a classified, token-redacted message (the SDK sends only
50
+ // `.message`/`.code`/`.data` to the client, never `.cause`) — same
51
+ // guarantee as errorResult() for tools. The raw `err` is kept as `cause`
52
+ // for server-side debugging; it never leaves the process.
53
+ throw new Error(redactSecret(describeError(err), ctx.config.token), { cause: err });
54
+ }
55
+ }));
56
+ }
@@ -0,0 +1,14 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { Config } from "./config.js";
3
+ import type { Logger } from "./logger.js";
4
+ import { GitBookClient } from "./gitbook/client.js";
5
+ export interface CreatedServer {
6
+ readonly server: McpServer;
7
+ readonly registeredTools: string[];
8
+ }
9
+ /**
10
+ * Build a fresh MCP server wired to a GitBook client. One instance per stdio
11
+ * process, or one per HTTP session. Pass a `gitbook` client to inject a mock
12
+ * in tests.
13
+ */
14
+ export declare function createServer(config: Config, logger: Logger, gitbook?: GitBookClient): CreatedServer;
package/dist/server.js ADDED
@@ -0,0 +1,31 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { GitBookClient } from "./gitbook/client.js";
3
+ import { registerTools } from "./tools/index.js";
4
+ import { registerResources } from "./resources.js";
5
+ import { SERVER_NAME, SERVER_VERSION } from "./version.js";
6
+ const INSTRUCTIONS = [
7
+ "GitBook access. Read flow: gitbook_list_orgs → gitbook_list_spaces(orgId) →",
8
+ "gitbook_list_pages(spaceId) → gitbook_get_page(spaceId,pageId). Search with",
9
+ "gitbook_search (give orgId OR spaceId). Pages are also readable as resources",
10
+ "at gitbook://{spaceId}/{pageId}.",
11
+ "Write flow (when not read-only): gitbook_create_change_request →",
12
+ "gitbook_import_content (import a URL into that change request) → review in",
13
+ "GitBook → gitbook_merge_change_request to publish. There is no direct",
14
+ "page-body edit; content is written via change-request + import.",
15
+ ].join(" ");
16
+ /**
17
+ * Build a fresh MCP server wired to a GitBook client. One instance per stdio
18
+ * process, or one per HTTP session. Pass a `gitbook` client to inject a mock
19
+ * in tests.
20
+ */
21
+ export function createServer(config, logger, gitbook) {
22
+ const client = gitbook ?? new GitBookClient(config, logger);
23
+ const ctx = { gitbook: client, logger, config };
24
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION, title: "GitBook MCP" },
25
+ // tools/resources capabilities are auto-enabled by register*(); we don't
26
+ // advertise `logging` because diagnostics go to stderr, not MCP notifications.
27
+ { instructions: INSTRUCTIONS });
28
+ const registeredTools = registerTools(server, ctx);
29
+ registerResources(server, ctx);
30
+ return { server, registeredTools };
31
+ }
@@ -0,0 +1,9 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ToolContext } from "./shared.js";
3
+ export type { ToolContext } from "./shared.js";
4
+ /**
5
+ * Register all tools. Read tools are always available; the change-request write
6
+ * tools are registered ONLY when not in read-only mode, so a read-only server
7
+ * never advertises them in tools/list. Returns the registered tool names.
8
+ */
9
+ export declare function registerTools(server: McpServer, ctx: ToolContext): string[];
@@ -0,0 +1,17 @@
1
+ import { registerReadTools } from "./read.js";
2
+ import { registerWriteTools } from "./write.js";
3
+ /**
4
+ * Register all tools. Read tools are always available; the change-request write
5
+ * tools are registered ONLY when not in read-only mode, so a read-only server
6
+ * never advertises them in tools/list. Returns the registered tool names.
7
+ */
8
+ export function registerTools(server, ctx) {
9
+ const names = registerReadTools(server, ctx);
10
+ if (!ctx.config.readOnly) {
11
+ names.push(...registerWriteTools(server, ctx));
12
+ }
13
+ else {
14
+ ctx.logger.info("read-only mode: write tools not registered");
15
+ }
16
+ return names;
17
+ }
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type ToolContext } from "./shared.js";
3
+ /** Register the read-only GitBook tools. Returns the registered tool names. */
4
+ export declare function registerReadTools(server: McpServer, ctx: ToolContext): string[];
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ import { jsonResult, errorResult, guard, READ_ANNOTATIONS, ToolInputError, } from "./shared.js";
3
+ const limit = z
4
+ .number()
5
+ .int()
6
+ .min(1)
7
+ .max(1000)
8
+ .optional()
9
+ .describe("Max results to return (server caps apply).");
10
+ const cursor = z
11
+ .string()
12
+ .optional()
13
+ .describe("Pagination cursor from a previous call's nextCursor.");
14
+ /** Register the read-only GitBook tools. Returns the registered tool names. */
15
+ export function registerReadTools(server, ctx) {
16
+ server.registerTool("gitbook_whoami", {
17
+ title: "Get authenticated user",
18
+ description: "Return the GitBook user the configured token authenticates as (id, name, email).",
19
+ inputSchema: {},
20
+ annotations: READ_ANNOTATIONS,
21
+ }, guard(ctx, "gitbook_whoami", async () => jsonResult(await ctx.gitbook.getAuthenticatedUser())));
22
+ server.registerTool("gitbook_list_orgs", {
23
+ title: "List organizations",
24
+ description: "List organizations the authenticated user can access. Use the returned id as orgId for other tools.",
25
+ inputSchema: { limit, cursor },
26
+ annotations: READ_ANNOTATIONS,
27
+ }, guard(ctx, "gitbook_list_orgs", async ({ limit, cursor }) => jsonResult(await ctx.gitbook.listOrganizations({ limit, cursor }))));
28
+ server.registerTool("gitbook_list_spaces", {
29
+ title: "List spaces in an organization",
30
+ description: "List the spaces inside an organization. Get orgId from gitbook_list_orgs.",
31
+ inputSchema: { orgId: z.string().describe("Organization id."), limit, cursor },
32
+ annotations: READ_ANNOTATIONS,
33
+ }, guard(ctx, "gitbook_list_spaces", async ({ orgId, limit, cursor }) => jsonResult(await ctx.gitbook.listSpaces(orgId, { limit, cursor }))));
34
+ server.registerTool("gitbook_get_space", {
35
+ title: "Get a space",
36
+ description: "Fetch a single space by id (title, visibility, urls, default revision).",
37
+ inputSchema: { spaceId: z.string().describe("Space id.") },
38
+ annotations: READ_ANNOTATIONS,
39
+ }, guard(ctx, "gitbook_get_space", async ({ spaceId }) => jsonResult(await ctx.gitbook.getSpace(spaceId))));
40
+ server.registerTool("gitbook_list_pages", {
41
+ title: "List pages in a space",
42
+ description: "List the page tree of a space's current revision (page ids, titles, paths). Not paginated.",
43
+ inputSchema: { spaceId: z.string().describe("Space id.") },
44
+ annotations: READ_ANNOTATIONS,
45
+ }, guard(ctx, "gitbook_list_pages", async ({ spaceId }) => jsonResult({ pages: await ctx.gitbook.listPages(spaceId) })));
46
+ server.registerTool("gitbook_get_page", {
47
+ title: "Get a page's content",
48
+ description: "Read a page by id. format='markdown' (default) returns rendered markdown; format='document' returns GitBook's structured Document JSON.",
49
+ inputSchema: {
50
+ spaceId: z.string().describe("Space id."),
51
+ pageId: z.string().describe("Page id (from gitbook_list_pages)."),
52
+ format: z
53
+ .enum(["markdown", "document"])
54
+ .optional()
55
+ .describe("Output format (default markdown)."),
56
+ },
57
+ annotations: READ_ANNOTATIONS,
58
+ }, guard(ctx, "gitbook_get_page", async ({ spaceId, pageId, format }) => jsonResult(await ctx.gitbook.getPage(spaceId, pageId, format ?? "markdown"))));
59
+ server.registerTool("gitbook_search", {
60
+ title: "Search content",
61
+ description: "Full-text search. Provide orgId to search an organization, or spaceId to search a single space (exactly one is required).",
62
+ inputSchema: {
63
+ query: z.string().min(1).max(512).describe("Search query."),
64
+ orgId: z.string().optional().describe("Organization id (search org-wide)."),
65
+ spaceId: z.string().optional().describe("Space id (search one space)."),
66
+ limit,
67
+ cursor,
68
+ },
69
+ annotations: READ_ANNOTATIONS,
70
+ }, guard(ctx, "gitbook_search", async ({ query, orgId, spaceId, limit, cursor }) => {
71
+ if (!orgId && !spaceId) {
72
+ return errorResult(new ToolInputError("Provide either orgId or spaceId."), ctx.config.token);
73
+ }
74
+ if (orgId && spaceId) {
75
+ return errorResult(new ToolInputError("Provide only one of orgId or spaceId, not both."), ctx.config.token);
76
+ }
77
+ const page = spaceId
78
+ ? await ctx.gitbook.searchSpace(spaceId, query, { limit, cursor })
79
+ : await ctx.gitbook.searchOrganization(orgId, query, { limit, cursor });
80
+ return jsonResult(page);
81
+ }));
82
+ return [
83
+ "gitbook_whoami",
84
+ "gitbook_list_orgs",
85
+ "gitbook_list_spaces",
86
+ "gitbook_get_space",
87
+ "gitbook_list_pages",
88
+ "gitbook_get_page",
89
+ "gitbook_search",
90
+ ];
91
+ }
@@ -0,0 +1,48 @@
1
+ import type { CallToolResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { Config } from "../config.js";
3
+ import { type Logger } from "../logger.js";
4
+ import type { GitBookClient } from "../gitbook/client.js";
5
+ /** Everything a tool handler needs, injected (so tests can mock the client). */
6
+ export interface ToolContext {
7
+ readonly gitbook: GitBookClient;
8
+ readonly logger: Logger;
9
+ readonly config: Config;
10
+ }
11
+ /**
12
+ * A deliberate, model-facing input-validation error raised by a tool handler
13
+ * (e.g. mutually-exclusive arguments). classifyError() preserves its `.message`
14
+ * as a `validation` error — a plain `Error` would instead be flattened to the
15
+ * generic "unknown" message, swallowing the guidance the model needs to self-correct.
16
+ */
17
+ export declare class ToolInputError extends Error {
18
+ readonly name = "ToolInputError";
19
+ }
20
+ /**
21
+ * Build a successful tool result. Always returns a text mirror (JSON) — every
22
+ * MCP client can read it — plus `structuredContent` for clients that consume it.
23
+ * structuredContent must be an object, so non-objects are wrapped.
24
+ */
25
+ export declare function jsonResult(data: unknown): CallToolResult;
26
+ /**
27
+ * Build an error tool result (isError:true) with a safe, classified message.
28
+ * Pass the token so any accidental interpolation of it (e.g. a credential-bearing
29
+ * URL) is scrubbed before reaching the model — the same invariant the logger holds.
30
+ */
31
+ export declare function errorResult(err: unknown, secret?: string): CallToolResult;
32
+ export interface GuardOptions {
33
+ /** Emit info-level invoke/ok audit lines (for write/destructive tools). */
34
+ audit?: boolean;
35
+ }
36
+ /**
37
+ * Wrap a tool handler with uniform error handling, metrics, and logging, inside
38
+ * a fresh request-correlation scope (a `requestId` is stamped on every log line
39
+ * the call produces — tool, client, and fetch/retry — so one invocation is
40
+ * traceable end to end). A thrown error becomes a clean `isError` result the
41
+ * model can read (never leaks the token). Write/destructive tools (`audit:true`)
42
+ * also emit info-level invoke/ok lines so there is an audit trail at the default
43
+ * log level.
44
+ */
45
+ export declare function guard<Args>(ctx: ToolContext, toolName: string, fn: (args: Args) => Promise<CallToolResult>, opts?: GuardOptions): (args: Args) => Promise<CallToolResult>;
46
+ export declare const READ_ANNOTATIONS: ToolAnnotations;
47
+ export declare const WRITE_ANNOTATIONS: ToolAnnotations;
48
+ export declare const DESTRUCTIVE_ANNOTATIONS: ToolAnnotations;
@@ -0,0 +1,99 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { redactSecret } from "../logger.js";
3
+ import { describeError } from "../gitbook/errors.js";
4
+ import { runWithRequestContext } from "../request-context.js";
5
+ import { metrics } from "../metrics.js";
6
+ /**
7
+ * A deliberate, model-facing input-validation error raised by a tool handler
8
+ * (e.g. mutually-exclusive arguments). classifyError() preserves its `.message`
9
+ * as a `validation` error — a plain `Error` would instead be flattened to the
10
+ * generic "unknown" message, swallowing the guidance the model needs to self-correct.
11
+ */
12
+ export class ToolInputError extends Error {
13
+ name = "ToolInputError";
14
+ }
15
+ /**
16
+ * Build a successful tool result. Always returns a text mirror (JSON) — every
17
+ * MCP client can read it — plus `structuredContent` for clients that consume it.
18
+ * structuredContent must be an object, so non-objects are wrapped.
19
+ */
20
+ export function jsonResult(data) {
21
+ const structuredContent = data !== null && typeof data === "object" && !Array.isArray(data)
22
+ ? data
23
+ : { result: data };
24
+ return {
25
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
26
+ structuredContent,
27
+ };
28
+ }
29
+ /**
30
+ * Build an error tool result (isError:true) with a safe, classified message.
31
+ * Pass the token so any accidental interpolation of it (e.g. a credential-bearing
32
+ * URL) is scrubbed before reaching the model — the same invariant the logger holds.
33
+ */
34
+ export function errorResult(err, secret) {
35
+ return {
36
+ content: [{ type: "text", text: redactSecret(describeError(err), secret) }],
37
+ isError: true,
38
+ };
39
+ }
40
+ /**
41
+ * Wrap a tool handler with uniform error handling, metrics, and logging, inside
42
+ * a fresh request-correlation scope (a `requestId` is stamped on every log line
43
+ * the call produces — tool, client, and fetch/retry — so one invocation is
44
+ * traceable end to end). A thrown error becomes a clean `isError` result the
45
+ * model can read (never leaks the token). Write/destructive tools (`audit:true`)
46
+ * also emit info-level invoke/ok lines so there is an audit trail at the default
47
+ * log level.
48
+ */
49
+ export function guard(ctx, toolName, fn, opts = {}) {
50
+ return (args) => runWithRequestContext({ requestId: randomUUID(), tool: toolName }, async () => {
51
+ const started = Date.now();
52
+ metrics.inc("gitbook_tool_calls_total", { tool: toolName });
53
+ if (opts.audit)
54
+ ctx.logger.info("tool invoked", { tool: toolName });
55
+ try {
56
+ const result = await fn(args);
57
+ const ms = Date.now() - started;
58
+ if (result.isError) {
59
+ // A handler that RETURNS isError (vs throws) is reporting bad user
60
+ // input — labeled "validation" so it doesn't pollute the operational
61
+ // error-rate signal that alerts page on.
62
+ metrics.inc("gitbook_tool_errors_total", { tool: toolName, kind: "validation" });
63
+ ctx.logger[opts.audit ? "warn" : "debug"]("tool returned error", { tool: toolName, ms });
64
+ }
65
+ else if (opts.audit) {
66
+ ctx.logger.info("tool ok", { tool: toolName, ms });
67
+ }
68
+ else {
69
+ ctx.logger.debug("tool ok", { tool: toolName, ms });
70
+ }
71
+ return result;
72
+ }
73
+ catch (err) {
74
+ metrics.inc("gitbook_tool_errors_total", { tool: toolName, kind: "operational" });
75
+ ctx.logger.error("tool failed", {
76
+ tool: toolName,
77
+ ms: Date.now() - started,
78
+ error: describeError(err),
79
+ });
80
+ return errorResult(err, ctx.config.token);
81
+ }
82
+ });
83
+ }
84
+ export const READ_ANNOTATIONS = {
85
+ readOnlyHint: true,
86
+ idempotentHint: true,
87
+ openWorldHint: true,
88
+ };
89
+ export const WRITE_ANNOTATIONS = {
90
+ readOnlyHint: false,
91
+ idempotentHint: false,
92
+ openWorldHint: true,
93
+ };
94
+ export const DESTRUCTIVE_ANNOTATIONS = {
95
+ readOnlyHint: false,
96
+ destructiveHint: true,
97
+ idempotentHint: false,
98
+ openWorldHint: true,
99
+ };
@@ -0,0 +1,8 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type ToolContext } from "./shared.js";
3
+ /**
4
+ * Register the change-request write tools. Only call this when NOT in read-only
5
+ * mode — gating registration (rather than rejecting at call time) keeps the
6
+ * write tools out of tools/list entirely. Returns the registered tool names.
7
+ */
8
+ export declare function registerWriteTools(server: McpServer, ctx: ToolContext): string[];
@@ -0,0 +1,88 @@
1
+ import { z } from "zod";
2
+ import { jsonResult, guard, WRITE_ANNOTATIONS, DESTRUCTIVE_ANNOTATIONS, } from "./shared.js";
3
+ import { isSafeImportUrl } from "../gitbook/import-url.js";
4
+ /** Audit write/destructive tools at info level (invoke + ok lines). */
5
+ const AUDIT = { audit: true };
6
+ /**
7
+ * Register the change-request write tools. Only call this when NOT in read-only
8
+ * mode — gating registration (rather than rejecting at call time) keeps the
9
+ * write tools out of tools/list entirely. Returns the registered tool names.
10
+ */
11
+ export function registerWriteTools(server, ctx) {
12
+ server.registerTool("gitbook_create_change_request", {
13
+ title: "Open a change request",
14
+ description: "Create a change request (draft branch) on a space. Returns its id to target with gitbook_import_content / gitbook_comment_change_request, then gitbook_merge_change_request to publish.",
15
+ inputSchema: {
16
+ spaceId: z.string().describe("Space id."),
17
+ subject: z.string().optional().describe("Title/subject of the change request."),
18
+ },
19
+ annotations: WRITE_ANNOTATIONS,
20
+ }, guard(ctx, "gitbook_create_change_request", async ({ spaceId, subject }) => jsonResult(await ctx.gitbook.createChangeRequest(spaceId, subject)), AUDIT));
21
+ server.registerTool("gitbook_import_content", {
22
+ title: "Import content into a change request",
23
+ description: "GitBook's content-write primitive: import a public web page (sourceUrl) into a space — scoped to a change request (and optionally a page), AI-enhanced by default. There is NO direct 'set page body' API; this is the supported write path. The import is asynchronous (returns a run id + status); review in GitBook, then gitbook_merge_change_request to publish.",
24
+ inputSchema: {
25
+ orgId: z.string().describe("Organization id that owns the space."),
26
+ spaceId: z.string().describe("Target space id."),
27
+ sourceUrl: z
28
+ .string()
29
+ .url()
30
+ .refine(isSafeImportUrl, {
31
+ message: "sourceUrl must be an http(s) URL with no embedded credentials.",
32
+ })
33
+ .describe("Public http(s) URL of the page to import as content (no credentials)."),
34
+ changeRequestId: z
35
+ .string()
36
+ .optional()
37
+ .describe("Target change request id (recommended — keeps the import off the live branch)."),
38
+ pageId: z.string().optional().describe("Target page id to import into (optional)."),
39
+ enhance: z.boolean().optional().describe("AI-enhance the imported content (default true)."),
40
+ },
41
+ annotations: WRITE_ANNOTATIONS,
42
+ }, guard(ctx, "gitbook_import_content", async (args) => jsonResult(await ctx.gitbook.importContent(args)), AUDIT));
43
+ server.registerTool("gitbook_comment_change_request", {
44
+ title: "Comment on a change request",
45
+ description: "Post a markdown comment on a change request (review feedback / notes).",
46
+ inputSchema: {
47
+ spaceId: z.string().describe("Space id."),
48
+ changeRequestId: z.string().describe("Change request id."),
49
+ body: z.string().min(1).describe("Comment text (markdown)."),
50
+ page: z.string().optional().describe("Page id to attach the comment to (optional)."),
51
+ },
52
+ annotations: WRITE_ANNOTATIONS,
53
+ }, guard(ctx, "gitbook_comment_change_request", async ({ spaceId, changeRequestId, body, page }) => jsonResult(await ctx.gitbook.commentOnChangeRequest(spaceId, changeRequestId, body, page)), AUDIT));
54
+ server.registerTool("gitbook_merge_change_request", {
55
+ title: "Merge (publish) a change request",
56
+ description: "Publish a change request into the live space. THIS IS DESTRUCTIVE — it changes the live published docs. Only call after the change request has been reviewed.",
57
+ inputSchema: {
58
+ spaceId: z.string().describe("Space id."),
59
+ changeRequestId: z.string().describe("Change request id to merge."),
60
+ },
61
+ annotations: DESTRUCTIVE_ANNOTATIONS,
62
+ }, guard(ctx, "gitbook_merge_change_request", async ({ spaceId, changeRequestId }) => {
63
+ const merge = await ctx.gitbook.mergeChangeRequest(spaceId, changeRequestId);
64
+ // GitBook returns HTTP 200 with result:"conflicts" when it could NOT merge
65
+ // — the change request is NOT published. Flag it as an error so the model
66
+ // never mistakes this no-op for a successful publish of the live docs.
67
+ if (merge.result === "conflicts") {
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text",
72
+ text: "Merge did NOT publish: the change request has conflicts and remains open. " +
73
+ `Resolve them in GitBook, then retry.\n${JSON.stringify(merge, null, 2)}`,
74
+ },
75
+ ],
76
+ structuredContent: merge,
77
+ isError: true,
78
+ };
79
+ }
80
+ return jsonResult(merge);
81
+ }, AUDIT));
82
+ return [
83
+ "gitbook_create_change_request",
84
+ "gitbook_import_content",
85
+ "gitbook_comment_change_request",
86
+ "gitbook_merge_change_request",
87
+ ];
88
+ }
@@ -0,0 +1,20 @@
1
+ import { type Config } from "../config.js";
2
+ import type { Logger } from "../logger.js";
3
+ import type { RunningTransport } from "./stdio.js";
4
+ /** Constant-time string comparison that does not leak length. Exported for tests. */
5
+ export declare function secretEquals(a: string, b: string): boolean;
6
+ /**
7
+ * Run the server over Streamable HTTP with one transport+server per session.
8
+ *
9
+ * Security: binds to 127.0.0.1 by default, enables DNS-rebinding protection
10
+ * (Host/Origin allow-lists), and — when GITBOOK_HTTP_AUTH_TOKEN is set —
11
+ * requires a matching bearer token on every request. Running without an auth
12
+ * token is allowed only for localhost development and is logged as a warning.
13
+ *
14
+ * Operability: sessions are capped (new initializes past the cap get 503) and
15
+ * idle sessions are reaped on a TTL so the in-memory session store cannot grow
16
+ * unbounded; a per-IP rate limiter guards the endpoint; unauthenticated
17
+ * `/healthz` `/livez` `/readyz` serve orchestration probes; and a bearer-gated
18
+ * `/metrics` exposes Prometheus counters.
19
+ */
20
+ export declare function runHttp(config: Config, logger: Logger): Promise<RunningTransport>;