From 819e1c7256472ec67f1b7a1fc0bdbe947d8ba8e9 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Wed, 9 Sep 2026 15:16:30 -0700 Subject: [PATCH] feat: require explicit opt-in for MCP and OAuth registration --- README.md | 4 +++ packages/mcp/README.md | 10 ++++--- src/app/api/oauth/register/route.ts | 2 +- src/app/mcp/route.ts | 2 +- src/lib/oauth/config.test.ts | 43 +++++++++++++++++++++++++++++ src/lib/oauth/config.ts | 4 +-- src/lib/oauth/verify.test.ts | 12 +++++--- 7 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 src/lib/oauth/config.test.ts diff --git a/README.md b/README.md index e0ebef01b..6b55a27f6 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ Optional: set `TWO_FACTOR_ENCRYPTION_KEY` to a 64-character hex string to enable authentication. Generate one with `openssl rand -hex 32`. Two-factor authentication is unavailable and cannot be required until this key is set. +MCP and its OAuth endpoints are disabled by default. To enable them, set `MCP_ENABLED=1` and +`OAUTH_ISSUER` to Umami's public URL. Dynamic client registration remains disabled unless +`OAUTH_DCR_ENABLED=1` is also set. + The connection URL format: ```bash diff --git a/packages/mcp/README.md b/packages/mcp/README.md index f053170dd..9799667ec 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -44,6 +44,11 @@ All tools call the Cloud API gateway, which validates the key and routes request ## Remote: self-hosted with OAuth +MCP is disabled by default. Set `MCP_ENABLED=1` and set `OAUTH_ISSUER` to the public URL of +your Umami instance before exposing this endpoint. Dynamic client registration is disabled by +default; clients should use Client ID Metadata Documents. Set `OAUTH_DCR_ENABLED=1` only when a +client requires registration compatibility. + Add the MCP endpoint to your client: ```text @@ -53,10 +58,7 @@ https://your-umami.example.com/mcp The client discovers `/.well-known/oauth-protected-resource/mcp`, sends you to Umami to sign in and approve access (`websites:read`, `analytics:read`), and receives an OAuth token. No API key is pasted anywhere. Clients register via Client ID Metadata Documents; dynamic client registration -is available for compatibility (`OAUTH_DISABLE_DCR=1` turns it off). -Registration is limited to 20 attempts per hour per IP when Redis is configured. Without Redis, -the limit is shared by all clients in each server process and resets when that process restarts. -Use Redis for a shared limit across multiple processes or serverless instances. +is available only when `OAUTH_DCR_ENABLED=1` is set. Self-hosted instances can alternatively pass an API key (`umami_…`) as the bearer token. diff --git a/src/app/api/oauth/register/route.ts b/src/app/api/oauth/register/route.ts index 449b9b825..98136a70f 100644 --- a/src/app/api/oauth/register/route.ts +++ b/src/app/api/oauth/register/route.ts @@ -14,7 +14,7 @@ export function OPTIONS() { /** * Dynamic Client Registration (RFC 7591) compatibility endpoint for MCP clients that do not yet - * support Client ID Metadata Documents. Public clients only; disable with OAUTH_DISABLE_DCR=1. + * support Client ID Metadata Documents. Public clients only; opt in with OAUTH_DCR_ENABLED=1. */ export async function POST(request: Request) { if (!isDynamicRegistrationEnabled()) { diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index 09140769e..4f6caad27 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -38,7 +38,7 @@ const handler = createUmamiMcpHttpHandler({ }); async function handle(request: Request) { - if (!isOAuthEnabled() && process.env.MCP_DISABLED === '1') { + if (!isOAuthEnabled()) { return new Response(null, { status: 404 }); } diff --git a/src/lib/oauth/config.test.ts b/src/lib/oauth/config.test.ts new file mode 100644 index 000000000..7cfa1453f --- /dev/null +++ b/src/lib/oauth/config.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { getOAuthEndpoints, isDynamicRegistrationEnabled, isOAuthEnabled } from './config'; + +function clearFeatureFlags() { + delete process.env.MCP_ENABLED; + delete process.env.OAUTH_DCR_ENABLED; + delete process.env.DISABLE_LOGIN; +} + +beforeEach(() => { + clearFeatureFlags(); +}); + +afterEach(() => { + clearFeatureFlags(); +}); + +describe('MCP feature gates', () => { + test('keeps OAuth and MCP unavailable until explicitly enabled', () => { + expect(isOAuthEnabled()).toBe(false); + expect(isDynamicRegistrationEnabled()).toBe(false); + }); + + test('requires a separate opt-in for dynamic client registration', () => { + process.env.MCP_ENABLED = '1'; + + expect(isOAuthEnabled()).toBe(true); + expect(isDynamicRegistrationEnabled()).toBe(false); + expect(getOAuthEndpoints().registrationEndpoint).toBeUndefined(); + + process.env.OAUTH_DCR_ENABLED = '1'; + + expect(isDynamicRegistrationEnabled()).toBe(true); + expect(getOAuthEndpoints().registrationEndpoint).toContain('/api/oauth/register'); + }); + + test('does not enable OAuth when login is disabled', () => { + process.env.MCP_ENABLED = '1'; + process.env.DISABLE_LOGIN = '1'; + + expect(isOAuthEnabled()).toBe(false); + }); +}); diff --git a/src/lib/oauth/config.ts b/src/lib/oauth/config.ts index 434c769c3..e4546ab28 100644 --- a/src/lib/oauth/config.ts +++ b/src/lib/oauth/config.ts @@ -13,11 +13,11 @@ export const REFRESH_TOKEN_PREFIX = 'umami_rt_'; export const MCP_RESOURCE_PATH = '/mcp'; export function isOAuthEnabled() { - return process.env.OAUTH_DISABLED !== '1' && !process.env.DISABLE_LOGIN; + return process.env.MCP_ENABLED === '1' && !process.env.DISABLE_LOGIN; } export function isDynamicRegistrationEnabled() { - return isOAuthEnabled() && process.env.OAUTH_DISABLE_DCR !== '1'; + return isOAuthEnabled() && process.env.OAUTH_DCR_ENABLED === '1'; } function trimTrailingSlash(value: string) { diff --git a/src/lib/oauth/verify.test.ts b/src/lib/oauth/verify.test.ts index ebcb5847b..cf6fe838b 100644 --- a/src/lib/oauth/verify.test.ts +++ b/src/lib/oauth/verify.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { hash } from '@/lib/crypto'; import { getUser } from '@/queries/prisma/user'; import { createAccessToken } from './tokens'; @@ -26,11 +26,15 @@ function token(scopes: string[], overrides: Record = {}) { beforeEach(() => { process.env.APP_SECRET = 'test'; - delete process.env.OAUTH_DISABLED; + process.env.MCP_ENABLED = '1'; getUserMock.mockReset(); getUserMock.mockResolvedValue({ ...USER } as never); }); +afterEach(() => { + delete process.env.MCP_ENABLED; +}); + describe('verifyOAuthRequest', () => { test('ignores non-OAuth tokens', async () => { await expect(verifyOAuthRequest('umami_key', 'GET', '/api/websites')).resolves.toEqual({ @@ -95,8 +99,8 @@ describe('verifyOAuthRequest', () => { ).resolves.toEqual({ status: 'invalid' }); }); - test('rejects everything when OAuth is disabled', async () => { - process.env.OAUTH_DISABLED = '1'; + test('rejects everything when MCP is disabled', async () => { + delete process.env.MCP_ENABLED; await expect( verifyOAuthRequest(token(['websites:read']), 'GET', '/api/websites'),