Add getPage MCP tool (#4148)

This commit is contained in:
Nolann B.
2026-03-31 11:01:51 +02:00
committed by GitHub
parent 7e1ea48080
commit b77c4fc5b9
6 changed files with 256 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add getPage MCP tool
@@ -1,9 +1,11 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { getMarkdownForPageInSpace } from '@/lib/markdownPage';
import { resolvePagePath } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy } from '@/lib/sites';
import { findSiteSpaceBy, findSiteSpaceByUrl } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
import { createMcpHandler } from 'mcp-handler';
@@ -126,6 +128,89 @@ async function handler(
};
}
);
const siteUrl = context.siteSpace.urls.published;
server.tool(
'getPage',
`Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`,
{
url: z
.string()
.describe('The URL of the page to fetch')
.transform((value, ctx) => {
if (URL.canParse(value)) {
return value;
}
if (URL.canParse(`https://${value}`)) {
return `https://${value}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`,
});
return z.NEVER;
}),
},
async ({ url }) => {
try {
const match = findSiteSpaceByUrl(context.structure, url);
if (!match) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const revision = await throwIfDataError(
dataFetcher.getRevision({
spaceId: match.siteSpace.space.id,
revisionId: match.siteSpace.space.revision,
})
);
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const markdown = await getMarkdownForPageInSpace(
context,
match.siteSpace,
resolved.page
);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'page_view',
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
page: resolved.page.id,
space: match.siteSpace.space.id,
revision: match.siteSpace.space.revision,
},
},
],
request,
})
);
return { content: [{ type: 'text', text: markdown }] };
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
},
{},
{
+75 -4
View File
@@ -1,9 +1,15 @@
import type { GitBookSiteContext } from '@/lib/context';
import { DataFetcherError } from '@/lib/data';
import { DataFetcherError, throwIfDataError } from '@/lib/data';
import type { ResolvedPagePath } from '@/lib/pages';
import { getIndexablePages } from '@/lib/sitemap';
import { getFallbackSiteSpacePath } from '@/lib/sites';
import { getMarkdownForPagesTree } from '@/routes/llms';
import { type RevisionPageDocument, type RevisionPageGroup, RevisionPageType } from '@gitbook/api';
import {
type RevisionPageDocument,
type RevisionPageGroup,
RevisionPageType,
type SiteSpace,
} from '@gitbook/api';
import type { Root } from 'mdast';
import { fromMarkdown } from 'mdast-util-from-markdown';
import { frontmatterFromMarkdown } from 'mdast-util-frontmatter';
@@ -61,6 +67,55 @@ export async function getMarkdownForPage(
return toPageMarkdown(tree);
}
/**
* Get markdown for a page that belongs to a different site space than the current context.
*/
export async function getMarkdownForPageInSpace(
context: GitBookSiteContext,
siteSpace: SiteSpace,
page: RevisionPageDocument | RevisionPageGroup
): Promise<string> {
const { dataFetcher } = context;
const spaceBasePath = getFallbackSiteSpacePath(context, siteSpace);
const linker = context.linker.withOtherSiteSpace({
spaceBasePath,
});
// Handle group pages (pages with no content that list their children)
if (page.type === RevisionPageType.Group) {
const siteSpaceUrl = siteSpace.urls.published;
if (!siteSpaceUrl) {
throw new DataFetcherError(`Page "${page.title}" is not published`, 404);
}
return renderGroupPageMarkdown({ siteSpaceUrl, linker, page });
}
const rawMarkdown = await throwIfDataError(
dataFetcher.getRevisionPageMarkdown({
spaceId: siteSpace.space.id,
revisionId: siteSpace.space.revision,
pageId: page.id,
})
);
const tree = fromPageMarkdown({
linker,
markdown: rawMarkdown,
pagePath: page.path,
});
// Handle empty document pages which have children (same as getMarkdownForPage)
if (isEmptyMarkdownPage(tree) && page.pages.length > 0) {
const siteSpaceUrl = siteSpace.urls.published;
if (!siteSpaceUrl) {
throw new DataFetcherError(`Page "${page.title}" is not published`, 404);
}
return renderGroupPageMarkdown({ siteSpaceUrl, linker, page });
}
return toPageMarkdown(tree);
}
/**
* Parse markdown from a page, removing frontmatter and rewriting relative links to absolute links.
* Returns the markdown AST that can be further processed or converted back to markdown using `toPageMarkdown`.
@@ -136,9 +191,25 @@ async function servePageGroup(
throw new DataFetcherError(`Page "${page.title}" is not published`, 404);
}
return renderGroupPageMarkdown({
siteSpaceUrl,
linker: context.linker,
page,
});
}
/**
* Render markdown for a group page with explicit parameters.
* Use this when rendering a group page from a different space than the current context.
*/
async function renderGroupPageMarkdown(args: {
siteSpaceUrl: string;
linker: GitBookLinker;
page: RevisionPageDocument | RevisionPageGroup;
}): Promise<string> {
const { siteSpaceUrl, linker, page } = args;
const indexablePages = getIndexablePages(page.pages);
// Create a markdown tree with the page title as heading and a list of child pages
const markdownTree: Root = {
type: 'root',
children: [
@@ -149,7 +220,7 @@ async function servePageGroup(
},
...(await getMarkdownForPagesTree(indexablePages, {
siteSpaceUrl,
linker: context.linker,
linker,
withMarkdownPages: true,
})),
],
+30 -1
View File
@@ -5,7 +5,36 @@ import {
RevisionPageLayoutOptionsWidth,
} from '@gitbook/api';
import { resolveFirstDocument, resolvePagePath, resolvePagePathDocumentOrGroup } from './pages';
import {
extractPagePath,
resolveFirstDocument,
resolvePagePath,
resolvePagePathDocumentOrGroup,
} from './pages';
describe('extractPagePath', () => {
const baseURL = 'https://docs.example.com/api/';
it('extracts path from full URL', () => {
expect(extractPagePath('https://docs.example.com/api/getting-started', baseURL)).toBe(
'getting-started'
);
});
it('extracts nested path from full URL', () => {
expect(extractPagePath('https://docs.example.com/api/guides/installation', baseURL)).toBe(
'guides/installation'
);
});
it('returns undefined when URL does not match base', () => {
expect(extractPagePath('https://other.com/page', baseURL)).toBeUndefined();
});
it('returns empty string for root URL', () => {
expect(extractPagePath('https://docs.example.com/api/', baseURL)).toBe('');
});
});
describe('resolveFirstDocument', () => {
it('should go into the first group', () => {
+29
View File
@@ -5,6 +5,7 @@ import {
type RevisionPageGroup,
RevisionPageType,
} from '@gitbook/api';
import { removeLeadingSlash, removeTrailingSlash } from './paths';
export type AncestorRevisionPage = RevisionPageDocument | RevisionPageGroup;
@@ -251,3 +252,31 @@ function flattenPages(
return result;
}
/**
* Extract the page path from a URL relative to a base URL.
* Returns the path segment after the base, or undefined if the URL doesn't match.
*/
export function extractPagePath(url: string, baseURL: string): string | undefined {
const urlPath = getURLPathname(url);
const basePath = getURLPathname(baseURL);
if (!urlPath || !basePath) {
return undefined;
}
if (urlPath.startsWith(`${basePath}/`) || urlPath === basePath) {
return removeLeadingSlash(urlPath.slice(basePath.length));
}
}
/**
* Parse a URL and return its pathname.
*/
function getURLPathname(url: string): string | undefined {
if (!URL.canParse(url)) {
return undefined;
}
return removeTrailingSlash(new URL(url).pathname);
}
+30
View File
@@ -7,6 +7,7 @@ import type {
SiteStructure,
TranslationLanguage,
} from '@gitbook/api';
import { extractPagePath } from './pages';
import { joinPath } from './paths';
import { flattenSectionsFromGroup } from './utils';
@@ -57,6 +58,35 @@ export function listAllSiteSpaces(siteStructure: SiteStructure) {
});
}
type SiteSpaceMatch = { siteSpace: SiteSpace; pagePath: string; baseLength: number };
/**
* Find the site space matching a URL.
*/
export function findSiteSpaceByUrl(
siteStructure: SiteStructure,
url: string
): SiteSpaceMatch | null {
const siteSpaces = listAllSiteSpaces(siteStructure);
let bestMatch: SiteSpaceMatch | null = null;
for (const siteSpace of siteSpaces) {
const publishedUrl = siteSpace.urls.published;
if (!publishedUrl) continue;
const pagePath = extractPagePath(url, publishedUrl);
if (pagePath !== undefined) {
const baseLength = publishedUrl.length;
if (!bestMatch || baseLength > bestMatch.baseLength) {
bestMatch = { siteSpace, pagePath, baseLength };
}
}
}
return bestMatch;
}
/**
* Find a site space by its spaceId in a site structure.
*/