mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 19:06:31 +00:00
Add getPage MCP tool (#4148)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add getPage MCP tool
|
||||
+87
-2
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
{},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user