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,