diff --git a/README.md b/README.md index e0ebef01b..c641cee3a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ 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 is disabled by default. Set `MCP_ENABLED=1` to enable the `/mcp` endpoint, then +authenticate with an API key generated under Settings → API keys. + The connection URL format: ```bash diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 8c68caced..5e923bc92 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -38,6 +38,7 @@ x-umami-env: &umami-env DISABLE_BOT_CHECK: "1" DISABLE_TELEMETRY: "1" DISABLE_UPDATES: "1" + MCP_ENABLED: "1" services: db: diff --git a/packages/mcp/README.md b/packages/mcp/README.md index a89ffec51..59de9b28b 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -60,7 +60,7 @@ Authorization: Bearer umami_ Use a client that supports bearer tokens or custom authorization headers. The endpoint accepts self-hosted API keys; browser login tokens are not supported. Tools are read-only and respect the key owner's existing user/team permissions. Revoke the key in Settings to disconnect access. -Set `MCP_DISABLED=1` to disable the endpoint. +MCP is disabled by default. Set `MCP_ENABLED=1` to enable the endpoint. ## Local / stdio diff --git a/src/app/mcp/route.test.ts b/src/app/mcp/route.test.ts new file mode 100644 index 000000000..4f2d0f41f --- /dev/null +++ b/src/app/mcp/route.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { authenticateMcpRequest } from '@/lib/mcp/auth'; +import { DELETE, GET, POST } from './route'; + +vi.mock('@umami/mcp', () => ({ + createUmamiMcpHttpHandler: () => ({ fetch: vi.fn() }), +})); +vi.mock('@/lib/mcp/auth', () => ({ + authenticateMcpRequest: vi.fn(), + mcpAuthErrorResponse: () => new Response(null, { status: 401 }), +})); + +beforeEach(() => { + vi.mocked(authenticateMcpRequest).mockReset(); + vi.mocked(authenticateMcpRequest).mockResolvedValue({ + ok: false, + status: 401, + error: 'invalid_request', + description: 'Missing bearer API key.', + }); +}); + +afterEach(() => vi.unstubAllEnvs()); + +test.each([undefined, '', '0', 'true'])('MCP stays disabled for MCP_ENABLED=%s', async value => { + vi.stubEnv('MCP_ENABLED', value); + for (const handler of [GET, POST, DELETE]) { + const response = await handler(new Request('http://localhost/mcp')); + expect(response.status).toBe(404); + } + expect(authenticateMcpRequest).not.toHaveBeenCalled(); +}); + +test('MCP_ENABLED=1 enables API-key authentication', async () => { + vi.stubEnv('MCP_ENABLED', '1'); + const request = new Request('http://localhost/mcp'); + const response = await POST(request); + expect(response.status).toBe(401); + expect(authenticateMcpRequest).toHaveBeenCalledWith(request); +}); diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index a1eb97da4..2755910d7 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 (process.env.MCP_DISABLED === '1') { + if (process.env.MCP_ENABLED !== '1') { return new Response(null, { status: 404 }); }