diff --git a/.changeset/afraid-hairs-pull.md b/.changeset/afraid-hairs-pull.md new file mode 100644 index 000000000..605a15e03 --- /dev/null +++ b/.changeset/afraid-hairs-pull.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add getPage MCP tool diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts index d89ec7f82..ce0f95f3b 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts @@ -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, + }; + } + } + ); }, {}, { diff --git a/packages/gitbook/src/lib/markdownPage.ts b/packages/gitbook/src/lib/markdownPage.ts index ccfce7272..2458bd141 100644 --- a/packages/gitbook/src/lib/markdownPage.ts +++ b/packages/gitbook/src/lib/markdownPage.ts @@ -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 { + 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 { + 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, })), ], diff --git a/packages/gitbook/src/lib/pages.test.ts b/packages/gitbook/src/lib/pages.test.ts index f938e4705..ae9f955c5 100644 --- a/packages/gitbook/src/lib/pages.test.ts +++ b/packages/gitbook/src/lib/pages.test.ts @@ -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', () => { diff --git a/packages/gitbook/src/lib/pages.ts b/packages/gitbook/src/lib/pages.ts index 50e25fac1..5318d92a0 100644 --- a/packages/gitbook/src/lib/pages.ts +++ b/packages/gitbook/src/lib/pages.ts @@ -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); +} diff --git a/packages/gitbook/src/lib/sites.ts b/packages/gitbook/src/lib/sites.ts index 4d1999835..f37806544 100644 --- a/packages/gitbook/src/lib/sites.ts +++ b/packages/gitbook/src/lib/sites.ts @@ -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. */