Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- [EE] Avoid advertising OAuth support on MCP endpoints if that entitlement is not actually configured. [#985](https://github.com/sourcebot-dev/sourcebot/pull/985)

## [4.15.1] - 2026-03-06

### Fixed
Expand Down
6 changes: 5 additions & 1 deletion docs/docs/features/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ Sourcebot MCP uses a [Streamable HTTP](https://modelcontextprotocol.io/specifica

You can read more about the options in the [authorization](#authorization) section.

<Note>
If [anonymous access](https://docs.sourcebot.dev/docs/configuration/auth/access-settings#anonymous-access) is enabled on your Sourcebot instance, no OAuth token or API key is required. You can connect directly to the MCP endpoint without any authorization.
</Note>

<AccordionGroup>
<Accordion title="Claude Code">
[Claude Code MCP docs](https://code.claude.com/docs/en/mcp#connect-claude-code-to-tools-via-mcp)
Expand Down Expand Up @@ -273,7 +277,7 @@ You can read more about the options in the [authorization](#authorization) secti

## Authorization

The Sourcebot MCP server supports two authorization methods, OAuth and API keys.
The Sourcebot MCP server supports two authorization methods, OAuth and API keys. If [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) is enabled on your instance, no authorization is required.

Regardless of which method you use, all MCP requests are scoped to the associated Sourcebot user and inherit the [user's role and permissions](/docs/configuration/auth/roles-and-permissions). When [permission syncing](/docs/features/permission-syncing) is configured, this includes repository permissions - the MCP server will only surface results from repositories the user has access to.

Expand Down
12 changes: 12 additions & 0 deletions packages/web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ const nextConfig = {
source: "/.well-known/oauth-protected-resource/:path*",
destination: "/api/ee/.well-known/oauth-protected-resource/:path*",
},
// Non-spec fallback: some MCP clients (observed in Claude Code and Cursor) guess
// /register as the Dynamic Client Registration endpoint (RFC 7591) when OAuth
// Authorization Server Metadata discovery fails entirely. This happens when the
// instance does not hold an enterprise license, causing all well-known endpoints
// to return 404. Per the MCP spec, clients should only attempt Dynamic Client
// Registration if the authorization server advertises a registration_endpoint in
// its metadata — guessing the URL is not spec-compliant behavior. This rewrite
// ensures those requests reach the actual endpoint rather than hitting a 404.
{
source: "/register",
destination: "/api/ee/oauth/register",
}
];
},
// This is required to support PostHog trailing slash API requests
Expand Down
13 changes: 13 additions & 0 deletions packages/web/src/app/api/(server)/[...slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ErrorCode } from "@/lib/errorCodes"
import { serviceErrorResponse } from "@/lib/serviceError"
import { StatusCodes } from "http-status-codes"

const handler = () => {
return serviceErrorResponse({
statusCode: StatusCodes.NOT_FOUND,
errorCode: ErrorCode.NOT_FOUND,
message: "This API endpoint does not exist",
});
}

export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE }
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { apiHandler } from '@/lib/apiHandler';
import { env } from '@sourcebot/shared';
import { env, hasEntitlement } from '@sourcebot/shared';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';

// RFC 8414: OAuth 2.0 Authorization Server Metadata
// @note: we do not gate on entitlements here. That is handled in the /register,
// /token, and /revoke routes.
// @see: https://datatracker.ietf.org/doc/html/rfc8414
export const GET = apiHandler(async () => {
if (!hasEntitlement('oauth')) {
return Response.json(
{ error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 404 }
);
}

const issuer = env.AUTH_URL.replace(/\/$/, '');

return Response.json({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { apiHandler } from '@/lib/apiHandler';
import { env } from '@sourcebot/shared';
import { env, hasEntitlement } from '@sourcebot/shared';
import { NextRequest } from 'next/server';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';

// RFC 9728: OAuth 2.0 Protected Resource Metadata (path-specific form)
// For a resource at /api/mcp, the well-known URI is /.well-known/oauth-protected-resource/api/mcp.
// @note: we do not gate on entitlements here. That is handled in the /register,
// /token, and /revoke routes.
// @see: https://datatracker.ietf.org/doc/html/rfc9728#section-3
const PROTECTED_RESOURCES = new Set([
'api/mcp'
]);

export const GET = apiHandler(async (_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => {
if (!hasEntitlement('oauth')) {
return Response.json(
{ error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 404 }
);
}

const { path } = await params;
const resourcePath = path.join('/');

Expand Down

This file was deleted.

3 changes: 2 additions & 1 deletion packages/web/src/app/api/(server)/ee/oauth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { prisma } from '@/prisma';
import { hasEntitlement } from '@sourcebot/shared';
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';

// RFC 7591: OAuth 2.0 Dynamic Client Registration
// @see: https://datatracker.ietf.org/doc/html/rfc7591
Expand All @@ -16,7 +17,7 @@ const registerRequestSchema = z.object({
export const POST = apiHandler(async (request: NextRequest) => {
if (!hasEntitlement('oauth')) {
return Response.json(
{ error: 'access_denied', error_description: 'OAuth is not available on this plan. Please see https://sourcebot.dev/pricing' },
{ error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 403 }
);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { revokeToken } from '@/ee/features/oauth/server';
import { apiHandler } from '@/lib/apiHandler';
import { hasEntitlement } from '@sourcebot/shared';
import { NextRequest } from 'next/server';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';

// RFC 7009: OAuth 2.0 Token Revocation
// Always returns 200 regardless of whether the token existed.
// @see: https://datatracker.ietf.org/doc/html/rfc7009
export const POST = apiHandler(async (request: NextRequest) => {
if (!hasEntitlement('oauth')) {
return Response.json(
{ error: 'access_denied', error_description: 'OAuth is not available on this plan. Please see https://sourcebot.dev/pricing' },
{ error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 403 }
);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/app/api/(server)/ee/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { verifyAndExchangeCode, verifyAndRotateRefreshToken, ACCESS_TOKEN_TTL_SE
import { apiHandler } from '@/lib/apiHandler';
import { hasEntitlement } from '@sourcebot/shared';
import { NextRequest } from 'next/server';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';

// OAuth 2.0 Token Endpoint
// Supports grant_type=authorization_code with PKCE (RFC 7636).
// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-3.2
export const POST = apiHandler(async (request: NextRequest) => {
if (!hasEntitlement('oauth')) {
return Response.json(
{ error: 'access_denied', error_description: 'OAuth is not available on this plan. Please see https://sourcebot.dev/pricing' },
{ error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 403 }
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/app/api/(server)/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { StatusCodes } from 'http-status-codes';
import { NextRequest } from 'next/server';
import { sew } from '@/actions';
import { apiHandler } from '@/lib/apiHandler';
import { env } from '@sourcebot/shared';
import { env, hasEntitlement } from '@sourcebot/shared';

// On 401, tell MCP clients where to find the OAuth protected resource metadata (RFC 9728)
// so they can discover the authorization server and initiate the authorization code flow.
Expand All @@ -18,7 +18,7 @@ import { env } from '@sourcebot/shared';
// @see: https://datatracker.ietf.org/doc/html/rfc9728
function mcpErrorResponse(error: ServiceError): Response {
const response = serviceErrorResponse(error);
if (error.statusCode === StatusCodes.UNAUTHORIZED) {
if (error.statusCode === StatusCodes.UNAUTHORIZED && hasEntitlement('oauth')) {
const issuer = env.AUTH_URL.replace(/\/$/, '');
response.headers.set(
'WWW-Authenticate',
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/ee/features/oauth/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

export const OAUTH_NOT_SUPPORTED_ERROR_MESSAGE = 'OAuth is not supported on this instance. Please authenticate using a Sourcebot API key instead. See https://docs.sourcebot.dev/docs/features/mcp-server for more information.';
2 changes: 1 addition & 1 deletion packages/web/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ export async function proxy(request: NextRequest) {
export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: [
'/((?!api|_next/static|.well-known/oauth-authorization-server|.well-known/oauth-protected-resource|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt|manifest.json|logo_192.png|logo_512.png|sb_logo_light_large.png|arrow.png|placeholder_avatar.png|sb_logo_dark_small.png|sb_logo_light_small.png).*)',
'/((?!api|_next/static|.well-known/oauth-authorization-server|.well-known/oauth-protected-resource|register|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt|manifest.json|logo_192.png|logo_512.png|sb_logo_light_large.png|arrow.png|placeholder_avatar.png|sb_logo_dark_small.png|sb_logo_light_small.png).*)',
],
}