Add server card for published sites MCPs (#4618)

This commit is contained in:
spastorelli
2026-09-18 10:16:27 +02:00
committed by GitHub
parent c7d042a733
commit 6b4c453497
14 changed files with 1540 additions and 412 deletions
@@ -0,0 +1,11 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { serveSiteAiCatalog } from '@/lib/aiCatalog';
async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) {
const { context } = await getDynamicSiteContext(await params);
return serveSiteAiCatalog(context, request);
}
export { handler as GET, handler as OPTIONS };
@@ -1,41 +1,12 @@
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
import {
AgentFeedbackSource,
CustomizationPageActionType,
SiteInsightsDisplayContext,
} from '@gitbook/api';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
import {
AGENT_FEEDBACK_GOAL_MAX_LENGTH,
AGENT_FEEDBACK_MAX_LENGTH,
agentFeedbackDescriptions,
parseAgentFeedbackPageURL,
} from '@/lib/agentFeedback';
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy, findSiteSpaceByUrl, resolveSiteSpacePagePath } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
/**
* Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the
* insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error.
*/
function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) {
waitUntil(
trackServerInsightsEvents(args).catch((error) => {
console.error('Failed to track MCP insights event:', error);
})
);
}
import { isSiteMcpEnabled } from '@/lib/mcp/endpoints';
import { buildMcpServerInfo } from '@/lib/mcp/serverCard';
import { createSiteMcpTools, registerSiteMcpTools, trackMcpEvent } from '@/lib/mcp/tools';
export async function handleMcpRequest(
rawRequest: NextRequest,
@@ -43,11 +14,8 @@ export async function handleMcpRequest(
endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth'
) {
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
const { pageActions } = context.customization;
const isMcpEnabled = pageActions.items.includes(CustomizationPageActionType.Mcp);
if (!isMcpEnabled) {
if (!isSiteMcpEnabled(context)) {
return new Response('Not Found', { status: 404 });
}
@@ -72,375 +40,12 @@ export async function handleMcpRequest(
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
{
title: 'Search documentation',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request,
});
return {
content: results.flatMap((result) => {
if (result.type === 'record') {
return {
type: 'text',
text: [
`Title: ${result.title}`,
`Link: ${result.url}`,
result.description ? `Content: ${result.description}` : '',
]
.filter(Boolean)
.join('\n'),
};
}
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === result.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return result.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
// The search API returns sections ordered highest-score-first, so
// the first section with a body is the best-scoring preview.
const body = (pageResult.sections ?? []).find(
(section) => section.body
)?.body;
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
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;
}),
},
{
title: 'Get page content',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
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 = resolveSiteSpacePagePath(
match.siteSpace,
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
);
trackMcpEvent({
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,
};
}
}
);
// 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,
};
}
}
);
}
server.tool(
'sendFeedback',
`Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`,
{
content: z
.string()
.min(1)
.max(AGENT_FEEDBACK_MAX_LENGTH)
.describe(agentFeedbackDescriptions.finding),
pageUrl: z
.string()
.describe(agentFeedbackDescriptions.pageURL(siteUrl))
.transform((value, ctx) => {
const url = parseAgentFeedbackPageURL(value, siteUrl);
if (!url) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${value}" is not a valid URL on this site. Expected a full URL like ${siteUrl}/getting-started`,
});
return z.NEVER;
}
return url;
}),
goal: z
.string()
.max(AGENT_FEEDBACK_GOAL_MAX_LENGTH)
.optional()
.describe(agentFeedbackDescriptions.goal),
},
{
title: 'Send feedback',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
async ({ content, pageUrl, goal }) => {
try {
const result = await submitAgentFeedback(context, {
feedback: content,
goal,
page: pageUrl,
source: AgentFeedbackSource.Mcp,
});
if (!result.submitted) {
return {
content: [{ type: 'text', text: result.error }],
isError: true,
};
}
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'agent_feedback',
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
page: result.page.pageId,
space: result.page.spaceId,
revision: result.page.revisionId,
},
},
],
request,
});
return {
content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }],
};
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
registerSiteMcpTools(server, createSiteMcpTools(context, { request }));
},
{
// The same identity the server card republishes, so the card and the handshake agree.
serverInfo: buildMcpServerInfo(context),
},
{},
{
streamableHttpEndpoint: context.linker.toPathInSite(endpoint),
maxDuration: 60,
@@ -0,0 +1,11 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { serveSiteMcpServerCard } from '@/lib/mcp/serverCard';
async function handler(request: NextRequest, { params }: { params: Promise<RouteLayoutParams> }) {
const { context } = await getDynamicSiteContext(await params);
return serveSiteMcpServerCard(context, request);
}
export { handler as GET, handler as OPTIONS };
@@ -7,7 +7,6 @@ import {
type RevisionPageDocument,
type SiteSection,
type SiteSectionGroup,
SiteVisibility,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
@@ -25,6 +24,7 @@ import { categorizeVariants } from '../SpaceLayout/categorizeVariants';
import { BreadcrumbItemDropdown, type BreadcrumbSibling } from './BreadcrumbItemDropdown';
import { PageTags } from './PageTags';
import type { GitBookSiteContext, SiteStructureNode } from '@/lib/context';
import { hasAdaptiveMcpEndpoint } from '@/lib/mcp/endpoints';
import { type AncestorRevisionPage, resolveFirstDocument } from '@/lib/pages';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
@@ -559,12 +559,7 @@ function isPageActionEnabled(
* Return the MCP URL to be used in the page actions dropdown.
*/
function getPageActionsMCPURL(context: GitBookSiteContext) {
const useAuthenticatedEndpoint = Boolean(
context.site.visibility !== SiteVisibility.VisitorAuth &&
context.site.adaptiveContent?.enabled &&
context.site.urls.login &&
context.isLoggedInVisitor
);
const useAuthenticatedEndpoint = hasAdaptiveMcpEndpoint(context) && context.isLoggedInVisitor;
const endpoint = useAuthenticatedEndpoint ? '~gitbook/mcp/auth' : '~gitbook/mcp';
return context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint));
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'bun:test';
import { CustomizationPageActionType, SiteVisibility } from '@gitbook/api';
import {
AI_CATALOG_MEDIA_TYPE,
AI_CATALOG_SPEC_VERSION,
type AiCatalog,
buildSiteAiCatalog,
serveSiteAiCatalog,
} from '.';
import type { GitBookSiteContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
/**
* `urn:air:{publisher}:{namespace}:{name}`, per the AI Catalog specification.
*/
const AIR_URN_PATTERN = /^urn:air:[^:]+:[^:]+:[^:]+$/;
function makeContext(
options: {
host?: string;
siteBasePath?: string;
siteId?: string;
visibility?: SiteVisibility;
pageActions?: CustomizationPageActionType[];
} = {}
): GitBookSiteContext {
const {
host = 'docs.acme.org',
siteBasePath = '/',
siteId = 'site_123',
visibility = SiteVisibility.Public,
pageActions = [CustomizationPageActionType.Mcp],
} = options;
return {
site: { id: siteId, title: 'Acme', visibility },
customization: { pageActions: { items: pageActions } },
linker: createLinker({ host, siteBasePath, spaceBasePath: siteBasePath }),
} as unknown as GitBookSiteContext;
}
describe('buildSiteAiCatalog', () => {
it('advertises the site MCP server card', () => {
const catalog = buildSiteAiCatalog(makeContext());
expect(catalog).toEqual({
specVersion: AI_CATALOG_SPEC_VERSION,
entries: [
{
identifier: 'urn:air:docs.acme.org:mcp:site_123',
type: 'application/mcp-server-card+json',
url: 'https://docs.acme.org/~gitbook/mcp/server-card',
},
],
});
});
it('builds an identifier the specification can parse', () => {
const catalog = buildSiteAiCatalog(makeContext({ siteId: 'site_A1b2-c3' }));
expect(catalog?.entries[0]?.identifier).toMatch(AIR_URN_PATTERN);
});
it('omits fields the referenced card is authoritative for', () => {
const entry = buildSiteAiCatalog(makeContext())?.entries[0];
expect(entry).not.toHaveProperty('displayName');
expect(entry).not.toHaveProperty('description');
expect(entry).not.toHaveProperty('version');
});
it.each([
{
scenario: 'a custom domain',
options: { host: 'docs.acme.org', siteBasePath: '/' },
identifier: 'urn:air:docs.acme.org:mcp:site_123',
url: 'https://docs.acme.org/~gitbook/mcp/server-card',
},
{
scenario: 'a gitbook.io subdomain',
options: { host: 'acme.gitbook.io', siteBasePath: '/' },
identifier: 'urn:air:acme.gitbook.io:mcp:site_123',
url: 'https://acme.gitbook.io/~gitbook/mcp/server-card',
},
{
scenario: 'a subpath site',
options: { host: 'gitbook.com', siteBasePath: '/docs/' },
identifier: 'urn:air:gitbook.com:mcp:site_123',
url: 'https://gitbook.com/docs/~gitbook/mcp/server-card',
},
])('anchors the entry to $scenario', ({ options, identifier, url }) => {
const entry = buildSiteAiCatalog(makeContext(options))?.entries[0];
expect(entry?.identifier).toBe(identifier);
expect(entry?.url).toBe(url);
});
it('drops the port from the publisher, which a URN would read as a separator', () => {
const context = makeContext();
const linker = createLinker({
host: 'localhost:3000',
siteBasePath: '/',
spaceBasePath: '/',
});
const entry = buildSiteAiCatalog({ ...context, linker })?.entries[0];
expect(entry?.identifier).toBe('urn:air:localhost:mcp:site_123');
expect(entry?.identifier).toMatch(AIR_URN_PATTERN);
});
it('advertises nothing when the site publishes no MCP server', () => {
expect(buildSiteAiCatalog(makeContext({ pageActions: [] }))).toBeNull();
});
});
describe('serveSiteAiCatalog', () => {
const catalogRequest = (init?: RequestInit) =>
new Request('https://docs.acme.org/.well-known/ai-catalog.json', init);
it('serves the catalog with its media type and CORS headers', async () => {
const res = await serveSiteAiCatalog(makeContext(), catalogRequest());
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe(`${AI_CATALOG_MEDIA_TYPE}; charset=utf-8`);
expect(res.headers.get('access-control-allow-origin')).toBe('*');
expect(res.headers.get('cache-control')).toBe('public, max-age=3600');
const catalog = (await res.json()) as AiCatalog;
expect(catalog.specVersion).toBe(AI_CATALOG_SPEC_VERSION);
expect(catalog.entries).toHaveLength(1);
});
it('answers a preflight with a 204', async () => {
const res = await serveSiteAiCatalog(makeContext(), catalogRequest({ method: 'OPTIONS' }));
expect(res.status).toBe(204);
});
it('returns 304 when the client already has the current catalog', async () => {
const context = makeContext();
const first = await serveSiteAiCatalog(context, catalogRequest());
const etag = first.headers.get('etag');
const revalidated = await serveSiteAiCatalog(
context,
catalogRequest({ headers: { 'If-None-Match': `W/${etag}` } })
);
expect(revalidated.status).toBe(304);
});
it.each([SiteVisibility.ShareLink, SiteVisibility.VisitorAuth])(
'never caches the catalog of a %s site',
async (visibility) => {
const res = await serveSiteAiCatalog(makeContext({ visibility }), catalogRequest());
expect(res.headers.get('cache-control')).toBe('no-store');
}
);
it('does not serve a catalog when there is nothing to advertise', async () => {
const res = await serveSiteAiCatalog(makeContext({ pageActions: [] }), catalogRequest());
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,90 @@
import type { GitBookSiteContext } from '@/lib/context';
import { serveDiscoveryDocument } from '@/lib/discoveryDocument';
import { isSiteMcpEnabled } from '@/lib/mcp/endpoints';
import { MCP_SERVER_CARD_PATH } from '@/lib/mcp/paths';
import { SERVER_CARD_MEDIA_TYPE } from '@/lib/mcp/serverCard';
export const AI_CATALOG_MEDIA_TYPE = 'application/ai-catalog+json';
export const AI_CATALOG_SPEC_VERSION = '1.0';
/**
* The AI Catalog a site publishes at `/.well-known/ai-catalog.json`, per the
* [AI Catalog specification](https://github.com/Agent-Card/ai-catalog).
*/
export interface AiCatalog {
/**
* Required. The specification version, in "Major.Minor" form.
*/
specVersion: string;
/**
* Required, and may be empty.
*/
entries: AiCatalogEntry[];
}
export interface AiCatalogEntry {
/**
* Required. Domain-anchored `urn:air:{publisher}:{namespace}:{name}`.
*/
identifier: string;
/**
* Required. The media type of the artifact the entry points at.
*/
type: string;
/**
* Where the artifact can be retrieved. Exactly one of `url` or `data` is used.
*/
url: string;
}
/**
* Build the catalog of AI artifacts the site advertises, or `null` when it advertises none.
*/
export function buildSiteAiCatalog(context: GitBookSiteContext): AiCatalog | null {
const { linker, site } = context;
const entries: AiCatalogEntry[] = [];
if (isSiteMcpEnabled(context)) {
entries.push({
identifier: buildCatalogIdentifier(context, 'mcp', site.id),
type: SERVER_CARD_MEDIA_TYPE,
url: linker.toAbsoluteURL(linker.toPathInSite(MCP_SERVER_CARD_PATH)),
});
}
if (entries.length === 0) {
return null;
}
return { specVersion: AI_CATALOG_SPEC_VERSION, entries };
}
/**
* Serve the site's catalog, or a 404 when the site advertises nothing to discover.
*/
export async function serveSiteAiCatalog(
context: GitBookSiteContext,
request: Request
): Promise<Response> {
const catalog = buildSiteAiCatalog(context);
if (!catalog) {
return new Response('Not Found', { status: 404 });
}
return serveDiscoveryDocument(context, request, {
document: catalog,
mediaType: AI_CATALOG_MEDIA_TYPE,
});
}
function buildCatalogIdentifier(
context: GitBookSiteContext,
namespace: string,
name: string
): string {
const publisher = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite('')))
.hostname;
return `urn:air:${publisher}:${namespace}:${name}`;
}
@@ -0,0 +1,10 @@
/**
* Internal route the catalog is served from, which the well-known path rewrites to.
*/
export const AI_CATALOG_PATH = '~gitbook/ai-catalog';
/**
* Where domain-level discovery looks. Unlike a server card, a catalog *is* site-wide metadata, so
* `.well-known` is the spec's own home for it rather than a concession to scanners.
*/
export const AI_CATALOG_WELL_KNOWN_PATH = '.well-known/ai-catalog.json';
@@ -0,0 +1,70 @@
import { SiteVisibility } from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
/**
* CORS headers the server-card extension requires on a discovery endpoint.
*/
const CORS_HEADERS = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET',
'access-control-allow-headers': 'Content-Type, If-None-Match',
'access-control-expose-headers': 'ETag',
};
const CACHE_CONTROL = 'public, max-age=3600';
/**
* Serve a discovery document with the caching and CORS headers the extension asks a host for, or
* `304` when the client's copy is still current.
*/
export async function serveDiscoveryDocument(
context: GitBookSiteContext,
request: Request,
options: {
document: unknown;
mediaType: string;
}
): Promise<Response> {
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
const body = JSON.stringify(options.document);
const etag = `"${await hashDocument(body)}"`;
const isPubliclyReachable =
context.site.visibility === SiteVisibility.Public ||
context.site.visibility === SiteVisibility.Unlisted;
const headers = {
...CORS_HEADERS,
'cache-control': isPubliclyReachable ? CACHE_CONTROL : 'no-store',
etag,
};
if (matchesEtag(request.headers.get('if-none-match'), etag)) {
return new Response(null, { status: 304, headers });
}
return new Response(body, {
headers: { ...headers, 'content-type': `${options.mediaType}; charset=utf-8` },
});
}
async function hashDocument(body: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body));
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function matchesEtag(ifNoneMatch: string | null, etag: string): boolean {
if (!ifNoneMatch) {
return false;
}
if (ifNoneMatch.trim() === '*') {
return true;
}
return ifNoneMatch
.split(',')
.some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
}
+29
View File
@@ -0,0 +1,29 @@
import { CustomizationPageActionType, SiteVisibility } from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
/**
* Whether the site publishes an MCP server at all. Gates the endpoint and everything describing it.
*/
export function isSiteMcpEnabled(context: GitBookSiteContext): boolean {
return context.customization.pageActions.items.includes(CustomizationPageActionType.Mcp);
}
/**
* Whether `~gitbook/mcp` endpoint requires auth for the site (i.e VA site).
*/
export function mcpEndpointRequiresAuth(context: GitBookSiteContext): boolean {
return context.site.visibility === SiteVisibility.VisitorAuth;
}
/**
* Whether the site exposes `~gitbook/mcp/auth`, the endpoint a visitor authenticates against to
* read adaptive content.
*/
export function hasAdaptiveMcpEndpoint(context: GitBookSiteContext): boolean {
return Boolean(
!mcpEndpointRequiresAuth(context) &&
context.site.adaptiveContent?.enabled &&
context.site.urls.login
);
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Path the card is served on, appended to the Streamable HTTP endpoint as the extension reserves
* (`GET <streamable-http-url>/server-card`).
*/
export const MCP_SERVER_CARD_PATH = '~gitbook/mcp/server-card';
/**
* Path crawlers probe today, kept alongside the reserved one. The extension argues against
* `.well-known` for a single server's card (it is application-level, not site-wide metadata), but
* the scanners in the wild look here, so the card answers on both.
*/
export const MCP_SERVER_CARD_WELL_KNOWN_PATH = '.well-known/mcp/server-card.json';
@@ -0,0 +1,418 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { describe, expect, it } from 'bun:test';
import { CustomizationAIMode, CustomizationPageActionType, SiteVisibility } from '@gitbook/api';
import { hasAdaptiveMcpEndpoint, mcpEndpointRequiresAuth } from './endpoints';
import {
MCP_SERVER_VERSION,
SERVER_CARD_MEDIA_TYPE,
SERVER_CARD_SCHEMA_URL,
type SiteMcpServerCard,
buildMcpServerInfo,
buildSiteMcpServerCard,
serveSiteMcpServerCard,
} from './serverCard';
import { createSiteMcpTools, registerSiteMcpTools } from './tools';
import type { GitBookSiteContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
const SERVER_CARD_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/;
const MAX_TEXT_LENGTH = 100;
function makeContext(
options: {
host?: string;
siteBasePath?: string;
spaceBasePath?: string;
siteId?: string;
title?: string;
visibility?: SiteVisibility;
adaptiveContent?: boolean;
aiMode?: CustomizationAIMode;
pageActions?: CustomizationPageActionType[];
} = {}
): GitBookSiteContext {
const {
host = 'docs.acme.org',
siteBasePath = '/',
spaceBasePath = siteBasePath,
siteId = 'site_123',
title = 'Acme',
visibility = SiteVisibility.Public,
adaptiveContent = true,
aiMode = CustomizationAIMode.Search,
pageActions = [CustomizationPageActionType.Mcp],
} = options;
return {
organizationId: 'org_123',
site: {
id: siteId,
title,
visibility,
adaptiveContent: { enabled: adaptiveContent },
urls: { login: `https://${host}/~gitbook/auth/login` },
},
siteSpace: { urls: { published: `https://${host}${siteBasePath}` } },
customization: {
ai: { mode: aiMode },
pageActions: { items: pageActions },
},
linker: createLinker({ host, siteBasePath, spaceBasePath }),
} as unknown as GitBookSiteContext;
}
const request = new Request('https://docs.acme.org/~gitbook/mcp/server-card');
function buildCard(options?: Parameters<typeof makeContext>[0]): SiteMcpServerCard {
const context = makeContext(options);
return buildSiteMcpServerCard(context, createSiteMcpTools(context, { request }));
}
describe('buildSiteMcpServerCard', () => {
describe('schema constraints', () => {
it('declares every required member', () => {
const card = buildCard();
expect(card.$schema).toBe(SERVER_CARD_SCHEMA_URL);
expect(card.name).toBeString();
expect(card.version).toBeString();
expect(card.description).toBeString();
});
it('names the server with a reverse-DNS namespace and exactly one slash', () => {
const card = buildCard({ siteId: 'site_A1b2-c3' });
expect(card.name).toBe('com.gitbook.sites.mcp/site_A1b2-c3');
expect(card.name).toMatch(SERVER_CARD_NAME_PATTERN);
expect(card.name.split('/')).toHaveLength(2);
});
it('keeps the description and title within 100 characters', () => {
const card = buildCard({ title: 'Acme' });
expect(card.title).toBe('Acme MCP Server');
expect(card.description).toBe('Search and read the Acme documentation over MCP.');
expect(card.description.length).toBeLessThanOrEqual(MAX_TEXT_LENGTH);
expect(card.title.length).toBeLessThanOrEqual(MAX_TEXT_LENGTH);
});
it('does not repeat an article a customer title already carries', () => {
const card = buildCard({ title: 'The Acme Handbook' });
expect(card.title).toBe('The Acme Handbook MCP Server');
expect(card.description).toBe(
'Search and read The Acme Handbook documentation over MCP.'
);
});
it('truncates an arbitrarily long customer title without losing what the server is', () => {
const card = buildCard({ title: 'A'.repeat(500) });
expect(card.title.length).toBe(MAX_TEXT_LENGTH);
expect(card.title.endsWith('… MCP Server')).toBe(true);
expect(card.description.length).toBe(MAX_TEXT_LENGTH);
expect(card.description.startsWith('Search and read the ')).toBe(true);
expect(card.description.endsWith('… documentation over MCP.')).toBe(true);
});
it('publishes an exact version rather than a range', () => {
const card = buildCard();
expect(card.version).toMatch(/^\d+\.\d+\.\d+/);
expect(card.version).toBe(MCP_SERVER_VERSION);
});
it('declares a transport type the extension allows on every remote', () => {
const card = buildCard();
for (const remote of card.remotes) {
expect(['streamable-http', 'sse']).toContain(remote.type);
expect(URL.canParse(remote.url)).toBe(true);
expect(remote.supportedProtocolVersions.length).toBeGreaterThan(0);
}
});
});
describe('server identity', () => {
it('reports the same identity the transport reports at initialize', () => {
const context = makeContext();
const card = buildSiteMcpServerCard(context, createSiteMcpTools(context, { request }));
expect(card.serverInfo).toEqual(buildMcpServerInfo(context));
expect(card.serverInfo.title).toBe(card.title);
expect(card.serverInfo.version).toBe(card.version);
});
it('names the running server after its endpoint', () => {
const card = buildCard();
expect(card.serverInfo.name).toBe('docs.acme.org/~gitbook/mcp');
expect(card.endpoint).toBe(`https://${card.serverInfo.name}`);
});
it('keeps the card name stable and schema-valid, unlike the endpoint', () => {
const card = buildCard();
expect(card.name).toBe('com.gitbook.sites.mcp/site_123');
expect(card.name).toMatch(SERVER_CARD_NAME_PATTERN);
// The endpoint cannot be the card name: two slashes and a `~`.
expect(card.serverInfo.name).not.toMatch(SERVER_CARD_NAME_PATTERN);
});
it('names the server after the site rather than the software', () => {
const acme = buildMcpServerInfo(makeContext({ host: 'docs.acme.com', title: 'Acme' }));
const other = buildMcpServerInfo(
makeContext({ host: 'gitbook.com', siteBasePath: '/other/', title: 'Other' })
);
expect(acme.name).toBe('docs.acme.com/~gitbook/mcp');
expect(other.name).toBe('gitbook.com/other/~gitbook/mcp');
expect(acme.title).toBe('Acme MCP Server');
expect(other.title).toBe('Other MCP Server');
});
it('declares the capabilities the SDK reports at initialize', () => {
const card = buildCard();
// Verified against a live server: `McpServer` declares listChanged for its tools.
expect(card.capabilities).toEqual({ tools: { listChanged: true } });
});
it('points `endpoint` at the same URL as the public remote', () => {
const card = buildCard();
expect(card.endpoint).toBe(card.remotes[0]?.url ?? '');
});
});
describe('remotes', () => {
it('publishes one card with both endpoints when the site serves adaptive content', () => {
const card = buildCard({ adaptiveContent: true });
expect(card.remotes.map((remote) => remote.url)).toEqual([
'https://docs.acme.org/~gitbook/mcp',
'https://docs.acme.org/~gitbook/mcp/auth',
]);
expect(card.remotes[0]?.headers).toBeUndefined();
expect(card.remotes[1]?.headers).toEqual([
{
name: 'Authorization',
description: expect.stringContaining('Bearer token'),
isRequired: true,
isSecret: true,
},
]);
});
it('omits the adaptive endpoint when the site has no adaptive content', () => {
const card = buildCard({ adaptiveContent: false });
expect(card.remotes.map((remote) => remote.url)).toEqual([
'https://docs.acme.org/~gitbook/mcp',
]);
expect(card.remotes[0]?.headers).toBeUndefined();
});
it.each([SiteVisibility.Public, SiteVisibility.Unlisted, SiteVisibility.ShareLink])(
'leaves the public endpoint unauthenticated on a %s site',
(visibility) => {
const card = buildCard({ visibility, adaptiveContent: false });
expect(card.remotes[0]?.headers).toBeUndefined();
}
);
it('requires a token on the public endpoint of a visitor-auth site, and offers no adaptive endpoint', () => {
const card = buildCard({ visibility: SiteVisibility.VisitorAuth });
expect(card.remotes.map((remote) => remote.url)).toEqual([
'https://docs.acme.org/~gitbook/mcp',
]);
expect(card.remotes[0]?.headers).toEqual([
{
name: 'Authorization',
description: expect.stringContaining('restricted to authenticated visitors'),
isRequired: true,
isSecret: true,
},
]);
});
});
describe('urls', () => {
it.each([
{
scenario: 'a custom domain',
options: { host: 'docs.acme.org', siteBasePath: '/' },
websiteUrl: 'https://docs.acme.org',
endpoint: 'https://docs.acme.org/~gitbook/mcp',
icon: 'https://docs.acme.org/~gitbook/icon?size=medium',
},
{
scenario: 'a gitbook.io subdomain',
options: { host: 'acme.gitbook.io', siteBasePath: '/' },
websiteUrl: 'https://acme.gitbook.io',
endpoint: 'https://acme.gitbook.io/~gitbook/mcp',
icon: 'https://acme.gitbook.io/~gitbook/icon?size=medium',
},
{
scenario: 'a subpath site',
options: {
host: 'gitbook.com',
siteBasePath: '/docs/',
spaceBasePath: '/docs/v1/',
},
websiteUrl: 'https://gitbook.com/docs',
endpoint: 'https://gitbook.com/docs/~gitbook/mcp',
icon: 'https://gitbook.com/docs/~gitbook/icon?size=medium',
},
])('builds absolute URLs for $scenario', ({ options, websiteUrl, endpoint, icon }) => {
const card = buildCard(options);
expect(card.websiteUrl).toBe(websiteUrl);
expect(card.endpoint).toBe(endpoint);
expect(card.remotes[1]?.url).toBe(`${endpoint}/auth`);
expect(card.icons[0]?.src).toBe(icon);
});
});
describe('endpoint predicates', () => {
it.each([
{
scenario: 'a public site with adaptive content',
options: { visibility: SiteVisibility.Public, adaptiveContent: true },
requiresAuth: false,
hasAdaptive: true,
},
{
scenario: 'a public site without adaptive content',
options: { visibility: SiteVisibility.Public, adaptiveContent: false },
requiresAuth: false,
hasAdaptive: false,
},
{
scenario: 'a visitor-auth site',
options: { visibility: SiteVisibility.VisitorAuth, adaptiveContent: true },
requiresAuth: true,
hasAdaptive: false,
},
])('resolves the endpoints of $scenario', ({ options, requiresAuth, hasAdaptive }) => {
const context = makeContext(options);
expect(mcpEndpointRequiresAuth(context)).toBe(requiresAuth);
expect(hasAdaptiveMcpEndpoint(context)).toBe(hasAdaptive);
});
});
describe('tools', () => {
it('advertises exactly the tools registered on the server', () => {
const context = makeContext();
const request = new Request('https://docs.acme.org/~gitbook/mcp');
const tools = createSiteMcpTools(context, { request });
const registered: string[] = [];
registerSiteMcpTools(
{ tool: (name: string) => registered.push(name) } as unknown as McpServer,
tools
);
const card = buildSiteMcpServerCard(context, tools);
expect(card.tools.map((tool) => tool.name)).toEqual(registered);
});
it('carries a description and annotations, but not input schemas', () => {
const card = buildCard();
const search = card.tools.find((tool) => tool.name === 'searchDocumentation');
expect(search?.description).toContain('Acme');
expect(search?.annotations).toMatchObject({
title: 'Search documentation',
readOnlyHint: true,
});
expect(search).not.toHaveProperty('inputSchema');
});
it('omits askQuestion when the site has no AI mode', () => {
const withAI = buildCard({ aiMode: CustomizationAIMode.Assistant });
const withoutAI = buildCard({ aiMode: CustomizationAIMode.None });
expect(withAI.tools.map((tool) => tool.name)).toEqual([
'searchDocumentation',
'getPage',
'askQuestion',
'sendFeedback',
]);
expect(withoutAI.tools.map((tool) => tool.name)).toEqual([
'searchDocumentation',
'getPage',
'sendFeedback',
]);
});
});
});
describe('serveSiteMcpServerCard', () => {
const cardRequest = (init?: RequestInit) =>
new Request('https://docs.acme.org/~gitbook/mcp/server-card', init);
it('serves the card with the media type and CORS headers the extension requires', async () => {
const res = await serveSiteMcpServerCard(makeContext(), cardRequest());
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe(`${SERVER_CARD_MEDIA_TYPE}; charset=utf-8`);
expect(res.headers.get('access-control-allow-origin')).toBe('*');
expect(res.headers.get('access-control-allow-methods')).toBe('GET');
expect(res.headers.get('access-control-allow-headers')).toBe('Content-Type, If-None-Match');
expect(res.headers.get('access-control-expose-headers')).toBe('ETag');
const card = (await res.json()) as SiteMcpServerCard;
expect(card.$schema).toBe(SERVER_CARD_SCHEMA_URL);
});
it('answers a preflight with a 204', async () => {
const res = await serveSiteMcpServerCard(makeContext(), cardRequest({ method: 'OPTIONS' }));
expect(res.status).toBe(204);
expect(res.headers.get('access-control-allow-origin')).toBe('*');
});
it('returns 304 when the client already has the current card', async () => {
const context = makeContext();
const first = await serveSiteMcpServerCard(context, cardRequest());
const etag = first.headers.get('etag');
expect(etag).toBeTruthy();
const revalidated = await serveSiteMcpServerCard(
context,
cardRequest({ headers: { 'If-None-Match': `W/${etag}` } })
);
expect(revalidated.status).toBe(304);
expect(revalidated.headers.get('etag')).toBe(etag);
});
it.each([
{ visibility: SiteVisibility.Public, cacheControl: 'public, max-age=3600' },
{ visibility: SiteVisibility.Unlisted, cacheControl: 'public, max-age=3600' },
{ visibility: SiteVisibility.ShareLink, cacheControl: 'no-store' },
{ visibility: SiteVisibility.VisitorAuth, cacheControl: 'no-store' },
])('caches a $visibility site with $cacheControl', async ({ visibility, cacheControl }) => {
const res = await serveSiteMcpServerCard(makeContext({ visibility }), cardRequest());
expect(res.headers.get('cache-control')).toBe(cacheControl);
});
it.each([
{ scenario: 'no page action is enabled', pageActions: [] },
{
scenario: 'another page action is enabled',
pageActions: [CustomizationPageActionType.Markdown],
},
])('does not serve a card when $scenario', async ({ pageActions }) => {
const res = await serveSiteMcpServerCard(makeContext({ pageActions }), cardRequest());
expect(res.status).toBe(404);
});
});
+245
View File
@@ -0,0 +1,245 @@
import type { Implementation, ServerCapabilities, Tool } from '@modelcontextprotocol/sdk/types.js';
import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/sdk/types.js';
import packageJSON from '../../../package.json';
import { hasAdaptiveMcpEndpoint, isSiteMcpEnabled, mcpEndpointRequiresAuth } from './endpoints';
import { type SiteMcpTool, createSiteMcpTools } from './tools';
import type { GitBookSiteContext } from '@/lib/context';
import { serveDiscoveryDocument } from '@/lib/discoveryDocument';
export const SERVER_CARD_MEDIA_TYPE = 'application/mcp-server-card+json';
export const SERVER_CARD_SCHEMA_URL =
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json';
/**
* Namespace for every published site's card.
*/
const SERVER_CARD_NAMESPACE = 'com.gitbook.sites.mcp';
/**
* Version reported on the card and at `initialize` alike, so the two cannot drift.
*/
export const MCP_SERVER_VERSION = packageJSON.version;
const MAX_TEXT_LENGTH = 100;
const TITLE_SUFFIX = ' MCP Server';
const DESCRIPTION_PREFIX = 'Search and read ';
const DESCRIPTION_ARTICLE = 'the ';
const DESCRIPTION_SUFFIX = ' documentation over MCP.';
/**
* A site's MCP Server Card, as defined by
* [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127).
*
* Its schema is still experimental and published neither on npm nor at the `$schema` URL it pins,
* so the fields we serve are declared here and their constraints are checked in `serverCard.test.ts`.
*/
export interface SiteMcpServerCard {
/**
* Required, and exactly {@link SERVER_CARD_SCHEMA_URL}.
*/
$schema: string;
/**
* Required. Reverse-DNS, with exactly one slash separating namespace from server name.
*/
name: string;
/**
* Required. The version the server reports at `initialize`.
*/
version: string;
/**
* Required, and at most 100 characters.
*/
description: string;
/**
* Display name, at most 100 characters.
*/
title: string;
websiteUrl: string;
icons: {
src: string;
mimeType?: string;
sizes?: string[];
}[];
remotes: SiteMcpServerCardRemote[];
serverInfo: SiteMcpServerInfo;
/**
* The Streamable HTTP endpoint, the same URL as `remotes[0].url`.
*/
endpoint: string;
capabilities: ServerCapabilities;
tools: SiteMcpServerCardTool[];
}
/**
* Identity the server reports at `initialize`, republished on the card so a card.
*/
export type SiteMcpServerInfo = Implementation & {
/**
* Required here, though the SDK leaves it optional: a client with no card to read has nothing
* else to display, since `name` is the Streamable HTTP endpoint without its scheme.
*/
title: string;
};
export interface SiteMcpServerCardRemote {
type: 'streamable-http' | 'sse';
url: string;
supportedProtocolVersions: string[];
headers?: {
name: string;
description: string;
isRequired: boolean;
isSecret: boolean;
}[];
}
/**
* One tool the server registers, as a client sees it from `tools/list` minus the input schema:
* `tools/list` stays authoritative for that.
*/
export type SiteMcpServerCardTool = Pick<Tool, 'name' | 'description' | 'annotations'>;
/**
* Build the card for a site's MCP server: the metadata an agent can read before it connects.
*/
export function buildSiteMcpServerCard(
context: GitBookSiteContext,
tools: SiteMcpTool[]
): SiteMcpServerCard {
const { linker, site } = context;
const endpoint = getSiteMcpEndpoint(context);
const serverInfo = buildMcpServerInfo(context);
return {
$schema: SERVER_CARD_SCHEMA_URL,
// Not the endpoint: the schema allows exactly one slash and no `~`, and a registry keys on
// this, so it stays put when a customer moves the site to another domain.
name: `${SERVER_CARD_NAMESPACE}/${site.id}`,
version: serverInfo.version,
title: serverInfo.title,
description: buildDescription(site.title),
websiteUrl: linker.toAbsoluteURL(linker.toPathInSite('')),
icons: [
{
src: linker.toAbsoluteURL(linker.toPathInSite('~gitbook/icon?size=medium')),
mimeType: 'image/png',
sizes: ['180x180'],
},
],
remotes: buildRemotes(context, endpoint),
serverInfo,
endpoint,
// What the SDK declares at `initialize` for a server with registered tools. A card that
// said otherwise would contradict the live connection.
capabilities: { tools: { listChanged: true } },
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
annotations: tool.annotations,
})),
};
}
/**
* Build the identity the server reports at `initialize`, which the card republishes verbatim.
*/
export function buildMcpServerInfo(context: GitBookSiteContext): SiteMcpServerInfo {
const endpoint = new URL(getSiteMcpEndpoint(context));
return {
name: `${endpoint.host}${endpoint.pathname}`,
title: buildTitle(context.site.title),
version: MCP_SERVER_VERSION,
};
}
export function getSiteMcpEndpoint(context: GitBookSiteContext): string {
return context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'));
}
/**
* Serve a site's MCP server card.
*
* A card names the site and lists its tools, so it is gated exactly like the MCP endpoint it
* describes.
*/
export async function serveSiteMcpServerCard(
context: GitBookSiteContext,
request: Request
): Promise<Response> {
if (!isSiteMcpEnabled(context)) {
return new Response('Not Found', { status: 404 });
}
return serveDiscoveryDocument(context, request, {
document: buildSiteMcpServerCard(context, createSiteMcpTools(context, { request })),
mediaType: SERVER_CARD_MEDIA_TYPE,
});
}
/**
* Returns the info of the MCP endpoints a client can connect to.
*/
function buildRemotes(context: GitBookSiteContext, endpoint: string): SiteMcpServerCardRemote[] {
const { linker } = context;
const remotes: SiteMcpServerCardRemote[] = [
{
type: 'streamable-http',
url: endpoint,
supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
...(mcpEndpointRequiresAuth(context)
? {
headers: authorizationHeader(
'Bearer token obtained from the OAuth 2.0 flow advertised at /.well-known/oauth-protected-resource/~gitbook/mcp. This site is restricted to authenticated visitors.'
),
}
: {}),
},
];
if (hasAdaptiveMcpEndpoint(context)) {
remotes.push({
type: 'streamable-http',
url: linker.toAbsoluteURL(linker.toPathInSite('~gitbook/mcp/auth')),
supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
headers: authorizationHeader(
'Bearer token obtained from the OAuth 2.0 flow advertised at /.well-known/oauth-protected-resource/~gitbook/mcp/auth. Serves the content adapted to the authenticated visitor; connect to the public endpoint to read the site as an anonymous one.'
),
});
}
return remotes;
}
function authorizationHeader(description: string): SiteMcpServerCardRemote['headers'] {
return [{ name: 'Authorization', description, isRequired: true, isSecret: true }];
}
/**
* Name the server after the site it serves, within the 100 characters the schema allows a `title`.
*/
function buildTitle(siteTitle: string): string {
return `${truncate(siteTitle, MAX_TEXT_LENGTH - TITLE_SUFFIX.length)}${TITLE_SUFFIX}`;
}
/**
* Fit the site's title into the 100 characters the schema allows a `description`.
*/
function buildDescription(siteTitle: string): string {
// Drop the article for a title that carries its own, which would read "read the The Acme Docs".
const article = /^the\s/i.test(siteTitle.trim()) ? '' : DESCRIPTION_ARTICLE;
const budget =
MAX_TEXT_LENGTH - DESCRIPTION_PREFIX.length - article.length - DESCRIPTION_SUFFIX.length;
return `${DESCRIPTION_PREFIX}${article}${truncate(siteTitle, budget)}${DESCRIPTION_SUFFIX}`;
}
function truncate(value: string, maxLength: number): string {
const normalized = value.trim();
return normalized.length <= maxLength
? normalized
: `${normalized.slice(0, maxLength - 1).trimEnd()}`;
}
+456
View File
@@ -0,0 +1,456 @@
import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
import type { ZodRawShape } from 'zod';
import { z } from 'zod';
import { AgentFeedbackSource, SiteInsightsDisplayContext } from '@gitbook/api';
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
import {
AGENT_FEEDBACK_GOAL_MAX_LENGTH,
AGENT_FEEDBACK_MAX_LENGTH,
agentFeedbackDescriptions,
parseAgentFeedbackPageURL,
} from '@/lib/agentFeedback';
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
import type { GitBookSiteContext } from '@/lib/context';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy, findSiteSpaceByUrl, resolveSiteSpacePagePath } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
/**
* One tool the site's MCP server exposes.
*/
export interface SiteMcpTool<Args extends ZodRawShape = ZodRawShape> {
name: string;
description: string;
/**
* Zod shape of the tool arguments, passed as-is to `server.tool`.
*/
inputSchema: Args;
annotations: ToolAnnotations;
handler: ToolCallback<Args>;
}
/**
* Build the tools the site's MCP server exposes, for the site the request is served from.
*/
export function createSiteMcpTools(
context: GitBookSiteContext,
options: {
/**
* The MCP request being served, attached to the insights events the tools emit.
*/
request: Request;
}
): SiteMcpTool[] {
const { request } = options;
const { dataFetcher, linker, site } = context;
const siteUrl = context.siteSpace.urls.published;
const tools: SiteMcpTool[] = [
defineTool({
name: 'searchDocumentation',
description: `Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
inputSchema: {
query: z.string(),
},
annotations: {
title: 'Search documentation',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
handler: async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request,
});
return {
content: results.flatMap((result) => {
if (result.type === 'record') {
return {
type: 'text',
text: [
`Title: ${result.title}`,
`Link: ${result.url}`,
result.description ? `Content: ${result.description}` : '',
]
.filter(Boolean)
.join('\n'),
};
}
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === result.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return result.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
// The search API returns sections ordered highest-score-first, so
// the first section with a body is the best-scoring preview.
const body = (pageResult.sections ?? []).find(
(section) => section.body
)?.body;
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
},
}),
defineTool({
name: 'getPage',
description: `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.`,
inputSchema: {
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;
}),
},
annotations: {
title: 'Get page content',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
handler: 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 = resolveSiteSpacePagePath(
match.siteSpace,
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
);
trackMcpEvent({
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,
};
}
},
}),
];
// 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)) {
tools.push(
defineTool({
name: 'askQuestion',
description: `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.`,
inputSchema: {
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.'
),
},
annotations: {
title: 'Ask a question',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
handler: 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,
};
}
},
})
);
}
tools.push(
defineTool({
name: 'sendFeedback',
description: `Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`,
inputSchema: {
content: z
.string()
.min(1)
.max(AGENT_FEEDBACK_MAX_LENGTH)
.describe(agentFeedbackDescriptions.finding),
pageUrl: z
.string()
.describe(agentFeedbackDescriptions.pageURL(siteUrl))
.transform((value, ctx) => {
const url = parseAgentFeedbackPageURL(value, siteUrl);
if (!url) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${value}" is not a valid URL on this site. Expected a full URL like ${siteUrl}/getting-started`,
});
return z.NEVER;
}
return url;
}),
goal: z
.string()
.max(AGENT_FEEDBACK_GOAL_MAX_LENGTH)
.optional()
.describe(agentFeedbackDescriptions.goal),
},
annotations: {
title: 'Send feedback',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
handler: async ({ content, pageUrl, goal }) => {
try {
const result = await submitAgentFeedback(context, {
feedback: content,
goal,
page: pageUrl,
source: AgentFeedbackSource.Mcp,
});
if (!result.submitted) {
return {
content: [{ type: 'text', text: result.error }],
isError: true,
};
}
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'agent_feedback',
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
page: result.page.pageId,
space: result.page.spaceId,
revision: result.page.revisionId,
},
},
],
request,
});
return {
content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }],
};
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
},
})
);
return tools;
}
/**
* Erase a tool's argument shape so differently-shaped tools can share one array.
*/
function defineTool<Args extends ZodRawShape>(tool: SiteMcpTool<Args>): SiteMcpTool {
return tool as SiteMcpTool;
}
/**
* Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the
* insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error.
*/
export function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) {
waitUntil(
trackServerInsightsEvents(args).catch((error) => {
console.error('Failed to track MCP insights event:', error);
})
);
}
/**
* Register tools on the MCP server. Only used by the transport: the server card reads the same
* definitions without registering anything.
*/
export function registerSiteMcpTools(server: McpServer, tools: SiteMcpTool[]) {
for (const tool of tools) {
server.tool(tool.name, tool.description, tool.inputSchema, tool.annotations, tool.handler);
}
}
+8
View File
@@ -21,6 +21,7 @@ import {
serveProxyAnalyticsEvent,
trackServerInsightsEvents,
} from './lib/tracking';
import { AI_CATALOG_PATH, AI_CATALOG_WELL_KNOWN_PATH } from '@/lib/aiCatalog/paths';
import { getAPITokenFromCookies, getAPITokenResponseCookies } from '@/lib/api-token-cookie';
import { isChatGPTRequest } from '@/lib/chatgpt';
import { MAX_CHUNKED_COOKIE_LENGTH } from '@/lib/chunked-cookies';
@@ -37,6 +38,7 @@ import {
import { GITBOOK_DISABLE_INSIGHTS, isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env';
import { getImageResizingContextId } from '@/lib/images';
import { isAITrainingOrIndexingRequest } from '@/lib/indexing-crawlers';
import { MCP_SERVER_CARD_PATH, MCP_SERVER_CARD_WELL_KNOWN_PATH } from '@/lib/mcp/paths';
import { MiddlewareHeaders } from '@/lib/middleware';
import {
createOAuthProtectedResourceMetadataResponse,
@@ -770,6 +772,10 @@ const EMBED_PAGE_PATH_REGEX = /^~gitbook\/embed\/page(\/(\S*))?$/;
const PATH_ALIASES: Record<string, string> = {
'sitemap.md': 'llms.txt',
'.well-known/sitemap.md': 'llms.txt',
// Scanners probe `.well-known` for a server card even though the MCP extension reserves
// `<streamable-http-url>/server-card`; both paths serve the same document.
[MCP_SERVER_CARD_WELL_KNOWN_PATH]: MCP_SERVER_CARD_PATH,
[AI_CATALOG_WELL_KNOWN_PATH]: AI_CATALOG_PATH,
};
/**
@@ -889,6 +895,8 @@ function encodePathInSiteContent(
return { pathname, routeType: 'static' };
case '~gitbook/mcp':
case '~gitbook/mcp/auth':
case MCP_SERVER_CARD_PATH:
case AI_CATALOG_PATH:
case '~gitbook/pdf':
case '~gitbook/search':
case '~gitbook/auth/login':