Merge branch 'main' into steeve/fix-set-cookie-on-rsc-reqs-conflicting-logout

This commit is contained in:
Steeve Pastorelli
2026-09-11 21:54:02 +02:00
8 changed files with 220 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix select filters not working on table and cards blocks.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Preserve the full site preview path and query parameters when redirecting users to log in.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Expose the site's MCP tools to browser agents through WebMCP when the MCP page action is enabled.
@@ -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}
</DropdownMenuItem>
@@ -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: {
</AIContextProvider>
<LoadIntegrations />
{customization.pageActions.items.includes(CustomizationPageActionType.Mcp) ? (
<WebMCP mcpURL={context.linker.toPathInSite('~gitbook/mcp')} />
) : null}
{scripts.map(({ script }) =>
isDeferrableScript(script) ? (
<Script key={script} src={script} strategy="lazyOnload" />
@@ -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<unknown>;
},
options?: { signal?: AbortSignal }
) => Promise<void>;
};
/**
* 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<string, unknown> },
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;
}
@@ -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<typeof spyOn> | 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<typeof api.apiClient>);
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<typeof api.apiClient>);
const result = await lookupPublishedContentByUrl({
url: requestURL,
apiToken: null,
redirectOnError: false,
visitorPayload: {},
});
expect(result).toEqual({ data: { ...data, redirect: expectedRedirect } });
}
);
});
+8
View File
@@ -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<GitBookAPI['urls']['resolvePublishedContentByUrl']>[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) {