From 6b4c453497ea80c577037975bcefcae967da05bc Mon Sep 17 00:00:00 2001 From: spastorelli Date: Fri, 18 Sep 2026 10:16:27 +0200 Subject: [PATCH] Add server card for published sites MCPs (#4618) --- .../[siteData]/~gitbook/ai-catalog/route.ts | 11 + .../[siteData]/~gitbook/mcp/handler.ts | 415 +--------------- .../~gitbook/mcp/server-card/route.ts | 11 + .../src/components/PageBody/PageHeader.tsx | 9 +- .../gitbook/src/lib/aiCatalog/index.test.ts | 168 +++++++ packages/gitbook/src/lib/aiCatalog/index.ts | 90 ++++ packages/gitbook/src/lib/aiCatalog/paths.ts | 10 + packages/gitbook/src/lib/discoveryDocument.ts | 70 +++ packages/gitbook/src/lib/mcp/endpoints.ts | 29 ++ packages/gitbook/src/lib/mcp/paths.ts | 12 + .../gitbook/src/lib/mcp/serverCard.test.ts | 418 ++++++++++++++++ packages/gitbook/src/lib/mcp/serverCard.ts | 245 ++++++++++ packages/gitbook/src/lib/mcp/tools.ts | 456 ++++++++++++++++++ packages/gitbook/src/middleware.ts | 8 + 14 files changed, 1540 insertions(+), 412 deletions(-) create mode 100644 packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/ai-catalog/route.ts create mode 100644 packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/server-card/route.ts create mode 100644 packages/gitbook/src/lib/aiCatalog/index.test.ts create mode 100644 packages/gitbook/src/lib/aiCatalog/index.ts create mode 100644 packages/gitbook/src/lib/aiCatalog/paths.ts create mode 100644 packages/gitbook/src/lib/discoveryDocument.ts create mode 100644 packages/gitbook/src/lib/mcp/endpoints.ts create mode 100644 packages/gitbook/src/lib/mcp/paths.ts create mode 100644 packages/gitbook/src/lib/mcp/serverCard.test.ts create mode 100644 packages/gitbook/src/lib/mcp/serverCard.ts create mode 100644 packages/gitbook/src/lib/mcp/tools.ts diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/ai-catalog/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/ai-catalog/route.ts new file mode 100644 index 000000000..f1c8e3244 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/ai-catalog/route.ts @@ -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 }) { + const { context } = await getDynamicSiteContext(await params); + return serveSiteAiCatalog(context, request); +} + +export { handler as GET, handler as OPTIONS }; diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts index c46ce788d..97c234b0c 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts @@ -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[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, diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/server-card/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/server-card/route.ts new file mode 100644 index 000000000..a213cfd75 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/server-card/route.ts @@ -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 }) { + const { context } = await getDynamicSiteContext(await params); + return serveSiteMcpServerCard(context, request); +} + +export { handler as GET, handler as OPTIONS }; diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 4b7d822e6..00e30bf87 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -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)); diff --git a/packages/gitbook/src/lib/aiCatalog/index.test.ts b/packages/gitbook/src/lib/aiCatalog/index.test.ts new file mode 100644 index 000000000..62a3e34f7 --- /dev/null +++ b/packages/gitbook/src/lib/aiCatalog/index.test.ts @@ -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); + }); +}); diff --git a/packages/gitbook/src/lib/aiCatalog/index.ts b/packages/gitbook/src/lib/aiCatalog/index.ts new file mode 100644 index 000000000..9a92f624e --- /dev/null +++ b/packages/gitbook/src/lib/aiCatalog/index.ts @@ -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 { + 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}`; +} diff --git a/packages/gitbook/src/lib/aiCatalog/paths.ts b/packages/gitbook/src/lib/aiCatalog/paths.ts new file mode 100644 index 000000000..c0a254aa4 --- /dev/null +++ b/packages/gitbook/src/lib/aiCatalog/paths.ts @@ -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'; diff --git a/packages/gitbook/src/lib/discoveryDocument.ts b/packages/gitbook/src/lib/discoveryDocument.ts new file mode 100644 index 000000000..204a1f8ad --- /dev/null +++ b/packages/gitbook/src/lib/discoveryDocument.ts @@ -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 { + 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 { + 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); +} diff --git a/packages/gitbook/src/lib/mcp/endpoints.ts b/packages/gitbook/src/lib/mcp/endpoints.ts new file mode 100644 index 000000000..edb189383 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/endpoints.ts @@ -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 + ); +} diff --git a/packages/gitbook/src/lib/mcp/paths.ts b/packages/gitbook/src/lib/mcp/paths.ts new file mode 100644 index 000000000..a71f88f44 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/paths.ts @@ -0,0 +1,12 @@ +/** + * Path the card is served on, appended to the Streamable HTTP endpoint as the extension reserves + * (`GET /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'; diff --git a/packages/gitbook/src/lib/mcp/serverCard.test.ts b/packages/gitbook/src/lib/mcp/serverCard.test.ts new file mode 100644 index 000000000..9f10f74a6 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/serverCard.test.ts @@ -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[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); + }); +}); diff --git a/packages/gitbook/src/lib/mcp/serverCard.ts b/packages/gitbook/src/lib/mcp/serverCard.ts new file mode 100644 index 000000000..11b087821 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/serverCard.ts @@ -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; + +/** + * 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 { + 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()}…`; +} diff --git a/packages/gitbook/src/lib/mcp/tools.ts b/packages/gitbook/src/lib/mcp/tools.ts new file mode 100644 index 000000000..1935e70f7 --- /dev/null +++ b/packages/gitbook/src/lib/mcp/tools.ts @@ -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 { + name: string; + description: string; + /** + * Zod shape of the tool arguments, passed as-is to `server.tool`. + */ + inputSchema: Args; + annotations: ToolAnnotations; + handler: ToolCallback; +} + +/** + * 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(tool: SiteMcpTool): 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[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); + } +} diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index d7479dcb8..2edee662f 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -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 = { 'sitemap.md': 'llms.txt', '.well-known/sitemap.md': 'llms.txt', + // Scanners probe `.well-known` for a server card even though the MCP extension reserves + // `/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':