Skip to content

Commit 2bc272c

Browse files
fix(web): stop advertising MCP OAuth without entitlement (#985)
* fix(web): stop advertising MCP OAuth without entitlement * docs(changelog): add PR link for MCP OAuth fix * fix * update changelog * nit --------- Co-authored-by: Brendan Kellam <brendan@sourcebot.dev>
1 parent 5e1ae96 commit 2bc272c

File tree

13 files changed

+62
-29
lines changed

13 files changed

+62
-29
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- [EE] Avoid advertising OAuth support on MCP endpoints if that entitlement is not actually configured. [#985](https://github.com/sourcebot-dev/sourcebot/pull/985)
12+
1013
## [4.15.1] - 2026-03-06
1114

1215
### Fixed

docs/docs/features/mcp-server.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ Sourcebot MCP uses a [Streamable HTTP](https://modelcontextprotocol.io/specifica
2828

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

31+
<Note>
32+
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.
33+
</Note>
34+
3135
<AccordionGroup>
3236
<Accordion title="Claude Code">
3337
[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
273277

274278
## Authorization
275279

276-
The Sourcebot MCP server supports two authorization methods, OAuth and API keys.
280+
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.
277281

278282
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.
279283

packages/web/next.config.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ const nextConfig = {
3737
source: "/.well-known/oauth-protected-resource/:path*",
3838
destination: "/api/ee/.well-known/oauth-protected-resource/:path*",
3939
},
40+
// Non-spec fallback: some MCP clients (observed in Claude Code and Cursor) guess
41+
// /register as the Dynamic Client Registration endpoint (RFC 7591) when OAuth
42+
// Authorization Server Metadata discovery fails entirely. This happens when the
43+
// instance does not hold an enterprise license, causing all well-known endpoints
44+
// to return 404. Per the MCP spec, clients should only attempt Dynamic Client
45+
// Registration if the authorization server advertises a registration_endpoint in
46+
// its metadata — guessing the URL is not spec-compliant behavior. This rewrite
47+
// ensures those requests reach the actual endpoint rather than hitting a 404.
48+
{
49+
source: "/register",
50+
destination: "/api/ee/oauth/register",
51+
}
4052
];
4153
},
4254
// This is required to support PostHog trailing slash API requests
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ErrorCode } from "@/lib/errorCodes"
2+
import { serviceErrorResponse } from "@/lib/serviceError"
3+
import { StatusCodes } from "http-status-codes"
4+
5+
const handler = () => {
6+
return serviceErrorResponse({
7+
statusCode: StatusCodes.NOT_FOUND,
8+
errorCode: ErrorCode.NOT_FOUND,
9+
message: "This API endpoint does not exist",
10+
});
11+
}
12+
13+
export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE }

packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { apiHandler } from '@/lib/apiHandler';
2-
import { env } from '@sourcebot/shared';
2+
import { env, hasEntitlement } from '@sourcebot/shared';
3+
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
34

45
// RFC 8414: OAuth 2.0 Authorization Server Metadata
5-
// @note: we do not gate on entitlements here. That is handled in the /register,
6-
// /token, and /revoke routes.
76
// @see: https://datatracker.ietf.org/doc/html/rfc8414
87
export const GET = apiHandler(async () => {
8+
if (!hasEntitlement('oauth')) {
9+
return Response.json(
10+
{ error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
11+
{ status: 404 }
12+
);
13+
}
14+
915
const issuer = env.AUTH_URL.replace(/\/$/, '');
1016

1117
return Response.json({

packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import { apiHandler } from '@/lib/apiHandler';
2-
import { env } from '@sourcebot/shared';
2+
import { env, hasEntitlement } from '@sourcebot/shared';
33
import { NextRequest } from 'next/server';
4+
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
45

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

1413
export const GET = apiHandler(async (_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => {
14+
if (!hasEntitlement('oauth')) {
15+
return Response.json(
16+
{ error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
17+
{ status: 404 }
18+
);
19+
}
20+
1521
const { path } = await params;
1622
const resourcePath = path.join('/');
1723

packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/route.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

packages/web/src/app/api/(server)/ee/oauth/register/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { prisma } from '@/prisma';
44
import { hasEntitlement } from '@sourcebot/shared';
55
import { NextRequest } from 'next/server';
66
import { z } from 'zod';
7+
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
78

89
// RFC 7591: OAuth 2.0 Dynamic Client Registration
910
// @see: https://datatracker.ietf.org/doc/html/rfc7591
@@ -16,7 +17,7 @@ const registerRequestSchema = z.object({
1617
export const POST = apiHandler(async (request: NextRequest) => {
1718
if (!hasEntitlement('oauth')) {
1819
return Response.json(
19-
{ error: 'access_denied', error_description: 'OAuth is not available on this plan. Please see https://sourcebot.dev/pricing' },
20+
{ error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
2021
{ status: 403 }
2122
);
2223
}

packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { revokeToken } from '@/ee/features/oauth/server';
22
import { apiHandler } from '@/lib/apiHandler';
33
import { hasEntitlement } from '@sourcebot/shared';
44
import { NextRequest } from 'next/server';
5+
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
56

67
// RFC 7009: OAuth 2.0 Token Revocation
78
// Always returns 200 regardless of whether the token existed.
89
// @see: https://datatracker.ietf.org/doc/html/rfc7009
910
export const POST = apiHandler(async (request: NextRequest) => {
1011
if (!hasEntitlement('oauth')) {
1112
return Response.json(
12-
{ error: 'access_denied', error_description: 'OAuth is not available on this plan. Please see https://sourcebot.dev/pricing' },
13+
{ error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
1314
{ status: 403 }
1415
);
1516
}

packages/web/src/app/api/(server)/ee/oauth/token/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { verifyAndExchangeCode, verifyAndRotateRefreshToken, ACCESS_TOKEN_TTL_SE
22
import { apiHandler } from '@/lib/apiHandler';
33
import { hasEntitlement } from '@sourcebot/shared';
44
import { NextRequest } from 'next/server';
5+
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
56

67
// OAuth 2.0 Token Endpoint
78
// Supports grant_type=authorization_code with PKCE (RFC 7636).
89
// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-3.2
910
export const POST = apiHandler(async (request: NextRequest) => {
1011
if (!hasEntitlement('oauth')) {
1112
return Response.json(
12-
{ error: 'access_denied', error_description: 'OAuth is not available on this plan. Please see https://sourcebot.dev/pricing' },
13+
{ error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
1314
{ status: 403 }
1415
);
1516
}

0 commit comments

Comments
 (0)