Expose a ~gitbook/mcp/auth endpoint for non-VA adaptive content sites (#4155)

This commit is contained in:
spastorelli
2026-04-07 12:50:39 +02:00
committed by GitHub
parent 50653abd08
commit 569d4046be
8 changed files with 191 additions and 23 deletions
@@ -0,0 +1,12 @@
import type { RouteLayoutParams } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { handleMcpRequest } from '../handler';
async function handler(
rawRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp/auth');
}
export { handler as GET, handler as POST };
@@ -1,6 +1,6 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { getMarkdownForPageInSpace } from '@/lib/markdownPage';
import { resolvePagePath } from '@/lib/pages';
@@ -12,17 +12,16 @@ import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
async function handler(
export async function handleMcpRequest(
rawRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
params: RouteLayoutParams,
endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth'
) {
const { context } = await getStaticSiteContext(await params);
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
);
const requestURL = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)));
requestURL.search = rawRequest.nextUrl.search;
const request = new Request(requestURL, rawRequest);
@@ -60,7 +59,6 @@ async function handler(
})
);
// Track the search event server-side
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
@@ -214,8 +212,7 @@ async function handler(
},
{},
{
basePath: context.linker.toPathInSite('~gitbook/'),
streamableHttpEndpoint: '/mcp',
streamableHttpEndpoint: context.linker.toPathInSite(endpoint),
maxDuration: 60,
verboseLogs: true,
disableSse: true,
@@ -224,5 +221,3 @@ async function handler(
return mcpHandler(request);
}
export { handler as GET, handler as POST };
@@ -0,0 +1,12 @@
import type { RouteLayoutParams } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { handleMcpRequest } from './handler';
async function handler(
rawRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp');
}
export { handler as GET, handler as POST };
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test';
import {
createOAuthProtectedResourceMetadataResponse,
createOAuthProtectedResourceUnauthResponse,
handleUnauthedOAuthProtectedResourceRequest,
isOAuthProtectedResourceMetadataRequest,
@@ -34,6 +35,30 @@ describe('OAuth protected resources flow', () => {
});
});
it('returns PRM JSON for the authenticated MCP resource metadata request', async () => {
const url = new URL(
'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/mcp/auth'
);
const res = handleUnauthedOAuthProtectedResourceRequest({
siteRequestURL: url,
siteURLData: {
target: 'external',
redirect: 'https://login.acme.org/oauth2',
site: 'site_123',
},
urlMode: 'url-host',
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({
resource: 'https://docs.acme.org/~gitbook/mcp/auth',
authorization_servers: ['https://sites.gitbook.com/oauth2/v1/site_123'],
});
});
it('returns the 401 unauth response for the protected resource itself', () => {
const url = new URL('https://docs.acme.org/~gitbook/mcp');
@@ -56,8 +81,38 @@ describe('OAuth protected resources flow', () => {
});
});
describe('createOAuthProtectedResourceMetadataResponse', () => {
it('returns PRM JSON for the requested metadata URL', async () => {
const url = new URL(
'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/mcp/auth'
);
const res = createOAuthProtectedResourceMetadataResponse({
siteRequestURL: url,
siteId: 'site_123',
urlMode: 'url-host',
});
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toContain('application/json');
const json = await res.json();
expect(json).toEqual({
resource: 'https://docs.acme.org/~gitbook/mcp/auth',
authorization_servers: ['https://sites.gitbook.com/oauth2/v1/site_123'],
});
});
});
describe('createMcpUnauthenticatedResponse', () => {
it.each([
{
scenario: 'custom domain auth endpoint (with realm)',
input: 'https://docs.acme.org/~gitbook/mcp/auth',
expectedResourceMetadataUrl:
'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/mcp/auth',
expectedRealm: 'mcp',
},
{
scenario: 'custom domain (with realm)',
input: 'https://docs.acme.org/~gitbook/mcp',
@@ -116,6 +171,11 @@ describe('OAuth protected resources flow', () => {
describe('isOAuthProtectedResourceRequest', () => {
it.each([
{
scenario: 'should match a protected auth endpoint',
input: 'https://docs.acme.org/~gitbook/mcp/auth',
expected: true,
},
{
scenario: 'should match a protected endpoint',
input: 'https://docs.acme.org/~gitbook/mcp',
@@ -141,6 +201,12 @@ describe('OAuth protected resources flow', () => {
input: 'https://docs.acme.org/~gitbook/mcpx',
expected: false,
},
{
scenario:
'should also match auth metadata doc path (protected resource & PR metadata)',
input: 'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/mcp/auth',
expected: true,
},
{
scenario: 'should also match metadata doc path (protected resource & PR metadata)',
input: 'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/mcp',
@@ -154,6 +220,11 @@ describe('OAuth protected resources flow', () => {
describe('isOAuthProtectedResourceMetadataRequest', () => {
it.each([
{
scenario: 'should match metadata doc for the authenticated resource',
input: 'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/mcp/auth',
expected: true,
},
{
scenario: 'should match metadata doc for the resource',
input: 'https://docs.acme.org/.well-known/oauth-protected-resource/~gitbook/mcp',
+28 -10
View File
@@ -18,9 +18,32 @@ const OAUTH_PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-res
* List of OAuth protected resources.
*/
const OAUTH_PROTECTED_RESOURCES: OAuthProtectedResource[] = [
{ endpoint: '/~gitbook/mcp/auth', realm: 'mcp' },
{ endpoint: '/~gitbook/mcp', realm: 'mcp' },
];
/**
* Create a response for an OAuth protected resource metadata request.
*/
export function createOAuthProtectedResourceMetadataResponse(args: {
siteRequestURL: URL;
siteId: string;
urlMode: 'url' | 'url-host';
}) {
const { siteRequestURL, urlMode, siteId } = args;
const resourceUrl =
urlMode === 'url-host'
? siteRequestURL
: new URL(`/url/${siteRequestURL.host}${siteRequestURL.pathname}`, GITBOOK_URL);
const protectedResourceMetadata = {
resource: resourceUrl.toString().replace(OAUTH_PROTECTED_RESOURCE_METADATA_PATH, ''),
authorization_servers: [`${GITBOOK_OAUTH_SERVER_URL}/${siteId}`],
};
return NextResponse.json(protectedResourceMetadata);
}
/**
* Handle an authenticated request for an OAuth protected resource.
*/
@@ -33,16 +56,11 @@ export function handleUnauthedOAuthProtectedResourceRequest(args: {
// When the request is for the protected resource metadata return the info relative to the site.
if (isOAuthProtectedResourceMetadataRequest(siteRequestURL)) {
const resourceUrl =
urlMode === 'url-host'
? siteRequestURL
: new URL(`/url/${siteRequestURL.host}${siteRequestURL.pathname}`, GITBOOK_URL);
const protectedResourceMetadata = {
resource: resourceUrl.toString().replace(OAUTH_PROTECTED_RESOURCE_METADATA_PATH, ''),
authorization_servers: [`${GITBOOK_OAUTH_SERVER_URL}/${siteURLData.site}`],
};
return NextResponse.json(protectedResourceMetadata);
return createOAuthProtectedResourceMetadataResponse({
siteRequestURL,
siteId: siteURLData.site,
urlMode,
});
}
// Otherwise return a 401 WWW-Authenticate pointing to the PRM doc to tell client where to auth.
+30
View File
@@ -130,6 +130,21 @@ describe('getVisitorAuthToken', () => {
});
});
it('should return token for authenticated MCP request when included in the auth header', () => {
expect(
getVisitorToken({
cookies: [],
headers: new Headers({
Authorization: 'Bearer token-in-header',
}),
url: new URL('https://docs.acme.org/~gitbook/mcp/auth'),
})
).toEqual({
source: 'visitor-oauth-protected',
token: 'token-in-header',
});
});
it('should return undefined for malformced auth header', () => {
expect(
getVisitorToken({
@@ -178,6 +193,21 @@ describe('getVisitorAuthToken', () => {
token: 'token-in-query',
});
});
it('should return token for authenticated MCP request when included in the URL', () => {
expect(
getVisitorToken({
cookies: [],
headers: new Headers(),
url: new URL(
'https://docs.acme.org/~gitbook/mcp/auth?access_token=token-in-query'
),
})
).toEqual({
source: 'visitor-oauth-protected',
token: 'token-in-query',
});
});
});
});
+14 -1
View File
@@ -22,7 +22,9 @@ import { GITBOOK_OAUTH_SERVER_URL, isGitBookAssetsHostURL, isGitBookHostURL } fr
import { getImageResizingContextId } from '@/lib/images';
import { MiddlewareHeaders } from '@/lib/middleware';
import {
createOAuthProtectedResourceMetadataResponse,
handleUnauthedOAuthProtectedResourceRequest,
isOAuthProtectedResourceMetadataRequest,
isOAuthProtectedResourceRequest,
} from '@/lib/oauth-protected';
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
@@ -293,6 +295,16 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return createRedirectResponse(siteURLData.redirect);
}
// Handles OAuth protected resource metadata for non-VA adaptive content sites.
// If the requested URL resolved directly to a site, synthesize the metadata response immediately.
if (isOAuthProtectedResourceMetadataRequest(siteRequestURL)) {
return createOAuthProtectedResourceMetadataResponse({
siteRequestURL,
siteId: siteURLData.site,
urlMode: mode,
});
}
cookies.push(
...getResponseCookiesForVisitorAuth(
getVisitorAuthBasePath(siteRequestURL, siteURLData),
@@ -731,13 +743,14 @@ function encodePathInSiteContent(
},
],
};
case '~gitbook/mcp':
case 'sitemap.xml':
case 'sitemap-pages.xml':
case 'robots.txt':
case '~gitbook/embed/script.js':
case '~gitbook/embed/demo':
return { pathname, routeType: 'static' };
case '~gitbook/mcp':
case '~gitbook/mcp/auth':
case '~gitbook/pdf':
case '~gitbook/search':
case '~gitbook/auth/login':
+17
View File
@@ -28,3 +28,20 @@ it('should expose a MCP server', async () => {
// @ts-expect-error - response.content is of type unknown
expect(response.content[0]?.text).toContain('Title:');
});
it('should expose a MCP server on the authenticated path', async () => {
const client = new Client({
name: 'test',
version: '1.0.0',
});
await client.connect(
new StreamableHTTPClientTransport(
new URL(getContentTestURL('https://gitbook.com/docs/~gitbook/mcp/auth'))
)
);
const tools = await client.listTools();
expect(tools.tools[0]?.name).toBe('searchDocumentation');
expect(tools.tools[1]?.name).toBe('getPage');
});