diff --git a/.changeset/tidy-tools-webmcp.md b/.changeset/tidy-tools-webmcp.md new file mode 100644 index 000000000..3f06cb746 --- /dev/null +++ b/.changeset/tidy-tools-webmcp.md @@ -0,0 +1,7 @@ +--- +'@rspress/plugin-webmcp': minor +'@rspress/core': minor +'@rspress/plugin-algolia': patch +--- + +feat: add native WebMCP site discovery, page retrieval, provider-aware search, navigation, and typed custom tool APIs diff --git a/e2e/fixtures/plugin-webmcp/doc/v1/en/_search-fragment.mdx b/e2e/fixtures/plugin-webmcp/doc/v1/en/_search-fragment.mdx new file mode 100644 index 000000000..6f8f73358 --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/doc/v1/en/_search-fragment.mdx @@ -0,0 +1 @@ +The imported search phrase is **indigo wombat**. diff --git a/e2e/fixtures/plugin-webmcp/doc/v1/en/failure.mdx b/e2e/fixtures/plugin-webmcp/doc/v1/en/failure.mdx new file mode 100644 index 000000000..ba64e8d6c --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/doc/v1/en/failure.mdx @@ -0,0 +1,3 @@ +# WebMCP failure route + +This unlinked page exercises failed lazy-route navigation. diff --git a/e2e/fixtures/plugin-webmcp/doc/v1/en/guide.mdx b/e2e/fixtures/plugin-webmcp/doc/v1/en/guide.mdx new file mode 100644 index 000000000..96ddfcea1 --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/doc/v1/en/guide.mdx @@ -0,0 +1,11 @@ +import { PageScopedTool } from '../../../src/PageScopedTool'; + +# WebMCP guide + + + +## Register tools + +This page demonstrates a page-scoped WebMCP tool. + +[Return home](/) diff --git a/e2e/fixtures/plugin-webmcp/doc/v1/en/index.mdx b/e2e/fixtures/plugin-webmcp/doc/v1/en/index.mdx new file mode 100644 index 000000000..16d238d51 --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/doc/v1/en/index.mdx @@ -0,0 +1,11 @@ +import SearchFragment from './_search-fragment.mdx'; + +# WebMCP home + +This fixture validates Rspress WebMCP tools. + +The unique search phrase is **cobalt platypus**. + + + +[Open the guide](/guide) diff --git a/e2e/fixtures/plugin-webmcp/doc/v1/en/provider.mdx b/e2e/fixtures/plugin-webmcp/doc/v1/en/provider.mdx new file mode 100644 index 000000000..701b6a2ee --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/doc/v1/en/provider.mdx @@ -0,0 +1,7 @@ +--- +title: Search provider +--- + +# Search provider + +This page installs a fixture search provider for WebMCP testing. diff --git a/e2e/fixtures/plugin-webmcp/doc/v1/zh/index.mdx b/e2e/fixtures/plugin-webmcp/doc/v1/zh/index.mdx new file mode 100644 index 000000000..04c83441c --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/doc/v1/zh/index.mdx @@ -0,0 +1,5 @@ +# WebMCP 中文主页 + +此页面用于验证语言切换后会重新加载本地搜索索引。 + +The unique search phrase is **jade pangolin**. diff --git a/e2e/fixtures/plugin-webmcp/doc/v2/en/index.mdx b/e2e/fixtures/plugin-webmcp/doc/v2/en/index.mdx new file mode 100644 index 000000000..32e651b64 --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/doc/v2/en/index.mdx @@ -0,0 +1,5 @@ +# WebMCP version two + +This page validates version-specific local search. + +The unique search phrase is **amber axolotl**. diff --git a/e2e/fixtures/plugin-webmcp/doc/v2/zh/index.mdx b/e2e/fixtures/plugin-webmcp/doc/v2/zh/index.mdx new file mode 100644 index 000000000..1a2a1091c --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/doc/v2/zh/index.mdx @@ -0,0 +1,7 @@ +--- +title: WebMCP 第二版 +--- + +# WebMCP 第二版 + +第二版中文测试页。 diff --git a/e2e/fixtures/plugin-webmcp/index.test.ts b/e2e/fixtures/plugin-webmcp/index.test.ts new file mode 100644 index 000000000..d6e006c75 --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/index.test.ts @@ -0,0 +1,709 @@ +import { existsSync, rmSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { expect, test, type Route } from '@playwright/test'; +import { + getPort, + killProcess, + runBuildCommand, + runDevCommand, + runPreviewCommand, +} from '../../utils/runCommands'; +import { + executeTool, + findTool, + listRegistrations, + listToolNames, + listTools, +} from './webmcpTestUtils'; + +const appDir = import.meta.dirname; +const outputDir = path.join(appDir, 'doc_build'); +const counterToolsFile = path.join(appDir, 'src/CounterTools.tsx'); +const pageScopedToolFile = path.join(appDir, 'src/PageScopedTool.tsx'); +const homePageFile = path.join(appDir, 'doc/v1/en/index.mdx'); +const searchFragmentFile = path.join(appDir, 'doc/v1/en/_search-fragment.mdx'); +const guidePageFile = path.join(appDir, 'doc/v1/en/guide.mdx'); + +test('normalizes native WebMCP execution results', async ({ page }) => { + await page.goto('about:blank'); + await page.evaluate(() => { + const tools = ['native_echo', 'native_void'].map(name => ({ + name, + description: name, + inputSchema: '{"type":"object"}', + })); + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: { + async getTools() { + return tools; + }, + async executeTool(tool: { name: string }, input: string) { + return tool.name === 'native_void' + ? undefined + : { tool: tool.name, input: JSON.parse(input) }; + }, + }, + }); + }); + + await expect( + executeTool(page, 'native_echo', { value: 'ok' }), + ).resolves.toEqual({ + structuredContent: { + tool: 'native_echo', + input: { value: 'ok' }, + }, + content: [ + { + type: 'text', + text: '{"tool":"native_echo","input":{"value":"ok"}}', + }, + ], + }); + await expect(executeTool(page, 'native_void', {})).resolves.toEqual({ + structuredContent: null, + content: [{ type: 'text', text: 'undefined' }], + }); +}); + +test.describe('plugin-webmcp preview', () => { + let appPort: number; + let app: Awaited> | undefined; + + test.beforeAll(async () => { + appPort = await getPort(); + rmSync(outputDir, { recursive: true, force: true }); + await runBuildCommand(appDir); + app = await runPreviewCommand(appDir, appPort); + }); + + test.afterAll(async () => { + if (app) { + await killProcess(app); + } + rmSync(outputDir, { recursive: true, force: true }); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(`http://localhost:${appPort}/`, { + waitUntil: 'networkidle', + }); + await expect + .poll(async () => (await listToolNames(page)).sort()) + .toEqual([ + 'fixture_increment_counter', + 'fixture_reset_counter', + 'rspress_get_current_page', + 'rspress_get_page', + 'rspress_get_site_info', + 'rspress_list_pages', + 'rspress_navigate', + 'rspress_search_docs', + ]); + }); + + test('lists descriptors and reads generated Markdown', async ({ page }) => { + const tools = await listTools(page); + const currentPageTool = tools.find( + tool => tool.name === 'rspress_get_current_page', + ); + const pageTool = tools.find(tool => tool.name === 'rspress_get_page'); + const siteInfoTool = tools.find( + tool => tool.name === 'rspress_get_site_info', + ); + const listPagesTool = tools.find( + tool => tool.name === 'rspress_list_pages', + ); + const searchTool = tools.find(tool => tool.name === 'rspress_search_docs'); + const navigateTool = tools.find(tool => tool.name === 'rspress_navigate'); + expect(JSON.parse(currentPageTool!.inputSchema!)).toMatchObject({ + type: 'object', + additionalProperties: false, + }); + expect(JSON.parse(pageTool!.inputSchema!)).toMatchObject({ + required: ['routePath'], + }); + expect(JSON.parse(siteInfoTool!.inputSchema!)).toMatchObject({ + properties: {}, + additionalProperties: false, + }); + expect(JSON.parse(listPagesTool!.inputSchema!)).toMatchObject({ + properties: { + limit: { maximum: 100 }, + offset: { minimum: 0 }, + }, + }); + expect(JSON.parse(searchTool!.inputSchema!)).toMatchObject({ + required: ['query'], + properties: { limit: { maximum: 20 } }, + }); + expect(JSON.parse(navigateTool!.inputSchema!)).toMatchObject({ + required: ['routePath'], + }); + expect( + (await listRegistrations(page)) + .filter(registration => registration.name.startsWith('rspress_')) + .every( + registration => + registration.exposedTo?.[0] === 'https://agent.example', + ), + ).toBe(true); + + const currentPage = await executeTool(page, 'rspress_get_current_page', {}); + expect(currentPage?.structuredContent).toMatchObject({ + title: 'WebMCP home', + routePath: '/', + }); + expect( + (currentPage?.structuredContent as { markdown: string }).markdown, + ).toContain('# WebMCP home'); + + const siteInfo = await executeTool(page, 'rspress_get_site_info', {}); + expect(siteInfo?.structuredContent).toMatchObject({ + title: 'WebMCP fixture', + lang: 'en', + version: 'v1', + locales: [{ lang: 'en' }, { lang: 'zh' }], + versions: ['v1', 'v2'], + defaultVersion: 'v1', + }); + expect(JSON.stringify(siteInfo?.structuredContent)).toContain('Guide'); + + const listedPages = await executeTool(page, 'rspress_list_pages', { + query: 'register tools', + limit: 10, + }); + expect(listedPages?.structuredContent).toMatchObject({ + pages: [expect.objectContaining({ routePath: '/guide' })], + total: 1, + offset: 0, + limit: 10, + }); + + const urlBeforeRead = page.url(); + const guide = await executeTool(page, 'rspress_get_page', { + routePath: '/guide.html?source=read#webmcp-guide', + }); + expect(guide?.structuredContent).toMatchObject({ + title: 'WebMCP guide', + routePath: '/guide', + }); + expect( + (guide?.structuredContent as { markdown: string }).markdown, + ).toContain('# WebMCP guide'); + expect(page.url()).toBe(urlBeforeRead); + }); + + test('executes search and both custom registration APIs', async ({ + page, + }) => { + const search = await executeTool(page, 'rspress_search_docs', { + query: 'cobalt platypus', + limit: 5, + }); + expect(JSON.stringify(search?.structuredContent)).toContain('WebMCP home'); + + const registrationsBeforeIncrement = ( + await listRegistrations(page, 'fixture_increment_counter') + ).length; + await executeTool(page, 'fixture_increment_counter', {}); + await expect(page.getByTestId('webmcp-counter')).toHaveText('Counter: 1'); + await expect + .poll(async () => { + const registrations = await listRegistrations( + page, + 'fixture_increment_counter', + ); + return ( + registrations.length > registrationsBeforeIncrement && + registrations.at(-2)?.aborted === true + ); + }) + .toBe(true); + await executeTool(page, 'fixture_reset_counter', {}); + await expect(page.getByTestId('webmcp-counter')).toHaveText('Counter: 0'); + + await expect( + executeTool(page, 'rspress_search_docs', { query: '', limit: 100 }), + ).rejects.toThrow(/Input validation error/); + }); + + test('uses an external search provider and restores local search', async ({ + page, + }) => { + await executeTool(page, 'rspress_navigate', { + routePath: '/provider', + }); + await expect + .poll(async () => { + const result = await executeTool(page, 'rspress_search_docs', { + query: 'provider query', + }); + return result?.structuredContent; + }) + .toEqual([ + { + group: 'fixture-provider', + results: [ + { + title: 'External provider result', + link: '/provider', + query: 'provider query', + }, + ], + }, + ]); + + await executeTool(page, 'rspress_navigate', { routePath: '/' }); + await expect + .poll(async () => { + const result = await executeTool(page, 'rspress_search_docs', { + query: 'cobalt platypus', + }); + return JSON.stringify(result?.structuredContent); + }) + .toContain('WebMCP home'); + }); + + test('awaits navigation and cleans up page-scoped tools', async ({ + page, + }) => { + const navigation = await executeTool(page, 'rspress_navigate', { + routePath: '/guide.html?source=webmcp#webmcp-guide', + }); + expect(navigation?.structuredContent).toMatchObject({ + routePath: '/guide?source=webmcp#webmcp-guide', + page: { title: 'WebMCP guide', lang: 'en', version: 'v1' }, + sections: [ + { + title: 'Register tools', + depth: 2, + routePath: '/guide?source=webmcp#register-tools', + }, + ], + previousPage: { title: 'Home', routePath: '/index.html' }, + nextPage: null, + }); + + const currentPage = await executeTool(page, 'rspress_get_current_page', {}); + expect(currentPage?.structuredContent).toMatchObject({ + title: 'WebMCP guide', + routePath: '/guide', + }); + expect( + (currentPage?.structuredContent as { markdown: string }).markdown, + ).toContain('# WebMCP guide'); + await expect(page).toHaveURL(/\/guide\?source=webmcp#webmcp-guide$/); + await expect + .poll(() => listToolNames(page)) + .toContain('fixture_page_scoped'); + + await expect( + executeTool(page, 'rspress_navigate', { + routePath: 'https://example.com/', + }), + ).rejects.toThrow(); + await expect( + executeTool(page, 'rspress_navigate', { routePath: '/missing' }), + ).rejects.toThrow(/Unknown internal route/); + await expect( + executeTool(page, 'rspress_get_page', { + routePath: 'https://example.com/', + }), + ).rejects.toThrow(); + await expect( + executeTool(page, 'rspress_get_page', { routePath: '/missing' }), + ).rejects.toThrow(/Unknown internal route/); + + await executeTool(page, 'rspress_navigate', { routePath: '/index.html' }); + await expect(page).toHaveURL(new RegExp(`:${appPort}/$`)); + await expect + .poll(() => listToolNames(page)) + .not.toContain('fixture_page_scoped'); + }); + + test('refreshes search for language and version navigation', async ({ + page, + }) => { + await executeTool(page, 'rspress_navigate', { routePath: '/zh/' }); + await expect + .poll(() => listToolNames(page)) + .toContain('rspress_search_docs'); + const zhSearch = await executeTool(page, 'rspress_search_docs', { + query: 'jade pangolin', + }); + expect(JSON.stringify(zhSearch?.structuredContent)).toContain( + 'WebMCP 中文主页', + ); + const zhSiteInfo = await executeTool(page, 'rspress_get_site_info', {}); + expect(zhSiteInfo?.structuredContent).toMatchObject({ + lang: 'zh', + version: 'v1', + }); + const zhPages = await executeTool(page, 'rspress_list_pages', {}); + expect(zhPages?.structuredContent).toMatchObject({ + pages: [expect.objectContaining({ title: 'WebMCP 中文主页' })], + total: 1, + }); + + await executeTool(page, 'rspress_navigate', { routePath: '/v2/' }); + await expect + .poll(() => listToolNames(page)) + .toContain('rspress_search_docs'); + const versionSearch = await executeTool(page, 'rspress_search_docs', { + query: 'amber axolotl', + }); + expect(JSON.stringify(versionSearch?.structuredContent)).toContain( + 'WebMCP version two', + ); + const versionPages = await executeTool(page, 'rspress_list_pages', {}); + expect(versionPages?.structuredContent).toMatchObject({ + pages: [expect.objectContaining({ title: 'WebMCP version two' })], + total: 1, + }); + }); + + test('serializes concurrent navigation calls', async ({ page }) => { + const [first, second] = await Promise.all([ + executeTool(page, 'rspress_navigate', { + routePath: '/guide?sequence=first', + }), + executeTool(page, 'rspress_navigate', { + routePath: '/index.html?sequence=second', + }), + ]); + + expect(first?.structuredContent).toMatchObject({ + routePath: '/guide?sequence=first', + page: { title: 'WebMCP guide' }, + }); + expect(second?.structuredContent).toMatchObject({ + routePath: '/?sequence=second', + page: { title: 'WebMCP home' }, + }); + await expect(page).toHaveURL(new RegExp(`:${appPort}/\\?sequence=second$`)); + const currentPage = await executeTool(page, 'rspress_get_current_page', {}); + expect(currentPage?.structuredContent).toMatchObject({ + title: 'WebMCP home', + routePath: '/', + }); + }); + + test('releases the navigation queue after a route load failure', async ({ + page, + }) => { + test.slow(); + const chunkPattern = '**/static/js/async/*.js'; + const abortChunk = (route: Route) => route.abort('failed'); + await page.route(chunkPattern, abortChunk); + try { + await expect( + executeTool(page, 'rspress_navigate', { routePath: '/failure' }), + ).rejects.toThrow( + /Navigation did not complete|Failed to fetch|Loading chunk/, + ); + } finally { + await page.unroute(chunkPattern, abortChunk); + } + + await executeTool(page, 'rspress_navigate', { routePath: '/guide' }); + await expect(page).toHaveURL(new RegExp(`:${appPort}/guide$`)); + }); + + test('cancels a timed-out navigation before it can commit late', async ({ + page, + }) => { + test.slow(); + const chunkPattern = '**/static/js/async/*.js'; + let releaseChunk!: () => void; + let markChunkBlocked!: () => void; + const chunkGate = new Promise(resolve => { + releaseChunk = resolve; + }); + const chunkBlocked = new Promise(resolve => { + markChunkBlocked = resolve; + }); + let heldFirstChunk = false; + let heldChunkUrl = ''; + const holdFirstChunk = async (route: Route) => { + if (heldFirstChunk) { + await route.continue(); + return; + } + heldFirstChunk = true; + heldChunkUrl = route.request().url(); + markChunkBlocked(); + await chunkGate; + await route.continue(); + }; + await page.route(chunkPattern, holdFirstChunk); + + const firstNavigation = executeTool(page, 'rspress_navigate', { + routePath: '/failure', + }); + await chunkBlocked; + await expect(firstNavigation).rejects.toThrow( + /Navigation did not complete/, + ); + + await executeTool(page, 'rspress_navigate', { routePath: '/guide' }); + await expect(page).toHaveURL(new RegExp(`:${appPort}/guide$`)); + + const heldChunkResponse = page.waitForResponse( + response => response.url() === heldChunkUrl, + ); + releaseChunk(); + await heldChunkResponse; + await page.waitForTimeout(100); + await expect(page).toHaveURL(new RegExp(`:${appPort}/guide$`)); + const currentPage = await executeTool(page, 'rspress_get_current_page', {}); + expect(currentPage?.structuredContent).toMatchObject({ + title: 'WebMCP guide', + routePath: '/guide', + }); + + await page.unroute(chunkPattern, holdFirstChunk); + }); + + test('build emits SSG-MD output', () => { + expect(existsSync(path.join(outputDir, 'index.md'))).toBe(true); + expect(existsSync(path.join(outputDir, 'guide.md'))).toBe(true); + expect(existsSync(path.join(outputDir, 'zh/index.md'))).toBe(true); + expect(existsSync(path.join(outputDir, 'v2/index.md'))).toBe(true); + }); +}); + +test.describe('plugin-webmcp development server', () => { + let appPort: number; + let app: Awaited> | undefined; + let originalCounterTools: string; + let originalPageScopedTool: string; + let originalHomePage: string; + let originalSearchFragment: string; + let originalGuidePage: string; + + test.beforeAll(async () => { + appPort = await getPort(); + [ + originalCounterTools, + originalPageScopedTool, + originalHomePage, + originalSearchFragment, + originalGuidePage, + ] = await Promise.all([ + readFile(counterToolsFile, 'utf8'), + readFile(pageScopedToolFile, 'utf8'), + readFile(homePageFile, 'utf8'), + readFile(searchFragmentFile, 'utf8'), + readFile(guidePageFile, 'utf8'), + ]); + app = await runDevCommand(appDir, appPort); + }); + + test.afterAll(async () => { + try { + if (app) { + await killProcess(app); + } + } finally { + await Promise.all([ + writeFile(counterToolsFile, originalCounterTools), + writeFile(pageScopedToolFile, originalPageScopedTool), + writeFile(homePageFile, originalHomePage), + writeFile(searchFragmentFile, originalSearchFragment), + writeFile(guidePageFile, originalGuidePage), + ]); + } + }); + + test.beforeEach(async ({ page }) => { + await page.goto(`http://localhost:${appPort}/`, { + waitUntil: 'networkidle', + }); + await expect.poll(() => listToolNames(page)).toContain('rspress_navigate'); + }); + + test('omits Markdown tools and keeps discovery tools', async ({ page }) => { + await expect + .poll(async () => (await listToolNames(page)).sort()) + .toEqual([ + 'fixture_increment_counter', + 'fixture_reset_counter', + 'rspress_get_site_info', + 'rspress_list_pages', + 'rspress_navigate', + 'rspress_search_docs', + ]); + await expect( + page.getByRole('button', { name: 'Copy Markdown' }), + ).toHaveCount(0); + }); + + test('hot-updates a hook tool descriptor and execute closure', async ({ + page, + }) => { + const updatedCounterTools = originalCounterTools + .replace('fixture counter by one', 'fixture counter by two') + .replace('countRef.current += 1', 'countRef.current += 2'); + + try { + await writeFile(counterToolsFile, updatedCounterTools); + await expect + .poll(async () => { + return (await findTool(page, 'fixture_increment_counter')) + ?.description; + }) + .toContain('by two'); + + await executeTool(page, 'fixture_increment_counter', {}); + await expect(page.getByTestId('webmcp-counter')).toHaveText('Counter: 2'); + } finally { + await writeFile(counterToolsFile, originalCounterTools); + } + + await expect + .poll(async () => { + return (await findTool(page, 'fixture_increment_counter'))?.description; + }) + .toContain('by one'); + await executeTool(page, 'fixture_reset_counter', {}); + }); + + test('hot-updates and unregisters a page-scoped hook tool', async ({ + page, + }) => { + await executeTool(page, 'rspress_navigate', { routePath: '/guide' }); + await expect + .poll(() => listToolNames(page)) + .toContain('fixture_page_scoped'); + + const updatedPageScopedTool = originalPageScopedTool + .replace('guide page is mounted', 'guide HMR page is mounted') + .replace("{ page: 'guide' }", "{ page: 'guide-hmr' }"); + try { + await writeFile(pageScopedToolFile, updatedPageScopedTool); + await expect + .poll(async () => { + return (await findTool(page, 'fixture_page_scoped'))?.description; + }) + .toContain('guide HMR'); + await expect( + executeTool(page, 'fixture_page_scoped', {}), + ).resolves.toMatchObject({ structuredContent: { page: 'guide-hmr' } }); + } finally { + await writeFile(pageScopedToolFile, originalPageScopedTool); + } + + await expect + .poll(async () => { + return (await findTool(page, 'fixture_page_scoped'))?.description; + }) + .toContain('guide page'); + + try { + await writeFile( + guidePageFile, + originalGuidePage.replace('', ''), + ); + await expect + .poll(() => listToolNames(page)) + .not.toContain('fixture_page_scoped'); + } finally { + await writeFile(guidePageFile, originalGuidePage); + } + await expect + .poll(() => listToolNames(page)) + .toContain('fixture_page_scoped'); + }); + + test('hot-updates page content and the local search tool', async ({ + page, + }) => { + const updatedHomePage = originalHomePage + .replace('# WebMCP home', '# WebMCP HMR home') + .replace('cobalt platypus', 'silver capybara'); + + try { + await writeFile(homePageFile, updatedHomePage); + await expect(page.getByText('silver capybara')).toBeVisible(); + await expect + .poll(async () => { + const result = await executeTool(page, 'rspress_list_pages', { + query: 'HMR home', + }); + return JSON.stringify(result?.structuredContent); + }) + .toContain('WebMCP HMR home'); + await expect + .poll(async () => { + const search = await executeTool(page, 'rspress_search_docs', { + query: 'silver capybara', + }); + return JSON.stringify(search?.structuredContent); + }) + .toContain('WebMCP HMR home'); + const navigation = await executeTool(page, 'rspress_navigate', { + routePath: '/index.html', + }); + expect(navigation?.structuredContent).toMatchObject({ + routePath: '/', + page: { title: 'WebMCP HMR home' }, + }); + } finally { + await writeFile(homePageFile, originalHomePage); + } + + await expect(page.getByText('cobalt platypus')).toBeVisible(); + await expect + .poll(async () => { + const result = await executeTool(page, 'rspress_list_pages', { + query: 'WebMCP home', + }); + return JSON.stringify(result?.structuredContent); + }) + .toContain('WebMCP home'); + await expect + .poll(async () => { + const search = await executeTool(page, 'rspress_search_docs', { + query: 'cobalt platypus', + }); + return JSON.stringify(search?.structuredContent); + }) + .toContain('WebMCP home'); + const navigation = await executeTool(page, 'rspress_navigate', { + routePath: '/index.html', + }); + expect(navigation?.structuredContent).toMatchObject({ + routePath: '/', + page: { title: 'WebMCP home' }, + }); + }); + + test('hot-updates imported Markdown in the local search tool', async ({ + page, + }) => { + const updatedFragment = originalSearchFragment.replace( + 'indigo wombat', + 'violet echidna', + ); + + try { + await writeFile(searchFragmentFile, updatedFragment); + await expect(page.getByText(/violet echidna/)).toBeVisible(); + await expect + .poll(async () => { + const search = await executeTool(page, 'rspress_search_docs', { + query: 'violet echidna', + }); + return JSON.stringify(search?.structuredContent); + }) + .toContain('WebMCP home'); + } finally { + await writeFile(searchFragmentFile, originalSearchFragment); + } + + await expect(page.getByText(/indigo wombat/)).toBeVisible(); + }); +}); diff --git a/e2e/fixtures/plugin-webmcp/package.json b/e2e/fixtures/plugin-webmcp/package.json new file mode 100644 index 000000000..9fee32aac --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/package.json @@ -0,0 +1,22 @@ +{ + "name": "@rspress-fixture/plugin-webmcp", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rspress build", + "dev": "rspress dev", + "preview": "rspress preview" + }, + "dependencies": { + "@mcp-b/webmcp-polyfill": "4.0.0", + "@rspress/core": "workspace:*", + "@rspress/plugin-webmcp": "workspace:*", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^22.8.1", + "@types/react": "^19.2.18" + } +} diff --git a/e2e/fixtures/plugin-webmcp/rspress.config.ts b/e2e/fixtures/plugin-webmcp/rspress.config.ts new file mode 100644 index 000000000..7300dbaa9 --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/rspress.config.ts @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { defineConfig } from '@rspress/core'; +import { pluginWebMcp } from '@rspress/plugin-webmcp'; + +export default defineConfig({ + root: path.join(import.meta.dirname, 'doc'), + title: 'WebMCP fixture', + lang: 'en', + locales: [ + { lang: 'en', label: 'English' }, + { lang: 'zh', label: '简体中文' }, + ], + multiVersion: { + default: 'v1', + versions: ['v1', 'v2'], + }, + search: {}, + themeConfig: { + nav: [{ text: 'Guide', link: '/guide' }], + sidebar: { + '/': [ + { text: 'Home', link: '/' }, + { text: 'Guide', link: '/guide' }, + ], + }, + }, + globalUIComponents: [ + path.join(import.meta.dirname, 'src/CounterTools.tsx'), + path.join(import.meta.dirname, 'src/SearchProvider.tsx'), + ], + builderConfig: { + source: { + preEntry: path.join(import.meta.dirname, 'src/polyfill.ts'), + }, + }, + plugins: [pluginWebMcp({ exposedTo: ['https://agent.example'] })], +}); diff --git a/e2e/fixtures/plugin-webmcp/src/CounterTools.tsx b/e2e/fixtures/plugin-webmcp/src/CounterTools.tsx new file mode 100644 index 000000000..bf721f21a --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/src/CounterTools.tsx @@ -0,0 +1,56 @@ +import { + registerWebMcpTool, + useWebMcpTool, +} from '@rspress/plugin-webmcp/runtime'; +import { useEffect, useRef, useState } from 'react'; + +export default function CounterTools() { + const [count, setCount] = useState(0); + const countRef = useRef(0); + + useWebMcpTool( + { + name: 'fixture_increment_counter', + title: 'Increment fixture counter', + description: 'Increment the visible fixture counter by one.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + execute() { + countRef.current += 1; + setCount(countRef.current); + return { count: countRef.current }; + }, + }, + {}, + [count], + ); + + useEffect(() => { + const registration = registerWebMcpTool({ + name: 'fixture_reset_counter', + title: 'Reset fixture counter', + description: 'Reset the visible fixture counter to zero.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + execute() { + countRef.current = 0; + setCount(0); + return { count: 0 }; + }, + }); + void registration?.ready.catch(error => { + console.error('Failed to register fixture_reset_counter', error); + }); + return registration?.unregister; + }, []); + + return
Counter: {count}
; +} diff --git a/e2e/fixtures/plugin-webmcp/src/PageScopedTool.tsx b/e2e/fixtures/plugin-webmcp/src/PageScopedTool.tsx new file mode 100644 index 000000000..6edaf0be4 --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/src/PageScopedTool.tsx @@ -0,0 +1,16 @@ +import { useWebMcpTool } from '@rspress/plugin-webmcp/runtime'; + +export function PageScopedTool() { + useWebMcpTool({ + name: 'fixture_page_scoped', + description: 'Return a value only while the guide page is mounted.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + annotations: { readOnlyHint: true }, + execute: () => ({ page: 'guide' }), + }); + return null; +} diff --git a/e2e/fixtures/plugin-webmcp/src/SearchProvider.tsx b/e2e/fixtures/plugin-webmcp/src/SearchProvider.tsx new file mode 100644 index 000000000..fc79bb221 --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/src/SearchProvider.tsx @@ -0,0 +1,30 @@ +import { usePage } from '@rspress/core/runtime'; +import { registerSearchProvider } from '@rspress/core/theme'; +import { useEffect } from 'react'; + +export default function SearchProvider() { + const { page } = usePage(); + + useEffect(() => { + if (page.routePath !== '/provider') { + return; + } + + return registerSearchProvider({ + search: async (query, limit = 20) => [ + { + group: 'fixture-provider', + result: [ + { + title: 'External provider result', + link: '/provider', + query, + }, + ].slice(0, limit), + }, + ], + }); + }, [page.routePath]); + + return null; +} diff --git a/e2e/fixtures/plugin-webmcp/src/polyfill.ts b/e2e/fixtures/plugin-webmcp/src/polyfill.ts new file mode 100644 index 000000000..33a004df8 --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/src/polyfill.ts @@ -0,0 +1,20 @@ +import '@mcp-b/webmcp-polyfill'; + +const registrations: { + name: string; + exposedTo?: string[]; + signal?: AbortSignal; +}[] = []; +if (typeof document !== 'undefined') { + const modelContext = document.modelContext; + const registerTool = modelContext.registerTool.bind(modelContext); + modelContext.registerTool = (tool, options) => { + registrations.push({ + name: tool.name, + exposedTo: options?.exposedTo, + signal: options?.signal, + }); + return registerTool(tool, options); + }; + Object.assign(globalThis, { __webMcpRegistrations: registrations }); +} diff --git a/e2e/fixtures/plugin-webmcp/webmcpTestUtils.ts b/e2e/fixtures/plugin-webmcp/webmcpTestUtils.ts new file mode 100644 index 000000000..e6bfaac4e --- /dev/null +++ b/e2e/fixtures/plugin-webmcp/webmcpTestUtils.ts @@ -0,0 +1,129 @@ +import type { Page } from '@playwright/test'; + +export interface TestingTool { + name: string; + description: string; + inputSchema?: string; +} + +interface ProducerModelContext { + getTools(): Promise; + executeTool(tool: TestingTool, input: string): Promise; +} + +interface TestingModelContext { + listTools(): TestingTool[]; + executeTool(name: string, input: string): Promise; +} + +export async function listTools(page: Page): Promise { + return page.evaluate(async () => { + const testing = ( + navigator as Navigator & { + modelContextTesting?: TestingModelContext; + } + ).modelContextTesting; + if (testing) { + return testing.listTools(); + } + const modelContext = ( + document as Document & { modelContext?: ProducerModelContext } + ).modelContext; + if (modelContext?.getTools) { + return (await modelContext.getTools()).map( + ({ name, description, inputSchema }) => ({ + name, + description, + inputSchema, + }), + ); + } + throw new Error('WebMCP tool discovery is unavailable'); + }); +} + +export async function listToolNames(page: Page): Promise { + return (await listTools(page)).map(tool => tool.name); +} + +export async function findTool(page: Page, name: string) { + return (await listTools(page)).find(tool => tool.name === name); +} + +export function listRegistrations(page: Page, name?: string) { + return page.evaluate(name => { + const registrations = ( + globalThis as typeof globalThis & { + __webMcpRegistrations: { + name: string; + exposedTo?: string[]; + signal?: AbortSignal; + }[]; + } + ).__webMcpRegistrations; + return registrations + .filter(registration => !name || registration.name === name) + .map(registration => ({ + name: registration.name, + exposedTo: registration.exposedTo, + aborted: registration.signal?.aborted ?? false, + })); + }, name); +} + +export async function executeTool( + page: Page, + name: string, + input: Record, +) { + const raw = await page.evaluate( + async ({ name, input }) => { + const inputJson = JSON.stringify(input); + const testing = ( + navigator as Navigator & { + modelContextTesting?: TestingModelContext; + } + ).modelContextTesting; + if (testing) { + return testing.executeTool(name, inputJson); + } + const modelContext = ( + document as Document & { modelContext?: ProducerModelContext } + ).modelContext; + if (modelContext?.getTools && modelContext.executeTool) { + const tool = (await modelContext.getTools()).find( + candidate => candidate.name === name, + ); + if (!tool) { + throw new Error(`Unknown WebMCP tool: ${name}`); + } + const result = await modelContext.executeTool(tool, inputJson); + if (result === null) { + return null; + } + const text = + typeof result === 'string' + ? result + : (JSON.stringify(result) ?? String(result)); + return JSON.stringify({ + structuredContent: result ?? null, + content: [ + { + type: 'text', + text, + }, + ], + }); + } + throw new Error('WebMCP tool execution is unavailable'); + }, + { name, input }, + ); + if (raw === null) { + return null; + } + return JSON.parse(raw) as { + structuredContent?: unknown; + content: { type: string; text: string }[]; + }; +} diff --git a/package.json b/package.json index f352f40bf..9a36df31e 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "lint": "rslint --type-check && prettier . --check && pnpm run check-spell", "prepare": "skills-package-manager install && pnpm run build && simple-git-hooks", "preview:website": "cd website && npm run preview", - "test": "pnpm test:unit && pnpm test:e2e", + "test": "pnpm test:type && pnpm test:unit && pnpm test:e2e", "test:e2e": "playwright test", + "test:type": "pnpm --recursive --if-present run test:type", "test:unit": "rstest run", "update:rsbuild": "npx taze minor --include /rsbuild/ -w -r -l" }, diff --git a/packages/core/src/node/PluginDriver.test.ts b/packages/core/src/node/PluginDriver.test.ts new file mode 100644 index 000000000..16873ec48 --- /dev/null +++ b/packages/core/src/node/PluginDriver.test.ts @@ -0,0 +1,37 @@ +import type { RspressPlugin, UserConfig } from '@rspress/shared'; +import { describe, expect, test } from '@rstest/core'; +import { PluginDriver } from './PluginDriver'; + +async function observeLlmsConfig(config: UserConfig) { + let observed: UserConfig['llms']; + const plugin: RspressPlugin = { + name: 'observe-llms-config', + config(currentConfig) { + observed = currentConfig.llms; + return currentConfig; + }, + }; + const driver = await PluginDriver.create( + { mediumZoom: false, ...config, plugins: [plugin] }, + '', + false, + ); + const modifiedConfig = await driver.modifyConfig(); + return { observed, modifiedConfig }; +} + +describe('PluginDriver config normalization', () => { + test('defaults llms after plugin config hooks', async () => { + const { observed, modifiedConfig } = await observeLlmsConfig({}); + expect(observed).toBeUndefined(); + expect(modifiedConfig.llms).toBe(false); + }); + + test('preserves explicit llms false for plugin config hooks', async () => { + const { observed, modifiedConfig } = await observeLlmsConfig({ + llms: false, + }); + expect(observed).toBe(false); + expect(modifiedConfig.llms).toBe(false); + }); +}); diff --git a/packages/core/src/node/PluginDriver.ts b/packages/core/src/node/PluginDriver.ts index 36be42a08..ed879fee0 100644 --- a/packages/core/src/node/PluginDriver.ts +++ b/packages/core/src/node/PluginDriver.ts @@ -120,7 +120,6 @@ export class PluginDriver { private async normalizeConfig() { this.#config.root ??= 'docs'; this.#config.ssg ??= true; - this.#config.llms ??= false; this.#config.base = addTrailingSlash( addLeadingSlash(this.#config.base ?? '/'), ); @@ -154,6 +153,7 @@ export class PluginDriver { ); } } + config.llms ??= false; this.#config = config; this.haveNavSidebarConfig = haveNavSidebarConfig(config); return this.#config; diff --git a/packages/core/src/node/route/extractPageData.test.ts b/packages/core/src/node/route/extractPageData.test.ts index f1d376a19..f8e1425f0 100644 --- a/packages/core/src/node/route/extractPageData.test.ts +++ b/packages/core/src/node/route/extractPageData.test.ts @@ -162,6 +162,10 @@ describe('getPageIndexInfoByRoute', async () => { ); expect(pageIndexInfo).toMatchInlineSnapshot(` { + "_deps": [ + "/packages/core/src/node/route/fixtures/recursive/Comp-in-comp.mdx", + "/packages/core/src/node/route/fixtures/recursive/Comp.mdx", + ], "_filepath": "/packages/core/src/node/route/fixtures/recursive/index.mdx", "_flattenContent": "# Recursive comp test diff --git a/packages/core/src/node/route/extractPageData.ts b/packages/core/src/node/route/extractPageData.ts index d64e21dac..cc62bbef5 100644 --- a/packages/core/src/node/route/extractPageData.ts +++ b/packages/core/src/node/route/extractPageData.ts @@ -350,7 +350,7 @@ async function getPageIndexInfoByRoute( // 1. Replace rules for frontmatter & content applyReplaceRulesToNestedObject(frontmatter, replaceRules); - const { flattenContent } = await flattenMdxContent( + const { flattenContent, deps } = await flattenMdxContent( applyReplaceRules(contentWithoutFrontMatter, replaceRules), route.absolutePath, alias, @@ -380,6 +380,7 @@ async function getPageIndexInfoByRoute( toc: rawToc.map(item => ({ ...item, charIndex: -1 })), content: '', description: frontmatter.description || extractedDescription || undefined, + ...(deps.length ? { _deps: deps } : {}), _flattenContent: flattenContent, frontmatter: { ...frontmatter, @@ -402,6 +403,7 @@ async function getPageIndexInfoByRoute( // processed markdown content for search index content: processedContent, description: frontmatter.description || extractedDescription || undefined, + ...(deps.length ? { _deps: deps } : {}), _flattenContent: flattenContent, frontmatter: { ...frontmatter, diff --git a/packages/core/src/node/runtimeModule/pageData/createPageData.ts b/packages/core/src/node/runtimeModule/pageData/createPageData.ts index b3d415c71..a53a6c1a5 100644 --- a/packages/core/src/node/runtimeModule/pageData/createPageData.ts +++ b/packages/core/src/node/runtimeModule/pageData/createPageData.ts @@ -106,18 +106,27 @@ export async function createPageData(context: FactoryContext): Promise<{ pages.map(async pageData => pluginDriver.extendPageData(pageData)), ); - const filepaths: string[] = []; + const filepaths = new Set(); const pageData: PageData = { pages: pages.map(page => { // omit some fields for runtime size - const { content: _content, _filepath, _flattenContent, ...rest } = page; - filepaths.push(_filepath); + const { + content: _content, + _deps, + _filepath, + _flattenContent, + ...rest + } = page; + filepaths.add(_filepath); + for (const dep of _deps ?? []) { + filepaths.add(dep); + } return rest; }), }; return { - filepaths, + filepaths: [...filepaths], pageData, searchIndex, indexHashByGroup, diff --git a/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.test.ts b/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.test.ts new file mode 100644 index 000000000..600ed750c --- /dev/null +++ b/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.test.ts @@ -0,0 +1,154 @@ +import { + afterEach, + beforeEach, + describe, + expect, + rs, + test, +} from '@rstest/core'; +import { pluginVirtualModule } from 'rsbuild-plugin-virtual-module'; +import { RuntimeModuleID } from '../types'; +import { createPageData } from './createPageData'; +import { rsbuildPluginDocVM } from './rsbuildPlugin'; + +rs.mock('rsbuild-plugin-virtual-module', () => ({ + pluginVirtualModule: rs.fn(() => ({ name: 'virtual-page-data' })), +})); + +rs.mock('./createPageData', () => ({ + createPageData: rs.fn(), +})); + +type ModifyBundlerChain = ( + chain: { + resolve: { alias: { entries: () => Record } }; + }, + context: { environment: { name: string } }, +) => void | Promise; + +const pageDataResult = { + filepaths: ['/docs/index.md'], + pageData: { pages: [] }, + searchIndex: {}, + indexHashByGroup: {}, +}; + +async function setupPlugin() { + const plugins = await rsbuildPluginDocVM({ + config: {}, + userDocRoot: '/docs', + routeService: {}, + pluginDriver: {}, + } as never); + let modifyBundlerChainCallback: ModifyBundlerChain | undefined; + const processAssets = rs.fn(); + await plugins[0].setup?.({ + modifyBundlerChain(callback: ModifyBundlerChain) { + modifyBundlerChainCallback = callback; + }, + processAssets, + } as never); + + const virtualModuleOptions = rs + .mocked(pluginVirtualModule) + .mock.calls.at(-1)?.[0]; + const renderPageData = + virtualModuleOptions?.virtualModules?.[RuntimeModuleID.PageData]; + if (!modifyBundlerChainCallback || typeof renderPageData !== 'function') { + throw new Error('Failed to initialize page data plugins'); + } + + return { + async configureEnvironment(name: string, alias: Record) { + await modifyBundlerChainCallback( + { resolve: { alias: { entries: () => alias } } }, + { environment: { name } }, + ); + if (!processAssets.mock.calls.length) { + throw new Error('Environment configuration callback did not run'); + } + }, + renderPageData: () => + renderPageData({ addDependency: rs.fn() } as never, {} as never), + }; +} + +describe('page data rsbuild plugin', () => { + const originalNodeEnv = process.env.NODE_ENV; + + beforeEach(() => { + process.env.NODE_ENV = 'development'; + rs.mocked(createPageData).mockResolvedValue(pageDataResult); + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + rs.clearAllMocks(); + }); + + test('uses the first environment alias until the web alias is available', async () => { + const plugin = await setupPlugin(); + await plugin.configureEnvironment('node', { fallback: '/fallback' }); + + await expect(plugin.renderPageData()).resolves.toContain('"pages": []'); + expect(createPageData).toHaveBeenLastCalledWith( + expect.objectContaining({ alias: { fallback: '/fallback' } }), + ); + + await plugin.configureEnvironment('web', { web: '/web' }); + await plugin.renderPageData(); + expect(createPageData).toHaveBeenLastCalledWith( + expect.objectContaining({ alias: { web: '/web' } }), + ); + }); + + test('caches production generation across compiler targets', async () => { + process.env.NODE_ENV = 'production'; + const plugin = await setupPlugin(); + await plugin.configureEnvironment('web', { web: '/web' }); + + await plugin.renderPageData(); + await plugin.renderPageData(); + + expect(createPageData).toHaveBeenCalledTimes(1); + }); + + test('rebuilds production data when the authoritative web alias arrives', async () => { + process.env.NODE_ENV = 'production'; + const plugin = await setupPlugin(); + await plugin.configureEnvironment('node', { fallback: '/fallback' }); + await plugin.renderPageData(); + + await plugin.configureEnvironment('web', { web: '/web' }); + + expect(createPageData).toHaveBeenCalledTimes(2); + expect(createPageData).toHaveBeenLastCalledWith( + expect.objectContaining({ alias: { web: '/web' } }), + ); + }); + + test('regenerates in development and deduplicates concurrent requests', async () => { + const plugin = await setupPlugin(); + await plugin.configureEnvironment('web', { web: '/web' }); + + await Promise.all([plugin.renderPageData(), plugin.renderPageData()]); + expect(createPageData).toHaveBeenCalledTimes(1); + + await plugin.renderPageData(); + expect(createPageData).toHaveBeenCalledTimes(2); + }); + + test('retries a failed production generation', async () => { + process.env.NODE_ENV = 'production'; + rs.mocked(createPageData) + .mockRejectedValueOnce(new Error('generation failed')) + .mockResolvedValueOnce(pageDataResult); + const plugin = await setupPlugin(); + + await expect( + plugin.configureEnvironment('web', { web: '/web' }), + ).rejects.toThrow('generation failed'); + await expect(plugin.renderPageData()).resolves.toContain('"pages": []'); + expect(createPageData).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts b/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts index 08d6d70d1..17859ee8e 100644 --- a/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts +++ b/packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts @@ -1,5 +1,5 @@ import type { RsbuildPlugin } from '@rsbuild/core'; -import type { PageData } from '@rspress/shared'; +import { isProduction } from '@rspress/shared'; import { logger } from '@rspress/shared/logger'; import { pluginVirtualModule } from 'rsbuild-plugin-virtual-module'; import { type FactoryContext, RuntimeModuleID } from '../types'; @@ -11,48 +11,98 @@ export const rsbuildPluginDocVM = async ({ routeService, pluginDriver, }: Omit): Promise => { - const ref: { - pageData: PageData | null; - searchIndex: Record | null; - indexHashByGroup: Record | null; - filepaths: string[]; - } = { - pageData: null, - searchIndex: null, - indexHashByGroup: null, - filepaths: [], + type PageDataResult = Awaited>; + type RefreshMode = 'compiler' | 'virtual-module'; + const pageDataState: { + alias?: Record; + generation?: { revision: number; promise: Promise }; + result?: PageDataResult; + revision: number; + } = { revision: 0 }; + + const setPageDataAlias = ( + alias: Record, + source: 'fallback' | 'web', + ) => { + if ( + (pageDataState.alias && source === 'fallback') || + pageDataState.alias === alias + ) { + return; + } + pageDataState.alias = alias; + pageDataState.generation = undefined; + pageDataState.revision += 1; + }; + + const refreshPageData = async (mode: RefreshMode) => { + const alias = pageDataState.alias; + if (!alias) { + return; + } + const revision = pageDataState.revision; + if (pageDataState.generation?.revision !== revision) { + const now = performance.now(); + pageDataState.generation = { + revision, + promise: createPageData({ + config, + alias, + userDocRoot, + routeService, + pluginDriver, + }).then(result => { + logger.debug(`createPageData cost: ${performance.now() - now}ms`); + return result; + }), + }; + } + const generation = pageDataState.generation; + try { + const result = await generation.promise; + if (pageDataState.revision === revision) { + pageDataState.result = result; + } + } catch (error) { + if (pageDataState.generation === generation) { + pageDataState.generation = undefined; + } + throw error; + } finally { + if ( + mode === 'virtual-module' && + !isProduction() && + pageDataState.generation === generation + ) { + pageDataState.generation = undefined; + } + } }; + const searchIndexRsbuildPlugin: RsbuildPlugin = { name: 'rsbuild-plugin-searchIndex', async setup(api) { api.modifyBundlerChain(async (bundlerChain, { environment }) => { - const alias = bundlerChain.resolve.alias.entries(); + const alias = bundlerChain.resolve.alias.entries() as Record< + string, + string + >; + setPageDataAlias( + alias, + environment.name === 'web' ? 'web' : 'fallback', + ); if (environment.name === 'web') { - const now = performance.now(); - const { pageData, indexHashByGroup, searchIndex, filepaths } = - await createPageData({ - config, - alias: alias as Record, - userDocRoot, - routeService, - pluginDriver, - }); - logger.debug(`createPageData cost: ${performance.now() - now}ms`); - - ref.pageData = pageData; - ref.searchIndex = searchIndex; - ref.indexHashByGroup = indexHashByGroup; - ref.filepaths = filepaths; + await refreshPageData('compiler'); } api.processAssets( { stage: 'report', environments: ['web'] }, ({ compilation, compiler }) => { - if (!ref.searchIndex) { + if (!pageDataState.result) { return; } for (const [filename, stringifiedIndex] of Object.entries( - ref.searchIndex, + pageDataState.result.searchIndex, )) { compilation.emitAsset( `static/${filename}`, @@ -71,14 +121,13 @@ export const rsbuildPluginDocVM = async ({ tempDir: '.rspress', virtualModules: { [RuntimeModuleID.PageData]: async ({ addDependency }) => { - // TODO: support hmr - // This place needs to obtain the specific file that has been modified and update the file information. - for (const file of ref.filepaths) { + await refreshPageData('virtual-module'); + for (const file of pageDataState.result?.filepaths ?? []) { addDependency(file); } - return `export const pageData = ${JSON.stringify(ref.pageData, null, 2)}; - export const searchIndexHash = ${JSON.stringify(ref.indexHashByGroup, null, 2)};`; + return `export const pageData = ${JSON.stringify(pageDataState.result?.pageData ?? null, null, 2)}; + export const searchIndexHash = ${JSON.stringify(pageDataState.result?.indexHashByGroup ?? null, null, 2)};`; }, }, }), diff --git a/packages/core/src/node/runtimeModule/siteData/createSiteData.test.ts b/packages/core/src/node/runtimeModule/siteData/createSiteData.test.ts new file mode 100644 index 000000000..529c54827 --- /dev/null +++ b/packages/core/src/node/runtimeModule/siteData/createSiteData.test.ts @@ -0,0 +1,35 @@ +import type { UserConfig } from '@rspress/shared'; +import { describe, expect, test } from '@rstest/core'; +import { createSiteData } from './createSiteData'; + +describe('createSiteData', () => { + test('preserves the default local search configuration', async () => { + const { siteData } = await createSiteData({}); + + expect(siteData.search).toEqual({ + mode: 'local', + searchHooks: undefined, + }); + }); + + test('preserves disabled search in runtime site data', async () => { + const { siteData } = await createSiteData({ search: false }); + + expect(siteData.search).toBe(false); + }); + + test('removes search hooks without mutating user config', async () => { + const search = { + mode: 'local', + searchHooks: '/absolute/search-hooks.ts', + } satisfies NonNullable; + + const { siteData } = await createSiteData({ search }); + + expect(siteData.search).toEqual({ + mode: 'local', + searchHooks: undefined, + }); + expect(search.searchHooks).toBe('/absolute/search-hooks.ts'); + }); +}); diff --git a/packages/core/src/node/runtimeModule/siteData/createSiteData.ts b/packages/core/src/node/runtimeModule/siteData/createSiteData.ts index b4bbe0841..b9c3be788 100644 --- a/packages/core/src/node/runtimeModule/siteData/createSiteData.ts +++ b/packages/core/src/node/runtimeModule/siteData/createSiteData.ts @@ -5,13 +5,15 @@ import { normalizeThemeConfig } from './normalizeThemeConfig'; export async function createSiteData(userConfig: UserConfig): Promise<{ siteData: Omit; }> { - // prevent modify the origin config object - const tempSearchObj = Object.assign({}, userConfig.search); - - // searchHooks is a absolute path which may leak information - if (tempSearchObj) { - tempSearchObj.searchHooks = undefined; - } + const search = + userConfig.search === false + ? false + : { + mode: 'local' as const, + ...userConfig.search, + // searchHooks is an absolute path which may leak information + searchHooks: undefined, + }; const siteData: Omit = { base: userConfig.base ?? '/', @@ -33,7 +35,7 @@ export async function createSiteData(userConfig: UserConfig): Promise<{ default: userConfig?.multiVersion?.default || '', versions: userConfig?.multiVersion?.versions || [], }, - search: tempSearchObj ?? { mode: 'local' }, + search, markdown: { showLineNumbers: userConfig?.markdown?.showLineNumbers ?? false, defaultWrapCode: userConfig?.markdown?.defaultWrapCode ?? false, diff --git a/packages/core/src/theme/components/Link/useLinkNavigate.test.ts b/packages/core/src/theme/components/Link/useLinkNavigate.test.ts new file mode 100644 index 000000000..af0664ab7 --- /dev/null +++ b/packages/core/src/theme/components/Link/useLinkNavigate.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, rs, test } from '@rstest/core'; + +rs.mock('@rspress/core/runtime', () => ({ + cleanUrlByConfig: (href: string) => href.replace(/\.html(?=[?#]|$)/, ''), + isExternalUrl: (href: string) => /^https?:\/\//.test(href), + removeBase: (href: string) => href.replace(/^\/docs(?=\/|$)/, '') || '/', + withBase: (href: string) => + href.startsWith('/docs') ? href : `/docs${href}`, +})); + +rs.mock('nprogress', () => ({ + default: { configure() {} }, +})); + +import { getAwaitedTarget } from './useLinkNavigate'; + +describe('getAwaitedTarget', () => { + test('matches the canonical router target', () => { + expect(getAwaitedTarget('/docs/guide.html?tab=api#types', '/current')).toBe( + '/guide?tab=api#types', + ); + }); + + test('resolves hash-only links against the current target', () => { + expect(getAwaitedTarget('#types', '/guide?tab=api#intro')).toBe( + '/guide?tab=api#types', + ); + }); +}); diff --git a/packages/core/src/theme/components/Link/useLinkNavigate.ts b/packages/core/src/theme/components/Link/useLinkNavigate.ts index 374236929..d9dea1c8d 100644 --- a/packages/core/src/theme/components/Link/useLinkNavigate.ts +++ b/packages/core/src/theme/components/Link/useLinkNavigate.ts @@ -16,10 +16,20 @@ import { startTransition as reactStartTransition, type TransitionStartFunction, useCallback, + useEffect, + useRef, } from 'react'; nprogress.configure({ showSpinner: false }); +const NAVIGATION_TIMEOUT_MS = 10_000; + +interface PendingNavigation { + cancel(error: Error): void; + resolve(): void; + target: string; +} + function isAbsoluteUrl(url: string): boolean { return url.startsWith('/'); } @@ -54,7 +64,8 @@ export function getHref(href: string): { } if (linkType === 'relative' && !import.meta.env.SSR) { - withBaseHref = new URL(href, window.location.href).pathname; + const url = new URL(href, window.location.href); + withBaseHref = `${url.pathname}${url.search}${url.hash}`; } else { withBaseHref = withBase(cleanUrlByConfig(href)); } @@ -63,6 +74,13 @@ export function getHref(href: string): { return { withBaseHref, removeBaseHref, linkType }; } +export function getAwaitedTarget(href: string, currentTarget: string): string { + const { linkType, removeBaseHref } = getHref(href); + return linkType === 'hashOnly' + ? `${currentTarget.split('#')[0]}${href}` + : removeBaseHref; +} + /** * For import { Link } from '@rspress/core/theme'; * useNavigate with preload logic @@ -73,14 +91,15 @@ export function useLinkNavigate( }: { startTransition?: TransitionStartFunction } = { startTransition: reactStartTransition, }, -): (href: string) => Promise { +): (href: string, options?: { signal?: AbortSignal }) => Promise { const { pathname: currPagePathname } = useLocation(); const navigate = useNavigateInner(); const { site } = useSite(); const useTransitions = site?.route?.useTransitions; return useCallback( - async (href: string) => { + async (href: string, { signal }: { signal?: AbortSignal } = {}) => { + signal?.throwIfAborted(); const { linkType, removeBaseHref, withBaseHref } = getHref(href); if (linkType === 'external' || linkType === 'hashOnly') { window.location.assign(href); @@ -97,15 +116,21 @@ export function useLinkNavigate( const timer = setTimeout(() => { nprogress.start(); }, 200); - const data = await initPageData(removeBaseHref); - warmPageData(removeBaseHref, data); - clearTimeout(timer); - nprogress.done(); + try { + const data = await initPageData(removeBaseHref); + signal?.throwIfAborted(); + warmPageData(removeBaseHref, data); + } finally { + clearTimeout(timer); + nprogress.done(); + } } else { + signal?.throwIfAborted(); window.location.assign(withBaseHref); return; } } + signal?.throwIfAborted(); if (isTransitionable) { startTransition(() => { return navigate(removeBaseHref, { replace: false }); @@ -115,12 +140,115 @@ export function useLinkNavigate( } }; - if (isTransitionable) { - startTransition(preloadChunkThenNavigate); - } else { - preloadChunkThenNavigate(); - } + await preloadChunkThenNavigate(); }, [useTransitions, currPagePathname, navigate, startTransition], ); } + +/** + * Navigate through the Rspress router and resolve after the target location + * commits. Calls are serialized and failed or timed-out attempts do not + * block later calls. + */ +export function useAwaitedLinkNavigate( + committedTarget?: string, +): (href: string) => Promise { + const navigate = useLinkNavigate(); + const { pathname, search, hash } = useLocation(); + const currentTarget = + committedTarget ?? `${removeBase(pathname)}${search}${hash}`; + const currentTargetRef = useRef(currentTarget); + const activeRef = useRef(true); + const pendingRef = useRef(null); + const queueRef = useRef>(Promise.resolve()); + + useEffect(() => { + currentTargetRef.current = currentTarget; + const pending = pendingRef.current; + if (pending?.target === currentTarget) { + pendingRef.current = null; + pending.resolve(); + } + }, [currentTarget]); + + useEffect(() => { + activeRef.current = true; + return () => { + activeRef.current = false; + pendingRef.current?.cancel(new Error('Navigation was interrupted')); + pendingRef.current = null; + }; + }, []); + + const navigateAndWait = useCallback( + async (href: string, target: string) => { + if (currentTargetRef.current === target) { + await navigate(href); + return; + } + + let pending!: PendingNavigation; + const controller = new AbortController(); + const completion = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.cancel( + new Error( + `Navigation did not complete within ${NAVIGATION_TIMEOUT_MS}ms`, + ), + ); + }, NAVIGATION_TIMEOUT_MS); + pending = { + cancel(error) { + clearTimeout(timeout); + controller.abort(error); + reject(error); + }, + resolve() { + clearTimeout(timeout); + resolve(); + }, + target, + }; + pendingRef.current = pending; + }); + + try { + await Promise.all([ + navigate(href, { signal: controller.signal }), + completion, + ]); + } catch (error) { + pending.cancel( + error instanceof Error + ? error + : new Error('Navigation failed', { cause: error }), + ); + if (pendingRef.current === pending) { + pendingRef.current = null; + } + throw error; + } + }, + [navigate], + ); + + return useCallback( + (target: string) => { + const queued = queueRef.current + .catch(() => undefined) + .then(() => { + if (!activeRef.current) { + throw new Error('Navigation was interrupted'); + } + return navigateAndWait( + target, + getAwaitedTarget(target, currentTargetRef.current), + ); + }); + queueRef.current = queued; + return queued; + }, + [navigateAndWait], + ); +} diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts index 145d98318..2876f3e07 100644 --- a/packages/core/src/theme/index.ts +++ b/packages/core/src/theme/index.ts @@ -29,7 +29,10 @@ export { HoverGroup, type HoverGroupProps } from './components/HoverGroup'; export { useHoverGroup } from './components/HoverGroup/useHoverGroup'; export { LastUpdated } from './components/LastUpdated/index'; export { Link, type LinkProps } from './components/Link/index'; -export { useLinkNavigate } from './components/Link/useLinkNavigate'; +export { + useAwaitedLinkNavigate, + useLinkNavigate, +} from './components/Link/useLinkNavigate'; export { LlmsContainer, type LlmsContainerProps, @@ -125,6 +128,13 @@ export { copyToClipboard } from './logic/copyToClipboard'; export { getCopyableText } from './logic/getCopyableText'; // logic export { mergeRefs } from './logic/mergeRefs'; +export { + registerSearchProvider, + type SearchProvider, + type SearchResultGroup, + useSearchProvider, +} from './logic/searchProvider'; +export { useDocsSearch } from './logic/useDocsSearch'; export { useFullTextSearch } from './logic/useFullTextSearch'; export { usePrevNextPage } from './logic/usePrevNextPage'; export { useScrollAfterNav } from './logic/useScrollAfterNav'; diff --git a/packages/core/src/theme/logic/searchProvider.test.ts b/packages/core/src/theme/logic/searchProvider.test.ts new file mode 100644 index 000000000..6fb69a635 --- /dev/null +++ b/packages/core/src/theme/logic/searchProvider.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, test } from '@rstest/core'; +import { + getSearchProvider, + registerSearchProvider, + type SearchProvider, +} from './searchProvider'; + +const cleanups: Array<() => void> = []; + +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) { + cleanup(); + } +}); + +function provider(name: string): SearchProvider { + return { + search: async () => [{ group: name, result: [] }], + }; +} + +describe('search providers', () => { + test('uses the most recently registered provider', () => { + const first = provider('first'); + const second = provider('second'); + const unregisterFirst = registerSearchProvider(first); + const unregisterSecond = registerSearchProvider(second); + cleanups.push(unregisterFirst, unregisterSecond); + + expect(getSearchProvider()).toBe(second); + unregisterSecond(); + expect(getSearchProvider()).toBe(first); + }); + + test('supports idempotent cleanup', () => { + const searchProvider = provider('provider'); + const unregister = registerSearchProvider(searchProvider); + cleanups.push(unregister); + + unregister(); + unregister(); + expect(getSearchProvider()).toBeUndefined(); + }); +}); diff --git a/packages/core/src/theme/logic/searchProvider.ts b/packages/core/src/theme/logic/searchProvider.ts new file mode 100644 index 000000000..98a1d5e2d --- /dev/null +++ b/packages/core/src/theme/logic/searchProvider.ts @@ -0,0 +1,50 @@ +import { useSyncExternalStore } from 'react'; + +export interface SearchResultGroup { + group: string; + result: TResult; +} + +export interface SearchProvider { + search: (query: string, limit?: number) => Promise; +} + +const providers: SearchProvider[] = []; +const listeners = new Set<() => void>(); + +export function getSearchProvider(): SearchProvider | undefined { + return providers.at(-1); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function notify() { + for (const listener of listeners) { + listener(); + } +} + +export function registerSearchProvider(provider: SearchProvider): () => void { + providers.push(provider); + notify(); + + return () => { + const index = providers.lastIndexOf(provider); + if (index === -1) { + return; + } + + const wasActive = index === providers.length - 1; + providers.splice(index, 1); + if (wasActive) { + notify(); + } + }; +} + +export function useSearchProvider(): SearchProvider | undefined { + return useSyncExternalStore(subscribe, getSearchProvider, getSearchProvider); +} diff --git a/packages/core/src/theme/logic/useDocsSearch.test.ts b/packages/core/src/theme/logic/useDocsSearch.test.ts new file mode 100644 index 000000000..8273bd7a9 --- /dev/null +++ b/packages/core/src/theme/logic/useDocsSearch.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, rs, test } from '@rstest/core'; +import { useFullTextSearch } from './useFullTextSearch'; +import { useDocsSearch } from './useDocsSearch'; +import { useSearchProvider } from './searchProvider'; + +rs.mock('./useFullTextSearch', () => ({ + useFullTextSearch: rs.fn(), +})); + +rs.mock('./searchProvider', () => ({ + useSearchProvider: rs.fn(), +})); + +const mockUseFullTextSearch = rs.mocked(useFullTextSearch); +const mockUseSearchProvider = rs.mocked(useSearchProvider); + +beforeEach(() => { + mockUseSearchProvider.mockReturnValue(undefined); + mockUseFullTextSearch.mockReturnValue({ + initialized: false, + search: undefined, + }); +}); + +describe('useDocsSearch', () => { + test('falls back to local search', () => { + const search = rs.fn(); + mockUseFullTextSearch.mockReturnValue({ initialized: true, search }); + + expect(useDocsSearch()).toEqual({ initialized: true, search }); + }); + + test('prefers a registered provider', () => { + const search = rs.fn(); + mockUseSearchProvider.mockReturnValue({ search }); + + expect(useDocsSearch()).toEqual({ initialized: true, search }); + }); + + test('is unavailable without any search provider', () => { + expect(useDocsSearch()).toEqual({ + initialized: false, + search: undefined, + }); + }); +}); diff --git a/packages/core/src/theme/logic/useDocsSearch.ts b/packages/core/src/theme/logic/useDocsSearch.ts new file mode 100644 index 000000000..8c6ee4aac --- /dev/null +++ b/packages/core/src/theme/logic/useDocsSearch.ts @@ -0,0 +1,16 @@ +import { useFullTextSearch } from './useFullTextSearch'; +import { type SearchProvider, useSearchProvider } from './searchProvider'; + +type DocsSearchState = + | { initialized: false; search: undefined } + | { initialized: true; search: SearchProvider['search'] }; + +export function useDocsSearch(): DocsSearchState { + const provider = useSearchProvider(); + const localSearch = useFullTextSearch(); + + if (provider) { + return { initialized: true, search: provider.search }; + } + return localSearch; +} diff --git a/packages/core/src/theme/logic/useFullTextSearch.ts b/packages/core/src/theme/logic/useFullTextSearch.ts index 93c592c40..13c38b27c 100644 --- a/packages/core/src/theme/logic/useFullTextSearch.ts +++ b/packages/core/src/theme/logic/useFullTextSearch.ts @@ -1,38 +1,59 @@ import { usePageData } from '@rspress/core/runtime'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { PageSearcher } from '../components/Search/logic/search'; import type { MatchResult } from '../components/Search/logic/types'; -export function useFullTextSearch(): { - initialized: boolean; - search: (keyword: string, limit?: number) => Promise; -} { +type Search = (keyword: string, limit?: number) => Promise; + +type FullTextSearchState = + | { initialized: false; search: undefined } + | { initialized: true; search: Search }; + +export function useFullTextSearch(): FullTextSearchState { const { siteData, page } = usePageData(); - const [initialized, setInitialized] = useState(false); - const searchRef = useRef(null); + const searchOptions = siteData.search; + const versionedSearch = + searchOptions !== false && (searchOptions.versioned ?? true); + const currentVersion = versionedSearch ? page.version : ''; + const searcher = useMemo( + () => + searchOptions === false + ? null + : new PageSearcher({ + ...searchOptions, + mode: 'local', + currentLang: page.lang, + currentVersion, + }), + [searchOptions, page.lang, currentVersion], + ); + const [initializedSearcher, setInitializedSearcher] = + useState(null); useEffect(() => { - async function init() { - if (!initialized) { - const searcher = new PageSearcher({ - ...siteData.search, - mode: 'local', - currentLang: page.lang, - currentVersion: page.version, - }); - searchRef.current = searcher; - await searcher.init(); - setInitialized(true); - } + setInitializedSearcher(null); + if (!searcher) { + return; } - init(); - }, []); + + let active = true; + void searcher.init().then(() => { + if (active) { + setInitializedSearcher(searcher); + } + }); + + return () => { + active = false; + }; + }, [searcher]); + + if (initializedSearcher !== searcher || !searcher) { + return { initialized: false, search: undefined }; + } return { - initialized, - search: searchRef.current?.match.bind(searchRef.current) as ( - keyword: string, - limit?: number, - ) => Promise, + initialized: true, + search: searcher.match.bind(searcher), }; } diff --git a/packages/plugin-algolia/package.json b/packages/plugin-algolia/package.json index 936ce5c7c..4148d6d03 100644 --- a/packages/plugin-algolia/package.json +++ b/packages/plugin-algolia/package.json @@ -40,7 +40,8 @@ }, "dependencies": { "@docsearch/css": "^4.7.0", - "@docsearch/react": "^4.7.0" + "@docsearch/react": "^4.7.0", + "algoliasearch": "^5.50.0" }, "devDependencies": { "@microsoft/api-extractor": "^7.58.12", diff --git a/packages/plugin-algolia/src/runtime/Search.tsx b/packages/plugin-algolia/src/runtime/Search.tsx index 87df53623..a746a5413 100644 --- a/packages/plugin-algolia/src/runtime/Search.tsx +++ b/packages/plugin-algolia/src/runtime/Search.tsx @@ -1,11 +1,23 @@ -import type { DocSearchProps } from '@docsearch/react'; +import type { + DocSearchProps, + DocSearchTransformClient, +} from '@docsearch/react'; import { DocSearch } from '@docsearch/react'; -import { removeBase, safePreconnect, useLang } from '@rspress/core/runtime'; -import { Link, useLinkNavigate } from '@rspress/core/theme'; +import { safePreconnect, useLang } from '@rspress/core/runtime'; +import { + Link, + registerSearchProvider, + useLinkNavigate, +} from '@rspress/core/theme'; +import { liteClient } from 'algoliasearch/lite'; import '@docsearch/css'; import './Search.css'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import type { Locales } from './locales'; +import { + createAlgoliaSearchProvider, + normalizeDocSearchItems, +} from './searchProvider'; const Hit: DocSearchProps['hitComponent'] = ({ hit, children }) => { return {children}; @@ -26,6 +38,25 @@ function Search({ locales = {}, docSearchProps }: SearchProps) { const { translations, placeholder } = locales?.[lang] ?? {}; const appId = docSearchProps.appId; + const searchClient = useMemo(() => { + const client = liteClient(docSearchProps.appId, docSearchProps.apiKey); + return (docSearchProps.transformSearchClient?.(client) ?? + client) as DocSearchTransformClient; + }, [ + docSearchProps.appId, + docSearchProps.apiKey, + docSearchProps.transformSearchClient, + ]); + const searchProvider = useMemo( + () => createAlgoliaSearchProvider(docSearchProps, searchClient), + [docSearchProps, searchClient], + ); + + useEffect( + () => (searchProvider ? registerSearchProvider(searchProvider) : undefined), + [searchProvider], + ); + if (appId) { if (typeof safePreconnect === 'function') { safePreconnect(`https://${appId}-dsn.algolia.net`, { crossOrigin: '' }); @@ -61,18 +92,12 @@ function Search({ locales = {}, docSearchProps }: SearchProps) { navigate(itemUrl); }, }} - transformItems={(items: any[]) => { - return items.map(item => { - const url = new URL(item.url); - return { - ...item, - // we already have basename, so pass the url without base to Link and navigate - url: removeBase(item.url.replace(url.origin, '')), - }; - }); - }} hitComponent={Hit} {...docSearchProps} + transformItems={ + docSearchProps.transformItems ?? normalizeDocSearchItems + } + transformSearchClient={() => searchClient} /> ); diff --git a/packages/plugin-algolia/src/runtime/searchProvider.ts b/packages/plugin-algolia/src/runtime/searchProvider.ts new file mode 100644 index 000000000..62b3daa8d --- /dev/null +++ b/packages/plugin-algolia/src/runtime/searchProvider.ts @@ -0,0 +1,74 @@ +import type { + DocSearchHit, + DocSearchIndex, + DocSearchProps, + DocSearchTransformClient, +} from '@docsearch/react'; +import { removeBase } from '@rspress/core/runtime'; +import type { SearchProvider } from '@rspress/core/theme'; +import type { SearchResponses } from 'algoliasearch/lite'; + +function getIndices({ + indexName, + indices, + searchParameters, +}: DocSearchProps): DocSearchIndex[] { + if (indices?.length) { + return indices.map(index => + typeof index === 'string' + ? { name: index, searchParameters } + : { + ...index, + searchParameters: { + ...searchParameters, + ...index.searchParameters, + }, + }, + ); + } + return indexName ? [{ name: indexName, searchParameters }] : []; +} + +export function normalizeDocSearchItems(items: DocSearchHit[]): DocSearchHit[] { + return items.map(item => { + const url = new URL(item.url); + return { + ...item, + url: removeBase(item.url.replace(url.origin, '')), + }; + }); +} + +export function createAlgoliaSearchProvider( + docSearchProps: DocSearchProps, + searchClient: DocSearchTransformClient, +): SearchProvider | undefined { + const indices = getIndices(docSearchProps); + if (!indices.length) { + return undefined; + } + + const transformItems = + docSearchProps.transformItems ?? normalizeDocSearchItems; + const defaultLimit = docSearchProps.maxResultsPerGroup ?? 20; + + return { + async search(query, limit = defaultLimit) { + const response: SearchResponses = + await searchClient.search({ + requests: indices.map(index => ({ + ...index.searchParameters, + indexName: index.name, + query, + hitsPerPage: limit, + })), + }); + + return response.results.map((result, index) => ({ + group: indices[index].name, + result: + 'hits' in result ? transformItems(result.hits as DocSearchHit[]) : [], + })); + }, + }; +} diff --git a/packages/plugin-algolia/tests/searchProvider.test.ts b/packages/plugin-algolia/tests/searchProvider.test.ts new file mode 100644 index 000000000..a8bf6286e --- /dev/null +++ b/packages/plugin-algolia/tests/searchProvider.test.ts @@ -0,0 +1,121 @@ +import type { + DocSearchHit, + DocSearchProps, + DocSearchTransformClient, +} from '@docsearch/react'; +import { describe, expect, rs, test } from '@rstest/core'; +import { createAlgoliaSearchProvider } from '../src/runtime/searchProvider'; + +rs.mock('@rspress/core/runtime', () => ({ + removeBase: (url: string) => url.replace('/docs', ''), +})); + +function hit(url: string): DocSearchHit { + return { + objectID: url, + content: 'Search content', + url, + url_without_anchor: url, + type: 'content', + anchor: null, + hierarchy: { + lvl0: 'Guide', + lvl1: 'Search', + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + _highlightResult: {} as DocSearchHit['_highlightResult'], + _snippetResult: {} as DocSearchHit['_snippetResult'], + }; +} + +function setup(props: Partial) { + const search = rs.fn().mockResolvedValue({ + results: [{ hits: [hit('https://example.com/docs/guide#search')] }], + }); + const client = { search } as unknown as DocSearchTransformClient; + const provider = createAlgoliaSearchProvider( + { appId: 'app', apiKey: 'key', ...props }, + client, + ); + return { provider, search }; +} + +describe('Algolia search provider', () => { + test('searches a legacy index and normalizes result URLs', async () => { + const { provider, search } = setup({ + indexName: 'docs', + searchParameters: { facetFilters: ['lang:en'] }, + }); + + await expect(provider?.search('routing', 5)).resolves.toEqual([ + { + group: 'docs', + result: [expect.objectContaining({ url: '/guide#search' })], + }, + ]); + expect(search).toHaveBeenCalledWith({ + requests: [ + { + facetFilters: ['lang:en'], + indexName: 'docs', + query: 'routing', + hitsPerPage: 5, + }, + ], + }); + }); + + test('supports multiple indices and custom item transforms', async () => { + const transformItems = rs.fn((items: DocSearchHit[]) => + items.map(item => ({ ...item, content: 'transformed' })), + ); + const { provider, search } = setup({ + indices: [ + 'main', + { name: 'api', searchParameters: { facetFilters: ['kind:api'] } }, + ], + searchParameters: { facetFilters: ['lang:en'], typoTolerance: false }, + maxResultsPerGroup: 8, + transformItems, + }); + search.mockResolvedValueOnce({ + results: [ + { hits: [hit('https://example.com/docs/main')] }, + { hits: [hit('https://example.com/docs/api')] }, + ], + }); + + const results = await provider?.search('config'); + expect(results?.map(result => result.group)).toEqual(['main', 'api']); + expect(results?.[0].result).toEqual([ + expect.objectContaining({ content: 'transformed' }), + ]); + expect(search).toHaveBeenCalledWith({ + requests: [ + { + facetFilters: ['lang:en'], + typoTolerance: false, + indexName: 'main', + query: 'config', + hitsPerPage: 8, + }, + { + facetFilters: ['kind:api'], + typoTolerance: false, + indexName: 'api', + query: 'config', + hitsPerPage: 8, + }, + ], + }); + expect(transformItems).toHaveBeenCalledTimes(2); + }); + + test('does not create a provider without an index', () => { + expect(setup({}).provider).toBeUndefined(); + }); +}); diff --git a/packages/plugin-webmcp/LICENSE b/packages/plugin-webmcp/LICENSE new file mode 100644 index 000000000..82d38c25b --- /dev/null +++ b/packages/plugin-webmcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-present Bytedance, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugin-webmcp/README.md b/packages/plugin-webmcp/README.md new file mode 100644 index 000000000..55333d2aa --- /dev/null +++ b/packages/plugin-webmcp/README.md @@ -0,0 +1,5 @@ +# @rspress/plugin-webmcp + +Expose Rspress documentation and application actions to browser agents through the native [WebMCP API](https://webmachinelearning.github.io/webmcp/). + +[Documentation](https://rspress.rs/plugin/official-plugins/webmcp) diff --git a/packages/plugin-webmcp/package.json b/packages/plugin-webmcp/package.json new file mode 100644 index 000000000..eb962d447 --- /dev/null +++ b/packages/plugin-webmcp/package.json @@ -0,0 +1,62 @@ +{ + "name": "@rspress/plugin-webmcp", + "version": "2.0.17", + "description": "Expose Rspress documentation tools through WebMCP.", + "bugs": "https://github.com/web-infra-dev/rspress/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/web-infra-dev/rspress.git", + "directory": "packages/plugin-webmcp" + }, + "license": "MIT", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "default": "./dist/runtime/index.js" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rslib build", + "dev": "rslib build -w", + "reset": "rimraf ./**/node_modules", + "test:type": "tsc -p tests/tsconfig.json" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@microsoft/api-extractor": "^7.58.12", + "@rsbuild/plugin-react": "~2.1.0", + "@rslib/core": "1.0.0-beta.1", + "@rspress/config": "workspace:*", + "@types/node": "^22.8.1", + "@types/react": "^19.2.18", + "react": "^19.2.8", + "rsbuild-plugin-publint": "^1.0.0", + "typescript": "^6.0.3", + "webmcp-types": "0.1.2" + }, + "peerDependencies": { + "@rspress/core": "workspace:^", + "react": ">=18.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/plugin-webmcp/rslib.config.ts b/packages/plugin-webmcp/rslib.config.ts new file mode 100644 index 000000000..30200f0b4 --- /dev/null +++ b/packages/plugin-webmcp/rslib.config.ts @@ -0,0 +1,54 @@ +import { fileURLToPath } from 'node:url'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rslib/core'; +import { pluginPublint } from 'rsbuild-plugin-publint'; + +const typescriptPath = fileURLToPath(import.meta.resolve('@typescript/native')); + +export default defineConfig({ + plugins: [pluginPublint()], + lib: [ + { + source: { + entry: { + index: './src/index.ts', + }, + }, + dts: { + typescriptPath, + }, + syntax: 'es2023', + redirect: { + dts: { + extension: true, + }, + }, + shims: { + esm: { + __dirname: true, + }, + }, + }, + { + source: { + entry: { + index: './src/runtime/*', + }, + }, + outBase: './src', + bundle: false, + syntax: 'esnext', + plugins: [pluginReact()], + output: { + externals: [ + '@rspress/core/runtime', + '@rspress/core/theme', + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + ], + target: 'web', + }, + }, + ], +}); diff --git a/packages/plugin-webmcp/src/env.d.ts b/packages/plugin-webmcp/src/env.d.ts new file mode 100644 index 000000000..0accf5701 --- /dev/null +++ b/packages/plugin-webmcp/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/plugin-webmcp/src/index.ts b/packages/plugin-webmcp/src/index.ts new file mode 100644 index 000000000..aab3d9300 --- /dev/null +++ b/packages/plugin-webmcp/src/index.ts @@ -0,0 +1,43 @@ +import { join } from 'node:path'; +import type { RspressPlugin, UserConfig } from '@rspress/core'; +import { + normalizePluginWebMcpOptions, + type PluginWebMcpOptions, +} from './options'; + +export type { PluginWebMcpOptions, PluginWebMcpToolsOptions } from './options'; + +export function pluginWebMcp(options?: PluginWebMcpOptions): RspressPlugin { + const runtimeOptions = normalizePluginWebMcpOptions(options); + + const requireSsgMd = (config: UserConfig, isProd: boolean) => { + if ( + (!runtimeOptions.tools.currentPage && !runtimeOptions.tools.getPage) || + !isProd + ) { + return false; + } + if (config.llms === false) { + throw new Error( + '[@rspress/plugin-webmcp] Enabled Markdown page tools require SSG-MD. Remove `llms: false`, enable `llms`, or disable both `currentPage` and `getPage`.', + ); + } + return true; + }; + + return { + name: '@rspress/plugin-webmcp', + config(config, _configUtils, isProd) { + if (requireSsgMd(config, isProd)) { + config.llms ??= true; + } + return config; + }, + beforeBuild(config, isProd) { + requireSsgMd(config, isProd); + }, + globalUIComponents: [ + [join(__dirname, 'runtime/WebMcpRuntime.js'), runtimeOptions], + ], + }; +} diff --git a/packages/plugin-webmcp/src/options.ts b/packages/plugin-webmcp/src/options.ts new file mode 100644 index 000000000..f1c2173a5 --- /dev/null +++ b/packages/plugin-webmcp/src/options.ts @@ -0,0 +1,64 @@ +export interface PluginWebMcpToolsOptions { + /** + * Expose site metadata, locales, versions, navigation, and sidebar. + * @default true + */ + siteInfo?: boolean; + /** + * Expose filtered, paginated page metadata. + * @default true + */ + listPages?: boolean; + /** + * Expose Markdown retrieval for any known route. + * @default true + */ + getPage?: boolean; + /** + * Expose metadata and Markdown for the active route. + * @default true + */ + currentPage?: boolean; + /** + * Expose documentation search when a search provider is available. + * @default true + */ + search?: boolean; + /** + * Expose navigation to known internal routes. + * @default true + */ + navigate?: boolean; +} + +export interface PluginWebMcpOptions { + /** + * Secure cross-origin agents allowed to discover and execute built-in tools. + * Same-origin and browser-integrated agents do not require this option. + */ + exposedTo?: string[]; + tools?: PluginWebMcpToolsOptions; +} + +export interface WebMcpRuntimeOptions { + exposedTo?: string[]; + tools: Required; +} + +export function normalizePluginWebMcpOptions( + options: PluginWebMcpOptions = {}, +): WebMcpRuntimeOptions { + return { + ...(options.exposedTo === undefined + ? {} + : { exposedTo: [...options.exposedTo] }), + tools: { + siteInfo: options.tools?.siteInfo ?? true, + listPages: options.tools?.listPages ?? true, + getPage: options.tools?.getPage ?? true, + currentPage: options.tools?.currentPage ?? true, + search: options.tools?.search ?? true, + navigate: options.tools?.navigate ?? true, + }, + }; +} diff --git a/packages/plugin-webmcp/src/runtime/WebMcpRuntime.tsx b/packages/plugin-webmcp/src/runtime/WebMcpRuntime.tsx new file mode 100644 index 000000000..17827b554 --- /dev/null +++ b/packages/plugin-webmcp/src/runtime/WebMcpRuntime.tsx @@ -0,0 +1,197 @@ +import { + isProduction, + pathnameToRouteService, + removeBase, + routePathToMdPath, + useLang, + useLocation, + useNav, + usePage, + usePages, + useSidebar, + useSite, + useVersion, +} from '@rspress/core/runtime'; +import { + useAwaitedLinkNavigate, + useDocsSearch, + usePrevNextPage, +} from '@rspress/core/theme'; +import { useCallback, useMemo, useRef } from 'react'; +import type { WebMcpRuntimeOptions } from '../options'; +import { + createCurrentPageTool, + createListPagesTool, + createNavigateTool, + createPageTool, + createSearchTool, + createSiteInfoTool, + type NavigatePageContext, + type SearchGroup, +} from './builtins'; +import { isWebMcpSupported } from './register'; +import { useWebMcpTool } from './useWebMcpTool'; + +function resolveRoutePath(pathname: string): string | undefined { + return pathnameToRouteService(removeBase(pathname))?.path; +} + +function createNavigatePageContext( + page: ReturnType['page'], + search: string, + prevPage: ReturnType['prevPage'], + nextPage: ReturnType['nextPage'], +): NavigatePageContext { + const toPageLink = (item: typeof prevPage) => + item?.link ? { title: item.text, routePath: item.link } : null; + return { + page: { + title: page.title, + ...(page.description === undefined + ? {} + : { description: page.description }), + lang: page.lang, + version: page.version, + }, + sections: page.toc.map(section => ({ + title: section.text, + depth: section.depth, + routePath: `${page.routePath}${search}#${section.id}`, + })), + previousPage: toPageLink(prevPage), + nextPage: toPageLink(nextPage), + }; +} + +function useAwaitedNavigate() { + const { page } = usePage(); + const { prevPage, nextPage } = usePrevNextPage(); + const { search, hash } = useLocation(); + const navigate = useAwaitedLinkNavigate(`${page.routePath}${search}${hash}`); + const pageContext = createNavigatePageContext( + page, + search, + prevPage, + nextPage, + ); + const pageContextRef = useRef(pageContext); + pageContextRef.current = pageContext; + return useCallback( + async (target: string) => { + await navigate(target); + return pageContextRef.current; + }, + [navigate], + ); +} + +interface BuiltInToolProps { + exposedTo?: string[]; +} + +function CurrentPageTool({ exposedTo }: BuiltInToolProps) { + const { page } = usePage(); + const tool = useMemo( + () => createCurrentPageTool(page, routePathToMdPath(page.routePath)), + [page], + ); + useWebMcpTool(tool, { exposedTo }); + return null; +} + +function PageTool({ exposedTo }: BuiltInToolProps) { + const { pages } = usePages(); + const origin = location.origin; + const tool = useMemo( + () => createPageTool(pages, resolveRoutePath, origin, routePathToMdPath), + [pages, origin], + ); + useWebMcpTool(tool, { exposedTo }); + return null; +} + +function SiteInfoTool({ exposedTo }: BuiltInToolProps) { + const { site } = useSite(); + const lang = useLang(); + const version = useVersion(); + const nav = useNav(); + const sidebar = useSidebar(); + const tool = useMemo( + () => + createSiteInfoTool({ + title: site.title, + description: site.description, + base: site.base, + siteOrigin: site.siteOrigin, + lang, + version, + locales: site.locales, + versions: site.multiVersion.versions, + defaultVersion: site.multiVersion.default, + nav, + sidebar, + }), + [site, lang, version, nav, sidebar], + ); + useWebMcpTool(tool, { exposedTo }); + return null; +} + +function ListPagesTool({ exposedTo }: BuiltInToolProps) { + const { pages } = usePages(); + const lang = useLang(); + const version = useVersion(); + const tool = useMemo( + () => createListPagesTool(pages, { lang, version }), + [pages, lang, version], + ); + useWebMcpTool(tool, { exposedTo }); + return null; +} + +function RegisteredSearchTool({ + exposedTo, + search, +}: { + exposedTo?: string[]; + search: (query: string, limit?: number) => Promise; +}) { + useWebMcpTool(createSearchTool(search), { exposedTo }); + return null; +} + +function SearchTool({ exposedTo }: BuiltInToolProps) { + const searchState = useDocsSearch(); + return searchState.initialized ? ( + + ) : null; +} + +function NavigateTool({ exposedTo }: BuiltInToolProps) { + const navigate = useAwaitedNavigate(); + const origin = location.origin; + const tool = createNavigateTool(resolveRoutePath, origin, navigate); + useWebMcpTool(tool, { exposedTo }); + return null; +} + +function SupportedWebMcpRuntime({ exposedTo, tools }: WebMcpRuntimeOptions) { + return ( + <> + {tools.siteInfo ? : null} + {tools.listPages ? : null} + {tools.getPage && isProduction() ? ( + + ) : null} + {tools.currentPage && isProduction() ? ( + + ) : null} + {tools.search ? : null} + {tools.navigate ? : null} + + ); +} + +export default function WebMcpRuntime(options: WebMcpRuntimeOptions) { + return isWebMcpSupported() ? : null; +} diff --git a/packages/plugin-webmcp/src/runtime/builtins.ts b/packages/plugin-webmcp/src/runtime/builtins.ts new file mode 100644 index 000000000..128ecf84e --- /dev/null +++ b/packages/plugin-webmcp/src/runtime/builtins.ts @@ -0,0 +1,344 @@ +import type { NavItem, PageIndexInfo, SidebarData } from '@rspress/core'; +import * as z from 'zod'; +import type { WebMcpInputSchema, WebMcpTool } from './types'; + +export const READ_ONLY_UNTRUSTED_ANNOTATIONS = { + readOnlyHint: true, + untrustedContentHint: true, +} as const; + +const EMPTY_INPUT = z.strictObject({}); +const NON_EMPTY_STRING = z.string().min(1).regex(/\S/); +const LIST_PAGES_INPUT = z.strictObject({ + query: NON_EMPTY_STRING.optional(), + lang: z.string().optional(), + version: z.string().optional(), + limit: z.int().min(1).max(100).optional(), + offset: z.int().min(0).optional(), +}); +const SEARCH_INPUT = z.strictObject({ + query: NON_EMPTY_STRING, + limit: z.int().min(1).max(20).optional(), +}); +const NAVIGATE_INPUT = z.strictObject({ routePath: NON_EMPTY_STRING }); + +const toInputSchema = (schema: z.ZodType): WebMcpInputSchema => + z.toJSONSchema(schema) as WebMcpInputSchema; + +export const EMPTY_INPUT_SCHEMA = toInputSchema(EMPTY_INPUT); + +export const CURRENT_PAGE_INPUT_SCHEMA = EMPTY_INPUT_SCHEMA; + +export const LIST_PAGES_INPUT_SCHEMA = toInputSchema(LIST_PAGES_INPUT); +export const SEARCH_INPUT_SCHEMA = toInputSchema(SEARCH_INPUT); +export const NAVIGATE_INPUT_SCHEMA = toInputSchema(NAVIGATE_INPUT); + +export type RuntimePageInfo = Pick< + PageIndexInfo, + | 'title' + | 'routePath' + | 'description' + | 'lang' + | 'version' + | 'frontmatter' + | 'toc' +>; + +export interface SiteInfo { + title: string; + description: string; + base: string; + siteOrigin: string; + lang: string; + version: string; + locales: { lang: string; label: string }[]; + versions: string[]; + defaultVersion: string; + nav: NavItem[]; + sidebar: SidebarData; +} + +export interface NavigatePageContext { + page: { + title: string; + description?: string; + lang: string; + version: string; + }; + sections: { + title: string; + depth: number; + routePath: string; + }[]; + previousPage: { title: string; routePath: string } | null; + nextPage: { title: string; routePath: string } | null; +} + +function validateToolInput( + input: unknown, + schema: T, +): z.infer { + const value = input === undefined ? {} : input; + const result = schema.safeParse(value); + if (!result.success) { + const issue = result.error.issues[0]; + const location = issue.path.length ? ` at /${issue.path.join('/')}` : ''; + throw new TypeError( + `Invalid WebMCP tool input${location}: ${issue.message}`, + ); + } + return result.data; +} + +function createMarkdownLoader(fetcher: typeof fetch) { + const cache = new Map>(); + + return (page: RuntimePageInfo, markdownUrl: string) => { + const cached = cache.get(markdownUrl); + if (cached) { + return cached; + } + + const request = fetcher(markdownUrl) + .then(async response => { + if (!response.ok) { + throw new Error( + `Failed to fetch Markdown for ${page.routePath}: ${response.status} ${response.statusText}`, + ); + } + const markdown = await response.text(); + const contentType = response.headers.get('content-type'); + if ( + contentType?.toLowerCase().includes('text/html') || + /^\s*(?:])/i.test(markdown) + ) { + throw new Error( + `SSG-MD Markdown is unavailable for ${page.routePath}. Run \`rspress build\` and serve the generated output instead of the development server.`, + ); + } + return markdown; + }) + .catch(error => { + cache.delete(markdownUrl); + throw error; + }); + cache.set(markdownUrl, request); + return request; + }; +} + +function pageMetadata(page: RuntimePageInfo) { + return { + title: page.title, + ...(page.description === undefined + ? {} + : { description: page.description }), + routePath: page.routePath, + lang: page.lang, + version: page.version, + }; +} + +function withMarkdown(page: RuntimePageInfo, markdown: string) { + return { + ...pageMetadata(page), + frontmatter: page.frontmatter, + toc: page.toc, + markdown, + }; +} + +export function createSiteInfoTool(info: SiteInfo): WebMcpTool { + return { + name: 'rspress_get_site_info', + title: 'Get documentation site information', + description: + 'Return Rspress site metadata, locales, versions, navigation, and the active sidebar.', + inputSchema: EMPTY_INPUT_SCHEMA, + annotations: READ_ONLY_UNTRUSTED_ANNOTATIONS, + execute(input) { + validateToolInput(input, EMPTY_INPUT); + return info; + }, + }; +} + +type ListPagesInput = z.infer; + +function pageSearchText(page: RuntimePageInfo) { + return [ + page.title, + page.description, + page.routePath, + ...page.toc.map(item => item.text), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); +} + +function pageSummary(page: RuntimePageInfo) { + return { + ...pageMetadata(page), + toc: page.toc, + }; +} + +export function createListPagesTool( + pages: RuntimePageInfo[], + active: { lang: string; version: string }, +): WebMcpTool { + return { + name: 'rspress_list_pages', + title: 'List documentation pages', + description: + 'List and filter Rspress page metadata for documentation discovery.', + inputSchema: LIST_PAGES_INPUT_SCHEMA, + annotations: READ_ONLY_UNTRUSTED_ANNOTATIONS, + execute(rawInput = {}) { + const input = validateToolInput(rawInput, LIST_PAGES_INPUT); + const { query, lang = active.lang, version = active.version } = input; + const limit = input.limit ?? 50; + const offset = input.offset ?? 0; + const terms = query?.trim().toLowerCase().split(/\s+/) ?? []; + const matches = pages.filter(page => { + if (page.lang !== lang || page.version !== version) { + return false; + } + return ( + terms.length === 0 || + terms.every(term => pageSearchText(page).includes(term)) + ); + }); + return { + pages: matches.slice(offset, offset + limit).map(pageSummary), + total: matches.length, + offset, + limit, + }; + }, + }; +} + +export function createCurrentPageTool( + page: RuntimePageInfo, + markdownUrl: string, + fetcher: typeof fetch = fetch, +): WebMcpTool { + const loadMarkdown = createMarkdownLoader(fetcher); + + return { + name: 'rspress_get_current_page', + title: 'Get current documentation page', + description: + 'Return metadata and generated Markdown for the current Rspress page.', + inputSchema: CURRENT_PAGE_INPUT_SCHEMA, + annotations: READ_ONLY_UNTRUSTED_ANNOTATIONS, + async execute(input) { + validateToolInput(input, EMPTY_INPUT); + return withMarkdown(page, await loadMarkdown(page, markdownUrl)); + }, + }; +} + +export function createPageTool( + pages: RuntimePageInfo[], + resolvePath: (pathname: string) => string | undefined, + origin: string, + getMarkdownUrl: (routePath: string) => string, + fetcher: typeof fetch = fetch, +): WebMcpTool<{ routePath: string }> { + const loadMarkdown = createMarkdownLoader(fetcher); + return { + name: 'rspress_get_page', + title: 'Get documentation page', + description: + 'Return metadata and generated Markdown for a known Rspress route without navigating.', + inputSchema: NAVIGATE_INPUT_SCHEMA, + annotations: READ_ONLY_UNTRUSTED_ANNOTATIONS, + async execute(rawInput) { + const input = validateToolInput(rawInput, NAVIGATE_INPUT); + const resolvedRoute = resolveInternalRoute( + input.routePath, + resolvePath, + origin, + ); + const routePath = new URL(resolvedRoute, origin).pathname; + const page = pages.find(candidate => candidate.routePath === routePath); + if (!page) { + throw new TypeError(`Unknown internal route: ${routePath}`); + } + return withMarkdown( + page, + await loadMarkdown(page, getMarkdownUrl(page.routePath)), + ); + }, + }; +} + +export interface SearchGroup { + group: string; + result: unknown; +} + +export function createSearchTool( + search: (query: string, limit?: number) => Promise, +): WebMcpTool<{ query: string; limit?: number }> { + return { + name: 'rspress_search_docs', + title: 'Search documentation', + description: 'Search the active Rspress documentation index.', + inputSchema: SEARCH_INPUT_SCHEMA, + annotations: READ_ONLY_UNTRUSTED_ANNOTATIONS, + async execute(rawInput) { + const input = validateToolInput(rawInput, SEARCH_INPUT); + return (await search(input.query, input.limit)).map( + ({ group, result }) => ({ group, results: result }), + ); + }, + }; +} + +export function resolveInternalRoute( + routePath: string, + resolvePath: (pathname: string) => string | undefined, + origin: string, +): string { + if (!routePath.startsWith('/') || routePath.startsWith('//')) { + throw new TypeError('routePath must be an absolute internal path'); + } + + const url = new URL(routePath, origin); + if (url.origin !== origin) { + throw new TypeError('routePath must not target an external origin'); + } + + const resolvedPath = resolvePath(url.pathname); + if (!resolvedPath) { + throw new TypeError(`Unknown internal route: ${url.pathname}`); + } + return `${resolvedPath}${url.search}${url.hash}`; +} + +export function createNavigateTool( + resolvePath: (pathname: string) => string | undefined, + origin: string, + navigate: ( + routePath: string, + ) => NavigatePageContext | Promise, +): WebMcpTool<{ routePath: string }> { + return { + name: 'rspress_navigate', + title: 'Navigate documentation', + description: + 'Navigate to a known internal Rspress documentation route and return the rendered page summary, section routes, and adjacent pages.', + inputSchema: NAVIGATE_INPUT_SCHEMA, + annotations: { readOnlyHint: false, untrustedContentHint: true }, + async execute(rawInput) { + const input = validateToolInput(rawInput, NAVIGATE_INPUT); + const target = resolveInternalRoute(input.routePath, resolvePath, origin); + const page = await navigate(target); + return { routePath: target, ...page }; + }, + }; +} diff --git a/packages/plugin-webmcp/src/runtime/index.ts b/packages/plugin-webmcp/src/runtime/index.ts new file mode 100644 index 000000000..8c7520e1f --- /dev/null +++ b/packages/plugin-webmcp/src/runtime/index.ts @@ -0,0 +1,15 @@ +export { registerWebMcpTool } from './register'; +export type { + WebMcpInputSchema, + WebMcpInputSchemaProperty, + WebMcpJsonSchema, + WebMcpModelContext, + WebMcpModelContextClient, + WebMcpTool, + WebMcpToolAnnotations, + WebMcpToolHookState, + WebMcpToolRegistration, + WebMcpToolRegistrationOptions, + WebMcpToolStatus, +} from './types'; +export { useWebMcpTool } from './useWebMcpTool'; diff --git a/packages/plugin-webmcp/src/runtime/register.ts b/packages/plugin-webmcp/src/runtime/register.ts new file mode 100644 index 000000000..62e6fe0b0 --- /dev/null +++ b/packages/plugin-webmcp/src/runtime/register.ts @@ -0,0 +1,52 @@ +import type { + WebMcpModelContext, + WebMcpTool, + WebMcpToolRegistration, + WebMcpToolRegistrationOptions, +} from './types'; + +function getModelContext(): WebMcpModelContext | undefined { + if (typeof document === 'undefined') { + return undefined; + } + return (document as Document & { modelContext?: WebMcpModelContext }) + .modelContext; +} + +export function isWebMcpSupported(): boolean { + return getModelContext() !== undefined; +} + +export function registerWebMcpTool< + TInput extends Record = Record, + TResult = unknown, + TName extends string = string, +>( + tool: WebMcpTool, + options: WebMcpToolRegistrationOptions = {}, +): WebMcpToolRegistration | undefined { + const modelContext = getModelContext(); + if (!modelContext) { + return undefined; + } + + const controller = new AbortController(); + const ready = (async () => { + try { + await modelContext.registerTool(tool, { + signal: controller.signal, + exposedTo: options.exposedTo, + }); + } catch (error) { + controller.abort(); + throw error; + } + })(); + + return { + ready, + unregister() { + controller.abort(); + }, + }; +} diff --git a/packages/plugin-webmcp/src/runtime/types.ts b/packages/plugin-webmcp/src/runtime/types.ts new file mode 100644 index 000000000..599376853 --- /dev/null +++ b/packages/plugin-webmcp/src/runtime/types.ts @@ -0,0 +1,65 @@ +export type WebMcpJsonSchema = Record; + +export type WebMcpInputSchemaProperty = WebMcpJsonSchema | boolean; + +export type WebMcpInputSchema = WebMcpJsonSchema; + +export interface WebMcpToolAnnotations { + title?: string; + readOnlyHint?: boolean; + untrustedContentHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + +/** Optional compatibility client supplied by runtimes that implement it. */ +export interface WebMcpModelContextClient { + requestUserInteraction(callback: () => Promise): Promise; +} + +export interface WebMcpTool< + TInput extends Record = Record, + TResult = unknown, + TName extends string = string, +> { + name: TName; + title?: string; + description: string; + inputSchema?: WebMcpInputSchema; + /** Compatibility extension; not yet part of the native WebMCP draft. */ + outputSchema?: WebMcpJsonSchema; + annotations?: WebMcpToolAnnotations; + execute( + input: TInput, + client?: WebMcpModelContextClient, + ): TResult | Promise; +} + +export interface WebMcpToolRegistrationOptions { + exposedTo?: string[]; +} + +export interface WebMcpToolRegistration { + ready: Promise; + unregister(): void; +} + +export type WebMcpToolStatus = + 'registering' | 'registered' | 'unsupported' | 'error'; + +export interface WebMcpToolHookState { + status: WebMcpToolStatus; + error: Error | null; +} + +export interface WebMcpModelContext { + registerTool< + TInput extends Record, + TResult, + TName extends string, + >( + tool: WebMcpTool, + options?: { signal?: AbortSignal; exposedTo?: string[] }, + ): Promise; +} diff --git a/packages/plugin-webmcp/src/runtime/useWebMcpTool.ts b/packages/plugin-webmcp/src/runtime/useWebMcpTool.ts new file mode 100644 index 000000000..5c389a577 --- /dev/null +++ b/packages/plugin-webmcp/src/runtime/useWebMcpTool.ts @@ -0,0 +1,87 @@ +import { type DependencyList, useEffect, useRef, useState } from 'react'; +import { registerWebMcpTool } from './register'; +import type { + WebMcpTool, + WebMcpToolHookState, + WebMcpToolRegistrationOptions, +} from './types'; + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +export function toDescriptorDependency(value: unknown) { + try { + return { key: JSON.stringify(value), error: null }; + } catch (error) { + const cause = toError(error); + return { + key: `serialization-error:${cause.name}:${cause.message}`, + error: new TypeError( + `WebMCP tool descriptors must be JSON-serializable: ${cause.message}`, + { cause }, + ), + }; + } +} + +export function useWebMcpTool< + TInput extends Record = Record, + TResult = unknown, + TName extends string = string, +>( + tool: WebMcpTool, + options: WebMcpToolRegistrationOptions = {}, + deps: DependencyList = [], +): WebMcpToolHookState { + const toolRef = useRef(tool); + toolRef.current = tool; + const [state, setState] = useState({ + status: 'registering', + error: null, + }); + const { execute: _execute, ...descriptor } = tool; + const registrationDependency = toDescriptorDependency({ + ...descriptor, + options, + }); + + useEffect(() => { + if (registrationDependency.error) { + setState({ status: 'error', error: registrationDependency.error }); + return; + } + const registeredTool: WebMcpTool = { + ...toolRef.current, + execute: (input, client) => toolRef.current.execute(input, client), + }; + const registration = registerWebMcpTool(registeredTool, options); + + if (!registration) { + setState({ status: 'unsupported', error: null }); + return; + } + + setState({ status: 'registering', error: null }); + let active = true; + registration.ready.then( + () => { + if (active) { + setState({ status: 'registered', error: null }); + } + }, + error => { + if (active) { + setState({ status: 'error', error: toError(error) }); + } + }, + ); + + return () => { + active = false; + registration.unregister(); + }; + }, [registrationDependency.key, ...deps]); + + return state; +} diff --git a/packages/plugin-webmcp/tests/builtins.test.ts b/packages/plugin-webmcp/tests/builtins.test.ts new file mode 100644 index 000000000..8bf71f8a2 --- /dev/null +++ b/packages/plugin-webmcp/tests/builtins.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, test } from '@rstest/core'; +import { + createCurrentPageTool, + createListPagesTool, + createNavigateTool, + createPageTool, + createSearchTool, + createSiteInfoTool, + CURRENT_PAGE_INPUT_SCHEMA, + EMPTY_INPUT_SCHEMA, + LIST_PAGES_INPUT_SCHEMA, + NAVIGATE_INPUT_SCHEMA, + READ_ONLY_UNTRUSTED_ANNOTATIONS, + resolveInternalRoute, + SEARCH_INPUT_SCHEMA, +} from '../src/runtime/builtins'; + +const pages = [ + { + title: 'WebMCP home', + description: 'Home description', + routePath: '/', + lang: 'en', + version: 'v1', + frontmatter: {}, + toc: [{ id: 'introduction', text: 'Introduction', depth: 2, charIndex: 0 }], + }, + { + title: 'Tool guide', + routePath: '/guide', + lang: 'en', + version: 'v1', + frontmatter: { sidebar: true }, + toc: [ + { id: 'register-tools', text: 'Register tools', depth: 2, charIndex: 0 }, + ], + }, + { + title: '中文主页', + routePath: '/zh/', + lang: 'zh', + version: 'v1', + frontmatter: {}, + toc: [], + }, +]; + +describe('WebMCP built-in tools', () => { + test('returns structured site information', async () => { + const info = { + title: 'Rspress', + description: 'Static site generator', + base: '/', + siteOrigin: 'https://rspress.dev', + lang: 'en', + version: 'v1', + locales: [{ lang: 'en', label: 'English' }], + versions: ['v1', 'v2'], + defaultVersion: 'v1', + nav: [{ text: 'Guide', link: '/guide' }], + sidebar: [{ text: 'Introduction', link: '/' }], + }; + const tool = createSiteInfoTool(info); + expect(tool.execute({})).toEqual(info); + expect(() => tool.execute(null as never)).toThrow('expected object'); + expect(() => tool.execute({ unexpected: true })).toThrow( + 'Unrecognized key', + ); + expect(() => tool.execute({ toString: true })).toThrow('Unrecognized key'); + expect(tool.inputSchema).toBe(EMPTY_INPUT_SCHEMA); + expect(tool.annotations).toBe(READ_ONLY_UNTRUSTED_ANNOTATIONS); + }); + + test('filters and paginates page metadata', async () => { + const tool = createListPagesTool(pages, { lang: 'en', version: 'v1' }); + expect(tool.execute({ query: 'register tools' })).toEqual({ + pages: [ + expect.objectContaining({ title: 'Tool guide', routePath: '/guide' }), + ], + total: 1, + offset: 0, + limit: 50, + }); + expect(tool.execute({ limit: 1, offset: 1 })).toMatchObject({ + pages: [expect.objectContaining({ title: 'Tool guide' })], + total: 2, + offset: 1, + limit: 1, + }); + expect(tool.execute({ lang: 'zh' })).toMatchObject({ + pages: [expect.objectContaining({ title: '中文主页' })], + total: 1, + }); + expect(() => tool.execute({ query: '' })).toThrow('>=1 characters'); + expect(() => tool.execute({ limit: 101 })).toThrow('<=100'); + expect(() => tool.execute({ offset: -1 })).toThrow('>=0'); + expect(() => tool.execute({ unexpected: true } as never)).toThrow( + 'Unrecognized key', + ); + expect(tool.inputSchema).toBe(LIST_PAGES_INPUT_SCHEMA); + expect(tool.annotations).toBe(READ_ONLY_UNTRUSTED_ANNOTATIONS); + }); + + test('fetches the current page Markdown and metadata', async () => { + let fetchCount = 0; + const fetcher = async (url: string | URL | Request) => { + fetchCount += 1; + expect(String(url)).toBe('/guide.md'); + return new Response('# Guide\n\nMarkdown body.'); + }; + const tool = createCurrentPageTool( + { + title: 'Guide', + description: 'Guide description', + routePath: '/guide.html', + lang: 'en', + version: 'v1', + frontmatter: { sidebar: true }, + toc: [], + }, + '/guide.md', + fetcher, + ); + + await expect(tool.execute({ unexpected: true })).rejects.toThrow( + 'Unrecognized key', + ); + expect(fetchCount).toBe(0); + + const [first, second] = await Promise.all([ + tool.execute({}), + tool.execute({}), + ]); + expect(first).toMatchObject({ + title: 'Guide', + routePath: '/guide.html', + markdown: '# Guide\n\nMarkdown body.', + }); + expect(second).toMatchObject({ + markdown: '# Guide\n\nMarkdown body.', + }); + expect(fetchCount).toBe(1); + expect(tool.inputSchema).toBe(CURRENT_PAGE_INPUT_SCHEMA); + expect(tool.annotations).toBe(READ_ONLY_UNTRUSTED_ANNOTATIONS); + }); + + test('reports Markdown fetch failures', async () => { + let fetchCount = 0; + const tool = createCurrentPageTool( + { + title: 'Missing', + routePath: '/missing', + lang: 'en', + version: '', + frontmatter: {}, + toc: [], + }, + '/missing.md', + async () => { + fetchCount += 1; + return new Response('', { status: 404, statusText: 'Not Found' }); + }, + ); + await expect(tool.execute({})).rejects.toThrow( + 'Failed to fetch Markdown for /missing: 404 Not Found', + ); + await expect(tool.execute({})).rejects.toThrow( + 'Failed to fetch Markdown for /missing: 404 Not Found', + ); + expect(fetchCount).toBe(2); + }); + + test('rejects the development server HTML fallback', async () => { + const tool = createCurrentPageTool( + { + title: 'Home', + routePath: '/', + lang: 'en', + version: '', + frontmatter: {}, + toc: [], + }, + '/index.md', + async () => + new Response('Rspress', { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }), + ); + + await expect(tool.execute({})).rejects.toThrow( + 'SSG-MD Markdown is unavailable for /', + ); + }); + + test('reads a known page without navigation and caches Markdown', async () => { + let fetchCount = 0; + const resolvePath = (pathname: string) => + pathname.replace(/\.html$/, '') === '/guide' ? '/guide' : undefined; + const tool = createPageTool( + pages, + resolvePath, + 'https://rspress.dev', + routePath => `${routePath}.md`, + async url => { + fetchCount += 1; + expect(String(url)).toBe('/guide.md'); + return new Response('# Tool guide'); + }, + ); + const [first, second] = await Promise.all([ + tool.execute({ routePath: '/guide.html?source=mcp#tools' }), + tool.execute({ routePath: '/guide' }), + ]); + expect(first).toMatchObject({ + title: 'Tool guide', + routePath: '/guide', + markdown: '# Tool guide', + }); + expect(second).toEqual(first); + expect(first).not.toHaveProperty('description'); + expect(fetchCount).toBe(1); + expect(tool.inputSchema).toBe(NAVIGATE_INPUT_SCHEMA); + expect(tool.annotations).toBe(READ_ONLY_UNTRUSTED_ANNOTATIONS); + await expect( + tool.execute({ routePath: 'https://example.com/guide' }), + ).rejects.toThrow('absolute internal path'); + await expect(tool.execute({ routePath: '/missing' })).rejects.toThrow( + 'Unknown internal route', + ); + await expect( + tool.execute({ routePath: '/guide', unexpected: true } as never), + ).rejects.toThrow('Unrecognized key'); + }); + + test('retries a failed arbitrary-page Markdown request', async () => { + let fetchCount = 0; + const tool = createPageTool( + pages, + () => '/guide', + 'https://rspress.dev', + () => '/guide.md', + async () => { + fetchCount += 1; + return new Response('', { status: 503, statusText: 'Unavailable' }); + }, + ); + await expect(tool.execute({ routePath: '/guide' })).rejects.toThrow( + '503 Unavailable', + ); + await expect(tool.execute({ routePath: '/guide' })).rejects.toThrow( + '503 Unavailable', + ); + expect(fetchCount).toBe(2); + }); + + test('normalizes local search results and validates inputs', async () => { + const tool = createSearchTool(async (query, limit) => { + expect(query).toBe('webmcp'); + expect(limit).toBe(5); + return [{ group: 'Guide', result: [{ title: 'WebMCP' }] }]; + }); + await expect(tool.execute({ query: 'webmcp', limit: 5 })).resolves.toEqual([ + { group: 'Guide', results: [{ title: 'WebMCP' }] }, + ]); + await expect(tool.execute({ query: '' })).rejects.toThrow('>=1 characters'); + await expect(tool.execute({ query: 'x', limit: 21 })).rejects.toThrow( + '<=20', + ); + await expect( + tool.execute({ query: 'x', unexpected: true } as never), + ).rejects.toThrow('Unrecognized key'); + expect(tool.inputSchema).toBe(SEARCH_INPUT_SCHEMA); + expect(tool.annotations).toBe(READ_ONLY_UNTRUSTED_ANNOTATIONS); + }); + + test('accepts only known internal routes with query and hash', async () => { + const resolvePath = (pathname: string) => { + const normalized = decodeURIComponent(pathname) + .replace(/\.html$/, '') + .replace(/\/index$/, '/') + .replace(/\/$/, '') + .toLowerCase(); + return new Map([ + ['', '/'], + ['/guide', '/guide'], + ['/api', '/api/'], + ]).get(normalized); + }; + expect( + resolveInternalRoute( + '/guide?tab=tools#register', + resolvePath, + 'https://rspress.dev', + ), + ).toBe('/guide?tab=tools#register'); + expect( + resolveInternalRoute( + '/API/index.html?tab=tools#register', + resolvePath, + 'https://rspress.dev', + ), + ).toBe('/api/?tab=tools#register'); + expect(() => + resolveInternalRoute('/unknown', resolvePath, 'https://rspress.dev'), + ).toThrow('Unknown internal route'); + expect(() => + resolveInternalRoute( + 'https://evil.example/guide', + resolvePath, + 'https://rspress.dev', + ), + ).toThrow('absolute internal path'); + expect(() => + resolveInternalRoute( + '//evil.example/guide', + resolvePath, + 'https://rspress.dev', + ), + ).toThrow('absolute internal path'); + + let target = ''; + const pageContext = { + page: { + title: 'API', + description: 'API reference', + lang: 'en', + version: 'v1', + }, + sections: [], + previousPage: { title: 'Guide', routePath: '/guide' }, + nextPage: null, + }; + const tool = createNavigateTool( + resolvePath, + 'https://rspress.dev', + routePath => { + target = routePath; + return pageContext; + }, + ); + await expect(tool.execute({ routePath: '/api#types' })).resolves.toEqual({ + routePath: '/api/#types', + ...pageContext, + }); + await expect( + tool.execute({ routePath: '/api', unexpected: true } as never), + ).rejects.toThrow('Unrecognized key'); + expect(target).toBe('/api/#types'); + expect(tool.inputSchema).toBe(NAVIGATE_INPUT_SCHEMA); + expect(tool.annotations).toEqual({ + readOnlyHint: false, + untrustedContentHint: true, + }); + }); +}); diff --git a/packages/plugin-webmcp/tests/plugin.test.ts b/packages/plugin-webmcp/tests/plugin.test.ts new file mode 100644 index 000000000..efb58fa8a --- /dev/null +++ b/packages/plugin-webmcp/tests/plugin.test.ts @@ -0,0 +1,157 @@ +import type { RspressPlugin, UserConfig } from '@rspress/core'; +import { describe, expect, test } from '@rstest/core'; +import { pluginWebMcp } from '../src'; +import { normalizePluginWebMcpOptions } from '../src/options'; + +const configUtils = { addPlugin() {}, removePlugin() {} }; + +async function runPlugins(plugins: RspressPlugin[], config: UserConfig = {}) { + let resolvedConfig = config; + for (const plugin of plugins) { + resolvedConfig = + (await plugin.config?.(resolvedConfig, configUtils, true)) ?? + resolvedConfig; + } + for (const plugin of plugins) { + await plugin.beforeBuild?.(resolvedConfig, true); + } + return resolvedConfig; +} + +function getRuntimeOptions(plugin: RspressPlugin) { + const component = plugin.globalUIComponents?.[0]; + if (!Array.isArray(component)) { + throw new TypeError('Expected a global component with runtime options'); + } + return component[1] as { + exposedTo?: string[]; + tools: { + siteInfo: boolean; + listPages: boolean; + getPage: boolean; + currentPage: boolean; + search: boolean; + navigate: boolean; + }; + }; +} + +describe('pluginWebMcp', () => { + test('normalizes built-in tools', () => { + expect(normalizePluginWebMcpOptions()).toEqual({ + tools: { + siteInfo: true, + listPages: true, + getPage: true, + currentPage: true, + search: true, + navigate: true, + }, + }); + expect( + normalizePluginWebMcpOptions({ + exposedTo: ['https://agent.example'], + tools: { currentPage: false, navigate: false }, + }), + ).toEqual({ + exposedTo: ['https://agent.example'], + tools: { + siteInfo: true, + listPages: true, + getPage: true, + currentPage: false, + search: true, + navigate: false, + }, + }); + }); + + test('automatically enables SSG-MD', async () => { + const plugin = pluginWebMcp(); + const config = await plugin.config?.({}, configUtils, true); + expect(config?.llms).toBe(true); + expect(plugin.globalUIComponents).toHaveLength(1); + expect(getRuntimeOptions(plugin).tools.currentPage).toBe(true); + }); + + test('does not enable SSG-MD during development', async () => { + const plugin = pluginWebMcp(); + const runtimeOptions = getRuntimeOptions(plugin); + const config = await plugin.config?.({}, configUtils, false); + + expect(config?.llms).toBeUndefined(); + expect(runtimeOptions.tools.currentPage).toBe(true); + }); + + test('keeps requested tools unchanged across the build lifecycle', async () => { + const plugin = pluginWebMcp({ + exposedTo: ['https://agent.example'], + tools: { currentPage: true, search: false, navigate: false }, + }); + const runtimeOptions = getRuntimeOptions(plugin); + + await plugin.config?.({}, configUtils, true); + await plugin.beforeBuild?.({ llms: true, search: false }, true); + + expect(runtimeOptions.tools).toEqual({ + siteInfo: true, + listPages: true, + getPage: true, + currentPage: true, + search: false, + navigate: false, + }); + expect(runtimeOptions.exposedTo).toEqual(['https://agent.example']); + }); + + test.each([true, { remarkSplitMdxOptions: {} }])( + 'preserves an existing llms configuration', + async llms => { + const plugin = pluginWebMcp(); + const config = { llms }; + expect(await plugin.config?.(config, configUtils, false)).toBe(config); + expect(config.llms).toBe(llms); + }, + ); + + test('fails clearly when SSG-MD is explicitly disabled', () => { + const plugin = pluginWebMcp(); + expect(() => plugin.config?.({ llms: false }, configUtils, true)).toThrow( + 'Enabled Markdown page tools require SSG-MD', + ); + }); + + test('requires SSG-MD when only get-page is enabled', () => { + const plugin = pluginWebMcp({ tools: { currentPage: false } }); + expect(() => plugin.config?.({ llms: false }, configUtils, true)).toThrow( + 'Enabled Markdown page tools require SSG-MD', + ); + }); + + test('allows llms false when both Markdown tools are disabled', async () => { + const plugin = pluginWebMcp({ + tools: { currentPage: false, getPage: false }, + }); + const config = { llms: false }; + expect(await plugin.config?.(config, configUtils, true)).toBe(config); + expect(plugin.beforeBuild?.(config, true)).toBeUndefined(); + }); + + test('validates llms after every plugin config hook', async () => { + const webMcp = pluginWebMcp(); + const disableLlms: RspressPlugin = { + name: 'disable-llms', + config(config) { + config.llms = false; + return config; + }, + }; + + await expect(runPlugins([webMcp, disableLlms])).rejects.toThrow( + 'Enabled Markdown page tools require SSG-MD', + ); + await expect(runPlugins([disableLlms, webMcp])).rejects.toThrow( + 'Enabled Markdown page tools require SSG-MD', + ); + }); +}); diff --git a/packages/plugin-webmcp/tests/register.test.ts b/packages/plugin-webmcp/tests/register.test.ts new file mode 100644 index 000000000..e5423c71b --- /dev/null +++ b/packages/plugin-webmcp/tests/register.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test } from '@rstest/core'; +import { registerWebMcpTool } from '../src/runtime/register'; +import type { WebMcpTool } from '../src/runtime/types'; + +const tool: WebMcpTool = { + name: 'test_tool', + title: 'Test tool', + description: 'Test descriptor forwarding.', + inputSchema: { type: 'object' }, + annotations: { readOnlyHint: true }, + execute: () => ({ ok: true }), +}; + +function setDocument(value: unknown) { + Object.defineProperty(globalThis, 'document', { + value, + configurable: true, + }); +} + +afterEach(() => { + Reflect.deleteProperty(globalThis, 'document'); +}); + +describe('registerWebMcpTool', () => { + test('returns undefined in unsupported environments', () => { + expect(registerWebMcpTool(tool)).toBeUndefined(); + }); + + test('forwards the descriptor and exposed origins', async () => { + let registeredTool: WebMcpTool | undefined; + let registeredOptions: + { signal?: AbortSignal; exposedTo?: string[] } | undefined; + setDocument({ + modelContext: { + registerTool( + descriptor: WebMcpTool, + options: { signal?: AbortSignal; exposedTo?: string[] }, + ) { + registeredTool = descriptor; + registeredOptions = options; + return Promise.resolve(); + }, + }, + }); + + const registration = registerWebMcpTool(tool, { + exposedTo: ['https://agent.example'], + }); + await registration?.ready; + expect(registeredTool).toBe(tool); + expect(registeredOptions?.exposedTo).toEqual(['https://agent.example']); + expect(registeredOptions?.signal?.aborted).toBe(false); + + registration?.unregister(); + expect(registeredOptions?.signal?.aborted).toBe(true); + }); + + test.each(['synchronous', 'asynchronous'] as const)( + 'exposes %s registration failures', + async mode => { + const error = new Error(`${mode} failure`); + let signal: AbortSignal | undefined; + setDocument({ + modelContext: { + registerTool(_tool: WebMcpTool, options: { signal?: AbortSignal }) { + signal = options.signal; + if (mode === 'synchronous') { + throw error; + } + return Promise.reject(error); + }, + }, + }); + + await expect(registerWebMcpTool(tool)?.ready).rejects.toBe(error); + expect(signal?.aborted).toBe(true); + }, + ); +}); diff --git a/packages/plugin-webmcp/tests/runtime.test.tsx b/packages/plugin-webmcp/tests/runtime.test.tsx new file mode 100644 index 000000000..bfb646b04 --- /dev/null +++ b/packages/plugin-webmcp/tests/runtime.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, rs, test } from '@rstest/core'; +import WebMcpRuntime from '../src/runtime/WebMcpRuntime'; + +rs.mock('@rspress/core/runtime', () => ({ + pathnameToRouteService: rs.fn(), + removeBase: rs.fn(), + routePathToMdPath: rs.fn(), + useLang: rs.fn(), + useLocation: rs.fn(), + useNav: rs.fn(), + usePage: rs.fn(), + usePages: rs.fn(), + useSidebar: rs.fn(), + useSite: rs.fn(), + useVersion: rs.fn(), +})); + +rs.mock('@rspress/core/theme', () => ({ + useAwaitedLinkNavigate: rs.fn(), + useDocsSearch: rs.fn(), +})); + +describe('WebMcpRuntime', () => { + test('does not mount runtime tools in unsupported environments', () => { + expect( + WebMcpRuntime({ + tools: { + siteInfo: true, + listPages: true, + getPage: true, + currentPage: true, + search: true, + navigate: true, + }, + }), + ).toBeNull(); + }); +}); diff --git a/packages/plugin-webmcp/tests/tsconfig.json b/packages/plugin-webmcp/tests/tsconfig.json new file mode 100644 index 000000000..75d408db2 --- /dev/null +++ b/packages/plugin-webmcp/tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "rootDir": ".." + }, + "include": ["../src", "."] +} diff --git a/packages/plugin-webmcp/tests/types.test.ts b/packages/plugin-webmcp/tests/types.test.ts new file mode 100644 index 000000000..810b63d99 --- /dev/null +++ b/packages/plugin-webmcp/tests/types.test.ts @@ -0,0 +1,73 @@ +/// + +import { describe, expect, test } from '@rstest/core'; +import { registerWebMcpTool } from '../src/runtime/register'; +import type { + WebMcpModelContext, + WebMcpTool, + WebMcpToolAnnotations, + WebMcpToolRegistrationOptions, +} from '../src/runtime/types'; + +type Assert = T; +type IsAssignable = [TSource] extends [TTarget] + ? true + : false; + +const compatibilityAssertions: [ + Assert>, + Assert>, + Assert< + IsAssignable< + WebMcpToolRegistrationOptions, + Omit + > + >, + Assert< + IsAssignable> + >, +] = [true, true, true, true]; + +describe('WebMCP public types', () => { + test('remains structurally compatible with WebMCP types', () => { + expect(compatibilityAssertions).toEqual([true, true, true, true]); + }); + + test('preserves custom input, result, and name generics', () => { + const registration = registerWebMcpTool< + { query: string }, + { result: string }, + 'typed_search' + >({ + name: 'typed_search', + description: 'Exercise the typed registration API.', + inputSchema: { + type: 'object', + properties: { query: { type: ['string', 'null'] } }, + required: ['query'], + }, + outputSchema: { + oneOf: [ + { + type: 'object', + properties: { result: { type: ['string', 'null'] } }, + required: ['result'], + }, + ], + }, + annotations: { + readOnlyHint: true, + untrustedContentHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute(input, client) { + void client?.requestUserInteraction(async () => undefined); + return { result: input.query }; + }, + }); + + expect(registration).toBeUndefined(); + }); +}); diff --git a/packages/plugin-webmcp/tests/useWebMcpTool.test.ts b/packages/plugin-webmcp/tests/useWebMcpTool.test.ts new file mode 100644 index 000000000..e1f7b5daa --- /dev/null +++ b/packages/plugin-webmcp/tests/useWebMcpTool.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from '@rstest/core'; +import { toDescriptorDependency } from '../src/runtime/useWebMcpTool'; + +describe('useWebMcpTool descriptor dependencies', () => { + test('creates stable errors for non-serializable descriptors', () => { + const circular: Record = {}; + circular.self = circular; + const first = toDescriptorDependency(circular); + const second = toDescriptorDependency(circular); + + expect(first.key).toBe(second.key); + expect(first.error).toBeInstanceOf(TypeError); + expect(first.error?.message).toContain('must be JSON-serializable'); + expect(toDescriptorDependency({ value: 1n }).key).toContain( + 'serialization-error', + ); + }); + + test('tracks serializable descriptor changes by value', () => { + const schema = { type: 'object', properties: {} }; + const before = toDescriptorDependency(schema); + schema.properties = { query: { type: 'string' } }; + const after = toDescriptorDependency(schema); + + expect(before.error).toBeNull(); + expect(after.error).toBeNull(); + expect(before.key).not.toBe(after.key); + }); +}); diff --git a/packages/plugin-webmcp/tsconfig.json b/packages/plugin-webmcp/tsconfig.json new file mode 100644 index 000000000..4e8159179 --- /dev/null +++ b/packages/plugin-webmcp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@rspress/config/tsconfig", + "compilerOptions": { + "outDir": "dist", + "composite": true, + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"], + "references": [ + { + "path": "../shared" + }, + { + "path": "../core" + } + ] +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7609efd98..1c6e8f125 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -480,6 +480,7 @@ export interface PageIndexInfo { toc: Header[]; content: string; description?: string; + _deps?: string[]; _flattenContent?: string; frontmatter: FrontMatterMeta; lang: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dac84812..324578076 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1242,6 +1242,31 @@ importers: specifier: ^19.2.4 version: 19.2.4(@types/react@19.2.18) + e2e/fixtures/plugin-webmcp: + dependencies: + '@mcp-b/webmcp-polyfill': + specifier: 4.0.0 + version: 4.0.0 + '@rspress/core': + specifier: workspace:* + version: link:../../../packages/core + '@rspress/plugin-webmcp': + specifier: workspace:* + version: link:../../../packages/plugin-webmcp + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@types/node': + specifier: ^22.8.1 + version: 22.19.15 + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + e2e/fixtures/production: dependencies: '@rspress/core': @@ -2034,6 +2059,9 @@ importers: '@rspress/core': specifier: workspace:^2.0.10 version: link:../core + algoliasearch: + specifier: ^5.50.0 + version: 5.50.0 devDependencies: '@microsoft/api-extractor': specifier: ^7.58.12 @@ -2510,6 +2538,46 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/plugin-webmcp: + dependencies: + '@rspress/core': + specifier: workspace:^ + version: link:../core + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@microsoft/api-extractor': + specifier: ^7.58.12 + version: 7.58.12(@types/node@22.19.15) + '@rsbuild/plugin-react': + specifier: ~2.1.0 + version: 2.1.0(@rsbuild/core@2.1.9)(@rspack/core@2.1.7(@swc/helpers@0.5.23)) + '@rslib/core': + specifier: 1.0.0-beta.1 + version: 1.0.0-beta.1(@microsoft/api-extractor@7.58.12(@types/node@22.19.15))(typescript@6.0.3) + '@rspress/config': + specifier: workspace:* + version: link:../../scripts/config + '@types/node': + specifier: ^22.8.1 + version: 22.19.15 + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + react: + specifier: ^19.2.8 + version: 19.2.8 + rsbuild-plugin-publint: + specifier: ^1.0.0 + version: 1.0.0(@rsbuild/core@2.1.9) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + webmcp-types: + specifier: 0.1.2 + version: 0.1.2 + packages/shared: dependencies: '@rsbuild/core': @@ -2600,6 +2668,9 @@ importers: '@rspress/plugin-twoslash': specifier: workspace:* version: link:../packages/plugin-twoslash + '@rspress/plugin-webmcp': + specifier: workspace:* + version: link:../packages/plugin-webmcp '@rstack-dev/doc-ui': specifier: ^1.14.7 version: 1.14.7(@rspress/core@packages+core) @@ -2889,6 +2960,9 @@ packages: '@bufbuild/protobuf@2.11.0': resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -3556,6 +3630,13 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@mcp-b/webmcp-polyfill@4.0.0': + resolution: {integrity: sha512-7M6WS3IcxaIEhidbS9N7wrrGjtJf474Vi4Fn1hGkFkmYFT5EoFw/kS/4CSdUR/KmlInO1kyluHRY6t3avMQfFw==} + engines: {node: '>=18'} + + '@mcp-b/webmcp-types@4.0.0': + resolution: {integrity: sha512-CUBBmiut1UBsiD3FAF6xrLKeKAuJkzd6/BlL1C/uo8sVpwY42NW4mls9rLcTxmLF/8Cj5/ZOwJiLzXl/T3GIQg==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -4425,6 +4506,9 @@ packages: peerDependencies: '@rspress/core': ^2.0.0-rc.1 || ^2.0.0 + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -8323,6 +8407,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webmcp-types@0.1.2: + resolution: {integrity: sha512-weWM+Iyj7rITLBoANf3GjxOD25MgriZnKUafJOXErmOnTbbHp5kr7hcPl+UlfeSlwtvE8+55HozF6jxtv0j17g==} + webpack-sources@3.5.0: resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} engines: {node: '>=10.13.0'} @@ -8406,6 +8493,9 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -8733,6 +8823,8 @@ snapshots: '@bufbuild/protobuf@2.11.0': {} + '@cfworker/json-schema@4.1.1': {} + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -9441,6 +9533,14 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@mcp-b/webmcp-polyfill@4.0.0': + dependencies: + '@cfworker/json-schema': 4.1.1 + '@mcp-b/webmcp-types': 4.0.0 + '@standard-schema/spec': 1.1.0 + + '@mcp-b/webmcp-types@4.0.0': {} + '@mdx-js/mdx@3.1.1(supports-color@7.2.0)': dependencies: '@types/estree': 1.0.8 @@ -10354,6 +10454,8 @@ snapshots: sharp: 0.34.5 ufo: 1.6.3 + '@standard-schema/spec@1.1.0': {} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.0(supports-color@9.4.0))': dependencies: '@babel/core': 7.29.0(supports-color@9.4.0) @@ -15352,6 +15454,8 @@ snapshots: web-namespaces@2.0.1: {} + webmcp-types@0.1.2: {} + webpack-sources@3.5.0: optional: true @@ -15441,6 +15545,8 @@ snapshots: yocto-queue@1.2.2: {} + zod@4.4.3: {} + zwitch@2.0.4: {} zx@8.8.5: {} diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 189d37679..982d2acfc 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -177,6 +177,7 @@ unshift upath watchpack webm +webmcp webp wechat weibo diff --git a/website/docs/en/plugin/official-plugins/_meta.json b/website/docs/en/plugin/official-plugins/_meta.json index 0db050443..233ab38aa 100644 --- a/website/docs/en/plugin/official-plugins/_meta.json +++ b/website/docs/en/plugin/official-plugins/_meta.json @@ -1,6 +1,7 @@ [ "overview", "llms", + "webmcp", "sitemap", "client-redirects", "typedoc", diff --git a/website/docs/en/plugin/official-plugins/algolia.mdx b/website/docs/en/plugin/official-plugins/algolia.mdx index 1a4ee6e68..cba4d1338 100644 --- a/website/docs/en/plugin/official-plugins/algolia.mdx +++ b/website/docs/en/plugin/official-plugins/algolia.mdx @@ -46,6 +46,8 @@ export { Search }; export * from '@rspress/core/theme-original'; ``` +When `@rspress/plugin-webmcp` is also enabled, this `Search` component automatically supplies Algolia results to `rspress_search_docs`. No additional WebMCP configuration is required. + ## Configuration The plugin accepts an options object with the following type: diff --git a/website/docs/en/plugin/official-plugins/overview.mdx b/website/docs/en/plugin/official-plugins/overview.mdx index 734811fe4..6432e1100 100644 --- a/website/docs/en/plugin/official-plugins/overview.mdx +++ b/website/docs/en/plugin/official-plugins/overview.mdx @@ -14,3 +14,4 @@ Official plugins include: - [@rspress/plugin-sitemap](./sitemap): Generate a [sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview) automatically for SEO and search engine crawling. - [@rspress/plugin-twoslash](./twoslash): Integrate [Twoslash](https://github.com/twoslashes/twoslash) to automatically generate rich code blocks with type information. - [@rspress/plugin-typedoc](./typedoc): Integrate [TypeDoc](https://github.com/TypeStrong/typedoc) to generate API documentation for TypeScript modules automatically. +- [@rspress/plugin-webmcp](./webmcp): Expose documentation tools to browser agents through WebMCP. diff --git a/website/docs/en/plugin/official-plugins/webmcp.mdx b/website/docs/en/plugin/official-plugins/webmcp.mdx new file mode 100644 index 000000000..c00794ff1 --- /dev/null +++ b/website/docs/en/plugin/official-plugins/webmcp.mdx @@ -0,0 +1,174 @@ +--- +description: Expose Rspress documentation tools to browser agents with WebMCP. +--- + +# @rspress/plugin-webmcp + +import { SourceCode, PackageManagerTabs } from '@rspress/core/theme'; + +Expose documentation content and site actions to browser agents through the [WebMCP API](https://webmachinelearning.github.io/webmcp/). + +## Installation + + + +## Usage + +```ts title="rspress.config.ts" +import { defineConfig } from '@rspress/core'; +import { pluginWebMcp } from '@rspress/plugin-webmcp'; + +export default defineConfig({ + plugins: [pluginWebMcp()], +}); +``` + +The plugin registers these tools by default: + +- `rspress_get_site_info`: Returns site metadata, locales, versions, navigation, and the active sidebar. +- `rspress_list_pages`: Filters and paginates page metadata for the active locale and version. It works independently of the configured search provider. +- `rspress_get_page`: Returns metadata and generated SSG-MD Markdown for any known internal route without navigating. +- `rspress_get_current_page`: Returns the current page metadata and generated SSG-MD Markdown. +- `rspress_search_docs`: Searches through the active Rspress search provider. Local search and [`@rspress/plugin-algolia`](/plugin/official-plugins/algolia) are supported. It is omitted only when no search provider is available. +- `rspress_navigate`: Navigates only to known internal documentation routes. Query strings and hashes are supported. It returns lightweight metadata, section headings, and previous/next pages for the destination without fetching Markdown. + +`rspress_navigate` resolves after the SPA route renders. Its result confirms the destination and provides immediate navigation choices: + +```json +{ + "routePath": "/guide?source=agent#install", + "page": { "title": "Guide", "lang": "en", "version": "v2" }, + "sections": [ + { + "title": "Install", + "depth": 2, + "routePath": "/guide?source=agent#install" + } + ], + "previousPage": { "title": "Introduction", "routePath": "/intro" }, + "nextPage": { "title": "Configuration", "routePath": "/config" } +} +``` + +Call `rspress_get_page` with the returned `routePath` only when the agent needs the full Markdown. + +The two Markdown tools automatically enable [`llms: true`](/guide/basic/ssg-md). An existing `true` or object configuration is preserved. An explicit `llms: false` conflicts unless both `getPage` and `currentPage` are disabled. + +SSG-MD emits `.md` files during `rspress build`. `rspress_get_page` and `rspress_get_current_page` are omitted during `rspress dev`, where generated Markdown is unavailable. Site information, page listings, search, navigation, and custom tools remain available and update through HMR. + +## Options + +Disable individual built-in tools with `tools`: + +```ts title="rspress.config.ts" +pluginWebMcp({ + exposedTo: ['https://agent.example'], + tools: { + siteInfo: true, + listPages: true, + getPage: true, + currentPage: true, + search: false, + navigate: true, + }, +}); +``` + +All six options default to `true`. + +Set `exposedTo` to forward secure origins to every built-in tool registration. Same-origin and browser-integrated agents do not need it. A cross-origin agent must also request the site origin with `getTools({ fromOrigins })`; cross-origin iframes additionally require the `tools` Permissions Policy. + +## Search providers + +Local search is used by default. Mounting the `Search` component from [`@rspress/plugin-algolia`](/plugin/official-plugins/algolia) automatically switches `rspress_search_docs` to Algolia. + +Other search integrations can register a provider from a theme or global UI component: + +```ts +import { registerSearchProvider } from '@rspress/core/theme'; + +export function registerMySearchProvider( + searchDocs: (query: string, limit: number) => Promise, +) { + return registerSearchProvider({ + async search(query, limit = 20) { + return [ + { + group: 'Documentation', + result: await searchDocs(query, limit), + }, + ]; + }, + }); +} +``` + +The returned function unregisters the provider. The most recently mounted provider is active; unregistering it restores the previous provider. Each `group` is returned by the WebMCP tool with its `result` value exposed as `results`. + +## Custom tools + +Use `registerWebMcpTool` for imperative code. Keep the returned `AbortSignal`-backed handle for the lifetime of the tool, then call `unregister` during cleanup. + +```ts +import { registerWebMcpTool } from '@rspress/plugin-webmcp/runtime'; + +export function mountCopyExampleTool() { + const registration = registerWebMcpTool( + { + name: 'copy_example', + description: 'Copy the current example.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + execute: () => navigator.clipboard.writeText('example'), + }, + { exposedTo: ['https://agent.example'] }, + ); + + void registration?.ready.catch(console.error); + return () => registration?.unregister(); +} +``` + +Use `useWebMcpTool` in React components. It registers on mount and unregisters on unmount. + +```tsx +import { useWebMcpTool } from '@rspress/plugin-webmcp/runtime'; + +export function Counter({ count, increment }) { + const { status, error } = useWebMcpTool({ + name: 'increment_counter', + description: 'Increment the visible counter.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + execute: increment, + }); + + return ( + + {status}: {count} + + ); +} +``` + +`status` is `registering`, `registered`, `unsupported`, or `error`. `error` contains registration failures. Descriptor metadata or registration-option changes automatically re-register the tool, while the latest `execute` callback is used without registration churn. + +Pass an optional dependency list as the third argument when an external value must force a fresh browser registration: `useWebMcpTool(tool, options, deps)`. Cleanup aborts the previous registration before the replacement is installed. + +The built-in tools validate inputs again during execution. Custom `execute` callbacks should also validate their arguments because draft browser runtimes may not enforce the published JSON Schema before invocation. + +The runtime also exports local `WebMcpTool`, annotation, registration, client, and hook-state types. The optional `outputSchema`, extended MCP annotations, and execution client are compatibility extensions that are forwarded when a browser runtime supports them; the native draft currently standardizes `inputSchema`, `readOnlyHint`, and `untrustedContentHint`. + +## Browser support + +The production plugin uses only `document.modelContext`. Unsupported browsers safely skip registration, including during SSR. It does not ship a polyfill or an MCP-B runtime dependency. + +For tests or demos in browsers without native WebMCP, consumers can optionally install `@mcp-b/webmcp-polyfill` themselves. Load it before the Rspress client runtime so the support check sees it during the first render. The API is still a draft, so check the [current specification](https://webmachinelearning.github.io/webmcp/) when integrating browser-specific agent features. diff --git a/website/docs/zh/plugin/official-plugins/_meta.json b/website/docs/zh/plugin/official-plugins/_meta.json index 0db050443..233ab38aa 100644 --- a/website/docs/zh/plugin/official-plugins/_meta.json +++ b/website/docs/zh/plugin/official-plugins/_meta.json @@ -1,6 +1,7 @@ [ "overview", "llms", + "webmcp", "sitemap", "client-redirects", "typedoc", diff --git a/website/docs/zh/plugin/official-plugins/algolia.mdx b/website/docs/zh/plugin/official-plugins/algolia.mdx index 2115b7b12..7f790dde0 100644 --- a/website/docs/zh/plugin/official-plugins/algolia.mdx +++ b/website/docs/zh/plugin/official-plugins/algolia.mdx @@ -50,6 +50,8 @@ export { Search }; export * from '@rspress/core/theme-original'; ``` +同时启用 `@rspress/plugin-webmcp` 时,此 `Search` 组件会自动为 `rspress_search_docs` 提供 Algolia 搜索结果,无需额外配置 WebMCP。 + ## 配置 这个插件接受一个对象参数,类型如下: diff --git a/website/docs/zh/plugin/official-plugins/overview.mdx b/website/docs/zh/plugin/official-plugins/overview.mdx index 2b1b2f63c..37af36092 100644 --- a/website/docs/zh/plugin/official-plugins/overview.mdx +++ b/website/docs/zh/plugin/official-plugins/overview.mdx @@ -14,3 +14,4 @@ - [@rspress/plugin-sitemap](./sitemap):自动生成用于 SEO 的[站点地图 (sitemap)](https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview),有利于搜索引擎抓取。 - [@rspress/plugin-twoslash](./twoslash):集成 [Twoslash](https://github.com/twoslashes/twoslash),用于自动生成带有类型信息的丰富代码块。 - [@rspress/plugin-typedoc](./typedoc):[TypeDoc](https://github.com/TypeStrong/typedoc) 集成插件,用于自动生成 TS 模块的 API 文档。 +- [@rspress/plugin-webmcp](./webmcp):通过 WebMCP 向浏览器智能体开放文档工具。 diff --git a/website/docs/zh/plugin/official-plugins/webmcp.mdx b/website/docs/zh/plugin/official-plugins/webmcp.mdx new file mode 100644 index 000000000..4fe4e8bcc --- /dev/null +++ b/website/docs/zh/plugin/official-plugins/webmcp.mdx @@ -0,0 +1,174 @@ +--- +description: 使用 WebMCP 向浏览器智能体开放 Rspress 文档工具。 +--- + +# @rspress/plugin-webmcp + +import { SourceCode, PackageManagerTabs } from '@rspress/core/theme'; + +通过 [WebMCP API](https://webmachinelearning.github.io/webmcp/) 向浏览器智能体开放文档内容和站点操作。 + +## 安装 + + + +## 使用 + +```ts title="rspress.config.ts" +import { defineConfig } from '@rspress/core'; +import { pluginWebMcp } from '@rspress/plugin-webmcp'; + +export default defineConfig({ + plugins: [pluginWebMcp()], +}); +``` + +插件默认注册以下工具: + +- `rspress_get_site_info`:返回站点元数据、语言、版本、导航和当前侧边栏。 +- `rspress_list_pages`:筛选当前语言和版本的页面元数据并进行分页,不依赖站点使用的搜索服务。 +- `rspress_get_page`:无需导航,即可返回任意已知站内路由的元数据和由 SSG-MD 生成的 Markdown。 +- `rspress_get_current_page`:返回当前页面的元数据和由 SSG-MD 生成的 Markdown。 +- `rspress_search_docs`:通过当前启用的 Rspress 搜索服务查询文档。支持本地搜索和 [`@rspress/plugin-algolia`](/zh/plugin/official-plugins/algolia);仅在没有可用搜索服务时不注册。 +- `rspress_navigate`:仅导航至已知的站内文档路由,支持查询参数和哈希。它会返回目标页面的轻量元数据、章节标题和上一篇/下一篇页面,但不会获取 Markdown。 + +`rspress_navigate` 会在 SPA 路由渲染完成后返回。结果既确认了目标页面,也提供了后续导航选项: + +```json +{ + "routePath": "/guide?source=agent#install", + "page": { "title": "指南", "lang": "zh", "version": "v2" }, + "sections": [ + { + "title": "安装", + "depth": 2, + "routePath": "/guide?source=agent#install" + } + ], + "previousPage": { "title": "简介", "routePath": "/intro" }, + "nextPage": { "title": "配置", "routePath": "/config" } +} +``` + +仅当智能体需要完整 Markdown 时,再使用返回的 `routePath` 调用 `rspress_get_page`。 + +两个 Markdown 工具会自动启用 [`llms: true`](/guide/basic/ssg-md)。已有的 `true` 或对象配置会被保留。除非同时关闭 `getPage` 和 `currentPage`,否则显式配置 `llms: false` 会产生冲突。 + +SSG-MD 会在 `rspress build` 期间生成 `.md` 文件。在 `rspress dev` 中不会注册 `rspress_get_page` 与 `rspress_get_current_page`,因为此时尚无生成的 Markdown;站点信息、页面列表、搜索、导航和自定义工具仍可用并通过 HMR 更新。 + +## 选项 + +通过 `tools` 关闭单个内置工具: + +```ts title="rspress.config.ts" +pluginWebMcp({ + exposedTo: ['https://agent.example'], + tools: { + siteInfo: true, + listPages: true, + getPage: true, + currentPage: true, + search: false, + navigate: true, + }, +}); +``` + +六个选项的默认值均为 `true`。 + +通过 `exposedTo` 可将安全来源透传给所有内置工具的注册选项。同源智能体和浏览器集成智能体无需配置它。跨源智能体还必须使用 `getTools({ fromOrigins })` 请求站点来源;跨源 iframe 还需要启用 `tools` 权限策略。 + +## 搜索服务 + +默认使用本地搜索。挂载 [`@rspress/plugin-algolia`](/zh/plugin/official-plugins/algolia) 的 `Search` 组件后,`rspress_search_docs` 会自动切换到 Algolia。 + +其他搜索集成可在主题或全局 UI 组件中注册搜索服务: + +```ts +import { registerSearchProvider } from '@rspress/core/theme'; + +export function registerMySearchProvider( + searchDocs: (query: string, limit: number) => Promise, +) { + return registerSearchProvider({ + async search(query, limit = 20) { + return [ + { + group: 'Documentation', + result: await searchDocs(query, limit), + }, + ]; + }, + }); +} +``` + +返回的函数用于注销搜索服务。最后挂载的服务生效;注销后会恢复上一个服务。WebMCP 工具会返回每个 `group`,并将对应的 `result` 值作为 `results` 输出。 + +## 自定义工具 + +在命令式代码中使用 `registerWebMcpTool`。请在工具的整个生命周期内保留返回的 `AbortSignal` 句柄,并在清理时调用 `unregister`。 + +```ts +import { registerWebMcpTool } from '@rspress/plugin-webmcp/runtime'; + +export function mountCopyExampleTool() { + const registration = registerWebMcpTool( + { + name: 'copy_example', + description: 'Copy the current example.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + execute: () => navigator.clipboard.writeText('example'), + }, + { exposedTo: ['https://agent.example'] }, + ); + + void registration?.ready.catch(console.error); + return () => registration?.unregister(); +} +``` + +在 React 组件中使用 `useWebMcpTool`。它会在组件挂载时注册,并在卸载时注销。 + +```tsx +import { useWebMcpTool } from '@rspress/plugin-webmcp/runtime'; + +export function Counter({ count, increment }) { + const { status, error } = useWebMcpTool({ + name: 'increment_counter', + description: 'Increment the visible counter.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + annotations: { readOnlyHint: false }, + execute: increment, + }); + + return ( + + {status}: {count} + + ); +} +``` + +`status` 的值为 `registering`、`registered`、`unsupported` 或 `error`,注册失败信息保存在 `error` 中。工具描述元数据或注册选项变化时会自动重新注册;仅 `execute` 回调变化时会直接使用最新回调,不会反复注册。 + +当外部值变化后必须重新向浏览器注册时,可将依赖数组作为第三个参数传入:`useWebMcpTool(tool, options, deps)`。安装新注册前,Hook 会通过中止信号清理旧注册。 + +内置工具会在执行时再次校验输入。由于草案阶段的浏览器运行时不一定会在调用前校验已发布的 JSON Schema,自定义 `execute` 回调也应校验参数。 + +运行时还导出了本地的 `WebMcpTool`、注解、注册、客户端和 Hook 状态类型。可选的 `outputSchema`、扩展 MCP 注解和执行客户端属于兼容性扩展,仅在浏览器运行时支持时透传;原生草案目前标准化了 `inputSchema`、`readOnlyHint` 和 `untrustedContentHint`。 + +## 浏览器支持 + +生产插件仅使用 `document.modelContext`。不支持 WebMCP 的浏览器会安全地跳过注册,SSR 期间也不会报错。插件不会附带 polyfill 或 MCP-B 运行时依赖。 + +如果测试或演示所用浏览器尚未原生支持 WebMCP,使用者可以自行选择安装 `@mcp-b/webmcp-polyfill`。请在 Rspress 客户端运行时之前加载它,确保首次渲染时支持检测即可发现它。该 API 仍处于草案阶段,集成浏览器专用智能体功能时请查阅[最新规范](https://webmachinelearning.github.io/webmcp/)。 diff --git a/website/package.json b/website/package.json index d98b8975f..4ff065d7d 100644 --- a/website/package.json +++ b/website/package.json @@ -18,6 +18,7 @@ "@rspress/plugin-preview": "workspace:*", "@rspress/plugin-sitemap": "workspace:*", "@rspress/plugin-twoslash": "workspace:*", + "@rspress/plugin-webmcp": "workspace:*", "@rstack-dev/doc-ui": "^1.14.7", "@shikijs/transformers": "^4.2.0", "@types/react": "^19.2.18", diff --git a/website/rspress.config.ts b/website/rspress.config.ts index 045e11967..b61264d33 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -9,6 +9,7 @@ import { pluginPlayground } from '@rspress/plugin-playground'; import { pluginPreview } from '@rspress/plugin-preview'; import { pluginSitemap } from '@rspress/plugin-sitemap'; import { pluginTwoslash } from '@rspress/plugin-twoslash'; +import { pluginWebMcp } from '@rspress/plugin-webmcp'; import { transformerNotationDiff, transformerNotationErrorLevel, @@ -107,6 +108,7 @@ export default defineConfig({ }), pluginPlayground(), pluginTwoslash(), + pluginWebMcp(), // pluginFontOpenSans(), // removed this line for Rspress preview pluginSitemap({ siteUrl: siteOrigin,