Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ node_modules/
dist/
.env
.env.local
.dev.vars
*.tsbuildinfo
.sentry-build/
.dev.vars
.wrangler/

# Sentry Config File
Expand Down
20 changes: 17 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ MCP server for Plausible Analytics — wraps the Plausible Stats API v2 (`POST /

Two entry points:
- **STDIO** (`src/index.ts`) — local use, reads `PLAUSIBLE_API_KEY` from env
- **Cloudflare Worker** (`src/worker.ts`) — multi-tenant remote, each user passes their own API key via `Authorization: Bearer` header
- **Cloudflare Worker** (`src/worker.ts`) — remote, with two endpoints:
- `/mcp` — bring-your-own-key: each user passes their own Plausible API key via `Authorization: Bearer` (header clients like Claude Code/Cursor).
- `/internal` — OAuth 2.1 server (via `@cloudflare/workers-oauth-provider`) for managed connectors (Cowork/Claude.ai). Federates login to Cloudflare Access as an upstream OIDC provider (`src/access-handler.ts` + vendored `src/workers-oauth-utils.ts`), gates on `@sentry.io`, and queries a shared server-side `PLAUSIBLE_API_KEY`. The Access id_token is verified by reusing `src/cf-access.ts`.

## Commands

Expand Down Expand Up @@ -46,12 +48,24 @@ Shared Zod schemas and filter builders live in `src/schemas.ts`. Plausible filte

## Testing

Tests use Vitest with mocked `fetch` — no Plausible account needed. Test helpers are in `__tests__/tools/_helpers.ts` (`createMockClient`, `getToolHandler`). The `worker.ts` entry point is excluded from `tsconfig.json` (it uses Cloudflare-specific types).
Tests use Vitest with mocked `fetch` — no Plausible account needed. Test helpers are in `__tests__/tools/_helpers.ts` (`createMockClient`, `getToolHandler`). The Cloudflare-specific worker files (`worker.ts`, `env.ts`, `access-handler.ts`, `workers-oauth-utils.ts`) are excluded from the default `tsconfig.json` (they use Cloudflare/Workers globals, not Node), and are type-checked separately via `pnpm typecheck` (`tsconfig.worker.json`, which swaps in `@cloudflare/workers-types`).

## Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `PLAUSIBLE_API_KEY` | Yes (STDIO only) | Plausible API key |
| `PLAUSIBLE_API_KEY` | Yes (STDIO; also Worker `/internal`) | Plausible API key. On the Worker it's the shared key used by the OAuth `/internal` endpoint (the `/mcp` BYOK endpoint takes the user's key via Bearer). |
| `PLAUSIBLE_BASE_URL` | No | Custom Plausible instance URL (default: `https://plausible.io`) |
| `PLAUSIBLE_DEFAULT_SITE_ID` | No | Default site domain to avoid passing `site_id` every call |

Worker `/internal` (OAuth 2.1 via Cloudflare Access) also needs these secrets — see README "Setting up the `/internal` OAuth endpoint":

| Variable | Description |
|----------|-------------|
| `CF_ACCESS_TEAM_DOMAIN` | `https://<team>.cloudflareaccess.com` — verifies the Access id_token JWKS + issuer |
| `ACCESS_CLIENT_ID` / `ACCESS_CLIENT_SECRET` | Credentials from the Access SaaS/OIDC app |
| `ACCESS_AUTHORIZATION_URL` / `ACCESS_TOKEN_URL` | Access OIDC authorize/token endpoints (upstream) |
| `COOKIE_ENCRYPTION_KEY` | `openssl rand -hex 32` — signs approval/CSRF cookies |
| `OAUTH_KV` (binding) | KV namespace for OAuth tokens/grants/state |
| `SERVICE_HOSTNAME` (var, optional) | Pins the accepted Host on `/authorize` + `/callback`; skipped when unset |
| `ALLOWED_EMAIL_DOMAIN` (var, optional) | Comma-separated email domain(s) gating `/internal` login. Defaults to `sentry.io`; the `@sentry.io` gate is **not** hardcoded — self-hosters set their own domain |
53 changes: 48 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ All tools are **read-only** and annotated with `readOnlyHint: true`.

### Remote (Hosted)

A hosted instance is available at **`https://plausible-mcp.sentry.dev`**. Each user provides their own Plausible API key as a Bearer token — no setup required.
A hosted instance is available at **`https://plausible-mcp.sentry.dev`**.

Add to Claude Code:
**With your own Plausible API key** (any user):

```bash
claude mcp add --transport http plausible https://plausible-mcp.sentry.dev/mcp --header "Authorization: Bearer YOUR_PLAUSIBLE_API_KEY"
claude mcp add plausible --transport http --header "Authorization: Bearer YOUR_PLAUSIBLE_API_KEY" https://plausible-mcp.sentry.dev/mcp
```

Or add manually to your MCP client config (Claude Desktop, Cursor, etc.):
Expand All @@ -45,6 +45,18 @@ Or add manually to your MCP client config (Claude Desktop, Cursor, etc.):
}
```

**Sentry employees** (via OAuth 2.1 + Cloudflare Access):

The `/internal` endpoint is an OAuth 2.1 server — no API key needed. Add it as a remote/custom connector in any OAuth-capable MCP client (Cowork, Claude.ai connectors, Claude Desktop):

```
https://plausible-mcp.sentry.dev/internal
```

The client discovers the OAuth endpoints automatically, sends you through Sentry SSO (Cloudflare Access), and only `@sentry.io` identities are granted access. Queries run against a shared, server-side Plausible API key — you never handle a key.

> The **hosted** `/internal` at `plausible-mcp.sentry.dev` is Sentry-only and can't be used outside the org. To run `/internal` for a different organization, [self-host](#self-hosting-cloudflare-workers) and set `ALLOWED_EMAIL_DOMAIN` to your own domain. (The public `/mcp` bring-your-own-key endpoint has no such restriction.)

### Local (STDIO)

If you prefer to run it locally:
Expand Down Expand Up @@ -89,7 +101,36 @@ pnpm install
npx wrangler deploy
```

The worker is multi-tenant — each user passes their own Plausible API key via the `Authorization: Bearer` header. No shared secrets needed on the server.
The worker exposes two endpoints:

- **`/mcp`** — bring-your-own-key. Each user passes their own Plausible API key via the `Authorization: Bearer` header. No shared secrets needed on the server. Works with any header-capable MCP client (Claude Code, Cursor, MCP Inspector).
- **`/internal`** — OAuth 2.1 server for managed connectors (Cowork, Claude.ai). Federates login to Cloudflare Access and queries a shared, server-side Plausible API key. Access is gated to the email domain(s) in `ALLOWED_EMAIL_DOMAIN` (defaults to `sentry.io`) — **not** tied to Sentry when you self-host; set it to your own domain.

#### Setting up the `/internal` OAuth endpoint

1. **Create the KV namespace** (stores OAuth tokens/grants/state) and paste the id into `wrangler.toml`:
```bash
npx wrangler kv namespace create OAUTH_KV
```
2. **Register a Cloudflare Access SaaS app** (Zero Trust → Access → Applications → *Add an application* → *SaaS* → **OIDC**):
- **Redirect URL**: `https://<your-worker-host>/callback`
- Scopes: `openid`, `email`, `profile`
- Add an Access **policy** restricting to your email domain (e.g. `@acme.com`) and your identity provider (Google SSO).
- Copy the **Client ID**, **Client secret**, **Authorization endpoint**, and **Token endpoint**.
3. **Set the worker secrets**:
```bash
npx wrangler secret put CF_ACCESS_TEAM_DOMAIN # https://<team>.cloudflareaccess.com
npx wrangler secret put ACCESS_CLIENT_ID
npx wrangler secret put ACCESS_CLIENT_SECRET
npx wrangler secret put ACCESS_AUTHORIZATION_URL
npx wrangler secret put ACCESS_TOKEN_URL
npx wrangler secret put COOKIE_ENCRYPTION_KEY # openssl rand -hex 32
npx wrangler secret put PLAUSIBLE_API_KEY # shared key for /internal queries
```
4. **Set the `[vars]` in `wrangler.toml`**:
- `SERVICE_HOSTNAME` — your worker host (default `plausible-mcp.sentry.dev`). Pins the accepted Host on the OAuth authorize/callback endpoints; remove it to disable that check.
- `ALLOWED_EMAIL_DOMAIN` — the email domain(s) allowed to sign in, comma-separated, `@` optional (default `sentry.io`). This is enforced in code **in addition to** the Access policy in step 2, so you must set it to your own domain — otherwise every login is rejected.
5. **Deploy** (`npx wrangler deploy`), then point an OAuth MCP client at `https://<your-worker-host>/internal`.

## Configuration

Expand All @@ -98,8 +139,10 @@ The worker is multi-tenant — each user passes their own Plausible API key via
| `PLAUSIBLE_API_KEY` | Yes (STDIO) | — | Your Plausible API key ([get one here](https://plausible.io/docs/stats-api)) |
| `PLAUSIBLE_BASE_URL` | No | `https://plausible.io` | URL of your Plausible instance (for self-hosted) |
| `PLAUSIBLE_DEFAULT_SITE_ID` | No | — | Default site domain so you don't have to pass `site_id` every call |
| `SERVICE_HOSTNAME` | No (Worker) | — | Public host to pin the OAuth authorize/callback endpoints to (defense in depth). Skipped when unset. |
| `ALLOWED_EMAIL_DOMAIN` | No (Worker `/internal`) | `sentry.io` | Comma-separated email domain(s) allowed to sign in to `/internal`. Set to your own domain when self-hosting. |

For the Cloudflare Worker, `PLAUSIBLE_API_KEY` is not needed as an env var — each user passes their own key via the `Authorization: Bearer` header.
On the Worker, the `/mcp` endpoint needs no server-side key — each user passes their own via `Authorization: Bearer`. The `/internal` OAuth endpoint uses a shared server-side `PLAUSIBLE_API_KEY` secret (see [self-hosting](#setting-up-the-internal-oauth-endpoint)).

## Plausible API

Expand Down
196 changes: 196 additions & 0 deletions __tests__/access-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { describe, it, expect, vi } from "vitest";
import { handleAccessRequest } from "../src/access-handler.js";
import type { Env } from "../src/env.js";

const HOST = "https://plausible-mcp.sentry.dev";
const COOKIE_KEY = "test-cookie-encryption-key-0123456789abcdef";
const CSRF = "csrf-token-123";

const CLIENT_ID = "client-1";
const REGISTERED_REDIRECT = "https://claude.ai/api/mcp/auth_callback";

function makeEnv(overrides: Partial<Env> = {}): Env {
return {
SERVICE_HOSTNAME: "plausible-mcp.sentry.dev",
COOKIE_ENCRYPTION_KEY: COOKIE_KEY,
ACCESS_CLIENT_ID: "access-client-id",
ACCESS_CLIENT_SECRET: "access-client-secret",
ACCESS_AUTHORIZATION_URL: "https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/authorize",
ACCESS_TOKEN_URL: "https://team.cloudflareaccess.com/cdn-cgi/access/token",
CF_ACCESS_TEAM_DOMAIN: "https://team.cloudflareaccess.com",
OAUTH_KV: { put: vi.fn().mockResolvedValue(undefined) } as unknown as KVNamespace,
OAUTH_PROVIDER: {
lookupClient: vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
redirectUris: [REGISTERED_REDIRECT],
}),
} as unknown as Env["OAUTH_PROVIDER"],
...overrides,
} as Env;
}

function postAuthorize(redirectUri: string, host = HOST): Request {
const oauthReqInfo = {
clientId: CLIENT_ID,
redirectUri,
responseType: "code",
scope: ["mcp"],
state: "client-state",
codeChallenge: "challenge",
codeChallengeMethod: "S256",
};
const encodedState = btoa(JSON.stringify({ oauthReqInfo }));
const body = new URLSearchParams({ csrf_token: CSRF, state: encodedState });
return new Request(`${host}/authorize`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Cookie: `__Host-CSRF_TOKEN=${CSRF}`,
},
body: body.toString(),
});
}

const ctx = {} as ExecutionContext;

describe("handleAccessRequest — host guard", () => {
it("rejects an unexpected host when SERVICE_HOSTNAME is configured", async () => {
const res = await handleAccessRequest(
new Request("https://evil.example/authorize", { method: "GET" }),
makeEnv(),
ctx,
);
expect(res.status).toBe(400);
expect(await res.text()).toBe("Unexpected host");
});

it("allows the configured SERVICE_HOSTNAME", async () => {
const res = await handleAccessRequest(
new Request(`${HOST}/unknown-path`, { method: "GET" }),
makeEnv(),
ctx,
);
// Passes the host guard; falls through to the 404 for unknown routes.
expect(res.status).toBe(404);
});

it("allows localhost for wrangler dev", async () => {
const res = await handleAccessRequest(
new Request("http://localhost:8787/unknown-path", { method: "GET" }),
makeEnv(),
ctx,
);
expect(res.status).toBe(404);
});

it("skips the host check when SERVICE_HOSTNAME is unset (self-hosting)", async () => {
const res = await handleAccessRequest(
new Request("https://my-own-worker.example.com/unknown-path", { method: "GET" }),
makeEnv({ SERVICE_HOSTNAME: undefined }),
ctx,
);
expect(res.status).toBe(404);
});
});

describe("handleAccessRequest — GET /authorize", () => {
it("returns 400 (not an uncaught 500) when the approval-dialog state can't be encoded", async () => {
const env = makeEnv({
OAUTH_PROVIDER: {
// Client-supplied `state` with a non-Latin1 char makes btoa throw in the dialog.
parseAuthRequest: vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
redirectUri: REGISTERED_REDIRECT,
responseType: "code",
scope: ["mcp"],
state: "emoji-\u{1F600}",
}),
lookupClient: vi.fn().mockResolvedValue({
clientId: CLIENT_ID,
redirectUris: [REGISTERED_REDIRECT],
}),
} as unknown as Env["OAUTH_PROVIDER"],
});

// No approved-client cookie → falls through to rendering the consent dialog.
const res = await handleAccessRequest(
new Request(`${HOST}/authorize?client_id=${CLIENT_ID}`, { method: "GET" }),
env,
ctx,
);
expect(res.status).toBe(400);
});
});

describe("handleAccessRequest — POST /authorize redirect_uri validation", () => {
it("rejects a redirect_uri not registered to the client (tampered state)", async () => {
const env = makeEnv();
const res = await handleAccessRequest(
postAuthorize("https://attacker.example/steal"),
env,
ctx,
);

expect(res.status).toBe(400);
expect(await res.text()).toBe("Invalid redirect_uri");
// Must reject before persisting any OAuth state.
expect(env.OAUTH_KV.put).not.toHaveBeenCalled();
});

it("rejects when the client is unknown", async () => {
const env = makeEnv({
OAUTH_PROVIDER: {
lookupClient: vi.fn().mockResolvedValue(null),
} as unknown as Env["OAUTH_PROVIDER"],
});
const res = await handleAccessRequest(
postAuthorize(REGISTERED_REDIRECT),
env,
ctx,
);

expect(res.status).toBe(400);
expect(await res.text()).toBe("Invalid request");
expect(env.OAUTH_KV.put).not.toHaveBeenCalled();
});

it("accepts a registered redirect_uri and redirects to Cloudflare Access", async () => {
const env = makeEnv();
const res = await handleAccessRequest(
postAuthorize(REGISTERED_REDIRECT),
env,
ctx,
);

expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toContain(
"https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/authorize",
);
// The redirect_uri sent upstream is our own /callback on the pinned host.
const upstream = new URL(location);
expect(upstream.searchParams.get("redirect_uri")).toBe(`${HOST}/callback`);
expect(upstream.searchParams.get("code_challenge_method")).toBe("S256");
// State was persisted for the callback to validate.
expect(env.OAUTH_KV.put).toHaveBeenCalledOnce();
});

it("rejects a mismatched CSRF token before touching client lookup", async () => {
const env = makeEnv();
const req = new Request(`${HOST}/authorize`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Cookie: `__Host-CSRF_TOKEN=different-token`,
},
body: new URLSearchParams({
csrf_token: CSRF,
state: btoa(JSON.stringify({ oauthReqInfo: { clientId: CLIENT_ID } })),
}).toString(),
});

const res = await handleAccessRequest(req, env, ctx);
expect(res.status).toBe(400);
expect(env.OAUTH_PROVIDER.lookupClient).not.toHaveBeenCalled();
});
});
Loading