diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d8cffe95..692445e2c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/docs/features/mcp-server.mdx b/docs/docs/features/mcp-server.mdx
index e77e6e205..0316a9954 100644
--- a/docs/docs/features/mcp-server.mdx
+++ b/docs/docs/features/mcp-server.mdx
@@ -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.
+
+ 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.
+
+
[Claude Code MCP docs](https://code.claude.com/docs/en/mcp#connect-claude-code-to-tools-via-mcp)
@@ -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.
diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs
index 84a0b85ca..de1b8b1ee 100644
--- a/packages/web/next.config.mjs
+++ b/packages/web/next.config.mjs
@@ -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
diff --git a/packages/web/src/app/api/(server)/[...slug]/route.ts b/packages/web/src/app/api/(server)/[...slug]/route.ts
new file mode 100644
index 000000000..f98fdfd13
--- /dev/null
+++ b/packages/web/src/app/api/(server)/[...slug]/route.ts
@@ -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 }
\ No newline at end of file
diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts
index 67c7e7e76..cdb71f642 100644
--- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts
+++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts
@@ -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({
diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts
index 6dd1055ff..2ec902530 100644
--- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts
+++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts
@@ -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('/');
diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/route.ts
deleted file mode 100644
index f311b400d..000000000
--- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/route.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { apiHandler } from '@/lib/apiHandler';
-import { env } from '@sourcebot/shared';
-
-// RFC 9728: OAuth 2.0 Protected Resource Metadata
-// Tells OAuth clients which authorization server protects this resource.
-// @see: https://datatracker.ietf.org/doc/html/rfc9728
-export const GET = apiHandler(async () => {
- const issuer = env.AUTH_URL.replace(/\/$/, '');
-
- return Response.json({
- resource: `${issuer}/api/mcp`,
- authorization_servers: [
- issuer
- ],
- });
-}, { track: false });
diff --git a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts
index b9f969ae9..932ef5318 100644
--- a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts
+++ b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts
@@ -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
@@ -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 }
);
}
diff --git a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts
index e82c17097..69d98db95 100644
--- a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts
+++ b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts
@@ -2,6 +2,7 @@ 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.
@@ -9,7 +10,7 @@ import { NextRequest } from 'next/server';
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 }
);
}
diff --git a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts
index e91b4726d..5638b10fd 100644
--- a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts
+++ b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts
@@ -2,6 +2,7 @@ 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).
@@ -9,7 +10,7 @@ import { NextRequest } from 'next/server';
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 }
);
}
diff --git a/packages/web/src/app/api/(server)/mcp/route.ts b/packages/web/src/app/api/(server)/mcp/route.ts
index e2b6789d0..a63148f56 100644
--- a/packages/web/src/app/api/(server)/mcp/route.ts
+++ b/packages/web/src/app/api/(server)/mcp/route.ts
@@ -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.
@@ -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',
diff --git a/packages/web/src/ee/features/oauth/constants.ts b/packages/web/src/ee/features/oauth/constants.ts
new file mode 100644
index 000000000..cb79b7077
--- /dev/null
+++ b/packages/web/src/ee/features/oauth/constants.ts
@@ -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.';
\ No newline at end of file
diff --git a/packages/web/src/proxy.ts b/packages/web/src/proxy.ts
index 8d66f595f..f8c71c363 100644
--- a/packages/web/src/proxy.ts
+++ b/packages/web/src/proxy.ts
@@ -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).*)',
],
}
\ No newline at end of file