mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-20 01:25:16 +00:00
Add server card for published sites MCPs (#4618)
This commit is contained in:
+11
@@ -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 };
|
||||
+10
-405
@@ -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,
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()}…`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user