mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 11:03:39 +00:00
Expose a ~gitbook/mcp/auth endpoint for non-VA adaptive content sites (#4155)
This commit is contained in:
+12
@@ -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 };
|
||||
+7
-12
@@ -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 };
|
||||
+12
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user