Compare commits

..

1 Commits

Author SHA1 Message Date
Nolann B. e6b8502036 Resolve .md page URLs in the MCP getPage tool (RND-13634) (#4735) 2026-11-02 12:59:34 +01:00
9 changed files with 46 additions and 83 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the MCP `getPage` tool returning "Page not found" for the Markdown URL of a page (`<page>.md`).
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Ignore tag category references when displaying assigned tags and update filters.
@@ -145,11 +145,7 @@ describe('getDocumentSections', () => {
type: 'update',
data: {
date: '2026-04-08',
tags: [
{ kind: 'tag', tag: 'improvements' },
{ kind: 'tag', tag: 'category' },
{ kind: 'tag', tag: 'missing' },
],
tags: [{ kind: 'tag', tag: 'improvements' }],
},
isVoid: false,
nodes: [
@@ -201,7 +197,6 @@ describe('getDocumentSections', () => {
...context,
revision: {
tags: [
{ slug: 'category', label: 'Products', color: 'default', kind: 'category' },
{
slug: 'improvements',
label: 'Improvements',
+10
View File
@@ -50,6 +50,16 @@ describe('extractPagePath', () => {
it('returns empty string when URL equals root base URL', () => {
expect(extractPagePath('https://docs.example.com/', 'https://docs.example.com/')).toBe('');
});
it('extracts path from the markdown URL of a page', () => {
expect(
extractPagePath('https://docs.example.com/api/guides/installation.md', baseURL)
).toBe('guides/installation');
expect(extractPagePath('https://docs.example.com/api/.md', baseURL)).toBe('');
expect(extractPagePath('https://docs.example.com/api/notes.md.html', baseURL)).toBe(
'notes.md.html'
);
});
});
describe('resolveFirstDocument', () => {
+2 -1
View File
@@ -412,7 +412,8 @@ function flattenPages(
* 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);
// Pages are also served as Markdown at `<page>.md`, the form llms.txt links to.
const urlPath = getURLPathname(url)?.replace(/\.md$/, '');
const basePath = getURLPathname(baseURL);
if (urlPath === undefined || basePath === undefined) {
@@ -143,30 +143,6 @@ describe('resolveStringContentRef', () => {
});
describe('resolveContentRef', () => {
it('does not resolve category or missing assignments as tag links', async () => {
const normal = { slug: 'constructor', label: 'Constructor', color: 'default' };
const category = {
slug: 'products',
label: 'Products',
color: 'default',
kind: 'category',
};
const context = {
space: { id: 'local' },
revision: { tags: [normal, category] },
linker: createLinker({
host: 'docs.example.com',
spaceBasePath: '/',
siteBasePath: '/',
}),
} as unknown as GitBookAnyContext;
expect(await resolveContentRef({ kind: 'tag', tag: 'products' }, context)).toBeNull();
expect(await resolveContentRef({ kind: 'tag', tag: 'missing' }, context)).toBeNull();
expect(
await resolveContentRef({ kind: 'tag', tag: 'constructor', space: 'local' }, context)
).toMatchObject({ text: 'Constructor' });
});
it("resolves a page in a different space using that space's page data, not the current context page", async () => {
const sharedPageId = 'page-shared';
+2 -4
View File
@@ -8,12 +8,10 @@ export function getRevisionTags(revision: Revision | undefined): RevisionTag[] {
}
/**
* Resolve an assignment to a normal tag, ignoring categories and missing definitions.
* Resolve a tag slug to its full RevisionTag.
*/
export function resolveTag(slug: string, revisionTags: RevisionTag[]): RevisionTag | undefined {
return revisionTags.find(
(tag) => tag.slug === slug && !('kind' in tag && tag.kind === 'category')
);
return revisionTags.find((t) => t.slug === slug);
}
/**
+3 -43
View File
@@ -1,13 +1,7 @@
import { describe, expect, it } from 'bun:test';
import {
DocumentTextColor,
type JSONDocument,
type Revision,
type RevisionTag,
} from '@gitbook/api';
import type { JSONDocument, Revision } from '@gitbook/api';
import { getRevisionTags, resolveBlockTags, resolveTag } from './tags';
import {
UPDATES_TAG_FILTER_CAP,
getDocumentFilterableTags,
@@ -15,40 +9,6 @@ import {
} from './updates';
describe('getDocumentFilterableTags', () => {
it('ignores category and deleted references without removing their stored assignments', () => {
const document = createUpdatesDocument([['category', 'constructor', 'missing']]);
const category = { ...createTag('category', 'Products'), kind: 'category' };
const normal = createTag('constructor', 'Constructor');
const revision = { tags: [category, normal] } as unknown as Revision;
const before = JSON.stringify(document);
expect(getDocumentFilterableTags(document, revision).map((tag) => tag.slug)).toEqual([
'constructor',
]);
expect(resolveTag('category', revision.tags)).toBeUndefined();
expect(resolveTag('missing', revision.tags)).toBeUndefined();
expect(resolveTag('constructor', revision.tags)).toEqual(normal);
expect(getRevisionTags(revision)).toEqual([category, normal]);
expect(JSON.stringify(document)).toBe(before);
});
it('rejects category assignments even when definitions are passed directly to the resolver', () => {
const normal = createTag('beta', 'Beta');
const tags = [
{ ...createTag('products', 'Products'), kind: 'category' },
normal,
] satisfies (RevisionTag & { kind?: 'category' })[];
expect(
resolveBlockTags(
[
{ kind: 'tag', tag: 'products' },
{ kind: 'tag', tag: 'beta' },
{ kind: 'tag', tag: 'missing' },
],
tags
)
).toEqual([normal]);
});
it('returns unique update tags in document order', () => {
const document = createUpdatesDocument([
['improvements', 'fixes'],
@@ -117,11 +77,11 @@ function createUpdatesDocument(entryTags: string[][]): JSONDocument {
};
}
function createTag(slug: string, label: string): RevisionTag {
function createTag(slug: string, label: string) {
return {
slug,
label,
color: DocumentTextColor.Default,
color: 'default',
icon: undefined,
};
}
+23
View File
@@ -55,6 +55,29 @@ it(
{ timeout: 15_000 }
);
it(
'should get a page through MCP from its markdown URL',
async () => {
const client = await connectMCPClient(
getContentTestURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/~gitbook/mcp/auth'
)
);
const response = await client.callTool({
name: 'getPage',
arguments: {
url: 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/3.0/other-page.md',
},
});
expect(response.isError).toBeFalsy();
// @ts-expect-error - response.content is of type unknown
expect(response.content[0]?.text).toContain('# Other Page');
},
{ timeout: 15_000 }
);
describe('MCP on a site behind visitor authentication', () => {
const VA_SITE_URL = 'https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback';