diff --git a/.changeset/rnd-11847-mcp-send-feedback.md b/.changeset/rnd-11847-mcp-send-feedback.md new file mode 100644 index 000000000..dc016291b --- /dev/null +++ b/.changeset/rnd-11847-mcp-send-feedback.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add a `sendFeedback` MCP tool so AI agents can report documentation findings (outdated / incoherent / gap / other) as `agent_feedback` insights events. The tool only accepts finding categories, so it never records positive feedback. 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 17c76df0b..fa319c0f2 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,4 +1,8 @@ -import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api'; +import { + CustomizationPageActionType, + SiteFindingType, + SiteInsightsDisplayContext, +} from '@gitbook/api'; import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils'; import { isAIEnabled } from '@/components/utils/isAIChatEnabled'; @@ -340,6 +344,126 @@ export async function handleMcpRequest( } ); } + + 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.`, + { + category: z + .nativeEnum(SiteFindingType) + .describe( + 'The kind of issue. "content-outdated": the content was correct at some point but no longer matches the current product or reality. "incoherence": the content contradicts itself or another page. "content-gap": information the reader needs is missing entirely, whether or not it was ever documented. "other": only as a last resort when none of the above fits.' + ), + content: z + .string() + .min(1) + .max(2048) + .describe( + 'Explain the issue in full, as if writing to a documentation maintainer who never saw this conversation. Describe what is wrong, where on the page it appears (quote the exact sentence or section title when possible), what the user was trying to do, and, when relevant, what the correct or expected information should be. Write a few clear, specific sentences in English. Never include personal or confidential information from the conversation. Up to 2048 characters.' + ), + pageUrl: z + .string() + .describe( + `The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started). Provide it whenever you can so the finding is linked to the exact page.` + ) + .transform((value, ctx) => { + const candidate = URL.canParse(value) + ? new URL(value) + : URL.canParse(value, siteUrl) + ? new URL(value, siteUrl) + : null; + + if ( + !candidate || + (candidate.protocol !== 'https:' && candidate.protocol !== 'http:') + ) { + 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 candidate.toString(); + }) + .optional(), + }, + { + title: 'Send feedback', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + async ({ category, content, pageUrl }) => { + try { + let pageLocation: + | { page: string; space: string; revision: string } + | undefined; + + if (pageUrl) { + const match = findSiteSpaceByUrl(context.structure, pageUrl); + if (!match) { + return { + content: [ + { type: 'text', text: `Page not found: "${pageUrl}"` }, + ], + isError: true, + }; + } + + const revision = await throwIfDataError( + dataFetcher.getRevision({ + spaceId: match.siteSpace.space.id, + revisionId: match.siteSpace.space.revision, + }) + ); + + const resolved = resolvePagePath(revision.pages, match.pagePath ?? ''); + if (!resolved) { + return { + content: [ + { type: 'text', text: `Page not found: "${pageUrl}"` }, + ], + isError: true, + }; + } + + pageLocation = { + page: resolved.page.id, + space: match.siteSpace.space.id, + revision: match.siteSpace.space.revision, + }; + } + + trackMcpEvent({ + organizationId: context.organizationId, + siteId: site.id, + events: [ + { + type: 'agent_feedback', + feedback: { content, category }, + location: { + displayContext: SiteInsightsDisplayContext.Mcp, + ...pageLocation, + }, + }, + ], + 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, + }; + } + } + ); }, {}, { diff --git a/packages/gitbook/tests/mcp.test.ts b/packages/gitbook/tests/mcp.test.ts index 395d21adb..3f6cfcfa6 100644 --- a/packages/gitbook/tests/mcp.test.ts +++ b/packages/gitbook/tests/mcp.test.ts @@ -50,6 +50,36 @@ it( const tools = await client.listTools(); expect(tools.tools[0]?.name).toBe('searchDocumentation'); expect(tools.tools[1]?.name).toBe('getPage'); + expect(tools.tools.some((tool) => tool.name === 'sendFeedback')).toBe(true); + }, + { timeout: 10_000 } +); + +it( + 'should record agent feedback through MCP', + async () => { + const client = new Client({ + name: 'test', + version: '1.0.0', + }); + + await client.connect( + new StreamableHTTPClientTransport( + new URL(getContentTestURL('https://gitbook.com/docs/~gitbook/mcp/auth')) + ) + ); + + const response = await client.callTool({ + name: 'sendFeedback', + arguments: { + category: 'content-gap', + content: 'The authentication section does not explain how to rotate API tokens.', + }, + }); + + expect(response.isError).toBeFalsy(); + // @ts-expect-error - response.content is of type unknown + expect(response.content[0]?.text).toContain('Feedback recorded'); }, { timeout: 10_000 } );