diff --git a/.changeset/chubby-adults-poke.md b/.changeset/chubby-adults-poke.md
new file mode 100644
index 000000000..f51701275
--- /dev/null
+++ b/.changeset/chubby-adults-poke.md
@@ -0,0 +1,5 @@
+---
+"gitbook": patch
+---
+
+Fix select filters not working on table and cards blocks.
diff --git a/.changeset/preview-auth-content-path.md b/.changeset/preview-auth-content-path.md
new file mode 100644
index 000000000..3d8dd6f2a
--- /dev/null
+++ b/.changeset/preview-auth-content-path.md
@@ -0,0 +1,5 @@
+---
+"gitbook": patch
+---
+
+Preserve the full site preview path and query parameters when redirecting users to log in.
diff --git a/.changeset/webmcp-published-docs.md b/.changeset/webmcp-published-docs.md
new file mode 100644
index 000000000..57454328c
--- /dev/null
+++ b/.changeset/webmcp-published-docs.md
@@ -0,0 +1,5 @@
+---
+"gitbook": patch
+---
+
+Expose the site's MCP tools to browser agents through WebMCP when the MCP page action is enabled.
diff --git a/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx
index b8f6c5901..6104f3fd8 100644
--- a/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx
+++ b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx
@@ -226,11 +226,10 @@ function SelectFilterDropdown(props: { column: TableSelectColumn }) {
key={option.value}
active={selected}
leadingIcon={selected ? 'check' : undefined}
- onSelect={(event) => {
- // Keep the menu open so several options can be toggled at once.
- event.preventDefault();
- toggleOption(column.id, option.value);
- }}
+ // `closeOnClick={false}` keeps the menu open so several options can be
+ // toggled at once.
+ closeOnClick={false}
+ onClick={() => toggleOption(column.id, option.value)}
>
{option.label || option.value}
diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx
index 939543b6e..156373fa0 100644
--- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx
+++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx
@@ -3,7 +3,7 @@ import Script from 'next/script';
import React from 'react';
import * as ReactDOM from 'react-dom';
-import { CustomizationDefaultThemeMode } from '@gitbook/api';
+import { CustomizationDefaultThemeMode, CustomizationPageActionType } from '@gitbook/api';
import { AIContextProvider } from '../AI';
import { RocketLoaderDetector } from './RocketLoaderDetector';
@@ -12,6 +12,7 @@ import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies';
import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout';
+import { WebMCP } from '@/components/WebMCP/WebMCP';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { buildVersion } from '@/lib/build';
import type { GitBookSiteContext } from '@/lib/context';
@@ -117,6 +118,9 @@ export async function SiteLayout(props: {
+ {customization.pageActions.items.includes(CustomizationPageActionType.Mcp) ? (
+
+ ) : null}
{scripts.map(({ script }) =>
isDeferrableScript(script) ? (
diff --git a/packages/gitbook/src/components/WebMCP/WebMCP.tsx b/packages/gitbook/src/components/WebMCP/WebMCP.tsx
new file mode 100644
index 000000000..e9cae97ab
--- /dev/null
+++ b/packages/gitbook/src/components/WebMCP/WebMCP.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import * as React from 'react';
+
+// Subset of the WebMCP `ModelContext` interface (https://webmachinelearning.github.io/webmcp/).
+type ModelContext = {
+ registerTool: (
+ tool: {
+ name: string;
+ description: string;
+ inputSchema?: object;
+ execute: (input: object, options?: { signal?: AbortSignal }) => Promise;
+ },
+ options?: { signal?: AbortSignal }
+ ) => Promise;
+};
+
+/**
+ * Expose the site's MCP tools (`~gitbook/mcp`) to browser agents through WebMCP, so anything
+ * added to the server is automatically available to them. Renders nothing.
+ */
+export function WebMCP(props: { mcpURL: string }) {
+ const { mcpURL } = props;
+
+ React.useEffect(() => {
+ const modelContext = (document as { modelContext?: ModelContext }).modelContext;
+ if (!modelContext) {
+ return;
+ }
+
+ // Aborting unregisters the tools and discards a load still in flight.
+ const controller = new AbortController();
+ const { signal } = controller;
+
+ (async () => {
+ // The MCP SDK is imported lazily: only agentic browsers pay for it.
+ const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([
+ import('@modelcontextprotocol/sdk/client/index.js'),
+ import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
+ ]);
+ // Tagged so WebMCP calls are distinguishable in insights (the request URL is tracked).
+ const url = new URL(mcpURL, window.location.href);
+ url.searchParams.set('client', 'webmcp');
+ const client = new Client({ name: 'gitbook-webmcp', version: '1.0.0' });
+ await client.connect(new StreamableHTTPClientTransport(url));
+ const { tools } = await client.listTools();
+ if (signal.aborted) {
+ return;
+ }
+
+ for (const tool of tools) {
+ // Answer synthesis takes 20-30s and browser agents abort tool calls around 30s.
+ if (tool.name === 'askQuestion') {
+ continue;
+ }
+ await modelContext.registerTool(
+ {
+ name: tool.name,
+ description: tool.description ?? tool.name,
+ inputSchema: tool.inputSchema,
+ // The MCP result (`content` blocks, plus `isError` on failure) is passed through.
+ execute: (input, options) =>
+ client.callTool(
+ { name: tool.name, arguments: input as Record },
+ undefined,
+ { signal: options?.signal }
+ ),
+ },
+ { signal }
+ );
+ }
+ })().catch((error) => {
+ // oxlint-disable-next-line no-console
+ console.warn('WebMCP: could not expose the site MCP tools', error);
+ });
+
+ return () => controller.abort();
+ }, [mcpURL]);
+
+ return null;
+}
diff --git a/packages/gitbook/src/lib/data/lookup.test.ts b/packages/gitbook/src/lib/data/lookup.test.ts
new file mode 100644
index 000000000..b450f875e
--- /dev/null
+++ b/packages/gitbook/src/lib/data/lookup.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, describe, expect, it, spyOn } from 'bun:test';
+
+import type { PublishedSiteContentLookup } from '@gitbook/api';
+
+import { GITBOOK_PREVIEW_BASE_URL } from '../env';
+import * as api from './api';
+import { lookupPublishedContentByUrl } from './lookup';
+
+describe('preview auth redirects', () => {
+ afterEach(() => {
+ apiClientSpy?.mockRestore();
+ });
+
+ let apiClientSpy: ReturnType | undefined;
+
+ it.each([
+ 'site_foo/~/changes/66',
+ 'site_foo/~/revisions/revision_123',
+ 'site_foo',
+ 'site_foo/~/changes/66/hello%20world?theme=dark&value=a%26b&value=c%2Bd',
+ 'site_foo/~/revisions/revision_123/guide?next=%2Fsome%3Fpath%3D1&empty=',
+ 'site_foo?theme=dark',
+ ])('preserves the requested URL for %s', async (path) => {
+ const requestURL = new URL(path, GITBOOK_PREVIEW_BASE_URL);
+ const authURL = new URL('https://app.gitbook.com/o/org_foo/sites/site_foo/preview/auth');
+ apiClientSpy = spyOn(api, 'apiClient').mockReturnValue({
+ urls: {
+ async resolvePublishedContentByUrl({ url }: { url: string }) {
+ // The API uses the lookup URL as the return target for preview authentication.
+ const redirect = new URL(authURL);
+ redirect.searchParams.set('redirect', url);
+ return { data: { target: 'application', redirect: redirect.toString() } };
+ },
+ },
+ } as ReturnType);
+
+ const result = await lookupPublishedContentByUrl({
+ url: requestURL.toString(),
+ apiToken: null,
+ redirectOnError: false,
+ visitorPayload: {},
+ });
+
+ expect(result.error).toBeUndefined();
+ if (!result.data || !('redirect' in result.data)) {
+ throw new Error('Expected an authentication redirect');
+ }
+ const redirect = new URL(result.data.redirect);
+ expect(redirect.origin + redirect.pathname).toBe(authURL.toString());
+ expect(redirect.searchParams.get('redirect')).toBe(requestURL.toString());
+ });
+
+ it.each([
+ {
+ name: 'non-preview application redirects',
+ requestURL: 'https://docs.example.com/~/changes/66',
+ target: 'application',
+ redirect: 'https://app.gitbook.com/o/org_foo/sites/site_foo',
+ expectedRedirect: 'https://app.gitbook.com/o/org_foo/sites/site_foo',
+ },
+ {
+ name: 'preview content redirects with a remaining page path',
+ requestURL: new URL(
+ 'site_foo/~/changes/66/hello%20world',
+ GITBOOK_PREVIEW_BASE_URL
+ ).toString(),
+ target: 'content',
+ redirect: 'https://docs.example.com/section?theme=dark',
+ expectedRedirect: 'https://docs.example.com/section/hello%20world?theme=dark',
+ },
+ {
+ name: 'preview external redirects with a remaining page path',
+ requestURL: new URL(
+ 'site_foo/~/revisions/revision_123/hello%20world',
+ GITBOOK_PREVIEW_BASE_URL
+ ).toString(),
+ target: 'external',
+ redirect: 'https://auth.example.com/login?location=%2Fsection&state=keep',
+ expectedRedirect:
+ 'https://auth.example.com/login?location=%2Fsection%2Fhello%2520world&state=keep',
+ },
+ ] as const)(
+ 'preserves handling of $name',
+ async ({ requestURL, target, redirect, expectedRedirect }) => {
+ const data: PublishedSiteContentLookup =
+ target === 'external'
+ ? { target, redirect, site: 'site_foo' }
+ : { target, redirect };
+ apiClientSpy = spyOn(api, 'apiClient').mockReturnValue({
+ urls: {
+ async resolvePublishedContentByUrl() {
+ return { data };
+ },
+ },
+ } as unknown as ReturnType);
+
+ const result = await lookupPublishedContentByUrl({
+ url: requestURL,
+ apiToken: null,
+ redirectOnError: false,
+ visitorPayload: {},
+ });
+
+ expect(result).toEqual({ data: { ...data, redirect: expectedRedirect } });
+ }
+ );
+});
diff --git a/packages/gitbook/src/lib/data/lookup.ts b/packages/gitbook/src/lib/data/lookup.ts
index b512bcd31..081d49162 100644
--- a/packages/gitbook/src/lib/data/lookup.ts
+++ b/packages/gitbook/src/lib/data/lookup.ts
@@ -8,6 +8,7 @@ import { isAPITokenExpired } from '@/lib/api-token';
import { race, tryCatch } from '@/lib/async';
import { getLogger } from '@/lib/logger';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
+import { isPreviewRequest } from '@/lib/preview';
import { trace } from '@/lib/tracing';
type ResolveBody = Parameters[0];
@@ -91,6 +92,13 @@ export async function lookupPublishedContentByUrl(
if ('redirect' in data) {
if (alternative.primary) {
+ if (data.target === 'application' && isPreviewRequest(lookupURL)) {
+ // The cached lookup omits content selectors and query params needed after login.
+ const redirect = new URL(data.redirect);
+ redirect.searchParams.set('redirect', lookupURL.toString());
+ return { data: { ...data, redirect: redirect.toString() } };
+ }
+
// Append the path to the redirect URL
// because we might have matched a shorter path and the redirect is relative to it
if (alternative.extraPath) {