feat: require explicit opt-in for MCP and OAuth registration

This commit is contained in:
Francis Cao committed 2026-09-09 15:16:30 -07:00
1 parent 16acac5530
commit 819e1c7256
7 files changed
+65 -12

No files matched your search

+4
View File
@@ -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
+6 -4
View File
@@ -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.
+1 -1
View File
@@ -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()) {
+1 -1
View File
@@ -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 });
}
+43
View File
@@ -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);
});
});
+2 -2
View File
@@ -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) {
+8 -4
View File
@@ -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<string, unknown> = {}) {
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'),