From 89c4a0f8081db0e89658e8feae4996ca63a324fe Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:11:05 +0200 Subject: [PATCH] feat(mcp): add askQuestion tool to the site MCP server (#4372) Co-authored-by: Claude Co-authored-by: Zeno Kapitein --- .changeset/mcp-ask-question-tool.md | 5 + bun.lock | 6 +- package.json | 2 +- .../[siteData]/~gitbook/mcp/handler.ts | 106 ++++++++++++++- packages/gitbook/src/lib/ask.ts | 124 ++++++++++++++++++ packages/gitbook/src/routes/markdownAsk.ts | 88 +------------ 6 files changed, 242 insertions(+), 89 deletions(-) create mode 100644 .changeset/mcp-ask-question-tool.md create mode 100644 packages/gitbook/src/lib/ask.ts diff --git a/.changeset/mcp-ask-question-tool.md b/.changeset/mcp-ask-question-tool.md new file mode 100644 index 000000000..25eedc203 --- /dev/null +++ b/.changeset/mcp-ask-question-tool.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Add an `askQuestion` tool to the site MCP server. Alongside `searchDocumentation` and `getPage`, MCP clients can now ask a natural-language question and get a synthesized answer with links to the source pages, powered by the same AI search backend as the site's "ask a question" experience. The tool accepts an optional `goal` param so calling agents can attach the intent they're trying to accomplish, which tailors the answer and is tracked in analytics. The tool is only exposed on sites that have AI enabled. diff --git a/bun.lock b/bun.lock index 742f26e50..d03076122 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.31.0", - "turbo": "^2.9.18", + "turbo": "^2.10.3", "vercel": "50.37.3", }, }, @@ -360,7 +360,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.186.0", + "@gitbook/api": "0.187.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -756,7 +756,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.186.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-UAF+tVtstqyA8s9OYXoYhgHfOUQaaQBRjQX4WLEB1YxcYtJZ9YM/EM8Ouj38Tg4qW3tr76t1/OESZipCE6t8cw=="], + "@gitbook/api": ["@gitbook/api@0.187.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ec2TuMm19ycPzFZXUt2D7xSNjbF3vh9Ft3zTgG4Td4UkVuNiB+zpRYmnduqzE0XsiMEwwKLEj/HWMXe+QK4Piw=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index f90d30817..3f1872296 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.186.0", + "@gitbook/api": "0.187.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts index 5b496a838..17c76df0b 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts @@ -1,8 +1,10 @@ import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api'; import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils'; +import { isAIEnabled } from '@/components/utils/isAIChatEnabled'; +import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask'; import { getExposableError, throwIfDataError } from '@/lib/data'; -import { getMarkdownForPageInSpace } from '@/lib/markdownPage'; +import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage'; import { resolvePagePath } from '@/lib/pages'; import { joinPathWithBaseURL } from '@/lib/paths'; import { getBestScoredResult } from '@/lib/search'; @@ -236,6 +238,108 @@ export async function handleMcpRequest( } } ); + + // Only expose the answer tool when the site has AI enabled, since it relies on + // the same AI search backend that powers the site's "ask a question" experience. + if (isAIEnabled(context.customization.ai.mode)) { + server.tool( + 'askQuestion', + `Ask a natural-language question about ${site.title} and get a synthesized answer, with links to the source pages. Prefer this over \`searchDocumentation\` when you want a direct answer to a question rather than a list of matching pages; use \`searchDocumentation\`/\`getPage\` when you need to browse or read full pages yourself.`, + { + question: z + .string() + .describe( + `The natural-language question to answer about ${site.title}.` + ), + goal: z + .string() + .optional() + .describe( + 'The broader end goal you are ultimately trying to accomplish (as/on behalf of the user). Used to tailor the answer to be most useful for your goal. Optional.' + ), + }, + { + title: 'Ask a question', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + async ({ question, goal }) => { + try { + const trimmedQuestion = question.trim(); + if (!trimmedQuestion) { + return { + content: [ + { + type: 'text', + text: 'Please provide a question to answer.', + }, + ], + isError: true, + }; + } + + const trimmedGoal = goal?.trim() || undefined; + + const answer = await streamSiteAskAnswer(context, trimmedQuestion, { + goal: trimmedGoal, + }); + + trackMcpEvent({ + organizationId: context.organizationId, + siteId: site.id, + events: [ + { + type: 'ask_question', + query: trimmedQuestion, + ...(trimmedGoal ? { goal: trimmedGoal } : {}), + location: { + displayContext: SiteInsightsDisplayContext.Mcp, + }, + }, + ], + request, + }); + + if (!answer || !answer.answer || !('markdown' in answer.answer)) { + return { + content: [ + { + type: 'text', + text: "We couldn't answer this question.", + }, + ], + }; + } + + const answerMarkdown = toPageMarkdown( + await fromPageMarkdown(context, { + markdown: answer.answer.markdown, + pagePath: '', + }) + ); + const sourcesMarkdown = await renderAskSourcesMarkdown( + context, + answer.sources ?? [] + ); + + let text = answerMarkdown.trim(); + if (sourcesMarkdown) { + text += `\n\n# Sources\n\n${sourcesMarkdown}`; + } + + return { content: [{ type: 'text', text }] }; + } catch (error) { + const exposable = getExposableError(error); + return { + content: [{ type: 'text', text: exposable.message }], + isError: true, + }; + } + } + ); + } }, {}, { diff --git a/packages/gitbook/src/lib/ask.ts b/packages/gitbook/src/lib/ask.ts new file mode 100644 index 000000000..9bf840ef0 --- /dev/null +++ b/packages/gitbook/src/lib/ask.ts @@ -0,0 +1,124 @@ +import type { GitBookSiteContext } from '@/lib/context'; +import { throwIfDataError } from '@/lib/data'; +import { resolvePageId } from '@/lib/pages'; +import { findSiteSpaceBy, getFallbackSiteSpacePath } from '@/lib/sites'; +import { filterOutNullable } from '@/lib/typescript'; +import type { SearchAIAnswer, SearchAIAnswerSource } from '@gitbook/api'; + +/** + * Options to steer a site AI answer. + */ +export interface StreamSiteAskOptions { + /** + * The end goal the calling agent is trying to accomplish on behalf of the user. + * Used by the backend to steer the answer. + */ + goal?: string; +} + +/** + * Ask a natural-language question against a site's AI search backend and return the + * final answer once the stream completes. + * + * This is the single entry point both the `?ask=` markdown route and the site MCP + * `askQuestion` tool go through, so they answer questions from the exact same backend + * (`streamAskInSite`) rather than reimplementing retrieval. + */ +export async function streamSiteAskAnswer( + context: GitBookSiteContext, + question: string, + options: StreamSiteAskOptions = {} +): Promise { + const apiClient = await context.dataFetcher.api(); + const stream = apiClient.orgs.streamAskInSite( + context.organizationId, + context.site.id, + { + question, + context: { + siteSpaceId: context.siteSpace.id, + goal: options.goal, + }, + scope: { + mode: 'default', + currentSiteSpace: context.siteSpace.id, + }, + }, + { format: 'markdown' } + ); + + let latestAnswer: SearchAIAnswer | null = null; + + for await (const chunk of stream) { + if (chunk.type === 'answer') { + latestAnswer = chunk.answer; + } + } + + return latestAnswer; +} + +/** + * Render the sources of an AI answer as a markdown list of links. + * + * @param options.markdownLinks when true, page links point at the `.md` variant of each + * page (for the crawler-facing `?ask=` markdown route); when false they point at the + * regular published page URLs (for the MCP tool, so the URLs can be fed back into `getPage`). + */ +export async function renderAskSourcesMarkdown( + context: GitBookSiteContext, + sources: SearchAIAnswerSource[], + options: { markdownLinks?: boolean } = {} +): Promise { + const { markdownLinks = false } = options; + + const items = ( + await Promise.all( + sources.map(async (source) => { + if (source.type === 'record') { + return { + title: source.title, + url: source.url, + }; + } + + const revision = + source.space === context.space.id && source.revision === context.revisionId + ? context.revision + : await throwIfDataError( + context.dataFetcher.getRevision({ + spaceId: source.space, + revisionId: source.revision, + }) + ); + const resolved = resolvePageId(revision.pages, source.page); + if (!resolved) { + return null; + } + + const found = findSiteSpaceBy( + context.structure, + (siteSpace) => siteSpace.space.id === source.space + ); + const linker = found + ? context.linker.withOtherSiteSpace({ + spaceBasePath: getFallbackSiteSpacePath(context, found.siteSpace), + }) + : context.linker; + + const path = linker.toPathInSpace(resolved.page.path); + + return { + title: resolved.page.title, + url: linker.toAbsoluteURL(markdownLinks ? `${path}.md` : path), + }; + }) + ) + ).filter(filterOutNullable); + + if (items.length === 0) { + return ''; + } + + return items.map((item) => `- [${item.title}](${item.url})`).join('\n'); +} diff --git a/packages/gitbook/src/routes/markdownAsk.ts b/packages/gitbook/src/routes/markdownAsk.ts index 87508cc49..19cc25e2b 100644 --- a/packages/gitbook/src/routes/markdownAsk.ts +++ b/packages/gitbook/src/routes/markdownAsk.ts @@ -1,13 +1,9 @@ import { isAIEnabled } from '@/components/utils/isAIChatEnabled'; +import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask'; import type { GitBookSiteContext } from '@/lib/context'; -import { throwIfDataError } from '@/lib/data'; import { linkerWithMarkdownPages } from '@/lib/links'; import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage'; -import { resolvePageId } from '@/lib/pages'; -import { findSiteSpaceBy, getFallbackSiteSpacePath } from '@/lib/sites'; -import { filterOutNullable } from '@/lib/typescript'; import { serveMarkdown } from '@/routes/markdownPage'; -import type { SearchAIAnswer, SearchAIAnswerSource } from '@gitbook/api'; /** * Options to steer the AI answer served as markdown. @@ -45,31 +41,7 @@ export async function serveAskMarkdown( return 'You forgot to pass a question in the `?ask=` parameter. Append a question to the URL in the `?ask=` search parameter to get a complete answer and associated sources.'; } - const apiClient = await context.dataFetcher.api(); - const stream = apiClient.orgs.streamAskInSite( - context.organizationId, - context.site.id, - { - question, - context: { - siteSpaceId: context.siteSpace.id, - goal, - }, - scope: { - mode: 'default', - currentSiteSpace: context.siteSpace.id, - }, - }, - { format: 'markdown' } - ); - - let latestAnswer: SearchAIAnswer | null = null; - - for await (const chunk of stream) { - if (chunk.type === 'answer') { - latestAnswer = chunk.answer; - } - } + const latestAnswer = await streamSiteAskAnswer(context, question, { goal }); if (!latestAnswer || !latestAnswer.answer || !('markdown' in latestAnswer.answer)) { return `We couldn't answer this question.`; @@ -89,7 +61,8 @@ export async function serveAskMarkdown( ); const sourcesMarkdown = await renderAskSourcesMarkdown( context, - latestAnswer?.sources ?? [] + latestAnswer?.sources ?? [], + { markdownLinks: true } ); let result = `# ${question}\n\n`; @@ -121,56 +94,3 @@ export async function serveAskMarkdown( return result; }); } - -async function renderAskSourcesMarkdown( - context: GitBookSiteContext, - sources: SearchAIAnswerSource[] -) { - const items = ( - await Promise.all( - sources.map(async (source) => { - if (source.type === 'record') { - return { - title: source.title, - url: source.url, - }; - } - - const revision = - source.space === context.space.id && source.revision === context.revisionId - ? context.revision - : await throwIfDataError( - context.dataFetcher.getRevision({ - spaceId: source.space, - revisionId: source.revision, - }) - ); - const resolved = resolvePageId(revision.pages, source.page); - if (!resolved) { - return null; - } - - const found = findSiteSpaceBy( - context.structure, - (siteSpace) => siteSpace.space.id === source.space - ); - const linker = found - ? context.linker.withOtherSiteSpace({ - spaceBasePath: getFallbackSiteSpacePath(context, found.siteSpace), - }) - : context.linker; - - return { - title: resolved.page.title, - url: linker.toAbsoluteURL(`${linker.toPathInSpace(resolved.page.path)}.md`), - }; - }) - ) - ).filter(filterOutNullable); - - if (items.length === 0) { - return ''; - } - - return items.map((item) => `- [${item.title}](${item.url})`).join('\n'); -}