mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-11 21:39:22 +00:00
Merge branch 'main' into steeve/fix-set-cookie-on-rsc-reqs-conflicting-logout
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix select filters not working on table and cards blocks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Preserve the full site preview path and query parameters when redirecting users to log in.
|
||||
@@ -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,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) {
|
||||
|
||||
Reference in New Issue
Block a user