diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/auth/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/auth/route.ts new file mode 100644 index 000000000..3fd3c2ccc --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/auth/route.ts @@ -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 } +) { + return handleMcpRequest(rawRequest, await params, '~gitbook/mcp/auth'); +} + +export { handler as GET, handler as POST }; diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts similarity index 94% rename from packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts rename to packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts index ce0f95f3b..2731c8e0d 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/handler.ts @@ -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 } + 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 }; diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts new file mode 100644 index 000000000..adce94298 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts @@ -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 } +) { + return handleMcpRequest(rawRequest, await params, '~gitbook/mcp'); +} + +export { handler as GET, handler as POST }; diff --git a/packages/gitbook/src/lib/oauth-protected.test.ts b/packages/gitbook/src/lib/oauth-protected.test.ts index f99a3bd6e..c3bad7c0e 100644 --- a/packages/gitbook/src/lib/oauth-protected.test.ts +++ b/packages/gitbook/src/lib/oauth-protected.test.ts @@ -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', diff --git a/packages/gitbook/src/lib/oauth-protected.ts b/packages/gitbook/src/lib/oauth-protected.ts index 51b9d3bfd..cff9519cc 100644 --- a/packages/gitbook/src/lib/oauth-protected.ts +++ b/packages/gitbook/src/lib/oauth-protected.ts @@ -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. diff --git a/packages/gitbook/src/lib/visitors.test.ts b/packages/gitbook/src/lib/visitors.test.ts index 0406ee1e7..664883fa7 100644 --- a/packages/gitbook/src/lib/visitors.test.ts +++ b/packages/gitbook/src/lib/visitors.test.ts @@ -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', + }); + }); }); }); diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index eaefc8869..d0965d068 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -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': diff --git a/packages/gitbook/tests/mcp.test.ts b/packages/gitbook/tests/mcp.test.ts index a16b8e9fc..99427435f 100644 --- a/packages/gitbook/tests/mcp.test.ts +++ b/packages/gitbook/tests/mcp.test.ts @@ -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'); +});