feat(mcp): add askQuestion tool to the site MCP server (#4372)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
This commit is contained in:
claude[bot]
2026-07-09 13:11:05 +02:00
committed by GitHub
parent 8e9a49de1a
commit 89c4a0f808
6 changed files with 242 additions and 89 deletions
+5
View File
@@ -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.
+3 -3
View File
@@ -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"],
+1 -1
View File
@@ -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",
@@ -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,
};
}
}
);
}
},
{},
{
+124
View File
@@ -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<SearchAIAnswer | null> {
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<string> {
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');
}
+4 -84
View File
@@ -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=<question>` 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');
}